@cmdctrl/cursor-cli 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ /**
3
+ * Cursor CLI Session Watcher
4
+ *
5
+ * Polls cursor-agent JSONL transcript files for new messages and emits events.
6
+ * Used with the SDK's onWatchSession / onUnwatchSession hooks.
7
+ */
8
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
9
+ if (k2 === undefined) k2 = k;
10
+ var desc = Object.getOwnPropertyDescriptor(m, k);
11
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
12
+ desc = { enumerable: true, get: function() { return m[k]; } };
13
+ }
14
+ Object.defineProperty(o, k2, desc);
15
+ }) : (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ o[k2] = m[k];
18
+ }));
19
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
20
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
21
+ }) : function(o, v) {
22
+ o["default"] = v;
23
+ });
24
+ var __importStar = (this && this.__importStar) || (function () {
25
+ var ownKeys = function(o) {
26
+ ownKeys = Object.getOwnPropertyNames || function (o) {
27
+ var ar = [];
28
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
29
+ return ar;
30
+ };
31
+ return ownKeys(o);
32
+ };
33
+ return function (mod) {
34
+ if (mod && mod.__esModule) return mod;
35
+ var result = {};
36
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
37
+ __setModuleDefault(result, mod);
38
+ return result;
39
+ };
40
+ })();
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.CursorSessionWatcher = void 0;
43
+ const fs = __importStar(require("fs"));
44
+ const session_discovery_1 = require("./session-discovery");
45
+ const POLL_INTERVAL_MS = 500;
46
+ const COMPLETION_DELAY_MS = 5000;
47
+ class CursorSessionWatcher {
48
+ watchedSessions = new Map();
49
+ completionTimers = new Map();
50
+ pollTimer = null;
51
+ onEvent;
52
+ onCompletion;
53
+ constructor(onEvent, onCompletion) {
54
+ this.onEvent = onEvent;
55
+ this.onCompletion = onCompletion || null;
56
+ }
57
+ watchSession(sessionId, filePath) {
58
+ if (this.watchedSessions.has(sessionId))
59
+ return;
60
+ if (!fs.existsSync(filePath)) {
61
+ console.warn(`[CursorWatcher] File not found: ${filePath}`);
62
+ return;
63
+ }
64
+ try {
65
+ const stat = fs.statSync(filePath);
66
+ const messages = (0, session_discovery_1.parseTranscriptFile)(filePath);
67
+ const lastAgent = [...messages].reverse().find(m => m.role === 'agent');
68
+ this.watchedSessions.set(sessionId, {
69
+ sessionId,
70
+ filePath,
71
+ lastSize: stat.size,
72
+ processedCount: messages.length,
73
+ messageCount: messages.length,
74
+ lastMessage: lastAgent?.content.slice(0, 200) || '',
75
+ });
76
+ console.log(`[CursorWatcher] Started watching session ${sessionId} (${messages.length} existing messages)`);
77
+ if (!this.pollTimer)
78
+ this.startPolling();
79
+ }
80
+ catch (err) {
81
+ console.error(`[CursorWatcher] Failed to watch ${filePath}:`, err);
82
+ }
83
+ }
84
+ unwatchSession(sessionId) {
85
+ this.cancelCompletionTimer(sessionId);
86
+ if (this.watchedSessions.delete(sessionId)) {
87
+ console.log(`[CursorWatcher] Stopped watching session ${sessionId}`);
88
+ }
89
+ if (this.watchedSessions.size === 0 && this.pollTimer) {
90
+ clearInterval(this.pollTimer);
91
+ this.pollTimer = null;
92
+ }
93
+ }
94
+ unwatchAll() {
95
+ for (const timer of this.completionTimers.values())
96
+ clearTimeout(timer);
97
+ this.completionTimers.clear();
98
+ this.watchedSessions.clear();
99
+ if (this.pollTimer) {
100
+ clearInterval(this.pollTimer);
101
+ this.pollTimer = null;
102
+ }
103
+ }
104
+ get watchCount() {
105
+ return this.watchedSessions.size;
106
+ }
107
+ startPolling() {
108
+ this.pollTimer = setInterval(() => {
109
+ for (const session of this.watchedSessions.values()) {
110
+ this.checkSession(session);
111
+ }
112
+ }, POLL_INTERVAL_MS);
113
+ }
114
+ checkSession(session) {
115
+ try {
116
+ if (!fs.existsSync(session.filePath)) {
117
+ this.unwatchSession(session.sessionId);
118
+ return;
119
+ }
120
+ const stat = fs.statSync(session.filePath);
121
+ if (stat.size === session.lastSize)
122
+ return;
123
+ session.lastSize = stat.size;
124
+ const allMessages = (0, session_discovery_1.parseTranscriptFile)(session.filePath);
125
+ const newMessages = allMessages.slice(session.processedCount);
126
+ if (newMessages.length === 0)
127
+ return;
128
+ let sawAgent = false;
129
+ for (const msg of newMessages) {
130
+ const uuid = (0, session_discovery_1.stableUuid)(session.sessionId + ':' + msg.id);
131
+ this.onEvent({
132
+ type: msg.role === 'user' ? 'USER_MESSAGE' : 'AGENT_RESPONSE',
133
+ sessionId: session.sessionId,
134
+ uuid,
135
+ content: msg.content,
136
+ });
137
+ if (msg.role === 'agent') {
138
+ sawAgent = true;
139
+ session.lastMessage = msg.content.slice(0, 200);
140
+ }
141
+ session.messageCount++;
142
+ }
143
+ session.processedCount = allMessages.length;
144
+ if (sawAgent)
145
+ this.startCompletionTimer(session);
146
+ }
147
+ catch (err) {
148
+ console.error(`[CursorWatcher] Error checking session ${session.sessionId}:`, err);
149
+ }
150
+ }
151
+ startCompletionTimer(session) {
152
+ this.cancelCompletionTimer(session.sessionId);
153
+ if (!this.onCompletion)
154
+ return;
155
+ const timer = setTimeout(() => {
156
+ this.completionTimers.delete(session.sessionId);
157
+ this.onCompletion?.({
158
+ sessionId: session.sessionId,
159
+ filePath: session.filePath,
160
+ lastMessage: session.lastMessage,
161
+ messageCount: session.messageCount,
162
+ });
163
+ }, COMPLETION_DELAY_MS);
164
+ this.completionTimers.set(session.sessionId, timer);
165
+ }
166
+ cancelCompletionTimer(sessionId) {
167
+ const timer = this.completionTimers.get(sessionId);
168
+ if (timer) {
169
+ clearTimeout(timer);
170
+ this.completionTimers.delete(sessionId);
171
+ }
172
+ }
173
+ }
174
+ exports.CursorSessionWatcher = CursorSessionWatcher;
175
+ //# sourceMappingURL=session-watcher.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-watcher.js","sourceRoot":"","sources":["../src/session-watcher.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmdctrl/cursor-cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "CmdCtrl daemon for Cursor CLI - connects your workstation to the CmdCtrl orchestration server",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {
@@ -2,7 +2,8 @@ import { readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { DaemonClient, ConfigManager } from '@cmdctrl/daemon-sdk';
4
4
  import { CursorAdapter } from '../adapter/cursor-cli';
5
- import { MessageStore } from '../message-store';
5
+ import { discoverSessions, readSessionMessages } from '../session-discovery';
6
+ import { CursorSessionWatcher } from '../session-watcher';
6
7
 
7
8
  const configManager = new ConfigManager('cursor-cli');
8
9
 
@@ -39,9 +40,33 @@ export async function start(): Promise<void> {
39
40
 
40
41
  configManager.writePidFile(process.pid);
41
42
 
42
- const messageStore = new MessageStore();
43
- const pendingInstructions = new Map<string, string>();
44
- const taskSessionMap = new Map<string, string>();
43
+ // Managed session IDs (started via task_start) – excluded from native discovery
44
+ const managedSessionIds = new Set<string>();
45
+
46
+ const sessionWatcher = new CursorSessionWatcher(
47
+ (event) => {
48
+ // Only send activity for user messages – agent responses are the final
49
+ // answer already shown via the transcript; sending them here duplicates.
50
+ if (event.type !== 'USER_MESSAGE') return;
51
+ client.sendSessionActivity(
52
+ event.sessionId,
53
+ '',
54
+ event.content,
55
+ 1,
56
+ false,
57
+ new Date().toISOString()
58
+ );
59
+ },
60
+ (completion) => {
61
+ client.sendSessionActivity(
62
+ completion.sessionId,
63
+ completion.filePath,
64
+ completion.lastMessage,
65
+ completion.messageCount,
66
+ true
67
+ );
68
+ }
69
+ );
45
70
 
46
71
  // Event callback wired into the DaemonClient below
47
72
  let sendEvent: (taskId: string, eventType: string, data: Record<string, unknown>) => void;
@@ -49,21 +74,8 @@ export async function start(): Promise<void> {
49
74
  const adapter = new CursorAdapter((taskId, eventType, data) => {
50
75
  const sessionId = data.session_id as string | undefined;
51
76
 
52
- // Store initial user message when session starts
53
77
  if (eventType === 'SESSION_STARTED' && sessionId) {
54
- const instruction = pendingInstructions.get(taskId);
55
- if (instruction) {
56
- messageStore.storeMessage(sessionId, 'USER', instruction);
57
- pendingInstructions.delete(taskId);
58
- }
59
- }
60
-
61
- // Store agent response on completion
62
- if (eventType === 'TASK_COMPLETE' && data.result) {
63
- const sid = (data.session_id as string) || taskSessionMap.get(taskId);
64
- if (sid) {
65
- messageStore.storeMessage(sid, 'AGENT', data.result as string);
66
- }
78
+ managedSessionIds.add(sessionId);
67
79
  }
68
80
 
69
81
  sendEvent(taskId, eventType, data);
@@ -77,10 +89,13 @@ export async function start(): Promise<void> {
77
89
  version: daemonVersion,
78
90
  });
79
91
 
80
- // Wire up sendEvent to the client's internal method
92
+ client.setSessionsProvider(() => discoverSessions(managedSessionIds));
93
+
81
94
  sendEvent = (taskId, eventType, data) => {
82
- // Use the client to send events the SDK handles this via task handles,
83
- // but since the adapter uses a callback pattern, we send raw events
95
+ // cursor-agent writes all content to transcript files suppress OUTPUT events
96
+ // and strip result from TASK_COMPLETE to avoid duplicating transcript content.
97
+ if (eventType === 'OUTPUT') return;
98
+ if (eventType === 'TASK_COMPLETE') data = { ...data, result: '' };
84
99
  (client as any).send({
85
100
  type: 'event',
86
101
  task_id: taskId,
@@ -89,8 +104,15 @@ export async function start(): Promise<void> {
89
104
  });
90
105
  };
91
106
 
107
+ client.onWatchSession((sessionId, filePath) => {
108
+ sessionWatcher.watchSession(sessionId, filePath);
109
+ });
110
+
111
+ client.onUnwatchSession((sessionId) => {
112
+ sessionWatcher.unwatchSession(sessionId);
113
+ });
114
+
92
115
  client.onTaskStart(async (task) => {
93
- pendingInstructions.set(task.taskId, task.instruction);
94
116
  try {
95
117
  await adapter.startTask(task.taskId, task.instruction, task.projectPath);
96
118
  } catch (err: unknown) {
@@ -99,8 +121,6 @@ export async function start(): Promise<void> {
99
121
  });
100
122
 
101
123
  client.onTaskResume(async (task) => {
102
- messageStore.storeMessage(task.sessionId, 'USER', task.message);
103
- taskSessionMap.set(task.taskId, task.sessionId);
104
124
  try {
105
125
  await adapter.resumeTask(task.taskId, task.sessionId, task.message, task.projectPath);
106
126
  } catch (err: unknown) {
@@ -112,19 +132,9 @@ export async function start(): Promise<void> {
112
132
  await adapter.cancelTask(taskId);
113
133
  });
114
134
 
135
+ // cursor-agent always writes to transcript files – use them as the single source of truth
115
136
  client.onGetMessages((req) => {
116
- const result = messageStore.getMessages(
117
- req.sessionId,
118
- req.limit,
119
- req.beforeUuid,
120
- req.afterUuid
121
- );
122
- return {
123
- messages: result.messages,
124
- hasMore: result.hasMore,
125
- oldestUuid: result.oldestUuid,
126
- newestUuid: result.newestUuid,
127
- };
137
+ return readSessionMessages(req.sessionId, req.limit, req.beforeUuid, req.afterUuid);
128
138
  });
129
139
 
130
140
  client.onVersionStatus((msg) => {
@@ -135,6 +145,7 @@ export async function start(): Promise<void> {
135
145
 
136
146
  const shutdown = async () => {
137
147
  console.log('\nShutting down...');
148
+ sessionWatcher.unwatchAll();
138
149
  await adapter.stopAll();
139
150
  await client.disconnect();
140
151
  configManager.deletePidFile();
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Cursor CLI Session Discovery
3
+ *
4
+ * Scans ~/.cursor/projects/<encoded-path>/agent-transcripts/<session-id>.jsonl
5
+ * to discover existing cursor-agent sessions.
6
+ *
7
+ * File format – each line is a JSON object:
8
+ * { role: "user" | "assistant", message: { content: [{ type: "text", text: "..." }] } }
9
+ *
10
+ * User messages have text wrapped in <user_query>...</user_query> tags.
11
+ * Session ID = filename (UUID, without .jsonl).
12
+ * Project path = decoded from the project directory name (hyphens → slashes).
13
+ */
14
+
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import * as os from 'os';
18
+ import * as crypto from 'crypto';
19
+
20
+ const ACTIVE_THRESHOLD_MS = 30 * 1000;
21
+
22
+ export interface ExternalSession {
23
+ session_id: string;
24
+ slug: string;
25
+ title: string;
26
+ project: string;
27
+ project_name: string;
28
+ file_path: string;
29
+ last_message: string;
30
+ last_activity: string;
31
+ is_active: boolean;
32
+ message_count: number;
33
+ }
34
+
35
+ export interface ParsedMessage {
36
+ id: string;
37
+ role: 'user' | 'agent';
38
+ content: string;
39
+ }
40
+
41
+ // Cache: file path → { session, fileMtime }
42
+ const sessionCache = new Map<string, { session: ExternalSession; fileMtime: number }>();
43
+
44
+ // Cache: file path → { messages, fileMtime }
45
+ const messageCache = new Map<string, { messages: ParsedMessage[]; fileMtime: number }>();
46
+
47
+ /**
48
+ * Strip <user_query>...</user_query> wrapper added by cursor-agent.
49
+ */
50
+ function stripUserQueryTags(text: string): string {
51
+ return text.replace(/^\s*<user_query>\s*/i, '').replace(/\s*<\/user_query>\s*$/i, '').trim();
52
+ }
53
+
54
+ /**
55
+ * Extract plain text from a cursor-agent message content array.
56
+ */
57
+ function extractText(content: Array<{ type: string; text?: string }>): string {
58
+ return content
59
+ .filter(b => b.type === 'text' && b.text)
60
+ .map(b => b.text!)
61
+ .join('')
62
+ .trim();
63
+ }
64
+
65
+ /**
66
+ * Parse all messages from a cursor-agent transcript JSONL file.
67
+ */
68
+ export function parseTranscriptFile(filePath: string): ParsedMessage[] {
69
+ try {
70
+ const raw = fs.readFileSync(filePath, 'utf-8');
71
+ const lines = raw.split('\n').filter(l => l.trim());
72
+ const messages: ParsedMessage[] = [];
73
+ let idx = 0;
74
+
75
+ for (const line of lines) {
76
+ try {
77
+ const obj = JSON.parse(line) as {
78
+ role: string;
79
+ message: { content: Array<{ type: string; text?: string }> };
80
+ };
81
+
82
+ if (!obj.role || !obj.message?.content) continue;
83
+
84
+ let text = extractText(obj.message.content);
85
+ if (!text) continue;
86
+
87
+ if (obj.role === 'user') {
88
+ text = stripUserQueryTags(text);
89
+ if (!text) continue;
90
+ messages.push({ id: `user-${idx++}`, role: 'user', content: text });
91
+ } else if (obj.role === 'assistant') {
92
+ // cursor-agent appends thinking after the first blank line – keep only the answer
93
+ const answerEnd = text.indexOf('\n\n');
94
+ if (answerEnd !== -1) text = text.slice(0, answerEnd).trim();
95
+ if (!text) continue;
96
+ messages.push({ id: `agent-${idx++}`, role: 'agent', content: text });
97
+ }
98
+ } catch {
99
+ // skip invalid lines
100
+ }
101
+ }
102
+
103
+ return messages;
104
+ } catch {
105
+ return [];
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Attempt to decode a cursor project directory name back to a filesystem path.
111
+ * The encoding replaces '/' with '-' and drops the leading '/'.
112
+ * e.g. "Users-mrwoof-src-testing" → "/Users/mrwoof/src/testing"
113
+ *
114
+ * We try all possible slash placements and return the first existing path.
115
+ * Falls back to returning the encoded name if nothing exists.
116
+ */
117
+ function decodeProjectPath(encoded: string): string {
118
+ // Simple heuristic: replace all hyphens with slashes and prepend /
119
+ const candidate = '/' + encoded.replace(/-/g, '/');
120
+ if (fs.existsSync(candidate)) return candidate;
121
+
122
+ // Walk all subdirs of ~/ looking for the encoded name match
123
+ // (handles paths with hyphens in component names by trying common prefixes)
124
+ const home = os.homedir();
125
+ const homeEncoded = home.replace(/^\//, '').replace(/\//g, '-');
126
+ if (encoded.startsWith(homeEncoded + '-')) {
127
+ const rest = encoded.slice(homeEncoded.length + 1);
128
+ const restPath = rest.replace(/-/g, '/');
129
+ const tryPath = path.join(home, restPath);
130
+ if (fs.existsSync(tryPath)) return tryPath;
131
+ }
132
+
133
+ return candidate;
134
+ }
135
+
136
+ /**
137
+ * Generate title from first user message.
138
+ */
139
+ function generateTitle(text: string): string {
140
+ const firstLine = text.split('\n')[0].trim();
141
+ if (firstLine.length <= 50) return firstLine;
142
+ const truncated = firstLine.slice(0, 50);
143
+ const lastSpace = truncated.lastIndexOf(' ');
144
+ if (lastSpace > 30) return truncated.slice(0, lastSpace) + '...';
145
+ return truncated + '...';
146
+ }
147
+
148
+ /**
149
+ * Discover all cursor-agent sessions on this device.
150
+ * Scans ~/.cursor/projects/<project>/agent-transcripts/<session>.jsonl
151
+ */
152
+ export function discoverSessions(excludeSessionIDs: Set<string> = new Set()): ExternalSession[] {
153
+ const projectsDir = path.join(os.homedir(), '.cursor', 'projects');
154
+ const sessions: ExternalSession[] = [];
155
+
156
+ if (!fs.existsSync(projectsDir)) return sessions;
157
+
158
+ let projectDirs: string[];
159
+ try {
160
+ projectDirs = fs.readdirSync(projectsDir);
161
+ } catch {
162
+ return sessions;
163
+ }
164
+
165
+ for (const projectDir of projectDirs) {
166
+ const transcriptsDir = path.join(projectsDir, projectDir, 'agent-transcripts');
167
+ if (!fs.existsSync(transcriptsDir)) continue;
168
+
169
+ const projectPath = decodeProjectPath(projectDir);
170
+ const projectName = path.basename(projectPath);
171
+
172
+ let transcriptFiles: string[];
173
+ try {
174
+ transcriptFiles = fs.readdirSync(transcriptsDir).filter(f => f.endsWith('.jsonl'));
175
+ } catch {
176
+ continue;
177
+ }
178
+
179
+ for (const file of transcriptFiles) {
180
+ const sessionId = file.replace('.jsonl', '');
181
+ if (excludeSessionIDs.has(sessionId)) continue;
182
+
183
+ const filePath = path.join(transcriptsDir, file);
184
+
185
+ try {
186
+ const stat = fs.statSync(filePath);
187
+ const fileMtime = stat.mtimeMs;
188
+
189
+ const cached = sessionCache.get(filePath);
190
+ if (cached && cached.fileMtime === fileMtime) {
191
+ const session = { ...cached.session };
192
+ session.is_active = Date.now() - new Date(session.last_activity).getTime() < ACTIVE_THRESHOLD_MS;
193
+ sessions.push(session);
194
+ continue;
195
+ }
196
+
197
+ const messages = parseTranscriptFile(filePath);
198
+ if (messages.length === 0) continue;
199
+
200
+ const firstUser = messages.find(m => m.role === 'user');
201
+ const lastUser = [...messages].reverse().find(m => m.role === 'user');
202
+ const title = generateTitle(firstUser?.content || '') || sessionId.slice(0, 8);
203
+ const lastMessage = lastUser?.content.slice(0, 100) || '';
204
+ const lastActivity = new Date(stat.mtimeMs).toISOString();
205
+ const isActive = Date.now() - stat.mtimeMs < ACTIVE_THRESHOLD_MS;
206
+
207
+ const session: ExternalSession = {
208
+ session_id: sessionId,
209
+ slug: '',
210
+ title,
211
+ project: projectPath,
212
+ project_name: projectName,
213
+ file_path: filePath,
214
+ last_message: lastMessage,
215
+ last_activity: lastActivity,
216
+ is_active: isActive,
217
+ message_count: messages.length,
218
+ };
219
+
220
+ sessionCache.set(filePath, { session, fileMtime });
221
+ sessions.push(session);
222
+ } catch {
223
+ continue;
224
+ }
225
+ }
226
+ }
227
+
228
+ sessions.sort((a, b) =>
229
+ new Date(b.last_activity).getTime() - new Date(a.last_activity).getTime()
230
+ );
231
+
232
+ return sessions;
233
+ }
234
+
235
+ /**
236
+ * Find the file path for a given session ID.
237
+ */
238
+ export function findSessionFile(sessionId: string): string | null {
239
+ for (const [filePath, cached] of sessionCache.entries()) {
240
+ if (cached.session.session_id === sessionId) return filePath;
241
+ }
242
+
243
+ const projectsDir = path.join(os.homedir(), '.cursor', 'projects');
244
+ if (!fs.existsSync(projectsDir)) return null;
245
+
246
+ try {
247
+ for (const projectDir of fs.readdirSync(projectsDir)) {
248
+ const candidate = path.join(projectsDir, projectDir, 'agent-transcripts', `${sessionId}.jsonl`);
249
+ if (fs.existsSync(candidate)) return candidate;
250
+ }
251
+ } catch {
252
+ // ignore
253
+ }
254
+
255
+ return null;
256
+ }
257
+
258
+ /**
259
+ * Read messages from a cursor-agent session for the CmdCtrl get_messages protocol.
260
+ */
261
+ export function readSessionMessages(
262
+ sessionId: string,
263
+ limit: number,
264
+ beforeUuid?: string,
265
+ afterUuid?: string
266
+ ): { messages: Array<{ uuid: string; role: 'USER' | 'AGENT'; content: string; timestamp: string }>; hasMore: boolean; oldestUuid?: string; newestUuid?: string } {
267
+ const filePath = findSessionFile(sessionId);
268
+ if (!filePath) return { messages: [], hasMore: false };
269
+
270
+ try {
271
+ const stat = fs.statSync(filePath);
272
+ const fileMtime = stat.mtimeMs;
273
+
274
+ let parsed: ParsedMessage[];
275
+ const cached = messageCache.get(filePath);
276
+ if (cached && cached.fileMtime === fileMtime) {
277
+ parsed = cached.messages;
278
+ } else {
279
+ parsed = parseTranscriptFile(filePath);
280
+ messageCache.set(filePath, { messages: parsed, fileMtime });
281
+ }
282
+
283
+ // Assign sequential timestamps 1s apart, ending at file mtime, to preserve order
284
+ const total = parsed.filter(m => m.content.length > 0).length;
285
+ let seq = 0;
286
+ let messages = parsed.map(msg => ({
287
+ uuid: stableUuid(sessionId + ':' + msg.id),
288
+ role: (msg.role === 'user' ? 'USER' : 'AGENT') as 'USER' | 'AGENT',
289
+ content: msg.content,
290
+ timestamp: new Date(stat.mtimeMs - (total - seq++) * 1000).toISOString(),
291
+ })).filter(m => m.content.length > 0);
292
+
293
+ if (beforeUuid) {
294
+ const idx = messages.findIndex(m => m.uuid === beforeUuid);
295
+ if (idx > 0) messages = messages.slice(0, idx);
296
+ }
297
+ if (afterUuid) {
298
+ const idx = messages.findIndex(m => m.uuid === afterUuid);
299
+ if (idx >= 0) messages = messages.slice(idx + 1);
300
+ }
301
+
302
+ const hasMore = messages.length > limit;
303
+ const limited = messages.slice(-limit);
304
+
305
+ return {
306
+ messages: limited,
307
+ hasMore,
308
+ oldestUuid: limited[0]?.uuid,
309
+ newestUuid: limited[limited.length - 1]?.uuid,
310
+ };
311
+ } catch {
312
+ return { messages: [], hasMore: false };
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Generate a stable UUID from an input string.
318
+ */
319
+ export function stableUuid(input: string): string {
320
+ const hash = crypto.createHash('sha256').update(input).digest('hex');
321
+ return [
322
+ hash.slice(0, 8),
323
+ hash.slice(8, 12),
324
+ '4' + hash.slice(13, 16),
325
+ '8' + hash.slice(17, 20),
326
+ hash.slice(20, 32),
327
+ ].join('-');
328
+ }