claude-inspect 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.de.md +84 -0
- package/README.md +167 -0
- package/dist/app/server/cli/demo.js +321 -0
- package/dist/app/server/cli/service.js +170 -0
- package/dist/app/server/cli-snapshot.js +34 -0
- package/dist/app/server/cli.js +84 -0
- package/dist/app/server/config.js +41 -0
- package/dist/app/server/formats/history/prompt.js +17 -0
- package/dist/app/server/formats/index.js +35 -0
- package/dist/app/server/formats/job/job.js +132 -0
- package/dist/app/server/formats/process/session-file.js +77 -0
- package/dist/app/server/formats/registry.js +87 -0
- package/dist/app/server/formats/subagent/meta.js +28 -0
- package/dist/app/server/formats/task/task.js +33 -0
- package/dist/app/server/formats/transcript/common.js +33 -0
- package/dist/app/server/formats/transcript/message.js +206 -0
- package/dist/app/server/formats/transcript/other.js +130 -0
- package/dist/app/server/formats/types.js +10 -0
- package/dist/app/server/formats/util.js +22 -0
- package/dist/app/server/history/aggregator.js +318 -0
- package/dist/app/server/history/search.js +138 -0
- package/dist/app/server/http.js +169 -0
- package/dist/app/server/inspector.js +468 -0
- package/dist/app/server/main.js +2 -0
- package/dist/app/server/model/analyze.js +112 -0
- package/dist/app/server/model/flow.js +47 -0
- package/dist/app/server/run.js +37 -0
- package/dist/app/server/sources/jobs.js +59 -0
- package/dist/app/server/sources/processes.js +99 -0
- package/dist/app/server/sources/projects.js +165 -0
- package/dist/app/server/sources/spawns.js +297 -0
- package/dist/app/server/sources/subagents.js +62 -0
- package/dist/app/server/sources/tasks.js +30 -0
- package/dist/app/server/sources/transcripts.js +121 -0
- package/dist/app/server/util/jsonl.js +124 -0
- package/dist/app/server/util/sanitize.js +42 -0
- package/dist/app/server/util/semver.js +33 -0
- package/dist/app/shared/pricing.js +57 -0
- package/dist/app/shared/tools.js +62 -0
- package/dist/app/shared/types.js +3 -0
- package/dist/web/assets/index-DdxYQpDc.js +14 -0
- package/dist/web/assets/index-VdEOo0XQ.css +1 -0
- package/dist/web/index.html +22 -0
- package/package.json +69 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { addUsage, emptyTotals, estimateCost, mergeTotals } from '../../shared/pricing.js';
|
|
4
|
+
import { toolSummary } from '../../shared/tools.js';
|
|
5
|
+
import { registry } from '../formats/index.js';
|
|
6
|
+
import { subagentDir } from '../sources/subagents.js';
|
|
7
|
+
import { JsonlTail } from '../util/jsonl.js';
|
|
8
|
+
const FILE_TOOLS = {
|
|
9
|
+
Read: 'reads',
|
|
10
|
+
Edit: 'edits',
|
|
11
|
+
MultiEdit: 'edits',
|
|
12
|
+
NotebookEdit: 'edits',
|
|
13
|
+
Write: 'writes',
|
|
14
|
+
};
|
|
15
|
+
function dayKey(ts) {
|
|
16
|
+
const d = new Date(ts);
|
|
17
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
18
|
+
}
|
|
19
|
+
function p95(values) {
|
|
20
|
+
if (!values.length)
|
|
21
|
+
return undefined;
|
|
22
|
+
const s = [...values].sort((a, b) => a - b);
|
|
23
|
+
return s[Math.min(s.length - 1, Math.floor(s.length * 0.95))];
|
|
24
|
+
}
|
|
25
|
+
export class HistoryIndex {
|
|
26
|
+
files = new Map();
|
|
27
|
+
ready = false;
|
|
28
|
+
totalFiles = 0;
|
|
29
|
+
/**
|
|
30
|
+
* @param projectOf liefert den Projektnamen einer Session (aus der Zusammenfassung)
|
|
31
|
+
* @param full beim ersten Lauf auch alle Subagent-Verzeichnisse durchsuchen
|
|
32
|
+
*/
|
|
33
|
+
async refresh(transcripts, projectOf, full) {
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
for (const f of transcripts) {
|
|
36
|
+
const project = await projectOf(f);
|
|
37
|
+
seen.add(f.path);
|
|
38
|
+
await this.scan(f.path, f.size, f.sessionId, undefined, project);
|
|
39
|
+
if (!full && Date.now() - f.mtime > 3600_000) {
|
|
40
|
+
for (const p of this.files.keys())
|
|
41
|
+
if (p.startsWith(subagentDir(f.path)))
|
|
42
|
+
seen.add(p);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const dir = subagentDir(f.path);
|
|
46
|
+
let names = [];
|
|
47
|
+
try {
|
|
48
|
+
names = await readdir(dir);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
/* keine Subagenten */
|
|
52
|
+
}
|
|
53
|
+
for (const n of names) {
|
|
54
|
+
const m = /^agent-(.+)\.jsonl$/.exec(n);
|
|
55
|
+
if (!m)
|
|
56
|
+
continue;
|
|
57
|
+
const path = join(dir, n);
|
|
58
|
+
seen.add(path);
|
|
59
|
+
try {
|
|
60
|
+
await this.scan(path, (await stat(path)).size, f.sessionId, m[1], project);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
/* gelöscht */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Von Claude Code aufgeräumte Dateien vergessen
|
|
68
|
+
for (const p of [...this.files.keys()])
|
|
69
|
+
if (!seen.has(p))
|
|
70
|
+
this.files.delete(p);
|
|
71
|
+
this.totalFiles = this.files.size;
|
|
72
|
+
this.ready = true;
|
|
73
|
+
}
|
|
74
|
+
async scan(path, size, sessionId, agentId, project) {
|
|
75
|
+
let agg = this.files.get(path);
|
|
76
|
+
if (agg && agg.size === size)
|
|
77
|
+
return;
|
|
78
|
+
if (!agg || size < agg.size) {
|
|
79
|
+
agg = { path, size: 0, sessionId, agentId, project, tail: new JsonlTail(path), usage: new Map(), calls: [], callIdx: new Map(), prompts: [] };
|
|
80
|
+
this.files.set(path, agg);
|
|
81
|
+
}
|
|
82
|
+
agg.size = size;
|
|
83
|
+
agg.project = project;
|
|
84
|
+
let lines;
|
|
85
|
+
try {
|
|
86
|
+
({ lines } = await agg.tail.readNew());
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
for (const l of lines) {
|
|
92
|
+
if (l.value === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
const { out } = registry.decode('transcript', l.value, path, l.line, { silent: true });
|
|
95
|
+
for (const e of out)
|
|
96
|
+
this.fold(agg, e);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
fold(agg, e) {
|
|
100
|
+
const ts = e.timestamp ? Date.parse(e.timestamp) : NaN;
|
|
101
|
+
if (!Number.isNaN(ts)) {
|
|
102
|
+
if (agg.firstTs === undefined || ts < agg.firstTs)
|
|
103
|
+
agg.firstTs = ts;
|
|
104
|
+
if (agg.lastTs === undefined || ts > agg.lastTs)
|
|
105
|
+
agg.lastTs = ts;
|
|
106
|
+
}
|
|
107
|
+
switch (e.kind) {
|
|
108
|
+
case 'tool-use': {
|
|
109
|
+
if (agg.callIdx.has(e.toolUseId))
|
|
110
|
+
break; // doppelte Records (Resume/Fork)
|
|
111
|
+
const file = typeof e.input.file_path === 'string' ? e.input.file_path : typeof e.input.notebook_path === 'string' ? e.input.notebook_path : undefined;
|
|
112
|
+
agg.callIdx.set(e.toolUseId, agg.calls.length);
|
|
113
|
+
agg.calls.push({
|
|
114
|
+
sessionId: agg.sessionId,
|
|
115
|
+
agentId: agg.agentId,
|
|
116
|
+
project: agg.project,
|
|
117
|
+
toolUseId: e.toolUseId,
|
|
118
|
+
name: e.name,
|
|
119
|
+
summary: toolSummary(e.name, e.input).slice(0, 200),
|
|
120
|
+
ts: Number.isNaN(ts) ? 0 : ts,
|
|
121
|
+
file,
|
|
122
|
+
});
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case 'tool-result': {
|
|
126
|
+
const i = agg.callIdx.get(e.toolUseId);
|
|
127
|
+
if (i === undefined)
|
|
128
|
+
break;
|
|
129
|
+
const c = agg.calls[i];
|
|
130
|
+
if (!Number.isNaN(ts) && c.ts)
|
|
131
|
+
c.durationMs = Math.max(0, ts - c.ts);
|
|
132
|
+
c.isError = e.isError || undefined;
|
|
133
|
+
c.denied = e.denied ? true : undefined;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case 'user-text':
|
|
137
|
+
if (!agg.agentId && !e.isMeta && !e.isCompactSummary && e.text && !e.text.startsWith('<') && !Number.isNaN(ts))
|
|
138
|
+
agg.prompts.push(ts);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
if ((e.kind === 'assistant-text' || e.kind === 'thinking' || e.kind === 'tool-use') && e.usage && e.messageId && e.model && e.model !== '<synthetic>') {
|
|
142
|
+
agg.usage.set(e.messageId, { model: e.model, ts: Number.isNaN(ts) ? 0 : ts, usage: e.usage });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
selected(f) {
|
|
147
|
+
const since = f.days ? Date.now() - f.days * 86_400_000 : 0;
|
|
148
|
+
const aggs = [...this.files.values()].filter((a) => (!f.project || a.project === f.project) && (a.lastTs ?? 0) >= since);
|
|
149
|
+
return { aggs, since };
|
|
150
|
+
}
|
|
151
|
+
stats(filter = {}) {
|
|
152
|
+
const { aggs, since } = this.selected(filter);
|
|
153
|
+
const byModel = new Map();
|
|
154
|
+
const days = new Map();
|
|
155
|
+
const projects = new Map();
|
|
156
|
+
const heat = Array.from({ length: 7 }, () => new Array(24).fill(0));
|
|
157
|
+
const tools = new Map();
|
|
158
|
+
const sessions = new Set();
|
|
159
|
+
const total = emptyTotals();
|
|
160
|
+
let costUSD = 0;
|
|
161
|
+
let prompts = 0;
|
|
162
|
+
let toolCalls = 0;
|
|
163
|
+
let from;
|
|
164
|
+
let to;
|
|
165
|
+
const day = (ts) => {
|
|
166
|
+
const k = dayKey(ts);
|
|
167
|
+
let d = days.get(k);
|
|
168
|
+
if (!d)
|
|
169
|
+
days.set(k, (d = { day: k, byModel: {}, prompts: 0, toolCalls: 0, sessions: 0, sessionSet: new Set() }));
|
|
170
|
+
return d;
|
|
171
|
+
};
|
|
172
|
+
const proj = (a) => {
|
|
173
|
+
let p = projects.get(a.project);
|
|
174
|
+
if (!p)
|
|
175
|
+
projects.set(a.project, (p = { project: a.project, sessions: 0, toolCalls: 0, outputTokens: 0, costUSD: 0, sessionSet: new Set() }));
|
|
176
|
+
return p;
|
|
177
|
+
};
|
|
178
|
+
for (const a of aggs) {
|
|
179
|
+
const p = proj(a);
|
|
180
|
+
for (const u of a.usage.values()) {
|
|
181
|
+
if (u.ts < since)
|
|
182
|
+
continue;
|
|
183
|
+
const one = emptyTotals();
|
|
184
|
+
addUsage(one, u.usage);
|
|
185
|
+
let m = byModel.get(u.model);
|
|
186
|
+
if (!m)
|
|
187
|
+
byModel.set(u.model, (m = emptyTotals()));
|
|
188
|
+
mergeTotals(m, one);
|
|
189
|
+
mergeTotals(total, one);
|
|
190
|
+
const cost = estimateCost(u.model, one) ?? 0;
|
|
191
|
+
costUSD += cost;
|
|
192
|
+
p.outputTokens += one.output;
|
|
193
|
+
p.costUSD += cost;
|
|
194
|
+
if (u.ts) {
|
|
195
|
+
const d = day(u.ts);
|
|
196
|
+
const dm = (d.byModel[u.model] ??= { output: 0, costUSD: 0 });
|
|
197
|
+
dm.output += one.output;
|
|
198
|
+
dm.costUSD += cost;
|
|
199
|
+
d.sessionSet.add(a.sessionId);
|
|
200
|
+
const dt = new Date(u.ts);
|
|
201
|
+
heat[(dt.getDay() + 6) % 7][dt.getHours()]++;
|
|
202
|
+
from = from === undefined ? u.ts : Math.min(from, u.ts);
|
|
203
|
+
to = to === undefined ? u.ts : Math.max(to, u.ts);
|
|
204
|
+
}
|
|
205
|
+
sessions.add(a.sessionId);
|
|
206
|
+
p.sessionSet.add(a.sessionId);
|
|
207
|
+
p.lastActivity = !p.lastActivity || u.ts > Date.parse(p.lastActivity) ? new Date(u.ts).toISOString() : p.lastActivity;
|
|
208
|
+
}
|
|
209
|
+
for (const t of a.prompts) {
|
|
210
|
+
if (t < since)
|
|
211
|
+
continue;
|
|
212
|
+
prompts++;
|
|
213
|
+
day(t).prompts++;
|
|
214
|
+
}
|
|
215
|
+
for (const c of a.calls) {
|
|
216
|
+
if (c.ts < since)
|
|
217
|
+
continue;
|
|
218
|
+
toolCalls++;
|
|
219
|
+
p.toolCalls++;
|
|
220
|
+
if (c.ts)
|
|
221
|
+
day(c.ts).toolCalls++;
|
|
222
|
+
let t = tools.get(c.name);
|
|
223
|
+
if (!t)
|
|
224
|
+
tools.set(c.name, (t = { count: 0, errors: 0, denied: 0, durations: [] }));
|
|
225
|
+
t.count++;
|
|
226
|
+
if (c.isError)
|
|
227
|
+
t.errors++;
|
|
228
|
+
if (c.denied)
|
|
229
|
+
t.denied++;
|
|
230
|
+
if (c.durationMs !== undefined)
|
|
231
|
+
t.durations.push(c.durationMs);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return {
|
|
235
|
+
ready: this.ready,
|
|
236
|
+
scannedFiles: this.files.size,
|
|
237
|
+
totalFiles: this.totalFiles,
|
|
238
|
+
from: from ? new Date(from).toISOString() : undefined,
|
|
239
|
+
to: to ? new Date(to).toISOString() : undefined,
|
|
240
|
+
totals: { sessions: sessions.size, prompts, toolCalls, costUSD, tokens: total },
|
|
241
|
+
byModel: [...byModel.entries()]
|
|
242
|
+
.map(([model, tokens]) => ({ model, tokens, costUSD: estimateCost(model, tokens) }))
|
|
243
|
+
.sort((a, b) => (b.costUSD ?? 0) - (a.costUSD ?? 0)),
|
|
244
|
+
byDay: [...days.values()]
|
|
245
|
+
.map(({ sessionSet, ...d }) => ({ ...d, sessions: sessionSet.size }))
|
|
246
|
+
.sort((a, b) => a.day.localeCompare(b.day)),
|
|
247
|
+
byProject: [...projects.values()]
|
|
248
|
+
.map(({ sessionSet, ...p }) => ({ ...p, sessions: sessionSet.size }))
|
|
249
|
+
.filter((p) => p.sessions > 0 || p.toolCalls > 0)
|
|
250
|
+
.sort((a, b) => b.costUSD - a.costUSD),
|
|
251
|
+
tools: [...tools.entries()]
|
|
252
|
+
.map(([name, t]) => ({
|
|
253
|
+
name,
|
|
254
|
+
count: t.count,
|
|
255
|
+
errors: t.errors,
|
|
256
|
+
denied: t.denied,
|
|
257
|
+
avgMs: t.durations.length ? t.durations.reduce((x, y) => x + y, 0) / t.durations.length : undefined,
|
|
258
|
+
p95Ms: p95(t.durations),
|
|
259
|
+
}))
|
|
260
|
+
.sort((a, b) => b.count - a.count),
|
|
261
|
+
heat,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
toolCalls(q) {
|
|
265
|
+
const { aggs, since } = this.selected(q);
|
|
266
|
+
const needle = q.q?.toLowerCase();
|
|
267
|
+
const names = new Set();
|
|
268
|
+
const rows = [];
|
|
269
|
+
for (const a of aggs) {
|
|
270
|
+
if (q.sessionId && a.sessionId !== q.sessionId)
|
|
271
|
+
continue;
|
|
272
|
+
for (const c of a.calls) {
|
|
273
|
+
names.add(c.name);
|
|
274
|
+
if (c.ts < since)
|
|
275
|
+
continue;
|
|
276
|
+
if (q.name && c.name !== q.name)
|
|
277
|
+
continue;
|
|
278
|
+
if (q.errorsOnly && !c.isError && !c.denied)
|
|
279
|
+
continue;
|
|
280
|
+
if (q.file && c.file !== q.file)
|
|
281
|
+
continue;
|
|
282
|
+
if (needle && !`${c.summary} ${c.file ?? ''}`.toLowerCase().includes(needle))
|
|
283
|
+
continue;
|
|
284
|
+
rows.push(c);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
rows.sort((x, y) => y.ts - x.ts);
|
|
288
|
+
const offset = q.offset ?? 0;
|
|
289
|
+
return { total: rows.length, rows: rows.slice(offset, offset + (q.limit ?? 100)), names: [...names].sort() };
|
|
290
|
+
}
|
|
291
|
+
fileStats(filter) {
|
|
292
|
+
const { aggs, since } = this.selected(filter);
|
|
293
|
+
const needle = filter.q?.toLowerCase();
|
|
294
|
+
const m = new Map();
|
|
295
|
+
for (const a of aggs) {
|
|
296
|
+
for (const c of a.calls) {
|
|
297
|
+
const kind = FILE_TOOLS[c.name];
|
|
298
|
+
if (!kind || !c.file || c.ts < since)
|
|
299
|
+
continue;
|
|
300
|
+
if (needle && !c.file.toLowerCase().includes(needle))
|
|
301
|
+
continue;
|
|
302
|
+
let f = m.get(c.file);
|
|
303
|
+
if (!f)
|
|
304
|
+
m.set(c.file, (f = { path: c.file, project: a.project, reads: 0, edits: 0, writes: 0, sessions: 0, lastTs: 0, sessionSet: new Set() }));
|
|
305
|
+
f[kind]++;
|
|
306
|
+
f.sessionSet.add(a.sessionId);
|
|
307
|
+
f.lastTs = Math.max(f.lastTs, c.ts);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const files = [...m.values()]
|
|
311
|
+
.map(({ sessionSet, ...f }) => ({ ...f, sessions: sessionSet.size }))
|
|
312
|
+
.sort((a, b) => b.edits + b.writes - (a.edits + a.writes) || b.lastTs - a.lastTs);
|
|
313
|
+
return { total: files.length, files: files.slice(0, filter.limit ?? 300) };
|
|
314
|
+
}
|
|
315
|
+
projects() {
|
|
316
|
+
return [...new Set([...this.files.values()].map((a) => a.project))].sort();
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { toolSummary } from '../../shared/tools.js';
|
|
3
|
+
import { paths } from '../config.js';
|
|
4
|
+
import { registry } from '../formats/index.js';
|
|
5
|
+
function snippet(text, needle, radius = 90) {
|
|
6
|
+
const i = text.toLowerCase().indexOf(needle);
|
|
7
|
+
if (i < 0)
|
|
8
|
+
return text.slice(0, radius * 2);
|
|
9
|
+
const start = Math.max(0, i - radius);
|
|
10
|
+
const end = Math.min(text.length, i + needle.length + radius);
|
|
11
|
+
return (start > 0 ? '…' : '') + text.slice(start, end).replace(/\s+/g, ' ') + (end < text.length ? '…' : '');
|
|
12
|
+
}
|
|
13
|
+
/** Durchsuchbarer Text eines Eintrags – Anhänge und Zustands-Records bewusst nicht. */
|
|
14
|
+
function searchable(e) {
|
|
15
|
+
switch (e.kind) {
|
|
16
|
+
case 'user-text':
|
|
17
|
+
case 'assistant-text':
|
|
18
|
+
case 'thinking':
|
|
19
|
+
return e.text;
|
|
20
|
+
case 'tool-use':
|
|
21
|
+
return `${toolSummary(e.name, e.input)}\n${JSON.stringify(e.input)}`;
|
|
22
|
+
case 'tool-result':
|
|
23
|
+
return e.parts.map((p) => (p.type === 'text' ? p.text : '')).join('\n');
|
|
24
|
+
case 'system':
|
|
25
|
+
return e.text;
|
|
26
|
+
default:
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Volltextsuche direkt über die JSONL-Dateien (kein Index): Rohtext wird
|
|
32
|
+
* nach dem Suchbegriff durchsucht, nur Trefferzeilen werden dekodiert.
|
|
33
|
+
* Neueste Dateien zuerst; bricht bei `limit` Treffern oder nach `budgetMs` ab.
|
|
34
|
+
*/
|
|
35
|
+
export async function search(query, files, knownSessions, limit = 200, budgetMs = 6000, perFile = 5) {
|
|
36
|
+
const t0 = Date.now();
|
|
37
|
+
const needle = query.trim().toLowerCase();
|
|
38
|
+
const hits = [];
|
|
39
|
+
let scanned = 0;
|
|
40
|
+
let truncated = false;
|
|
41
|
+
if (needle.length < 2)
|
|
42
|
+
return { query, hits, scannedFiles: 0, totalFiles: files.length, truncated, ms: 0 };
|
|
43
|
+
outer: for (const f of files) {
|
|
44
|
+
if (Date.now() - t0 > budgetMs) {
|
|
45
|
+
truncated = true;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
let text;
|
|
49
|
+
try {
|
|
50
|
+
text = await readFile(f.path, 'utf8');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
scanned++;
|
|
56
|
+
const lower = text.toLowerCase();
|
|
57
|
+
let pos = lower.indexOf(needle);
|
|
58
|
+
let lineNo = 0;
|
|
59
|
+
let counted = 0;
|
|
60
|
+
let lastLine = -1;
|
|
61
|
+
let inFile = 0;
|
|
62
|
+
while (pos !== -1 && inFile < perFile) {
|
|
63
|
+
for (let i = text.indexOf('\n', counted); i !== -1 && i < pos; i = text.indexOf('\n', i + 1)) {
|
|
64
|
+
lineNo++;
|
|
65
|
+
counted = i + 1;
|
|
66
|
+
}
|
|
67
|
+
const lineStart = counted;
|
|
68
|
+
const lineEnd = text.indexOf('\n', pos);
|
|
69
|
+
if (lineNo !== lastLine) {
|
|
70
|
+
lastLine = lineNo;
|
|
71
|
+
try {
|
|
72
|
+
const raw = JSON.parse(text.slice(lineStart, lineEnd === -1 ? undefined : lineEnd));
|
|
73
|
+
const { out } = registry.decode('transcript', raw, f.path, lineNo, { silent: true });
|
|
74
|
+
for (const e of out) {
|
|
75
|
+
const s = searchable(e);
|
|
76
|
+
if (!s || !s.toLowerCase().includes(needle))
|
|
77
|
+
continue;
|
|
78
|
+
hits.push({
|
|
79
|
+
sessionId: f.sessionId,
|
|
80
|
+
agentId: f.agentId,
|
|
81
|
+
project: f.project,
|
|
82
|
+
title: f.title,
|
|
83
|
+
line: lineNo,
|
|
84
|
+
kind: e.kind,
|
|
85
|
+
toolName: e.kind === 'tool-use' ? e.name : undefined,
|
|
86
|
+
toolUseId: e.kind === 'tool-use' || e.kind === 'tool-result' ? e.toolUseId : undefined,
|
|
87
|
+
ts: e.timestamp,
|
|
88
|
+
snippet: snippet(s, needle),
|
|
89
|
+
});
|
|
90
|
+
inFile++;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
/* defekte Zeile */
|
|
96
|
+
}
|
|
97
|
+
if (hits.length >= limit) {
|
|
98
|
+
truncated = true;
|
|
99
|
+
break outer;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (lineEnd === -1)
|
|
103
|
+
break;
|
|
104
|
+
pos = lower.indexOf(needle, lineEnd);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Prompts aus history.jsonl, deren Session-Transcript nicht mehr existiert
|
|
108
|
+
if (hits.length < limit) {
|
|
109
|
+
try {
|
|
110
|
+
const lines = (await readFile(paths.history, 'utf8')).split('\n');
|
|
111
|
+
for (let i = lines.length - 1; i >= 0 && hits.length < limit; i--) {
|
|
112
|
+
if (!lines[i].toLowerCase().includes(needle))
|
|
113
|
+
continue;
|
|
114
|
+
try {
|
|
115
|
+
const p = registry.decode('history', JSON.parse(lines[i]), paths.history, i, { silent: true }).out;
|
|
116
|
+
if (!p.display.toLowerCase().includes(needle) || (p.sessionId && knownSessions.has(p.sessionId)))
|
|
117
|
+
continue;
|
|
118
|
+
hits.push({
|
|
119
|
+
sessionId: p.sessionId ?? '',
|
|
120
|
+
project: p.project?.split('/').pop() ?? '?',
|
|
121
|
+
title: 'transcript no longer exists',
|
|
122
|
+
line: i,
|
|
123
|
+
kind: 'history-prompt',
|
|
124
|
+
ts: p.timestamp ? new Date(p.timestamp).toISOString() : undefined,
|
|
125
|
+
snippet: snippet(p.display, needle),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* defekte Zeile */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
/* keine history.jsonl */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { query, hits, scannedFiles: scanned, totalFiles: files.length, truncated, ms: Date.now() - t0 };
|
|
138
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { createReadStream } from 'node:fs';
|
|
3
|
+
import { stat } from 'node:fs/promises';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { extname, join, normalize } from 'node:path';
|
|
6
|
+
import { sanitize } from './util/sanitize.js';
|
|
7
|
+
const MIME = {
|
|
8
|
+
'.html': 'text/html; charset=utf-8',
|
|
9
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
10
|
+
'.css': 'text/css; charset=utf-8',
|
|
11
|
+
'.svg': 'image/svg+xml',
|
|
12
|
+
'.png': 'image/png',
|
|
13
|
+
'.ico': 'image/x-icon',
|
|
14
|
+
'.json': 'application/json',
|
|
15
|
+
};
|
|
16
|
+
export function startServer(inspector, opts) {
|
|
17
|
+
const token = opts.token || randomBytes(18).toString('base64url');
|
|
18
|
+
const COOKIE = 'ci_token';
|
|
19
|
+
const authed = (req, url) => {
|
|
20
|
+
if (opts.noAuth)
|
|
21
|
+
return true;
|
|
22
|
+
const given = url.searchParams.get('t') ?? /(?:^|;\s*)ci_token=([^;]+)/.exec(req.headers.cookie ?? '')?.[1] ?? '';
|
|
23
|
+
const a = Buffer.from(given);
|
|
24
|
+
const b = Buffer.from(token);
|
|
25
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
26
|
+
};
|
|
27
|
+
// Schutz gegen DNS-Rebinding: nur lokale Host-Header akzeptieren.
|
|
28
|
+
const hostOk = (req) => {
|
|
29
|
+
const host = (req.headers.host ?? '').replace(/:\d+$/, '');
|
|
30
|
+
return host === '127.0.0.1' || host === 'localhost' || host === '[::1]';
|
|
31
|
+
};
|
|
32
|
+
const json = (res, status, body) => {
|
|
33
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
34
|
+
res.end(JSON.stringify(body));
|
|
35
|
+
};
|
|
36
|
+
const sseClients = new Set();
|
|
37
|
+
inspector.on('event', (ev) => {
|
|
38
|
+
const msg = `event: ${ev.type}\ndata: ${JSON.stringify(ev.data)}\n\n`;
|
|
39
|
+
for (const c of sseClients)
|
|
40
|
+
c.write(msg);
|
|
41
|
+
});
|
|
42
|
+
setInterval(() => {
|
|
43
|
+
for (const c of sseClients)
|
|
44
|
+
c.write(': ping\n\n');
|
|
45
|
+
}, 20_000).unref();
|
|
46
|
+
async function serveStatic(res, pathname) {
|
|
47
|
+
const rel = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, '');
|
|
48
|
+
let file = join(opts.webRoot, rel);
|
|
49
|
+
if (!file.startsWith(opts.webRoot))
|
|
50
|
+
return json(res, 403, { error: 'forbidden' });
|
|
51
|
+
try {
|
|
52
|
+
if (!(await stat(file)).isFile())
|
|
53
|
+
throw new Error();
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
file = join(opts.webRoot, 'index.html'); // SPA-Fallback
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
await stat(file);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' });
|
|
63
|
+
res.end('UI not built. Run `npm run build` first (or `npm run dev:web` for development).');
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' });
|
|
67
|
+
createReadStream(file).pipe(res);
|
|
68
|
+
}
|
|
69
|
+
const server = createServer(async (req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
72
|
+
if (!hostOk(req))
|
|
73
|
+
return json(res, 403, { error: 'host not allowed' });
|
|
74
|
+
if (!authed(req, url)) {
|
|
75
|
+
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' });
|
|
76
|
+
return res.end('Not authorized – open the URL with ?t=… printed in the console.');
|
|
77
|
+
}
|
|
78
|
+
// Token aus der URL in ein Cookie übernehmen, damit Folgeaufrufe ohne ?t= funktionieren.
|
|
79
|
+
if (!opts.noAuth && url.searchParams.has('t')) {
|
|
80
|
+
// persistent for a year, so a bookmark without ?t= keeps working after browser restarts
|
|
81
|
+
res.setHeader('set-cookie', `${COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=31536000`);
|
|
82
|
+
}
|
|
83
|
+
const p = url.pathname;
|
|
84
|
+
if (req.method !== 'GET')
|
|
85
|
+
return json(res, 405, { error: 'read-only' });
|
|
86
|
+
if (p === '/api/dashboard')
|
|
87
|
+
return json(res, 200, await inspector.getDashboard());
|
|
88
|
+
if (p === '/api/stream') {
|
|
89
|
+
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-store', connection: 'keep-alive' });
|
|
90
|
+
res.write(`event: dashboard\ndata: ${JSON.stringify(await inspector.getDashboard())}\n\n`);
|
|
91
|
+
sseClients.add(res);
|
|
92
|
+
req.on('close', () => sseClients.delete(res));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (p === '/api/sessions') {
|
|
96
|
+
const offset = Number(url.searchParams.get('offset') ?? 0);
|
|
97
|
+
const limit = Math.min(Number(url.searchParams.get('limit') ?? 50), 500);
|
|
98
|
+
return json(res, 200, await inspector.sessions(offset, limit, url.searchParams.get('q') ?? undefined));
|
|
99
|
+
}
|
|
100
|
+
let m = /^\/api\/session\/([\w-]+)$/.exec(p);
|
|
101
|
+
if (m) {
|
|
102
|
+
const d = await inspector.sessionDetail(m[1]);
|
|
103
|
+
return d ? json(res, 200, d) : json(res, 404, { error: 'unknown session' });
|
|
104
|
+
}
|
|
105
|
+
m = /^\/api\/transcript\/([\w-]+)$/.exec(p);
|
|
106
|
+
if (m) {
|
|
107
|
+
const agent = url.searchParams.get('agent') || undefined;
|
|
108
|
+
const from = Number(url.searchParams.get('from') ?? 0);
|
|
109
|
+
const page = await inspector.transcript(m[1], agent, from);
|
|
110
|
+
return page ? json(res, 200, page) : json(res, 404, { error: 'unknown transcript' });
|
|
111
|
+
}
|
|
112
|
+
m = /^\/api\/raw\/([\w-]+)$/.exec(p);
|
|
113
|
+
if (m) {
|
|
114
|
+
const agent = url.searchParams.get('agent') || undefined;
|
|
115
|
+
const line = await inspector.rawLine(m[1], agent, Number(url.searchParams.get('line')));
|
|
116
|
+
if (line === undefined)
|
|
117
|
+
return json(res, 404, { error: 'unknown line' });
|
|
118
|
+
try {
|
|
119
|
+
// Auch in der Rohansicht Geheimnisse maskieren, aber nichts kürzen.
|
|
120
|
+
return json(res, 200, sanitize(JSON.parse(line), { maxString: Number.MAX_SAFE_INTEGER }));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return json(res, 200, { unparsable: line });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (p === '/api/formats')
|
|
127
|
+
return json(res, 200, inspector.formats());
|
|
128
|
+
// --- History (Phase 4) ---
|
|
129
|
+
const sp = url.searchParams;
|
|
130
|
+
const filter = {
|
|
131
|
+
project: sp.get('project') || undefined,
|
|
132
|
+
days: sp.get('days') ? Number(sp.get('days')) : undefined,
|
|
133
|
+
};
|
|
134
|
+
if (p === '/api/stats')
|
|
135
|
+
return json(res, 200, await inspector.historyStats(filter));
|
|
136
|
+
if (p === '/api/projects')
|
|
137
|
+
return json(res, 200, await inspector.historyProjects());
|
|
138
|
+
if (p === '/api/tools') {
|
|
139
|
+
return json(res, 200, await inspector.toolCalls({
|
|
140
|
+
...filter,
|
|
141
|
+
name: sp.get('name') || undefined,
|
|
142
|
+
q: sp.get('q') || undefined,
|
|
143
|
+
sessionId: sp.get('session') || undefined,
|
|
144
|
+
file: sp.get('file') || undefined,
|
|
145
|
+
errorsOnly: sp.get('errors') === '1',
|
|
146
|
+
offset: Number(sp.get('offset') ?? 0),
|
|
147
|
+
limit: Math.min(Number(sp.get('limit') ?? 100), 1000),
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
if (p === '/api/files')
|
|
151
|
+
return json(res, 200, await inspector.fileStats({ ...filter, q: sp.get('q') || undefined }));
|
|
152
|
+
if (p === '/api/search')
|
|
153
|
+
return json(res, 200, await inspector.search(sp.get('q') ?? ''));
|
|
154
|
+
m = /^\/api\/flow\/([\w-]+)$/.exec(p);
|
|
155
|
+
if (m) {
|
|
156
|
+
const flow = await inspector.flow(m[1]);
|
|
157
|
+
return flow ? json(res, 200, flow) : json(res, 404, { error: 'unknown session' });
|
|
158
|
+
}
|
|
159
|
+
if (p.startsWith('/api/'))
|
|
160
|
+
return json(res, 404, { error: 'not found' });
|
|
161
|
+
return serveStatic(res, p);
|
|
162
|
+
}
|
|
163
|
+
catch (e) {
|
|
164
|
+
json(res, 500, { error: e.message });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
server.listen(opts.port, opts.host);
|
|
168
|
+
return { server, token };
|
|
169
|
+
}
|