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.
- package/LICENSE +21 -0
- package/README.md +159 -0
- package/bin/llm-deep-trace.js +24 -0
- package/next.config.ts +8 -0
- package/package.json +56 -0
- package/postcss.config.mjs +5 -0
- package/public/banner-v2.png +0 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/logo.png +0 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/agent-config/route.ts +31 -0
- package/src/app/api/all-sessions/route.ts +9 -0
- package/src/app/api/analytics/route.ts +379 -0
- package/src/app/api/detect-agents/route.ts +170 -0
- package/src/app/api/image/route.ts +73 -0
- package/src/app/api/search/route.ts +28 -0
- package/src/app/api/session-by-key/route.ts +21 -0
- package/src/app/api/sessions/[sessionId]/messages/route.ts +46 -0
- package/src/app/api/sse/route.ts +86 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +3518 -0
- package/src/app/icon.svg +4 -0
- package/src/app/layout.tsx +20 -0
- package/src/app/page.tsx +5 -0
- package/src/components/AnalyticsDashboard.tsx +393 -0
- package/src/components/App.tsx +243 -0
- package/src/components/CopyButton.tsx +42 -0
- package/src/components/Logo.tsx +20 -0
- package/src/components/MainPanel.tsx +1128 -0
- package/src/components/MessageRenderer.tsx +983 -0
- package/src/components/SessionTree.tsx +505 -0
- package/src/components/SettingsPanel.tsx +160 -0
- package/src/components/SetupView.tsx +206 -0
- package/src/components/Sidebar.tsx +714 -0
- package/src/components/ThemeToggle.tsx +54 -0
- package/src/lib/client-utils.ts +360 -0
- package/src/lib/normalizers.ts +371 -0
- package/src/lib/sessions.ts +1223 -0
- package/src/lib/store.ts +518 -0
- package/src/lib/types.ts +112 -0
- package/src/lib/useSSE.ts +81 -0
- 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
|
+
}
|