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,379 @@
1
+ import { NextResponse, NextRequest } from "next/server";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ const HOME = os.homedir();
9
+
10
+ interface AnalyticsData {
11
+ sessionsPerDay: { date: string; count: number; byProvider: Record<string, number> }[];
12
+ messagesPerDay: { date: string; count: number; byProvider: Record<string, number> }[];
13
+ providerBreakdown: { provider: string; count: number; sessions: number; messages: number; pct: number }[];
14
+ topTools: { name: string; count: number }[];
15
+ tokenTotals: { inputTokens: number; outputTokens: number; avgPerSession: number };
16
+ sessionLengthDist: { bucket: string; count: number }[];
17
+ totalSessions: number;
18
+ totalMessages: number;
19
+ avgSessionMessages: number;
20
+ hourOfDay: number[][]; // [weekday 0-6][hour 0-23]
21
+ }
22
+
23
+ function scanJsonlForAnalytics(
24
+ filePath: string,
25
+ source: string,
26
+ acc: {
27
+ dates: Map<string, number>;
28
+ providers: Map<string, number>;
29
+ tools: Map<string, number>;
30
+ inputTokens: number;
31
+ outputTokens: number;
32
+ sessionCount: number;
33
+ msgCounts: number[];
34
+ }
35
+ ) {
36
+ let msgCount = 0;
37
+ try {
38
+ const data = fs.readFileSync(filePath, "utf-8");
39
+ for (const line of data.split("\n")) {
40
+ const trimmed = line.trim();
41
+ if (!trimmed) continue;
42
+ try {
43
+ const entry = JSON.parse(trimmed);
44
+
45
+ // Count messages
46
+ if (
47
+ entry.type === "message" ||
48
+ entry.type === "user" ||
49
+ entry.type === "assistant"
50
+ ) {
51
+ msgCount++;
52
+ }
53
+ if (entry.type === "response_item") {
54
+ msgCount++;
55
+ }
56
+
57
+ // Tool usage (Claude Code JSONL format)
58
+ if (source === "claude") {
59
+ if (
60
+ entry.type === "assistant" &&
61
+ entry.message &&
62
+ Array.isArray(entry.message.content)
63
+ ) {
64
+ for (const block of entry.message.content) {
65
+ if (block && block.type === "tool_use" && block.name) {
66
+ const name = block.name as string;
67
+ acc.tools.set(name, (acc.tools.get(name) || 0) + 1);
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ // Token usage from usage fields
74
+ if (entry.usage) {
75
+ const u = entry.usage;
76
+ if (u.input_tokens) acc.inputTokens += u.input_tokens;
77
+ if (u.output_tokens) acc.outputTokens += u.output_tokens;
78
+ }
79
+ // Claude Code also nests usage in message
80
+ if (entry.message?.usage) {
81
+ const u = entry.message.usage;
82
+ if (u.input_tokens) acc.inputTokens += u.input_tokens;
83
+ if (u.output_tokens) acc.outputTokens += u.output_tokens;
84
+ }
85
+ } catch {
86
+ continue;
87
+ }
88
+ }
89
+ } catch {
90
+ // file not readable
91
+ }
92
+ acc.msgCounts.push(msgCount);
93
+ }
94
+
95
+ function getFileDate(filePath: string): string | null {
96
+ try {
97
+ const stat = fs.statSync(filePath);
98
+ const d = new Date(stat.mtimeMs);
99
+ return d.toISOString().slice(0, 10);
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ export async function GET(req: NextRequest) {
106
+ const { searchParams } = new URL(req.url);
107
+ const period = searchParams.get("period") || "30d";
108
+ const agentFilter = searchParams.get("agent") || "all";
109
+
110
+ const cutoffDate = (() => {
111
+ if (period === "all") return null;
112
+ const d = new Date();
113
+ const days = period === "7d" ? 7 : period === "90d" ? 90 : 30;
114
+ d.setDate(d.getDate() - days);
115
+ return d.toISOString().slice(0, 10);
116
+ })();
117
+
118
+ const acc = {
119
+ dates: new Map<string, number>(),
120
+ datesByProvider: new Map<string, Map<string, number>>(),
121
+ msgsByDate: new Map<string, number>(),
122
+ msgsByDateByProvider: new Map<string, Map<string, number>>(),
123
+ providers: new Map<string, number>(),
124
+ providerMessages: new Map<string, number>(),
125
+ tools: new Map<string, number>(),
126
+ inputTokens: 0,
127
+ outputTokens: 0,
128
+ sessionCount: 0,
129
+ msgCounts: [] as number[],
130
+ // [weekday 0-6 Sun-Sat][hour 0-23]
131
+ hourOfDay: Array.from({ length: 7 }, () => new Array(24).fill(0)) as number[][],
132
+ };
133
+
134
+ function processFile(filePath: string, source: string) {
135
+ if (agentFilter !== "all" && source !== agentFilter) return;
136
+ const date = getFileDate(filePath);
137
+ const inPeriod = !cutoffDate || (date && date >= cutoffDate);
138
+ if (inPeriod && date) {
139
+ acc.dates.set(date, (acc.dates.get(date) || 0) + 1);
140
+ if (!acc.datesByProvider.has(source)) acc.datesByProvider.set(source, new Map());
141
+ acc.datesByProvider.get(source)!.set(date, (acc.datesByProvider.get(source)!.get(date) || 0) + 1);
142
+ }
143
+ // Track hour of day from file mtime
144
+ try {
145
+ const mtime = new Date(fs.statSync(filePath).mtimeMs);
146
+ const weekday = mtime.getDay();
147
+ const hour = mtime.getHours();
148
+ acc.hourOfDay[weekday][hour]++;
149
+ } catch { /* ignore */ }
150
+
151
+ acc.providers.set(source, (acc.providers.get(source) || 0) + 1);
152
+ acc.sessionCount++;
153
+ const prevMsgTotal = acc.msgCounts.reduce((s, v) => s + v, 0);
154
+ scanJsonlForAnalytics(filePath, source, acc);
155
+ const addedMsgs = acc.msgCounts.reduce((s, v) => s + v, 0) - prevMsgTotal;
156
+ if (inPeriod && date && addedMsgs > 0) {
157
+ acc.msgsByDate.set(date, (acc.msgsByDate.get(date) || 0) + addedMsgs);
158
+ if (!acc.msgsByDateByProvider.has(source)) acc.msgsByDateByProvider.set(source, new Map());
159
+ acc.msgsByDateByProvider.get(source)!.set(date, (acc.msgsByDateByProvider.get(source)!.get(date) || 0) + addedMsgs);
160
+ }
161
+ acc.providerMessages.set(source, (acc.providerMessages.get(source) || 0) + addedMsgs);
162
+ }
163
+
164
+ // Scan OpenClaw/Kova sessions
165
+ const kovaDir = path.join(HOME, ".openclaw", "agents", "main", "sessions");
166
+ if (fs.existsSync(kovaDir)) {
167
+ try {
168
+ for (const f of fs.readdirSync(kovaDir)) {
169
+ if (!f.endsWith(".jsonl") || f.endsWith(".lock") || f.endsWith(".bak")) continue;
170
+ processFile(path.join(kovaDir, f), "kova");
171
+ }
172
+ } catch { /* */ }
173
+ }
174
+
175
+ // Scan Claude Code sessions
176
+ const claudeDir = path.join(HOME, ".claude", "projects");
177
+ if (fs.existsSync(claudeDir)) {
178
+ try {
179
+ function scanClaude(dir: string) {
180
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
181
+ const fullPath = path.join(dir, entry.name);
182
+ if (entry.isDirectory()) {
183
+ scanClaude(fullPath);
184
+ } else if (entry.name.endsWith(".jsonl") && !entry.name.endsWith(".lock") && !entry.name.endsWith(".bak")) {
185
+ processFile(fullPath, "claude");
186
+ }
187
+ }
188
+ }
189
+ scanClaude(claudeDir);
190
+ } catch { /* */ }
191
+ }
192
+
193
+ // Scan Codex sessions
194
+ const codexDir = path.join(HOME, ".codex", "sessions");
195
+ if (fs.existsSync(codexDir)) {
196
+ try {
197
+ function scanCodex(dir: string) {
198
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
199
+ const fullPath = path.join(dir, entry.name);
200
+ if (entry.isDirectory()) {
201
+ scanCodex(fullPath);
202
+ } else if (entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) {
203
+ processFile(fullPath, "codex");
204
+ }
205
+ }
206
+ }
207
+ scanCodex(codexDir);
208
+ } catch { /* */ }
209
+ }
210
+
211
+ // Scan Kimi sessions
212
+ const kimiDir = path.join(HOME, ".kimi", "sessions");
213
+ if (fs.existsSync(kimiDir)) {
214
+ try {
215
+ for (const f of fs.readdirSync(kimiDir)) {
216
+ if (!f.endsWith(".jsonl")) continue;
217
+ processFile(path.join(kimiDir, f), "kimi");
218
+ }
219
+ } catch { /* */ }
220
+ }
221
+
222
+ // Scan Gemini sessions
223
+ const geminiDir = path.join(HOME, ".gemini", "tmp");
224
+ if (fs.existsSync(geminiDir)) {
225
+ try {
226
+ function scanGemini(dir: string) {
227
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
228
+ const fullPath = path.join(dir, entry.name);
229
+ if (entry.isDirectory()) scanGemini(fullPath);
230
+ else if (entry.name.endsWith(".json")) {
231
+ const date = getFileDate(fullPath);
232
+ if (date && (!cutoffDate || date >= cutoffDate)) {
233
+ acc.dates.set(date, (acc.dates.get(date) || 0) + 1);
234
+ }
235
+ acc.providers.set("gemini", (acc.providers.get("gemini") || 0) + 1);
236
+ acc.sessionCount++;
237
+ acc.msgCounts.push(0);
238
+ }
239
+ }
240
+ }
241
+ scanGemini(geminiDir);
242
+ } catch { /* */ }
243
+ }
244
+
245
+ // Scan Aider sessions
246
+ const aiderHistory = path.join(HOME, ".aider.chat.history.md");
247
+ if (fs.existsSync(aiderHistory)) {
248
+ const date = getFileDate(aiderHistory);
249
+ if (date && (!cutoffDate || date >= cutoffDate)) {
250
+ acc.dates.set(date, (acc.dates.get(date) || 0) + 1);
251
+ }
252
+ acc.providers.set("aider", (acc.providers.get("aider") || 0) + 1);
253
+ acc.sessionCount++;
254
+ acc.msgCounts.push(0);
255
+ }
256
+
257
+ // Scan Continue.dev sessions
258
+ const continueDir = path.join(HOME, ".continue", "sessions");
259
+ if (fs.existsSync(continueDir)) {
260
+ try {
261
+ for (const f of fs.readdirSync(continueDir)) {
262
+ if (!f.endsWith(".json")) continue;
263
+ const fullPath = path.join(continueDir, f);
264
+ const date = getFileDate(fullPath);
265
+ if (date && (!cutoffDate || date >= cutoffDate)) {
266
+ acc.dates.set(date, (acc.dates.get(date) || 0) + 1);
267
+ }
268
+ acc.providers.set("continue", (acc.providers.get("continue") || 0) + 1);
269
+ acc.sessionCount++;
270
+ acc.msgCounts.push(0);
271
+ }
272
+ } catch { /* */ }
273
+ }
274
+
275
+ // Scan Cursor sessions
276
+ const cursorDir = path.join(HOME, ".cursor-server");
277
+ if (fs.existsSync(cursorDir)) {
278
+ try {
279
+ function scanCursor(dir: string) {
280
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
281
+ const fullPath = path.join(dir, entry.name);
282
+ if (entry.isDirectory()) scanCursor(fullPath);
283
+ else if (entry.name.endsWith(".json") || entry.name.endsWith(".jsonl")) {
284
+ const date = getFileDate(fullPath);
285
+ if (date && (!cutoffDate || date >= cutoffDate)) {
286
+ acc.dates.set(date, (acc.dates.get(date) || 0) + 1);
287
+ }
288
+ acc.providers.set("cursor", (acc.providers.get("cursor") || 0) + 1);
289
+ acc.sessionCount++;
290
+ acc.msgCounts.push(0);
291
+ }
292
+ }
293
+ }
294
+ scanCursor(cursorDir);
295
+ } catch { /* */ }
296
+ }
297
+
298
+ // Build sessions per day + messages per day
299
+ const allProviders = Array.from(acc.providers.keys());
300
+ const sessionsPerDay: { date: string; count: number; byProvider: Record<string, number> }[] = [];
301
+ const messagesPerDay: { date: string; count: number; byProvider: Record<string, number> }[] = [];
302
+ const now = new Date();
303
+ const days = period === "7d" ? 7 : period === "90d" ? 90 : period === "all" ? 90 : 30;
304
+ for (let i = days - 1; i >= 0; i--) {
305
+ const d = new Date(now);
306
+ d.setDate(d.getDate() - i);
307
+ const key = d.toISOString().slice(0, 10);
308
+ const byProvider: Record<string, number> = {};
309
+ const msgByProvider: Record<string, number> = {};
310
+ for (const p of allProviders) {
311
+ byProvider[p] = acc.datesByProvider.get(p)?.get(key) || 0;
312
+ msgByProvider[p] = acc.msgsByDateByProvider.get(p)?.get(key) || 0;
313
+ }
314
+ sessionsPerDay.push({ date: key, count: acc.dates.get(key) || 0, byProvider });
315
+ messagesPerDay.push({ date: key, count: acc.msgsByDate.get(key) || 0, byProvider: msgByProvider });
316
+ }
317
+
318
+ // Provider breakdown
319
+ const totalProviderSessions = Array.from(acc.providers.values()).reduce((a, b) => a + b, 0) || 1;
320
+ const totalProviderMessages = Array.from(acc.providerMessages.values()).reduce((a, b) => a + b, 0) || 1;
321
+ const providerBreakdown = Array.from(acc.providers.entries())
322
+ .map(([provider, count]) => ({
323
+ provider,
324
+ count,
325
+ sessions: count,
326
+ messages: acc.providerMessages.get(provider) || 0,
327
+ pct: Math.round((count / totalProviderSessions) * 100),
328
+ }))
329
+ .sort((a, b) => b.count - a.count);
330
+ void totalProviderMessages;
331
+
332
+ // Top 10 tools
333
+ const topTools = Array.from(acc.tools.entries())
334
+ .map(([name, count]) => ({ name, count }))
335
+ .sort((a, b) => b.count - a.count)
336
+ .slice(0, 10);
337
+
338
+ // Token totals
339
+ const tokenTotals = {
340
+ inputTokens: acc.inputTokens,
341
+ outputTokens: acc.outputTokens,
342
+ avgPerSession: acc.sessionCount > 0
343
+ ? Math.round((acc.inputTokens + acc.outputTokens) / acc.sessionCount)
344
+ : 0,
345
+ };
346
+
347
+ // Session length distribution
348
+ const buckets = { "1-5": 0, "6-20": 0, "21-50": 0, "51-100": 0, "100+": 0 };
349
+ for (const count of acc.msgCounts) {
350
+ if (count <= 5) buckets["1-5"]++;
351
+ else if (count <= 20) buckets["6-20"]++;
352
+ else if (count <= 50) buckets["21-50"]++;
353
+ else if (count <= 100) buckets["51-100"]++;
354
+ else buckets["100+"]++;
355
+ }
356
+ const sessionLengthDist = Object.entries(buckets).map(([bucket, count]) => ({
357
+ bucket,
358
+ count,
359
+ }));
360
+
361
+ const totalMessages = acc.msgCounts.reduce((s, v) => s + v, 0);
362
+ const avgSessionMessages = acc.msgCounts.length > 0
363
+ ? Math.round(totalMessages / acc.msgCounts.length) : 0;
364
+
365
+ const result: AnalyticsData = {
366
+ sessionsPerDay,
367
+ messagesPerDay,
368
+ providerBreakdown,
369
+ topTools,
370
+ tokenTotals,
371
+ sessionLengthDist,
372
+ totalSessions: acc.sessionCount,
373
+ totalMessages,
374
+ avgSessionMessages,
375
+ hourOfDay: acc.hourOfDay,
376
+ };
377
+
378
+ return NextResponse.json(result);
379
+ }
@@ -0,0 +1,170 @@
1
+ import { NextResponse } from "next/server";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import { execSync } from "child_process";
5
+
6
+ const HOME = process.env.HOME || "/root";
7
+
8
+ function resolve(p: string) {
9
+ return p.startsWith("~/") ? path.join(HOME, p.slice(2)) : p;
10
+ }
11
+
12
+ function whichBinary(names: string[]): string | null {
13
+ for (const name of names) {
14
+ try {
15
+ const result = execSync(`which ${name} 2>/dev/null`, { encoding: "utf8", timeout: 2000 }).trim();
16
+ if (result) return result;
17
+ } catch { /* not found */ }
18
+ }
19
+ return null;
20
+ }
21
+
22
+ function countSessions(dir: string, ext = ".jsonl"): number {
23
+ try {
24
+ const resolved = resolve(dir);
25
+ if (!fs.existsSync(resolved)) return -1; // -1 = dir missing
26
+ let count = 0;
27
+ const scan = (d: string, depth = 0) => {
28
+ if (depth > 4) return;
29
+ try {
30
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
31
+ if (entry.isDirectory()) scan(path.join(d, entry.name), depth + 1);
32
+ else if (entry.name.endsWith(ext)) count++;
33
+ }
34
+ } catch { /* skip unreadable */ }
35
+ };
36
+ scan(resolved);
37
+ return count;
38
+ } catch { return -1; }
39
+ }
40
+
41
+ function loadConfig(): Record<string, { binaryPath?: string; sessionsDir?: string }> {
42
+ try {
43
+ const p = path.join(HOME, ".llm-deep-trace.json");
44
+ if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, "utf8"));
45
+ } catch { /* ignore */ }
46
+ return {};
47
+ }
48
+
49
+ const AGENTS = [
50
+ {
51
+ id: "claude",
52
+ name: "Claude Code",
53
+ color: "#3B82F6",
54
+ binaries: ["claude"],
55
+ defaultSessionsDir: "~/.claude/projects",
56
+ sessionExt: ".jsonl",
57
+ },
58
+ {
59
+ id: "codex",
60
+ name: "Codex",
61
+ color: "#F59E0B",
62
+ binaries: ["codex"],
63
+ defaultSessionsDir: "~/.codex/projects",
64
+ sessionExt: ".jsonl",
65
+ },
66
+ {
67
+ id: "kimi",
68
+ name: "Kimi",
69
+ color: "#06B6D4",
70
+ binaries: ["kimi", "kimi-cli"],
71
+ defaultSessionsDir: "~/.kimi/sessions",
72
+ sessionExt: ".jsonl",
73
+ },
74
+ {
75
+ id: "gemini",
76
+ name: "Gemini CLI",
77
+ color: "#22C55E",
78
+ binaries: ["gemini"],
79
+ defaultSessionsDir: "~/.gemini/sessions",
80
+ sessionExt: ".jsonl",
81
+ },
82
+ {
83
+ id: "kova",
84
+ name: "OpenClaw",
85
+ color: "#9B72EF",
86
+ binaries: ["openclaw"],
87
+ defaultSessionsDir: "~/.openclaw/sessions",
88
+ sessionExt: ".jsonl",
89
+ },
90
+ {
91
+ id: "copilot",
92
+ name: "GitHub Copilot",
93
+ color: "#52525B",
94
+ binaries: ["gh"],
95
+ defaultSessionsDir: "~/.config/github-copilot/sessions",
96
+ sessionExt: ".jsonl",
97
+ },
98
+ {
99
+ id: "factory",
100
+ name: "Factory Droid",
101
+ color: "#F97316",
102
+ binaries: ["droid"],
103
+ defaultSessionsDir: "~/.factory/sessions",
104
+ sessionExt: ".jsonl",
105
+ },
106
+ {
107
+ id: "opencode",
108
+ name: "OpenCode",
109
+ color: "#14B8A6",
110
+ binaries: ["opencode"],
111
+ defaultSessionsDir: "~/.opencode/sessions",
112
+ sessionExt: ".jsonl",
113
+ },
114
+ {
115
+ id: "cursor",
116
+ name: "Cursor",
117
+ color: "#818CF8",
118
+ binaries: ["cursor"],
119
+ defaultSessionsDir: "~/.cursor-server/data/User/workspaceStorage",
120
+ sessionExt: ".json",
121
+ },
122
+ {
123
+ id: "aider",
124
+ name: "Aider",
125
+ color: "#E879F9",
126
+ binaries: ["aider"],
127
+ defaultSessionsDir: "~/", // aider uses ~/.aider.chat.history.md
128
+ sessionExt: ".md",
129
+ },
130
+ {
131
+ id: "continue",
132
+ name: "Continue.dev",
133
+ color: "#FB923C",
134
+ binaries: ["continue"],
135
+ defaultSessionsDir: "~/.continue/sessions",
136
+ sessionExt: ".json",
137
+ },
138
+ ];
139
+
140
+ export async function GET() {
141
+ const config = loadConfig();
142
+
143
+ const results = AGENTS.map((agent) => {
144
+ const override = config[agent.id] || {};
145
+ const binaryPath = override.binaryPath || whichBinary(agent.binaries);
146
+ const sessionsDir = override.sessionsDir || agent.defaultSessionsDir;
147
+ const count = countSessions(sessionsDir, agent.sessionExt);
148
+
149
+ return {
150
+ id: agent.id,
151
+ name: agent.name,
152
+ color: agent.color,
153
+ binary: {
154
+ found: !!binaryPath,
155
+ path: binaryPath,
156
+ isCustom: !!override.binaryPath,
157
+ },
158
+ sessions: {
159
+ found: count > 0,
160
+ dir: sessionsDir,
161
+ defaultDir: agent.defaultSessionsDir,
162
+ count: Math.max(0, count),
163
+ dirExists: count >= 0,
164
+ isCustom: !!override.sessionsDir,
165
+ },
166
+ };
167
+ });
168
+
169
+ return NextResponse.json({ agents: results });
170
+ }
@@ -0,0 +1,73 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import os from "os";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ const HOME = os.homedir();
9
+
10
+ const ALLOWED_PREFIXES = [
11
+ path.join(HOME, ".claude"),
12
+ path.join(HOME, ".openclaw"),
13
+ path.join(HOME, ".codex"),
14
+ path.join(HOME, ".kimi"),
15
+ "/tmp",
16
+ ];
17
+
18
+ const MIME_MAP: Record<string, string> = {
19
+ ".png": "image/png",
20
+ ".jpg": "image/jpeg",
21
+ ".jpeg": "image/jpeg",
22
+ ".gif": "image/gif",
23
+ ".webp": "image/webp",
24
+ };
25
+
26
+ export async function GET(req: NextRequest) {
27
+ const encoded = req.nextUrl.searchParams.get("path");
28
+ if (!encoded) {
29
+ return NextResponse.json({ error: "Missing path parameter" }, { status: 400 });
30
+ }
31
+
32
+ let decoded: string;
33
+ try {
34
+ decoded = Buffer.from(encoded, "base64").toString("utf-8");
35
+ } catch {
36
+ return NextResponse.json({ error: "Invalid base64 path" }, { status: 400 });
37
+ }
38
+
39
+ const resolved = path.resolve(decoded);
40
+
41
+ // Security: only serve from allowed directories
42
+ const allowed = ALLOWED_PREFIXES.some((prefix) => resolved.startsWith(prefix));
43
+ if (!allowed) {
44
+ return NextResponse.json({ error: "Path not allowed" }, { status: 403 });
45
+ }
46
+
47
+ // Prevent path traversal
48
+ if (resolved.includes("..")) {
49
+ return NextResponse.json({ error: "Invalid path" }, { status: 400 });
50
+ }
51
+
52
+ if (!fs.existsSync(resolved)) {
53
+ return NextResponse.json({ error: "File not found" }, { status: 404 });
54
+ }
55
+
56
+ const ext = path.extname(resolved).toLowerCase();
57
+ const mime = MIME_MAP[ext];
58
+ if (!mime) {
59
+ return NextResponse.json({ error: "Unsupported image type" }, { status: 400 });
60
+ }
61
+
62
+ try {
63
+ const data = fs.readFileSync(resolved);
64
+ return new NextResponse(data, {
65
+ headers: {
66
+ "Content-Type": mime,
67
+ "Cache-Control": "public, max-age=3600",
68
+ },
69
+ });
70
+ } catch {
71
+ return NextResponse.json({ error: "Failed to read file" }, { status: 500 });
72
+ }
73
+ }
@@ -0,0 +1,28 @@
1
+ import { NextResponse } from "next/server";
2
+ import { searchSessions } from "@/lib/sessions";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function POST(request: Request) {
7
+ try {
8
+ const body = await request.json();
9
+ const query = (body.query as string) || "";
10
+ if (query.length < 2) {
11
+ return NextResponse.json([]);
12
+ }
13
+ const results = searchSessions(query, 50);
14
+ return NextResponse.json(results);
15
+ } catch {
16
+ return NextResponse.json({ error: "Search failed" }, { status: 500 });
17
+ }
18
+ }
19
+
20
+ export async function GET(request: Request) {
21
+ const url = new URL(request.url);
22
+ const q = url.searchParams.get("q") || "";
23
+ if (q.length < 2) {
24
+ return NextResponse.json([]);
25
+ }
26
+ const results = searchSessions(q, 50);
27
+ return NextResponse.json(results);
28
+ }
@@ -0,0 +1,21 @@
1
+ import { NextResponse } from "next/server";
2
+ import { loadSessionsIndex } from "@/lib/sessions";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET(request: Request) {
7
+ const url = new URL(request.url);
8
+ const key = url.searchParams.get("key") || "";
9
+ try {
10
+ const index = loadSessionsIndex();
11
+ if (key in index) {
12
+ return NextResponse.json({
13
+ sessionId: (index[key] as Record<string, unknown>).sessionId,
14
+ key,
15
+ });
16
+ }
17
+ } catch {
18
+ // ignore
19
+ }
20
+ return NextResponse.json({ error: "not found" }, { status: 404 });
21
+ }