llm-deep-trace 0.1.0

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +159 -0
  3. package/bin/llm-deep-trace.js +24 -0
  4. package/next.config.ts +8 -0
  5. package/package.json +56 -0
  6. package/postcss.config.mjs +5 -0
  7. package/public/banner-v2.png +0 -0
  8. package/public/file.svg +1 -0
  9. package/public/globe.svg +1 -0
  10. package/public/logo.png +0 -0
  11. package/public/next.svg +1 -0
  12. package/public/vercel.svg +1 -0
  13. package/public/window.svg +1 -0
  14. package/src/app/api/agent-config/route.ts +31 -0
  15. package/src/app/api/all-sessions/route.ts +9 -0
  16. package/src/app/api/analytics/route.ts +379 -0
  17. package/src/app/api/detect-agents/route.ts +170 -0
  18. package/src/app/api/image/route.ts +73 -0
  19. package/src/app/api/search/route.ts +28 -0
  20. package/src/app/api/session-by-key/route.ts +21 -0
  21. package/src/app/api/sessions/[sessionId]/messages/route.ts +46 -0
  22. package/src/app/api/sse/route.ts +86 -0
  23. package/src/app/favicon.ico +0 -0
  24. package/src/app/globals.css +3518 -0
  25. package/src/app/icon.svg +4 -0
  26. package/src/app/layout.tsx +20 -0
  27. package/src/app/page.tsx +5 -0
  28. package/src/components/AnalyticsDashboard.tsx +393 -0
  29. package/src/components/App.tsx +243 -0
  30. package/src/components/CopyButton.tsx +42 -0
  31. package/src/components/Logo.tsx +20 -0
  32. package/src/components/MainPanel.tsx +1128 -0
  33. package/src/components/MessageRenderer.tsx +983 -0
  34. package/src/components/SessionTree.tsx +505 -0
  35. package/src/components/SettingsPanel.tsx +160 -0
  36. package/src/components/SetupView.tsx +206 -0
  37. package/src/components/Sidebar.tsx +714 -0
  38. package/src/components/ThemeToggle.tsx +54 -0
  39. package/src/lib/client-utils.ts +360 -0
  40. package/src/lib/normalizers.ts +371 -0
  41. package/src/lib/sessions.ts +1223 -0
  42. package/src/lib/store.ts +518 -0
  43. package/src/lib/types.ts +112 -0
  44. package/src/lib/useSSE.ts +81 -0
  45. package/tsconfig.json +34 -0
@@ -0,0 +1,1223 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ import { SessionInfo, RawEntry } from "./types";
5
+
6
+ const HOME = os.homedir();
7
+ const SESSIONS_DIR = path.join(HOME, ".openclaw", "agents", "main", "sessions");
8
+ const SESSIONS_INDEX = path.join(SESSIONS_DIR, "sessions.json");
9
+
10
+ export function parseJsonl(filePath: string): RawEntry[] {
11
+ const entries: RawEntry[] = [];
12
+ try {
13
+ const data = fs.readFileSync(filePath, "utf-8");
14
+ for (const line of data.split("\n")) {
15
+ const trimmed = line.trim();
16
+ if (!trimmed) continue;
17
+ try {
18
+ entries.push(JSON.parse(trimmed));
19
+ } catch {
20
+ continue;
21
+ }
22
+ }
23
+ } catch {
24
+ // file not readable
25
+ }
26
+ return entries;
27
+ }
28
+
29
+ export function findSessionFiles(): Record<
30
+ string,
31
+ { path: string; isActive: boolean; isDeleted: boolean; isReset: boolean }
32
+ > {
33
+ const result: Record<
34
+ string,
35
+ { path: string; isActive: boolean; isDeleted: boolean; isReset: boolean }
36
+ > = {};
37
+ if (!fs.existsSync(SESSIONS_DIR)) return result;
38
+
39
+ for (const name of fs.readdirSync(SESSIONS_DIR)) {
40
+ if (!name.endsWith(".jsonl") && !name.includes(".jsonl.")) continue;
41
+ if (name.endsWith(".lock") || name.endsWith(".bak")) continue;
42
+
43
+ const sessionId = name.split(".jsonl")[0];
44
+ const isDeleted = name.includes(".deleted.");
45
+ const isReset = name.includes(".reset.");
46
+
47
+ if (sessionId in result && result[sessionId].isActive) continue;
48
+
49
+ result[sessionId] = {
50
+ path: path.join(SESSIONS_DIR, name),
51
+ isActive: !isDeleted && !isReset,
52
+ isDeleted,
53
+ isReset,
54
+ };
55
+ }
56
+ return result;
57
+ }
58
+
59
+ export function getMessagePreview(entries: RawEntry[]): string {
60
+ for (let i = entries.length - 1; i >= 0; i--) {
61
+ const entry = entries[i];
62
+ if (entry.type !== "message") continue;
63
+ const msg = (entry.message || {}) as Record<string, unknown>;
64
+ if (msg.role !== "user") continue;
65
+ const content = msg.content;
66
+ const texts: string[] = [];
67
+ if (Array.isArray(content)) {
68
+ for (const block of content) {
69
+ if (
70
+ typeof block === "object" &&
71
+ block !== null &&
72
+ (block as Record<string, unknown>).type === "text"
73
+ ) {
74
+ texts.push(((block as Record<string, unknown>).text as string) || "");
75
+ }
76
+ }
77
+ } else if (typeof content === "string") {
78
+ texts.push(content);
79
+ }
80
+ let text = texts.join(" ").trim();
81
+ if (text.startsWith("Conversation info")) {
82
+ const parts = text.split("\n");
83
+ let foundClose = false;
84
+ const cleanParts: string[] = [];
85
+ for (const p of parts) {
86
+ if (foundClose) cleanParts.push(p);
87
+ else if (p.trim() === "```") foundClose = true;
88
+ }
89
+ text = cleanParts.length ? cleanParts.join(" ").trim() : text;
90
+ }
91
+ if (text) return text.slice(0, 120);
92
+ }
93
+ return "";
94
+ }
95
+
96
+ export function countMessages(entries: RawEntry[]): number {
97
+ return entries.filter((e) => e.type === "message").length;
98
+ }
99
+
100
+ export function loadSessionsIndex(): Record<string, Record<string, unknown>> {
101
+ try {
102
+ if (fs.existsSync(SESSIONS_INDEX)) {
103
+ return JSON.parse(fs.readFileSync(SESSIONS_INDEX, "utf-8"));
104
+ }
105
+ } catch {
106
+ // ignore
107
+ }
108
+ return {};
109
+ }
110
+
111
+ export function listKovaSessions(): SessionInfo[] {
112
+ const index = loadSessionsIndex();
113
+ const idToKey: Record<string, string> = {};
114
+ const idToMeta: Record<string, Record<string, unknown>> = {};
115
+ for (const [key, meta] of Object.entries(index)) {
116
+ const sid = (meta.sessionId as string) || "";
117
+ if (sid) {
118
+ idToKey[sid] = key;
119
+ idToMeta[sid] = meta;
120
+ }
121
+ }
122
+
123
+ const files = findSessionFiles();
124
+ const sessions: SessionInfo[] = [];
125
+
126
+ for (const [sessionId, info] of Object.entries(files)) {
127
+ const entries = parseJsonl(info.path);
128
+ const key = idToKey[sessionId] || "";
129
+ const meta = idToMeta[sessionId] || {};
130
+
131
+ let updatedAt = (meta.updatedAt as number) || 0;
132
+ if (!updatedAt) {
133
+ try {
134
+ updatedAt = Math.floor(fs.statSync(info.path).mtimeMs);
135
+ } catch {
136
+ updatedAt = 0;
137
+ }
138
+ }
139
+
140
+ const isSubagent = key.includes("subagent") || key.includes(":sub:");
141
+
142
+ sessions.push({
143
+ sessionId,
144
+ key,
145
+ title: (meta.title as string) || undefined,
146
+ lastUpdated: updatedAt,
147
+ channel: (meta.lastChannel as string) || "",
148
+ chatType: (meta.chatType as string) || "",
149
+ messageCount: countMessages(entries),
150
+ preview: getMessagePreview(entries),
151
+ isActive: info.isActive,
152
+ isDeleted: info.isDeleted,
153
+ isSubagent,
154
+ compactionCount: (meta.compactionCount as number) || 0,
155
+ source: "kova",
156
+ filePath: info.path,
157
+ });
158
+ }
159
+
160
+ sessions.sort((a, b) => b.lastUpdated - a.lastUpdated);
161
+ return sessions;
162
+ }
163
+
164
+ export function listClaudeSessions(): SessionInfo[] {
165
+ const projectsDir = path.join(HOME, ".claude", "projects");
166
+ const sessions: SessionInfo[] = [];
167
+ if (!fs.existsSync(projectsDir)) return sessions;
168
+
169
+ for (const dirName of fs.readdirSync(projectsDir)) {
170
+ const projectDir = path.join(projectsDir, dirName);
171
+ if (!fs.statSync(projectDir).isDirectory()) continue;
172
+
173
+ let projectLabel = dirName.replace(/^-/, "").replace(/-/g, "/");
174
+ if (projectLabel.startsWith("home/")) {
175
+ const idx = projectLabel.indexOf("/", 5);
176
+ projectLabel = "~/" + (idx >= 0 ? projectLabel.slice(idx + 1) : projectLabel);
177
+ }
178
+
179
+ const parentSessionIds = new Set<string>();
180
+ const sessionFileMeta: Array<{ filePath: string; isSubagent: boolean; parentSessionId?: string }> = [];
181
+
182
+ for (const f of fs.readdirSync(projectDir)) {
183
+ const fullPath = path.join(projectDir, f);
184
+ const stat = fs.statSync(fullPath);
185
+ if (stat.isFile() && f.endsWith(".jsonl") && !f.endsWith(".lock") && !f.endsWith(".bak")) {
186
+ const uuid = f.replace(/\.jsonl$/, "");
187
+ parentSessionIds.add(uuid);
188
+ sessionFileMeta.push({ filePath: fullPath, isSubagent: false });
189
+ }
190
+ }
191
+
192
+ for (const f of fs.readdirSync(projectDir)) {
193
+ const fullPath = path.join(projectDir, f);
194
+ const stat = fs.statSync(fullPath);
195
+ if (!stat.isDirectory()) continue;
196
+ const subagentsDir = path.join(fullPath, "subagents");
197
+ if (!fs.existsSync(subagentsDir)) continue;
198
+ const parentId = f;
199
+ for (const agentFile of fs.readdirSync(subagentsDir)) {
200
+ if (!agentFile.endsWith(".jsonl") || agentFile.endsWith(".lock") || agentFile.endsWith(".bak")) continue;
201
+ sessionFileMeta.push({
202
+ filePath: path.join(subagentsDir, agentFile),
203
+ isSubagent: true,
204
+ parentSessionId: parentId,
205
+ });
206
+ }
207
+ }
208
+
209
+ const uuidsWithSubagents = new Set(
210
+ sessionFileMeta.filter(m => m.isSubagent).map(m => m.parentSessionId).filter(Boolean)
211
+ );
212
+
213
+ for (const meta of sessionFileMeta) {
214
+ const { filePath, isSubagent, parentSessionId: parentId } = meta;
215
+ const entries = parseJsonl(filePath);
216
+ let updatedAt = 0;
217
+ try {
218
+ updatedAt = Math.floor(fs.statSync(filePath).mtimeMs);
219
+ } catch {
220
+ updatedAt = 0;
221
+ }
222
+
223
+ let preview = "";
224
+ let msgCount = 0;
225
+ for (let i = entries.length - 1; i >= 0; i--) {
226
+ const e = entries[i];
227
+ if (e.type === "user" && !preview) {
228
+ const msg = (e.message || {}) as Record<string, unknown>;
229
+ const content = msg.content;
230
+ if (typeof content === "string") {
231
+ preview = content.slice(0, 120);
232
+ } else if (Array.isArray(content)) {
233
+ for (const block of content) {
234
+ if (
235
+ typeof block === "object" &&
236
+ block !== null &&
237
+ ((block as Record<string, unknown>).type === "input_text" ||
238
+ (block as Record<string, unknown>).type === "text")
239
+ ) {
240
+ preview = (
241
+ ((block as Record<string, unknown>).text as string) || ""
242
+ ).slice(0, 120);
243
+ break;
244
+ }
245
+ }
246
+ }
247
+ }
248
+ if (e.type === "user" || e.type === "assistant") msgCount++;
249
+ }
250
+
251
+ const sessionId = path.basename(filePath, ".jsonl");
252
+ const sessionKey = isSubagent
253
+ ? sessionId
254
+ : projectLabel;
255
+ const hasSubagents = !isSubagent && uuidsWithSubagents.has(sessionId);
256
+
257
+ sessions.push({
258
+ sessionId,
259
+ key: sessionKey,
260
+ label: isSubagent
261
+ ? "\u21b3 " + sessionId
262
+ : projectLabel,
263
+ lastUpdated: updatedAt,
264
+ channel: "claude-code",
265
+ chatType: "direct",
266
+ messageCount: msgCount,
267
+ preview,
268
+ isActive: true,
269
+ isDeleted: false,
270
+ isSubagent,
271
+ parentSessionId: parentId,
272
+ hasSubagents,
273
+ compactionCount: 0,
274
+ source: "claude",
275
+ filePath,
276
+ });
277
+ }
278
+ }
279
+
280
+ sessions.sort((a, b) => b.lastUpdated - a.lastUpdated);
281
+ return sessions;
282
+ }
283
+
284
+ export function listCodexSessions(): SessionInfo[] {
285
+ const codexDir = path.join(HOME, ".codex", "sessions");
286
+ const sessions: SessionInfo[] = [];
287
+ if (!fs.existsSync(codexDir)) return sessions;
288
+
289
+ function findRolloutFiles(dir: string): string[] {
290
+ const results: string[] = [];
291
+ try {
292
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
293
+ const fullPath = path.join(dir, entry.name);
294
+ if (entry.isDirectory()) {
295
+ results.push(...findRolloutFiles(fullPath));
296
+ } else if (entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
297
+ results.push(fullPath);
298
+ }
299
+ }
300
+ } catch {
301
+ // ignore
302
+ }
303
+ return results;
304
+ }
305
+
306
+ for (const filePath of findRolloutFiles(codexDir)) {
307
+ const entries = parseJsonl(filePath);
308
+ let meta: Record<string, unknown> = {};
309
+ let msgCount = 0;
310
+ let preview = "";
311
+
312
+ for (const e of entries) {
313
+ if (e.type === "session_meta" && !Object.keys(meta).length) {
314
+ meta = (e.payload as Record<string, unknown>) || {};
315
+ }
316
+ if (e.type === "response_item") {
317
+ const payload = (e.payload as Record<string, unknown>) || {};
318
+ const role = payload.role as string;
319
+ if (role === "user" || role === "assistant") msgCount++;
320
+ if (role === "user" && !preview) {
321
+ const content = payload.content;
322
+ if (Array.isArray(content)) {
323
+ for (const block of content) {
324
+ if (
325
+ typeof block === "object" &&
326
+ block !== null &&
327
+ (block as Record<string, unknown>).type === "input_text"
328
+ ) {
329
+ preview = (
330
+ ((block as Record<string, unknown>).text as string) || ""
331
+ ).slice(0, 120);
332
+ break;
333
+ }
334
+ }
335
+ }
336
+ }
337
+ }
338
+ }
339
+
340
+ let updatedAt = 0;
341
+ try {
342
+ updatedAt = Math.floor(fs.statSync(filePath).mtimeMs);
343
+ } catch {
344
+ updatedAt = 0;
345
+ }
346
+
347
+ const cwd = (meta.cwd as string) || "";
348
+ const label = cwd ? path.basename(cwd) : path.basename(filePath, ".jsonl").slice(0, 16);
349
+ const model = (meta.model_provider as string) || "openai";
350
+ const sessionId = (meta.id as string) || path.basename(filePath, ".jsonl");
351
+
352
+ sessions.push({
353
+ sessionId,
354
+ key: label,
355
+ label,
356
+ lastUpdated: updatedAt,
357
+ channel: `codex/${model}`,
358
+ chatType: "direct",
359
+ messageCount: msgCount,
360
+ preview,
361
+ isActive: true,
362
+ isDeleted: false,
363
+ isSubagent: false,
364
+ compactionCount: 0,
365
+ source: "codex",
366
+ model,
367
+ cwd,
368
+ filePath,
369
+ });
370
+ }
371
+
372
+ sessions.sort((a, b) => b.lastUpdated - a.lastUpdated);
373
+ return sessions;
374
+ }
375
+
376
+ // ── Kimi ──
377
+
378
+ export function listKimiSessions(): SessionInfo[] {
379
+ // Kimi structure: ~/.kimi/sessions/<project-hash>/<session-uuid>/context.jsonl
380
+ const kimiDir = path.join(HOME, ".kimi", "sessions");
381
+ const sessions: SessionInfo[] = [];
382
+ if (!fs.existsSync(kimiDir)) return sessions;
383
+
384
+ try {
385
+ for (const projectHash of fs.readdirSync(kimiDir)) {
386
+ const projectDir = path.join(kimiDir, projectHash);
387
+ try {
388
+ if (!fs.statSync(projectDir).isDirectory()) continue;
389
+ } catch { continue; }
390
+
391
+ try {
392
+ for (const sessionUuid of fs.readdirSync(projectDir)) {
393
+ const sessionDir = path.join(projectDir, sessionUuid);
394
+ try {
395
+ if (!fs.statSync(sessionDir).isDirectory()) continue;
396
+ } catch { continue; }
397
+
398
+ const contextFile = path.join(sessionDir, "context.jsonl");
399
+ if (!fs.existsSync(contextFile)) continue;
400
+
401
+ try {
402
+ const entries = parseJsonl(contextFile);
403
+ let updatedAt = 0;
404
+ try { updatedAt = Math.floor(fs.statSync(contextFile).mtimeMs); } catch { /* */ }
405
+
406
+ let preview = "";
407
+ let msgCount = 0;
408
+ for (const e of entries) {
409
+ const role = (e as Record<string, unknown>).role as string;
410
+ if (!role || role.startsWith("_")) continue;
411
+ msgCount++;
412
+ if (role === "user" && !preview) {
413
+ const content = (e as Record<string, unknown>).content;
414
+ if (typeof content === "string" && content.trim()) {
415
+ preview = content.slice(0, 120);
416
+ } else if (Array.isArray(content)) {
417
+ for (const block of content as Record<string, unknown>[]) {
418
+ if (block.type === "text" && block.text) {
419
+ preview = (block.text as string).slice(0, 120);
420
+ break;
421
+ }
422
+ }
423
+ }
424
+ }
425
+ }
426
+
427
+ sessions.push({
428
+ sessionId: sessionUuid,
429
+ key: sessionUuid,
430
+ label: preview ? preview.slice(0, 60) : sessionUuid.slice(0, 14),
431
+ lastUpdated: updatedAt,
432
+ channel: "kimi",
433
+ chatType: "direct",
434
+ messageCount: msgCount,
435
+ preview,
436
+ isActive: true,
437
+ isDeleted: false,
438
+ isSubagent: false,
439
+ compactionCount: 0,
440
+ source: "kimi",
441
+ filePath: contextFile,
442
+ });
443
+ } catch { /* skip bad session */ }
444
+ }
445
+ } catch { /* skip unreadable project dir */ }
446
+ }
447
+ } catch { /* dir not readable */ }
448
+
449
+ sessions.sort((a, b) => b.lastUpdated - a.lastUpdated);
450
+ return sessions;
451
+ }
452
+
453
+ // ── Gemini CLI ──
454
+
455
+ export function listGeminiSessions(): SessionInfo[] {
456
+ const geminiDir = path.join(HOME, ".gemini", "tmp");
457
+ const sessions: SessionInfo[] = [];
458
+ if (!fs.existsSync(geminiDir)) return sessions;
459
+
460
+ function scanDir(dir: string) {
461
+ try {
462
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
463
+ const fullPath = path.join(dir, entry.name);
464
+ if (entry.isDirectory()) {
465
+ scanDir(fullPath);
466
+ } else if (entry.name.endsWith(".json")) {
467
+ try {
468
+ const raw = JSON.parse(fs.readFileSync(fullPath, "utf-8"));
469
+ const messages = Array.isArray(raw) ? raw : (raw.messages || raw.history || []);
470
+ if (!Array.isArray(messages) || messages.length === 0) return;
471
+
472
+ let updatedAt = 0;
473
+ try { updatedAt = Math.floor(fs.statSync(fullPath).mtimeMs); } catch { /* */ }
474
+
475
+ let preview = "";
476
+ let msgCount = 0;
477
+ for (const m of messages as Record<string, unknown>[]) {
478
+ const role = (m.role as string) || "";
479
+ if (role === "user" || role === "model") msgCount++;
480
+ if (role === "user" && !preview) {
481
+ if (typeof m.content === "string") {
482
+ preview = (m.content as string).slice(0, 120);
483
+ } else if (m.parts && Array.isArray(m.parts)) {
484
+ for (const p of m.parts as Record<string, unknown>[]) {
485
+ if (typeof p === "string") { preview = (p as unknown as string).slice(0, 120); break; }
486
+ if (p.text) { preview = (p.text as string).slice(0, 120); break; }
487
+ }
488
+ }
489
+ }
490
+ }
491
+
492
+ const sessionId = path.basename(entry.name, ".json");
493
+ sessions.push({
494
+ sessionId,
495
+ key: sessionId,
496
+ label: preview ? preview.slice(0, 60) : sessionId.slice(0, 14),
497
+ lastUpdated: updatedAt,
498
+ channel: "gemini",
499
+ chatType: "direct",
500
+ messageCount: msgCount,
501
+ preview,
502
+ isActive: true,
503
+ isDeleted: false,
504
+ isSubagent: false,
505
+ compactionCount: 0,
506
+ source: "gemini",
507
+ filePath: fullPath,
508
+ });
509
+ } catch { /* skip bad files */ }
510
+ }
511
+ }
512
+ } catch { /* ignore */ }
513
+ }
514
+
515
+ scanDir(geminiDir);
516
+ sessions.sort((a, b) => b.lastUpdated - a.lastUpdated);
517
+ return sessions;
518
+ }
519
+
520
+ // ── GitHub Copilot CLI ──
521
+
522
+ export function listCopilotSessions(): SessionInfo[] {
523
+ const copilotDir = path.join(HOME, ".copilot", "session-state");
524
+ const sessions: SessionInfo[] = [];
525
+ if (!fs.existsSync(copilotDir)) return sessions;
526
+
527
+ function scanDir(dir: string) {
528
+ try {
529
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
530
+ const fullPath = path.join(dir, entry.name);
531
+ if (entry.isDirectory()) {
532
+ scanDir(fullPath);
533
+ } else if (entry.name.endsWith(".json") || entry.name.endsWith(".jsonl")) {
534
+ try {
535
+ let messages: Record<string, unknown>[] = [];
536
+ if (entry.name.endsWith(".jsonl")) {
537
+ messages = parseJsonl(fullPath) as unknown as Record<string, unknown>[];
538
+ } else {
539
+ const raw = JSON.parse(fs.readFileSync(fullPath, "utf-8"));
540
+ messages = Array.isArray(raw) ? raw : (raw.messages || []);
541
+ }
542
+ if (messages.length === 0) return;
543
+
544
+ let updatedAt = 0;
545
+ try { updatedAt = Math.floor(fs.statSync(fullPath).mtimeMs); } catch { /* */ }
546
+
547
+ let preview = "";
548
+ let msgCount = 0;
549
+ for (const m of messages) {
550
+ const role = (m.role as string) || "";
551
+ if (role === "user" || role === "assistant") msgCount++;
552
+ if (role === "user" && !preview) {
553
+ if (typeof m.content === "string") preview = (m.content as string).slice(0, 120);
554
+ }
555
+ }
556
+
557
+ const sessionId = path.basename(entry.name).replace(/\.(json|jsonl)$/, "");
558
+ sessions.push({
559
+ sessionId,
560
+ key: sessionId,
561
+ label: preview ? preview.slice(0, 60) : sessionId.slice(0, 14),
562
+ lastUpdated: updatedAt,
563
+ channel: "copilot",
564
+ chatType: "direct",
565
+ messageCount: msgCount,
566
+ preview,
567
+ isActive: true,
568
+ isDeleted: false,
569
+ isSubagent: false,
570
+ compactionCount: 0,
571
+ source: "copilot",
572
+ filePath: fullPath,
573
+ });
574
+ } catch { /* skip bad files */ }
575
+ }
576
+ }
577
+ } catch { /* ignore */ }
578
+ }
579
+
580
+ scanDir(copilotDir);
581
+ sessions.sort((a, b) => b.lastUpdated - a.lastUpdated);
582
+ return sessions;
583
+ }
584
+
585
+ // ── Factory Droid ──
586
+
587
+ export function listFactorySessions(): SessionInfo[] {
588
+ const dirs = [
589
+ path.join(HOME, ".factory", "sessions"),
590
+ path.join(HOME, ".factory", "projects"),
591
+ ];
592
+ const sessions: SessionInfo[] = [];
593
+
594
+ function scanDir(dir: string) {
595
+ if (!fs.existsSync(dir)) return;
596
+ try {
597
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
598
+ const fullPath = path.join(dir, entry.name);
599
+ if (entry.isDirectory()) {
600
+ scanDir(fullPath);
601
+ } else if (entry.name.endsWith(".jsonl") && !entry.name.endsWith(".lock") && !entry.name.endsWith(".bak")) {
602
+ try {
603
+ const entries = parseJsonl(fullPath);
604
+ let updatedAt = 0;
605
+ try { updatedAt = Math.floor(fs.statSync(fullPath).mtimeMs); } catch { /* */ }
606
+
607
+ let preview = "";
608
+ let msgCount = 0;
609
+ for (const e of entries) {
610
+ if (e.type === "user" || e.type === "assistant" || e.type === "message") {
611
+ const msg = (e.message || e) as Record<string, unknown>;
612
+ const role = (msg.role as string) || e.type;
613
+ if (role === "user" || role === "assistant") msgCount++;
614
+ if (role === "user" && !preview) {
615
+ const content = msg.content;
616
+ if (typeof content === "string") preview = content.slice(0, 120);
617
+ else if (Array.isArray(content)) {
618
+ for (const b of content as Record<string, unknown>[]) {
619
+ if ((b.type === "text" || b.type === "input_text") && b.text) {
620
+ preview = (b.text as string).slice(0, 120);
621
+ break;
622
+ }
623
+ }
624
+ }
625
+ }
626
+ }
627
+ }
628
+
629
+ const sessionId = path.basename(entry.name, ".jsonl");
630
+ sessions.push({
631
+ sessionId,
632
+ key: sessionId,
633
+ label: preview ? preview.slice(0, 60) : sessionId.slice(0, 14),
634
+ lastUpdated: updatedAt,
635
+ channel: "factory",
636
+ chatType: "direct",
637
+ messageCount: msgCount,
638
+ preview,
639
+ isActive: true,
640
+ isDeleted: false,
641
+ isSubagent: false,
642
+ compactionCount: 0,
643
+ source: "factory",
644
+ filePath: fullPath,
645
+ });
646
+ } catch { /* skip bad files */ }
647
+ }
648
+ }
649
+ } catch { /* ignore */ }
650
+ }
651
+
652
+ for (const d of dirs) scanDir(d);
653
+ sessions.sort((a, b) => b.lastUpdated - a.lastUpdated);
654
+ return sessions;
655
+ }
656
+
657
+ // ── OpenCode ──
658
+
659
+ export function listOpenCodeSessions(): SessionInfo[] {
660
+ const ocDir = path.join(HOME, ".local", "share", "opencode", "storage", "session");
661
+ const sessions: SessionInfo[] = [];
662
+ if (!fs.existsSync(ocDir)) return sessions;
663
+
664
+ function scanDir(dir: string) {
665
+ try {
666
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
667
+ const fullPath = path.join(dir, entry.name);
668
+ if (entry.isDirectory()) {
669
+ scanDir(fullPath);
670
+ } else if (entry.name.endsWith(".json")) {
671
+ try {
672
+ const raw = JSON.parse(fs.readFileSync(fullPath, "utf-8"));
673
+ const messages = raw.messages || [];
674
+ if (!Array.isArray(messages) || messages.length === 0) return;
675
+
676
+ let updatedAt = 0;
677
+ try { updatedAt = Math.floor(fs.statSync(fullPath).mtimeMs); } catch { /* */ }
678
+
679
+ const title = (raw.title as string) || "";
680
+ let preview = "";
681
+ let msgCount = 0;
682
+ for (const m of messages as Record<string, unknown>[]) {
683
+ const role = (m.role as string) || "";
684
+ if (role === "user" || role === "assistant") msgCount++;
685
+ if (role === "user" && !preview) {
686
+ if (typeof m.content === "string") preview = (m.content as string).slice(0, 120);
687
+ }
688
+ }
689
+
690
+ const sessionId = (raw.id as string) || path.basename(entry.name, ".json");
691
+ sessions.push({
692
+ sessionId,
693
+ key: sessionId,
694
+ title: title || undefined,
695
+ label: title || (preview ? preview.slice(0, 60) : sessionId.slice(0, 14)),
696
+ lastUpdated: updatedAt,
697
+ channel: "opencode",
698
+ chatType: "direct",
699
+ messageCount: msgCount,
700
+ preview,
701
+ isActive: true,
702
+ isDeleted: false,
703
+ isSubagent: false,
704
+ compactionCount: 0,
705
+ source: "opencode",
706
+ filePath: fullPath,
707
+ });
708
+ } catch { /* skip bad files */ }
709
+ }
710
+ }
711
+ } catch { /* ignore */ }
712
+ }
713
+
714
+ scanDir(ocDir);
715
+ sessions.sort((a, b) => b.lastUpdated - a.lastUpdated);
716
+ return sessions;
717
+ }
718
+
719
+ // ── Aider ──
720
+
721
+ export function listAiderSessions(): SessionInfo[] {
722
+ const historyPath = path.join(HOME, ".aider.chat.history.md");
723
+ const sessions: SessionInfo[] = [];
724
+ if (!fs.existsSync(historyPath)) {
725
+ console.warn("Aider: 0 sessions found (no history file)");
726
+ return sessions;
727
+ }
728
+
729
+ try {
730
+ const data = fs.readFileSync(historyPath, "utf-8");
731
+ let updatedAt = 0;
732
+ try { updatedAt = Math.floor(fs.statSync(historyPath).mtimeMs); } catch { /* */ }
733
+
734
+ // Split by "#### " lines which delimit user messages in aider history
735
+ const userMsgs = data.split(/^#### /m).filter(Boolean);
736
+ const msgCount = userMsgs.length;
737
+ const preview = userMsgs.length > 0 ? userMsgs[userMsgs.length - 1].split("\n")[0].slice(0, 120) : "";
738
+
739
+ sessions.push({
740
+ sessionId: "aider-history",
741
+ key: "aider-history",
742
+ label: "aider chat history",
743
+ lastUpdated: updatedAt,
744
+ channel: "aider",
745
+ chatType: "direct",
746
+ messageCount: msgCount,
747
+ preview,
748
+ isActive: true,
749
+ isDeleted: false,
750
+ isSubagent: false,
751
+ compactionCount: 0,
752
+ source: "aider",
753
+ filePath: historyPath,
754
+ });
755
+ } catch { /* skip */ }
756
+
757
+ if (sessions.length === 0) console.warn("Aider: 0 sessions found");
758
+ return sessions;
759
+ }
760
+
761
+ // ── Continue.dev ──
762
+
763
+ export function listContinueSessions(): SessionInfo[] {
764
+ const continueDir = path.join(HOME, ".continue", "sessions");
765
+ const sessions: SessionInfo[] = [];
766
+ if (!fs.existsSync(continueDir)) {
767
+ console.warn("Continue.dev: 0 sessions found (directory missing)");
768
+ return sessions;
769
+ }
770
+
771
+ try {
772
+ for (const f of fs.readdirSync(continueDir)) {
773
+ if (!f.endsWith(".json")) continue;
774
+ const fullPath = path.join(continueDir, f);
775
+ try {
776
+ const raw = JSON.parse(fs.readFileSync(fullPath, "utf-8"));
777
+ const history = raw.history || raw.messages || [];
778
+ let updatedAt = 0;
779
+ try { updatedAt = Math.floor(fs.statSync(fullPath).mtimeMs); } catch { /* */ }
780
+
781
+ let preview = "";
782
+ let msgCount = 0;
783
+ for (const m of history as Record<string, unknown>[]) {
784
+ const role = (m.role as string) || "";
785
+ if (role === "user" || role === "assistant") msgCount++;
786
+ if (role === "user" && !preview) {
787
+ if (typeof m.content === "string") preview = (m.content as string).slice(0, 120);
788
+ }
789
+ }
790
+
791
+ const sessionId = path.basename(f, ".json");
792
+ const title = (raw.title as string) || "";
793
+ sessions.push({
794
+ sessionId,
795
+ key: sessionId,
796
+ title: title || undefined,
797
+ label: title || (preview ? preview.slice(0, 60) : sessionId.slice(0, 14)),
798
+ lastUpdated: updatedAt,
799
+ channel: "continue",
800
+ chatType: "direct",
801
+ messageCount: msgCount,
802
+ preview,
803
+ isActive: true,
804
+ isDeleted: false,
805
+ isSubagent: false,
806
+ compactionCount: 0,
807
+ source: "continue",
808
+ filePath: fullPath,
809
+ });
810
+ } catch { /* skip bad files */ }
811
+ }
812
+ } catch { /* dir not readable */ }
813
+
814
+ if (sessions.length === 0) console.warn("Continue.dev: 0 sessions found");
815
+ sessions.sort((a, b) => b.lastUpdated - a.lastUpdated);
816
+ return sessions;
817
+ }
818
+
819
+ // ── Cursor ──
820
+
821
+ export function listCursorSessions(): SessionInfo[] {
822
+ const cursorDir = path.join(HOME, ".cursor-server");
823
+ const sessions: SessionInfo[] = [];
824
+ if (!fs.existsSync(cursorDir)) {
825
+ console.warn("Cursor: 0 sessions found (directory missing)");
826
+ return sessions;
827
+ }
828
+
829
+ function scanDir(dir: string) {
830
+ try {
831
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
832
+ const fullPath = path.join(dir, entry.name);
833
+ if (entry.isDirectory()) {
834
+ scanDir(fullPath);
835
+ } else if (entry.name.endsWith(".json") || entry.name.endsWith(".jsonl")) {
836
+ try {
837
+ let messages: Record<string, unknown>[] = [];
838
+ if (entry.name.endsWith(".jsonl")) {
839
+ messages = parseJsonl(fullPath) as unknown as Record<string, unknown>[];
840
+ } else {
841
+ const raw = JSON.parse(fs.readFileSync(fullPath, "utf-8"));
842
+ messages = Array.isArray(raw) ? raw : (raw.messages || raw.history || []);
843
+ }
844
+ if (!Array.isArray(messages) || messages.length === 0) return;
845
+
846
+ let updatedAt = 0;
847
+ try { updatedAt = Math.floor(fs.statSync(fullPath).mtimeMs); } catch { /* */ }
848
+
849
+ let preview = "";
850
+ let msgCount = 0;
851
+ for (const m of messages) {
852
+ const role = (m.role as string) || "";
853
+ if (role === "user" || role === "assistant") msgCount++;
854
+ if (role === "user" && !preview) {
855
+ if (typeof m.content === "string") preview = (m.content as string).slice(0, 120);
856
+ }
857
+ }
858
+
859
+ const sessionId = path.basename(entry.name).replace(/\.(json|jsonl)$/, "");
860
+ sessions.push({
861
+ sessionId,
862
+ key: sessionId,
863
+ label: preview ? preview.slice(0, 60) : sessionId.slice(0, 14),
864
+ lastUpdated: updatedAt,
865
+ channel: "cursor",
866
+ chatType: "direct",
867
+ messageCount: msgCount,
868
+ preview,
869
+ isActive: true,
870
+ isDeleted: false,
871
+ isSubagent: false,
872
+ compactionCount: 0,
873
+ source: "cursor",
874
+ filePath: fullPath,
875
+ });
876
+ } catch { /* skip bad files */ }
877
+ }
878
+ }
879
+ } catch { /* ignore */ }
880
+ }
881
+
882
+ scanDir(cursorDir);
883
+ if (sessions.length === 0) console.warn("Cursor: 0 sessions found");
884
+ sessions.sort((a, b) => b.lastUpdated - a.lastUpdated);
885
+ return sessions;
886
+ }
887
+
888
+ // ── Aggregate all providers ──
889
+
890
+ export function getAllSessions(): SessionInfo[] {
891
+ const all = [
892
+ ...listKovaSessions(),
893
+ ...listClaudeSessions(),
894
+ ...listCodexSessions(),
895
+ ...listKimiSessions(),
896
+ ...listGeminiSessions(),
897
+ ...listCopilotSessions(),
898
+ ...listFactorySessions(),
899
+ ...listOpenCodeSessions(),
900
+ ...listAiderSessions(),
901
+ ...listContinueSessions(),
902
+ ...listCursorSessions(),
903
+ ];
904
+ all.sort((a, b) => b.lastUpdated - a.lastUpdated);
905
+ return all;
906
+ }
907
+
908
+ // ── File path resolver ──
909
+
910
+ export function getSessionFilePath(
911
+ sessionId: string,
912
+ source: string
913
+ ): string | null {
914
+ if (source === "claude") {
915
+ const projectsDir = path.join(HOME, ".claude", "projects");
916
+ if (!fs.existsSync(projectsDir)) return null;
917
+ function findJsonl(dir: string): string | null {
918
+ try {
919
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
920
+ const fullPath = path.join(dir, entry.name);
921
+ if (entry.isDirectory()) {
922
+ const found = findJsonl(fullPath);
923
+ if (found) return found;
924
+ } else if (entry.name === sessionId + ".jsonl") {
925
+ return fullPath;
926
+ }
927
+ }
928
+ } catch { /* ignore */ }
929
+ return null;
930
+ }
931
+ return findJsonl(projectsDir);
932
+ }
933
+ if (source === "codex") {
934
+ const codexDir = path.join(HOME, ".codex", "sessions");
935
+ if (!fs.existsSync(codexDir)) return null;
936
+ function findRollout(dir: string): string | null {
937
+ try {
938
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
939
+ const fullPath = path.join(dir, entry.name);
940
+ if (entry.isDirectory()) {
941
+ const found = findRollout(fullPath);
942
+ if (found) return found;
943
+ } else if (entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
944
+ const entries = parseJsonl(fullPath);
945
+ const meta = entries.find((e) => e.type === "session_meta");
946
+ const payload = (meta?.payload as Record<string, unknown>) || {};
947
+ if (payload.id === sessionId || path.basename(entry.name, ".jsonl") === sessionId) {
948
+ return fullPath;
949
+ }
950
+ }
951
+ }
952
+ } catch { /* ignore */ }
953
+ return null;
954
+ }
955
+ return findRollout(codexDir);
956
+ }
957
+
958
+ // For new providers, search all sessions by filePath
959
+ if (["kimi", "gemini", "copilot", "factory", "opencode", "aider", "continue", "cursor"].includes(source)) {
960
+ const all = getAllSessions();
961
+ const match = all.find(s => s.sessionId === sessionId && s.source === source);
962
+ return match?.filePath || null;
963
+ }
964
+
965
+ // kova
966
+ const files = findSessionFiles();
967
+ const info = files[sessionId];
968
+ return info ? info.path : null;
969
+ }
970
+
971
+ // ── Search ──
972
+
973
+ export function searchSessions(
974
+ query: string,
975
+ limit: number = 50
976
+ ): { session: SessionInfo; snippet: string }[] {
977
+ const q = query.toLowerCase();
978
+ const allSessions = getAllSessions();
979
+ const results: { session: SessionInfo; snippet: string }[] = [];
980
+
981
+ for (const session of allSessions) {
982
+ if (results.length >= limit) break;
983
+
984
+ const titleMatch =
985
+ (session.title || "").toLowerCase().includes(q) ||
986
+ (session.label || "").toLowerCase().includes(q) ||
987
+ (session.preview || "").toLowerCase().includes(q);
988
+
989
+ if (titleMatch) {
990
+ const matchField = (session.title || session.label || session.preview || "");
991
+ const idx = matchField.toLowerCase().indexOf(q);
992
+ const start = Math.max(0, idx - 30);
993
+ const end = Math.min(matchField.length, idx + q.length + 50);
994
+ const snippet = (start > 0 ? "\u2026" : "") + matchField.slice(start, end) + (end < matchField.length ? "\u2026" : "");
995
+ results.push({ session, snippet });
996
+ continue;
997
+ }
998
+
999
+ if (!session.filePath) continue;
1000
+ try {
1001
+ const data = fs.readFileSync(session.filePath, "utf-8");
1002
+ const lowerData = data.toLowerCase();
1003
+ const idx = lowerData.indexOf(q);
1004
+ if (idx === -1) continue;
1005
+
1006
+ const start = Math.max(0, idx - 40);
1007
+ const end = Math.min(data.length, idx + q.length + 60);
1008
+ let snippet = data.slice(start, end).replace(/\n/g, " ").replace(/[{}"\\]/g, " ").replace(/\s+/g, " ").trim();
1009
+ if (start > 0) snippet = "\u2026" + snippet;
1010
+ if (end < data.length) snippet = snippet + "\u2026";
1011
+ results.push({ session, snippet: snippet.slice(0, 120) });
1012
+ } catch { /* ignore */ }
1013
+ }
1014
+
1015
+ return results;
1016
+ }
1017
+
1018
+ // ── Messages loader ──
1019
+
1020
+ export function getSessionMessages(
1021
+ sessionId: string,
1022
+ source: string
1023
+ ): RawEntry[] | null {
1024
+ // For JSON-based providers (gemini, opencode), convert to RawEntry format
1025
+ if (source === "gemini") {
1026
+ const fp = getSessionFilePath(sessionId, source);
1027
+ if (!fp) return null;
1028
+ try {
1029
+ const raw = JSON.parse(fs.readFileSync(fp, "utf-8"));
1030
+ const messages = Array.isArray(raw) ? raw : (raw.messages || raw.history || []);
1031
+ return (messages as Record<string, unknown>[]).map((m) => {
1032
+ const role = (m.role as string) || "user";
1033
+ const content = typeof m.content === "string"
1034
+ ? m.content
1035
+ : m.parts
1036
+ ? (m.parts as Record<string, unknown>[]).map(p => typeof p === "string" ? p : (p.text || "")).join("\n")
1037
+ : "";
1038
+ return {
1039
+ type: role === "model" ? "assistant" : "user",
1040
+ timestamp: (m.timestamp as string) || undefined,
1041
+ message: { role: role === "model" ? "assistant" : "user", content },
1042
+ } as RawEntry;
1043
+ });
1044
+ } catch { return null; }
1045
+ }
1046
+
1047
+ if (source === "opencode") {
1048
+ const fp = getSessionFilePath(sessionId, source);
1049
+ if (!fp) return null;
1050
+ try {
1051
+ const raw = JSON.parse(fs.readFileSync(fp, "utf-8"));
1052
+ const messages = raw.messages || [];
1053
+ return (messages as Record<string, unknown>[]).map((m) => {
1054
+ const role = (m.role as string) || "user";
1055
+ const content = (m.content as string) || "";
1056
+ return {
1057
+ type: role,
1058
+ timestamp: (m.time as string) || (m.timestamp as string) || undefined,
1059
+ message: { role, content },
1060
+ } as RawEntry;
1061
+ });
1062
+ } catch { return null; }
1063
+ }
1064
+
1065
+ if (source === "copilot") {
1066
+ const fp = getSessionFilePath(sessionId, source);
1067
+ if (!fp) return null;
1068
+ if (fp.endsWith(".jsonl")) return parseJsonl(fp);
1069
+ try {
1070
+ const raw = JSON.parse(fs.readFileSync(fp, "utf-8"));
1071
+ const messages = Array.isArray(raw) ? raw : (raw.messages || []);
1072
+ return (messages as Record<string, unknown>[]).map((m) => {
1073
+ const role = (m.role as string) || "user";
1074
+ const content = (m.content as string) || "";
1075
+ return {
1076
+ type: role,
1077
+ timestamp: (m.timestamp as string) || undefined,
1078
+ message: { role, content },
1079
+ } as RawEntry;
1080
+ });
1081
+ } catch { return null; }
1082
+ }
1083
+
1084
+ // Aider: markdown history file — convert to simple entries
1085
+ if (source === "aider") {
1086
+ const fp = getSessionFilePath(sessionId, source);
1087
+ if (!fp) return null;
1088
+ try {
1089
+ const data = fs.readFileSync(fp, "utf-8");
1090
+ const blocks = data.split(/^#### /m).filter(Boolean);
1091
+ const entries: RawEntry[] = [];
1092
+ for (const block of blocks) {
1093
+ const lines = block.split("\n");
1094
+ const userMsg = lines[0] || "";
1095
+ entries.push({
1096
+ type: "user",
1097
+ message: { role: "user", content: userMsg },
1098
+ });
1099
+ const rest = lines.slice(1).join("\n").trim();
1100
+ if (rest) {
1101
+ entries.push({
1102
+ type: "assistant",
1103
+ message: { role: "assistant", content: rest },
1104
+ });
1105
+ }
1106
+ }
1107
+ return entries;
1108
+ } catch { return null; }
1109
+ }
1110
+
1111
+ // Continue.dev: JSON with history/messages array
1112
+ if (source === "continue") {
1113
+ const fp = getSessionFilePath(sessionId, source);
1114
+ if (!fp) return null;
1115
+ try {
1116
+ const raw = JSON.parse(fs.readFileSync(fp, "utf-8"));
1117
+ const messages = raw.history || raw.messages || [];
1118
+ return (messages as Record<string, unknown>[]).map((m) => {
1119
+ const role = (m.role as string) || "user";
1120
+ const content = (m.content as string) || "";
1121
+ return {
1122
+ type: role,
1123
+ message: { role, content },
1124
+ } as RawEntry;
1125
+ });
1126
+ } catch { return null; }
1127
+ }
1128
+
1129
+ // Cursor: JSON/JSONL files
1130
+ if (source === "cursor") {
1131
+ const fp = getSessionFilePath(sessionId, source);
1132
+ if (!fp) return null;
1133
+ if (fp.endsWith(".jsonl")) return parseJsonl(fp);
1134
+ try {
1135
+ const raw = JSON.parse(fs.readFileSync(fp, "utf-8"));
1136
+ const messages = Array.isArray(raw) ? raw : (raw.messages || raw.history || []);
1137
+ return (messages as Record<string, unknown>[]).map((m) => {
1138
+ const role = (m.role as string) || "user";
1139
+ const content = (m.content as string) || "";
1140
+ return {
1141
+ type: role,
1142
+ message: { role, content },
1143
+ } as RawEntry;
1144
+ });
1145
+ } catch { return null; }
1146
+ }
1147
+
1148
+ // JSONL-based providers: kimi, factory, claude, codex, kova
1149
+ if (source === "kimi" || source === "factory") {
1150
+ const fp = getSessionFilePath(sessionId, source);
1151
+ if (!fp) return null;
1152
+ return parseJsonl(fp);
1153
+ }
1154
+
1155
+ if (source === "claude") {
1156
+ const projectsDir = path.join(HOME, ".claude", "projects");
1157
+ if (!fs.existsSync(projectsDir)) return null;
1158
+
1159
+ function findJsonl(dir: string): string | null {
1160
+ try {
1161
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1162
+ const fullPath = path.join(dir, entry.name);
1163
+ if (entry.isDirectory()) {
1164
+ const found = findJsonl(fullPath);
1165
+ if (found) return found;
1166
+ } else if (entry.name === sessionId + ".jsonl") {
1167
+ return fullPath;
1168
+ }
1169
+ }
1170
+ } catch {
1171
+ // ignore
1172
+ }
1173
+ return null;
1174
+ }
1175
+
1176
+ const filePath = findJsonl(projectsDir);
1177
+ if (filePath) return parseJsonl(filePath);
1178
+ return null;
1179
+ }
1180
+
1181
+ if (source === "codex") {
1182
+ const codexDir = path.join(HOME, ".codex", "sessions");
1183
+ if (!fs.existsSync(codexDir)) return null;
1184
+
1185
+ function findRollout(dir: string): string | null {
1186
+ try {
1187
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
1188
+ const fullPath = path.join(dir, entry.name);
1189
+ if (entry.isDirectory()) {
1190
+ const found = findRollout(fullPath);
1191
+ if (found) return found;
1192
+ } else if (
1193
+ entry.name.startsWith("rollout-") &&
1194
+ entry.name.endsWith(".jsonl")
1195
+ ) {
1196
+ const entries = parseJsonl(fullPath);
1197
+ const meta = entries.find((e) => e.type === "session_meta");
1198
+ const payload = (meta?.payload as Record<string, unknown>) || {};
1199
+ if (
1200
+ payload.id === sessionId ||
1201
+ path.basename(entry.name, ".jsonl") === sessionId
1202
+ ) {
1203
+ return fullPath;
1204
+ }
1205
+ }
1206
+ }
1207
+ } catch {
1208
+ // ignore
1209
+ }
1210
+ return null;
1211
+ }
1212
+
1213
+ const filePath = findRollout(codexDir);
1214
+ if (filePath) return parseJsonl(filePath);
1215
+ return null;
1216
+ }
1217
+
1218
+ // Default: kova
1219
+ const files = findSessionFiles();
1220
+ const info = files[sessionId];
1221
+ if (!info) return null;
1222
+ return parseJsonl(info.path);
1223
+ }