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,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
|
+
}
|