claude-inspect 0.6.0 → 0.8.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/README.de.md +21 -2
- package/README.md +5 -2
- package/dist/app/server/cli/demo.js +2 -0
- package/dist/app/server/cli.js +9 -2
- package/dist/app/server/cpu-monitor.js +23 -3
- package/dist/app/server/history/aggregator.js +48 -27
- package/dist/app/server/history/search.js +14 -1
- package/dist/app/server/http.js +57 -1
- package/dist/app/server/inspector.js +150 -7
- package/dist/app/server/model/cpu.js +1 -1
- package/dist/app/server/review/digest.js +362 -0
- package/dist/app/server/review/runner.js +249 -0
- package/dist/app/server/run.js +1 -1
- package/dist/app/server/sources/spawns.js +42 -19
- package/dist/app/server/util/jsonl.js +6 -4
- package/dist/app/shared/review.js +57 -0
- package/dist/web/assets/index-DKNJESE2.js +19 -0
- package/dist/web/assets/index-G8obC5Be.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-B7gX7gY4.css +0 -1
- package/dist/web/assets/index-C2VfGUFF.js +0 -18
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events';
|
|
2
2
|
import { watch } from 'node:fs';
|
|
3
3
|
import { stat } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
4
5
|
import { basename, join } from 'node:path';
|
|
5
|
-
import { CLAUDE_DIR, relToClaude } from './config.js';
|
|
6
|
+
import { CLAUDE_DIR, DATA_DIR, relToClaude } from './config.js';
|
|
7
|
+
import { buildDigest } from './review/digest.js';
|
|
8
|
+
import { findClaude, Reviewer } from './review/runner.js';
|
|
6
9
|
import { CpuMonitor } from './cpu-monitor.js';
|
|
7
10
|
import { registry } from './formats/index.js';
|
|
8
11
|
import { HistoryIndex } from './history/aggregator.js';
|
|
@@ -33,6 +36,9 @@ function idleIdeProcess(p) {
|
|
|
33
36
|
const SUBAGENT_STALE_MS = 10 * 60_000;
|
|
34
37
|
/** Subagenten erscheinen auf dem Dashboard, wenn sie so kürzlich aktiv waren. */
|
|
35
38
|
const SUBAGENT_DASHBOARD_MS = 30 * 60_000;
|
|
39
|
+
/** Sparbetrieb ohne offenen Tab: Prozessliste alle 30 s, Komplettaktualisierung alle 2 min */
|
|
40
|
+
const IDLE_POLL_MS = 30_000;
|
|
41
|
+
const IDLE_REBUILD_MS = 120_000;
|
|
36
42
|
export class Inspector extends EventEmitter {
|
|
37
43
|
index = new ProjectIndex();
|
|
38
44
|
store = new TranscriptStore();
|
|
@@ -42,6 +48,13 @@ export class Inspector extends EventEmitter {
|
|
|
42
48
|
/** Aggregierte History (Tokens, Kosten, Tool-Aufrufe, Dateien) – nur im Speicher. */
|
|
43
49
|
history = new HistoryIndex();
|
|
44
50
|
historyReady = false;
|
|
51
|
+
historyRefreshedAt = 0;
|
|
52
|
+
/**
|
|
53
|
+
* Offene, sichtbare Browser-Tabs (Live-Verbindungen). Ohne Zuschauer läuft nur ein
|
|
54
|
+
* Sparbetrieb: Prozessliste alle 30 s (für die CPU-Messung), alles andere alle 2 min.
|
|
55
|
+
*/
|
|
56
|
+
viewers = 0;
|
|
57
|
+
builtAt = 0;
|
|
45
58
|
historyReadyResolve;
|
|
46
59
|
/** Erfüllt, sobald der erste History-Scan durch ist (API-Aufrufe warten darauf). */
|
|
47
60
|
historyLoaded = new Promise((r) => (this.historyReadyResolve = r));
|
|
@@ -56,8 +69,12 @@ export class Inspector extends EventEmitter {
|
|
|
56
69
|
building;
|
|
57
70
|
/** CPU-Auslastung je Claude-Prozess samt Unterprozessen */
|
|
58
71
|
cpu;
|
|
72
|
+
/** "Analyze with Claude": startet auf Knopfdruck `claude -p` (abschaltbar mit --no-analyze) */
|
|
73
|
+
reviewer;
|
|
59
74
|
constructor(opts = {}) {
|
|
60
75
|
super();
|
|
76
|
+
if (opts.analyze !== false)
|
|
77
|
+
this.reviewer = new Reviewer(join(DATA_DIR, 'reviews'), (data) => this.emit('event', { type: 'review', data }));
|
|
61
78
|
this.cpu = new CpuMonitor({
|
|
62
79
|
log: opts.cpuLog ?? false,
|
|
63
80
|
roots: () => new Map(this.processes.filter((p) => p.alive && p.pid > 0).map((p) => [p.pid, p.sessionId])),
|
|
@@ -91,7 +108,7 @@ export class Inspector extends EventEmitter {
|
|
|
91
108
|
/* Fallback: nur Polling */
|
|
92
109
|
}
|
|
93
110
|
// Polling als Netz: Prozess-Liveness (kein Datei-Event beim Absturz) und verpasste Events.
|
|
94
|
-
this.
|
|
111
|
+
this.startPoller();
|
|
95
112
|
this.cpu.start();
|
|
96
113
|
}
|
|
97
114
|
stop() {
|
|
@@ -100,6 +117,28 @@ export class Inspector extends EventEmitter {
|
|
|
100
117
|
clearInterval(this.poller);
|
|
101
118
|
clearTimeout(this.timer);
|
|
102
119
|
}
|
|
120
|
+
get active() {
|
|
121
|
+
return this.viewers > 0;
|
|
122
|
+
}
|
|
123
|
+
startPoller() {
|
|
124
|
+
clearInterval(this.poller);
|
|
125
|
+
this.poller = setInterval(() => void this.poll(), this.active ? 5000 : IDLE_POLL_MS);
|
|
126
|
+
this.cpu.setIdle(!this.active);
|
|
127
|
+
}
|
|
128
|
+
/** Anzahl offener Live-Verbindungen (sichtbare Tabs); schaltet zwischen Live- und Sparbetrieb. */
|
|
129
|
+
setViewers(n) {
|
|
130
|
+
const was = this.active;
|
|
131
|
+
this.viewers = n;
|
|
132
|
+
if (was === this.active)
|
|
133
|
+
return;
|
|
134
|
+
this.startPoller();
|
|
135
|
+
if (this.active) {
|
|
136
|
+
// Zuschauer zurück: sofort alles auf Stand bringen
|
|
137
|
+
this.dirty.processes = this.dirty.jobs = this.dirty.dashboard = true;
|
|
138
|
+
this.historyRefreshedAt = 0;
|
|
139
|
+
void this.poll();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
103
142
|
onFsEvent(abs) {
|
|
104
143
|
const rel = relToClaude(abs);
|
|
105
144
|
if (rel.startsWith('sessions/'))
|
|
@@ -108,7 +147,8 @@ export class Inspector extends EventEmitter {
|
|
|
108
147
|
this.dirty.jobs = true;
|
|
109
148
|
else if (rel.startsWith('projects/')) {
|
|
110
149
|
if (rel.endsWith('.jsonl')) {
|
|
111
|
-
|
|
150
|
+
if (this.active)
|
|
151
|
+
void this.onTranscriptChanged(abs);
|
|
112
152
|
if (!rel.includes('/subagents/'))
|
|
113
153
|
void this.index.touch(abs);
|
|
114
154
|
}
|
|
@@ -116,7 +156,9 @@ export class Inspector extends EventEmitter {
|
|
|
116
156
|
}
|
|
117
157
|
else
|
|
118
158
|
return;
|
|
119
|
-
|
|
159
|
+
// ohne Zuschauer nur vormerken; der nächste Sparlauf holt es nach
|
|
160
|
+
if (this.active)
|
|
161
|
+
this.schedule();
|
|
120
162
|
}
|
|
121
163
|
async onTranscriptChanged(path) {
|
|
122
164
|
const r = await this.store.refreshIfLoaded(path);
|
|
@@ -127,6 +169,20 @@ export class Inspector extends EventEmitter {
|
|
|
127
169
|
this.emit('event', { type: 'transcript', data: { sessionId: t.sessionId, agentId: t.agentId, total: t.entries.length } });
|
|
128
170
|
}
|
|
129
171
|
async poll() {
|
|
172
|
+
if (!this.active) {
|
|
173
|
+
// Sparbetrieb: Prozessliste aktuell halten (CPU-Messung braucht die Claude-Prozesse),
|
|
174
|
+
// den Rest nur alle 2 Minuten
|
|
175
|
+
if (Date.now() - this.builtAt < IDLE_REBUILD_MS) {
|
|
176
|
+
if (!this.building) {
|
|
177
|
+
this.processes = await readProcesses();
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.dirty.processes = true;
|
|
182
|
+
this.dirty.jobs = true;
|
|
183
|
+
this.schedule(0);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
130
186
|
for (const t of this.store.all()) {
|
|
131
187
|
try {
|
|
132
188
|
const st = await stat(t.path);
|
|
@@ -161,9 +217,13 @@ export class Inspector extends EventEmitter {
|
|
|
161
217
|
}
|
|
162
218
|
if (this.spawnsReady)
|
|
163
219
|
await this.spawns.refresh(this.index.list(), this.processes, false);
|
|
164
|
-
|
|
220
|
+
// Statistik muss nicht im Sekundentakt nachziehen; laufende Agenten schreiben ständig
|
|
221
|
+
if (this.historyReady && (!this.active || Date.now() - this.historyRefreshedAt > 5000)) {
|
|
222
|
+
this.historyRefreshedAt = Date.now();
|
|
165
223
|
await this.history.refresh(this.index.list(), this.projectOf, false);
|
|
224
|
+
}
|
|
166
225
|
this.dirty.dashboard = false;
|
|
226
|
+
this.builtAt = Date.now();
|
|
167
227
|
const d = await this.buildDashboard();
|
|
168
228
|
const { generatedAt: _g, ...cmp } = d;
|
|
169
229
|
const json = JSON.stringify(cmp);
|
|
@@ -185,8 +245,11 @@ export class Inspector extends EventEmitter {
|
|
|
185
245
|
return this.cpu.logging ? this.cpu.dir : undefined;
|
|
186
246
|
}
|
|
187
247
|
async getDashboard() {
|
|
188
|
-
|
|
248
|
+
// Abruf ohne Live-Verbindung (z. B. API) im Sparbetrieb: nicht älter als 5 s ausliefern
|
|
249
|
+
if (!this.dashboard || (!this.active && Date.now() - this.builtAt > 5000)) {
|
|
250
|
+
this.dirty.processes = this.dirty.jobs = true;
|
|
189
251
|
await this.rebuild();
|
|
252
|
+
}
|
|
190
253
|
return this.dashboard;
|
|
191
254
|
}
|
|
192
255
|
// -------------------------------------------------------------------------
|
|
@@ -421,14 +484,62 @@ export class Inspector extends EventEmitter {
|
|
|
421
484
|
}
|
|
422
485
|
async search(q) {
|
|
423
486
|
const files = [];
|
|
487
|
+
const subMeta = new Map();
|
|
424
488
|
for (const f of this.index.list()) {
|
|
425
489
|
const sum = await this.index.summary(f);
|
|
426
490
|
files.push({ path: f.path, sessionId: f.sessionId, project: sum.project, title: sum.title });
|
|
427
491
|
for (const sf of await listSubagents(f.path)) {
|
|
428
492
|
files.push({ path: sf.path, sessionId: f.sessionId, agentId: sf.agentId, project: sum.project, title: sf.meta.description ?? sum.title });
|
|
493
|
+
subMeta.set(`${f.sessionId}/${sf.agentId}`, { type: sf.meta.agentType, description: sf.meta.description, path: sf.path });
|
|
429
494
|
}
|
|
430
495
|
}
|
|
431
|
-
|
|
496
|
+
const res = await search(q, files, new Set(this.index.list().map((f) => f.sessionId)));
|
|
497
|
+
// Agenten der Treffer samt Vorfahren (Hauptagent, per claude -p aufrufender Agent) für die Baumansicht
|
|
498
|
+
const agents = {};
|
|
499
|
+
const home = homedir();
|
|
500
|
+
const tilde = (p) => (home && p.startsWith(home) ? '~' + p.slice(home.length) : p);
|
|
501
|
+
const add = async (sessionId, agentId, depth = 0) => {
|
|
502
|
+
const key = agentId ? `${sessionId}/${agentId}` : sessionId;
|
|
503
|
+
if (agents[key])
|
|
504
|
+
return key;
|
|
505
|
+
const f = this.index.get(sessionId);
|
|
506
|
+
if (!f || depth > 8)
|
|
507
|
+
return undefined;
|
|
508
|
+
const sum = await this.index.summary(f);
|
|
509
|
+
if (agentId) {
|
|
510
|
+
const m = subMeta.get(key);
|
|
511
|
+
agents[key] = {
|
|
512
|
+
key,
|
|
513
|
+
sessionId,
|
|
514
|
+
agentId,
|
|
515
|
+
kind: 'subagent',
|
|
516
|
+
label: m?.type ?? 'subagent',
|
|
517
|
+
sublabel: m?.description,
|
|
518
|
+
project: sum.project,
|
|
519
|
+
parentKey: await add(sessionId, undefined, depth + 1),
|
|
520
|
+
file: tilde(m?.path ?? f.path),
|
|
521
|
+
};
|
|
522
|
+
return key;
|
|
523
|
+
}
|
|
524
|
+
const link = this.spawns.parentOf(sessionId);
|
|
525
|
+
const proc = this.processes.find((p) => p.sessionId === sessionId);
|
|
526
|
+
agents[key] = {
|
|
527
|
+
key,
|
|
528
|
+
sessionId,
|
|
529
|
+
kind: link ? 'spawn' : 'main',
|
|
530
|
+
label: proc?.name ?? sum.title ?? sessionId.slice(0, 8),
|
|
531
|
+
sublabel: link ? (this.spawns.childInfo(sessionId)?.agentSetting ?? 'claude -p') : undefined,
|
|
532
|
+
project: sum.project,
|
|
533
|
+
file: tilde(f.path),
|
|
534
|
+
};
|
|
535
|
+
if (link)
|
|
536
|
+
agents[key].parentKey = await add(link.parentSessionId, link.parentAgentId, depth + 1);
|
|
537
|
+
return key;
|
|
538
|
+
};
|
|
539
|
+
for (const h of res.hits)
|
|
540
|
+
if (h.kind !== 'history-prompt')
|
|
541
|
+
await add(h.sessionId, h.agentId);
|
|
542
|
+
return { ...res, agents };
|
|
432
543
|
}
|
|
433
544
|
/**
|
|
434
545
|
* Ablauf einer Session für Zeitleiste und Graph: Hauptagent, Subagenten
|
|
@@ -576,6 +687,38 @@ export class Inspector extends EventEmitter {
|
|
|
576
687
|
formats() {
|
|
577
688
|
return registry.report();
|
|
578
689
|
}
|
|
690
|
+
/** Kompakter Bericht über die Session samt Subagenten und gestarteten Sitzungen (Grundlage der Analyse). */
|
|
691
|
+
async reviewDigest(sessionId) {
|
|
692
|
+
const [flow, cost] = [await this.flow(sessionId), await this.sessionCost(sessionId)];
|
|
693
|
+
const f = this.index.get(sessionId);
|
|
694
|
+
if (!flow || !f)
|
|
695
|
+
return undefined;
|
|
696
|
+
const sum = await this.index.summary(f);
|
|
697
|
+
const costOf = new Map(cost?.rows.map((r) => [r.id, r.costUSD]));
|
|
698
|
+
const lanes = [];
|
|
699
|
+
for (const lane of flow.lanes) {
|
|
700
|
+
const t = await this.transcriptFor(lane.sessionId, lane.agentId);
|
|
701
|
+
if (t)
|
|
702
|
+
lanes.push({ lane, entries: t.entries, costUSD: costOf.get(lane.id) ?? 0 });
|
|
703
|
+
}
|
|
704
|
+
return buildDigest({ title: sum.title, project: sum.project, cwd: sum.cwd, gitBranch: sum.gitBranch, costUSD: cost?.costUSD ?? 0, lanes });
|
|
705
|
+
}
|
|
706
|
+
async reviewState(sessionId) {
|
|
707
|
+
if (!this.index.get(sessionId))
|
|
708
|
+
return undefined;
|
|
709
|
+
if (!this.reviewer)
|
|
710
|
+
return { available: false, disabled: true, runs: [] };
|
|
711
|
+
const claudePath = await findClaude();
|
|
712
|
+
return { available: !!claudePath, claudePath, disabled: false, digest: (await this.reviewDigest(sessionId))?.stats, runs: await this.reviewer.list(sessionId) };
|
|
713
|
+
}
|
|
714
|
+
async startReview(sessionId, req) {
|
|
715
|
+
if (!this.reviewer)
|
|
716
|
+
throw new Error('Analysis is turned off (--no-analyze).');
|
|
717
|
+
const digest = await this.reviewDigest(sessionId);
|
|
718
|
+
if (!digest)
|
|
719
|
+
throw new Error('unknown session');
|
|
720
|
+
return this.reviewer.start(sessionId, digest, req);
|
|
721
|
+
}
|
|
579
722
|
}
|
|
580
723
|
const MIN = 60_000;
|
|
581
724
|
const BUCKET_SIZES = [1, 2, 5, 10, 15, 30, 60, 120, 180, 360, 720, 1440].map((m) => m * MIN);
|
|
@@ -87,7 +87,7 @@ export function cpuHistory(samples, opts) {
|
|
|
87
87
|
for (let i = 0; i < samples.length; i++) {
|
|
88
88
|
const s = samples[i];
|
|
89
89
|
// Anteil dieser Messung an der Zeit: Abstand zur vorigen, höchstens zwei Messabstände
|
|
90
|
-
const dt = Math.min(i > 0 ? s.t - samples[i - 1].t : opts.intervalMs, 2 * opts.intervalMs) / 1000;
|
|
90
|
+
const dt = Math.min(i > 0 ? s.t - samples[i - 1].t : opts.intervalMs, opts.maxStepMs ?? 2 * opts.intervalMs) / 1000;
|
|
91
91
|
const byComm = new Map();
|
|
92
92
|
for (const p of s.procs) {
|
|
93
93
|
byComm.set(p.comm, (byComm.get(p.comm) ?? 0) + p.pct);
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { addUsage, emptyTotals, estimateCost } from '../../shared/pricing.js';
|
|
3
|
+
/** ~70k Tokens – genug für lange Sessions, ohne das Limit unnötig zu belasten */
|
|
4
|
+
const MAX_CHARS = 240_000;
|
|
5
|
+
const LEVELS = [
|
|
6
|
+
{ prompt: 4000, text: 1200, err: 600, ok: 160, cmd: 400 },
|
|
7
|
+
{ prompt: 2000, text: 500, err: 300, ok: 0, cmd: 240 },
|
|
8
|
+
{ prompt: 1000, text: 200, err: 160, ok: 0, cmd: 160 },
|
|
9
|
+
];
|
|
10
|
+
// --- Geheimnisse ---------------------------------------------------------------
|
|
11
|
+
const SECRET_PATTERNS = [
|
|
12
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g, '[REDACTED:private-key]'],
|
|
13
|
+
[/\bsk-ant-[A-Za-z0-9_-]{10,}/g, '[REDACTED:anthropic-key]'],
|
|
14
|
+
[/\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/g, '[REDACTED:api-key]'],
|
|
15
|
+
[/\bgithub_pat_[A-Za-z0-9_]{20,}/g, '[REDACTED:github-token]'],
|
|
16
|
+
[/\bgh[pousr]_[A-Za-z0-9]{20,}/g, '[REDACTED:github-token]'],
|
|
17
|
+
[/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, '[REDACTED:slack-token]'],
|
|
18
|
+
[/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, '[REDACTED:aws-key]'],
|
|
19
|
+
[/\bAIza[0-9A-Za-z_-]{35}\b/g, '[REDACTED:google-key]'],
|
|
20
|
+
[/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g, '[REDACTED:jwt]'],
|
|
21
|
+
[/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/-]{16,}=*/g, (_m, kind) => `${kind} [REDACTED:credential]`],
|
|
22
|
+
[/(:\/\/[^\s:/@]+:)[^\s@/]{3,}@/g, (_m, pre) => `${pre}[REDACTED:password]@`],
|
|
23
|
+
[
|
|
24
|
+
/\b((?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|client[_-]?secret|auth)["']?\s*[:=]\s*["']?)([^\s"',;]{6,})/gi,
|
|
25
|
+
(_m, pre) => `${pre}[REDACTED:secret]`,
|
|
26
|
+
],
|
|
27
|
+
];
|
|
28
|
+
export function redact(s, count) {
|
|
29
|
+
let out = s;
|
|
30
|
+
for (const [re, rep] of SECRET_PATTERNS) {
|
|
31
|
+
out = out.replace(re, (...args) => {
|
|
32
|
+
count.n++;
|
|
33
|
+
return typeof rep === 'string' ? rep : rep(...args);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
// --- Hilfen ------------------------------------------------------------------
|
|
39
|
+
const home = homedir();
|
|
40
|
+
const tildify = (p) => (home && p.startsWith(home) ? '~' + p.slice(home.length) : p);
|
|
41
|
+
function oneLine(s, max) {
|
|
42
|
+
const t = s.replace(/\r/g, '').replace(/\n+/g, ' ⏎ ').replace(/\s+/g, ' ').trim();
|
|
43
|
+
return t.length > max ? t.slice(0, max) + ` …[+${t.length - max}]` : t;
|
|
44
|
+
}
|
|
45
|
+
function block(s, max) {
|
|
46
|
+
const t = s.replace(/\r/g, '').trim();
|
|
47
|
+
return t.length > max ? t.slice(0, max) + `\n…[${t.length - max} more characters]` : t;
|
|
48
|
+
}
|
|
49
|
+
const time = (ts) => (ts ? new Date(ts).toLocaleTimeString('en-GB', { hour12: false }) : '--:--:--');
|
|
50
|
+
const usd = (n) => (n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`);
|
|
51
|
+
const kTok = (n) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
|
|
52
|
+
function dur(ms) {
|
|
53
|
+
if (ms < 1000)
|
|
54
|
+
return `${ms}ms`;
|
|
55
|
+
if (ms < 60_000)
|
|
56
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
57
|
+
if (ms < 3600_000)
|
|
58
|
+
return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`;
|
|
59
|
+
if (ms < 86_400_000)
|
|
60
|
+
return `${Math.floor(ms / 3600_000)}h${Math.round((ms % 3600_000) / 60_000)}m`;
|
|
61
|
+
return `${Math.floor(ms / 86_400_000)}d${Math.round((ms % 86_400_000) / 3600_000)}h`;
|
|
62
|
+
}
|
|
63
|
+
function resultText(r) {
|
|
64
|
+
return r.parts.map((p) => (p.type === 'text' ? p.text : p.type === 'image' ? '[image]' : '')).join('\n');
|
|
65
|
+
}
|
|
66
|
+
const s = (v) => (typeof v === 'string' ? v : '');
|
|
67
|
+
function toolDetail(e, lv) {
|
|
68
|
+
const i = e.input ?? {};
|
|
69
|
+
switch (e.name) {
|
|
70
|
+
case 'Bash':
|
|
71
|
+
return `${oneLine(s(i.command), lv.cmd)}${i.run_in_background ? ' (background)' : ''}`;
|
|
72
|
+
case 'Read':
|
|
73
|
+
case 'Write':
|
|
74
|
+
case 'Edit':
|
|
75
|
+
case 'MultiEdit':
|
|
76
|
+
case 'NotebookEdit': {
|
|
77
|
+
const p = tildify(s(i.file_path) || s(i.notebook_path));
|
|
78
|
+
if (e.name === 'Write')
|
|
79
|
+
return `${p} (${s(i.content).split('\n').length} lines)`;
|
|
80
|
+
if (e.name === 'Edit')
|
|
81
|
+
return `${p} (−${s(i.old_string).split('\n').length}/+${s(i.new_string).split('\n').length} lines)`;
|
|
82
|
+
if (e.name === 'Read' && (i.offset || i.limit))
|
|
83
|
+
return `${p} (from ${i.offset ?? 0}, ${i.limit ?? 'all'} lines)`;
|
|
84
|
+
return p;
|
|
85
|
+
}
|
|
86
|
+
case 'Grep':
|
|
87
|
+
case 'Glob':
|
|
88
|
+
return `${oneLine(s(i.pattern), 160)}${i.path ? ` in ${tildify(s(i.path))}` : ''}`;
|
|
89
|
+
case 'Agent':
|
|
90
|
+
case 'Task':
|
|
91
|
+
return `${s(i.subagent_type) || 'agent'}: ${oneLine(s(i.description), 120)} – prompt: ${oneLine(s(i.prompt), lv.cmd)}`;
|
|
92
|
+
case 'WebFetch':
|
|
93
|
+
return `${s(i.url)}${i.prompt ? ` – ${oneLine(s(i.prompt), 120)}` : ''}`;
|
|
94
|
+
case 'WebSearch':
|
|
95
|
+
return oneLine(s(i.query), 200);
|
|
96
|
+
}
|
|
97
|
+
return oneLine(JSON.stringify(i), lv.cmd);
|
|
98
|
+
}
|
|
99
|
+
// --- Automatische Befunde ------------------------------------------------------
|
|
100
|
+
const SENSITIVE_PATH = /(^|[/\\\s"'])(\.env(\.[\w-]+)?|id_rsa|id_ed25519|id_ecdsa|[\w-]+\.pem|[\w-]+\.key|\.netrc|\.npmrc|\.pypirc|credentials(\.json)?|\.aws[/\\]|\.ssh[/\\]|\.kube[/\\]config|\.docker[/\\]config\.json|secrets?\.(json|ya?ml|env))(\b|$)/i;
|
|
101
|
+
// Aufrufe an localhost zählen nicht als Netzwerkverkehr nach außen
|
|
102
|
+
const NETWORK = /\b(scp|rsync|sftp|ssh|ncat|telnet|ftp)\b|https?:\/\/(?!localhost\b|127\.0\.0\.1\b|\[::1\])|\b(curl|wget)\b(?![^\n|;&]*\b(localhost|127\.0\.0\.1)\b)/;
|
|
103
|
+
const UPLOAD = /\bcurl\b(?![^\n|;&]*\b(localhost|127\.0\.0\.1)\b)[^|]*(\s-d\b|--data|\s-F\b|--form|\s-T\b|--upload-file|-X\s*(POST|PUT|PATCH))|\bscp\b|\brsync\b.*:|\bgit\s+push\b/;
|
|
104
|
+
const RISKY = [
|
|
105
|
+
[/\brm\s+-[a-zA-Z]*r[a-zA-Z]*f|\brm\s+-[a-zA-Z]*f[a-zA-Z]*r/, 'rm -rf'],
|
|
106
|
+
[/\bgit\s+push\b[^\n]*(--force\b|-f\b|--force-with-lease)/, 'force push'],
|
|
107
|
+
[/\bgit\s+reset\s+--hard/, 'git reset --hard'],
|
|
108
|
+
[/\bgit\s+clean\s+-[a-zA-Z]*f/, 'git clean -f'],
|
|
109
|
+
[/\bgit\s+(checkout|restore)\s+(--\s+)?\.(\s|$)/, 'discard all changes'],
|
|
110
|
+
[/--no-verify\b/, '--no-verify'],
|
|
111
|
+
[/\bsudo\b/, 'sudo'],
|
|
112
|
+
[/\bchmod\s+(-R\s+)?777\b/, 'chmod 777'],
|
|
113
|
+
[/\b(curl|wget)\b[^|\n]*\|\s*(sudo\s+)?(ba|z)?sh\b/, 'pipe download to shell'],
|
|
114
|
+
[/\b(DROP\s+(TABLE|DATABASE)|TRUNCATE\s+TABLE|DELETE\s+FROM\s+\w+\s*;?\s*$)/i, 'destructive SQL'],
|
|
115
|
+
[/--dangerously|bypassPermissions/, 'permission bypass'],
|
|
116
|
+
];
|
|
117
|
+
class Findings {
|
|
118
|
+
toolCalls = 0;
|
|
119
|
+
errors = 0;
|
|
120
|
+
denied = 0;
|
|
121
|
+
failing = new Map();
|
|
122
|
+
reads = new Map();
|
|
123
|
+
sensitive = new Map();
|
|
124
|
+
network = [];
|
|
125
|
+
uploads = [];
|
|
126
|
+
risky = new Map();
|
|
127
|
+
webFetches = 0;
|
|
128
|
+
maxContext = 0;
|
|
129
|
+
cacheRead = 0;
|
|
130
|
+
inputAll = 0;
|
|
131
|
+
compactions = 0;
|
|
132
|
+
tool(lane, e, r, count) {
|
|
133
|
+
this.toolCalls++;
|
|
134
|
+
const i = e.input ?? {};
|
|
135
|
+
const cmd = e.name === 'Bash' ? s(i.command) : '';
|
|
136
|
+
const path = s(i.file_path) || s(i.notebook_path) || s(i.path);
|
|
137
|
+
if (r?.isError) {
|
|
138
|
+
this.errors++;
|
|
139
|
+
const key = `${lane}: ${e.name} ${oneLine(redact(cmd || path || JSON.stringify(i), count), 140)}`;
|
|
140
|
+
this.failing.set(key, (this.failing.get(key) ?? 0) + 1);
|
|
141
|
+
}
|
|
142
|
+
if (r?.denied)
|
|
143
|
+
this.denied++;
|
|
144
|
+
if (e.name === 'Read' && path)
|
|
145
|
+
this.reads.set(`${lane}: ${tildify(path)}`, (this.reads.get(`${lane}: ${tildify(path)}`) ?? 0) + 1);
|
|
146
|
+
for (const c of [path, cmd]) {
|
|
147
|
+
const m = c && SENSITIVE_PATH.exec(c);
|
|
148
|
+
if (m) {
|
|
149
|
+
const k = `${e.name} ${oneLine(tildify(c), 140)}`;
|
|
150
|
+
this.sensitive.set(redact(k, count), (this.sensitive.get(k) ?? 0) + 1);
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (e.name === 'WebFetch' || e.name === 'WebSearch')
|
|
155
|
+
this.webFetches++;
|
|
156
|
+
if (cmd) {
|
|
157
|
+
const short = oneLine(redact(cmd, count), 160);
|
|
158
|
+
if (UPLOAD.test(cmd))
|
|
159
|
+
this.uploads.push(`${time(e.timestamp)} ${lane}: ${short}`);
|
|
160
|
+
else if (NETWORK.test(cmd))
|
|
161
|
+
this.network.push(`${time(e.timestamp)} ${lane}: ${short}`);
|
|
162
|
+
for (const [re, label] of RISKY) {
|
|
163
|
+
if (re.test(cmd)) {
|
|
164
|
+
const l = this.risky.get(label) ?? [];
|
|
165
|
+
l.push(`${time(e.timestamp)} ${lane}: ${short}`);
|
|
166
|
+
this.risky.set(label, l);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
render(redactions) {
|
|
172
|
+
const out = [];
|
|
173
|
+
const top = (m, n, f) => [...m.entries()].slice(0, n).map(([k, v]) => f(k, v));
|
|
174
|
+
out.push(`- Tool calls: ${this.toolCalls}, failed: ${this.errors}, permission denied: ${this.denied}`);
|
|
175
|
+
const repeatedFails = new Map([...this.failing].filter(([, n]) => n >= 2).sort((a, b) => b[1] - a[1]));
|
|
176
|
+
if (repeatedFails.size)
|
|
177
|
+
out.push('- Same call failed repeatedly:', ...top(repeatedFails, 8, (k, n) => ` - ${n}× ${k}`));
|
|
178
|
+
const repeatedReads = new Map([...this.reads].filter(([, n]) => n >= 3).sort((a, b) => b[1] - a[1]));
|
|
179
|
+
if (repeatedReads.size)
|
|
180
|
+
out.push('- Same file read 3+ times:', ...top(repeatedReads, 8, (k, n) => ` - ${n}× ${k}`));
|
|
181
|
+
if (this.inputAll)
|
|
182
|
+
out.push(`- Prompt cache: ${Math.round((this.cacheRead / this.inputAll) * 100)}% of input tokens read from cache; largest context ${kTok(this.maxContext)} tokens; context compactions: ${this.compactions}`);
|
|
183
|
+
if (this.sensitive.size)
|
|
184
|
+
out.push('- Access to possibly sensitive files:', ...top(this.sensitive, 10, (k, n) => ` - ${n > 1 ? `${n}× ` : ''}${k}`));
|
|
185
|
+
if (redactions)
|
|
186
|
+
out.push(`- ${redactions} secret-looking values were found in the session and replaced with [REDACTED:…] in this digest`);
|
|
187
|
+
if (this.uploads.length)
|
|
188
|
+
out.push('- Commands that send data out (upload/POST/push/copy to remote):', ...this.uploads.slice(0, 10).map((l) => ` - ${l}`));
|
|
189
|
+
if (this.network.length)
|
|
190
|
+
out.push(`- Other network commands: ${this.network.length}`, ...this.network.slice(0, 6).map((l) => ` - ${l}`));
|
|
191
|
+
if (this.webFetches)
|
|
192
|
+
out.push(`- WebFetch/WebSearch calls: ${this.webFetches}`);
|
|
193
|
+
for (const [label, list] of this.risky)
|
|
194
|
+
out.push(`- Risky pattern "${label}": ${list.length}×`, ...list.slice(0, 5).map((l) => ` - ${l}`));
|
|
195
|
+
return out.join('\n');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
const SHOWN = new Set(['user-text', 'assistant-text', 'tool-use']);
|
|
199
|
+
function renderLane(dl, lv, childOf, f, count) {
|
|
200
|
+
const { lane, entries } = dl;
|
|
201
|
+
const results = new Map();
|
|
202
|
+
for (const e of entries)
|
|
203
|
+
if (e.kind === 'tool-result')
|
|
204
|
+
results.set(e.toolUseId, e);
|
|
205
|
+
const seenMsg = new Set();
|
|
206
|
+
const lines = [];
|
|
207
|
+
const R = (t) => redact(t, count);
|
|
208
|
+
const name = lane.kind === 'main' ? 'main agent' : lane.kind === 'subagent' ? `subagent ${lane.label}` : `spawned claude session ${lane.label}`;
|
|
209
|
+
let day = '';
|
|
210
|
+
for (const e of entries) {
|
|
211
|
+
const d = e.timestamp ? new Date(e.timestamp).toDateString() : '';
|
|
212
|
+
if (d && d !== day && SHOWN.has(e.kind)) {
|
|
213
|
+
day = d;
|
|
214
|
+
lines.push(`--- ${new Date(e.timestamp).toLocaleDateString('en-GB', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' })} ---`);
|
|
215
|
+
}
|
|
216
|
+
// Kosten und Kontextgröße je Modellantwort (einmal pro Message)
|
|
217
|
+
let turn = '';
|
|
218
|
+
if ((e.kind === 'assistant-text' || e.kind === 'thinking' || e.kind === 'tool-use') && e.usage && e.messageId && !seenMsg.has(e.messageId) && e.model !== '<synthetic>') {
|
|
219
|
+
seenMsg.add(e.messageId);
|
|
220
|
+
const t = emptyTotals();
|
|
221
|
+
addUsage(t, e.usage);
|
|
222
|
+
const ctx = t.input + t.cacheRead + t.cacheWrite5m + t.cacheWrite1h;
|
|
223
|
+
if (f) {
|
|
224
|
+
f.maxContext = Math.max(f.maxContext, ctx);
|
|
225
|
+
f.cacheRead += t.cacheRead;
|
|
226
|
+
f.inputAll += ctx;
|
|
227
|
+
}
|
|
228
|
+
const c = estimateCost(e.model, t) ?? 0;
|
|
229
|
+
if (c >= 0.02)
|
|
230
|
+
turn = ` {${usd(c)}, ctx ${kTok(ctx)}}`;
|
|
231
|
+
}
|
|
232
|
+
const at = `[${time(e.timestamp)}]`;
|
|
233
|
+
switch (e.kind) {
|
|
234
|
+
case 'user-text': {
|
|
235
|
+
if (e.isMeta || !e.text)
|
|
236
|
+
break;
|
|
237
|
+
if (e.isCompactSummary) {
|
|
238
|
+
lines.push(`${at} [context was compacted – earlier conversation summarized]`);
|
|
239
|
+
if (f)
|
|
240
|
+
f.compactions++;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
if (e.text.startsWith('<')) {
|
|
244
|
+
const cmd = /<command-name>([^<]+)<\/command-name>/.exec(e.text);
|
|
245
|
+
if (cmd)
|
|
246
|
+
lines.push(`${at} USER ran ${cmd[1]}`);
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
lines.push(`${at} USER:\n${R(block(e.text, lv.prompt))}`);
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case 'assistant-text':
|
|
253
|
+
if (e.isApiError)
|
|
254
|
+
lines.push(`${at} API ERROR: ${R(oneLine(e.text, 300))}`);
|
|
255
|
+
else if (e.text.trim())
|
|
256
|
+
lines.push(`${at} ASSISTANT${turn}: ${R(block(e.text, lv.text))}`);
|
|
257
|
+
else if (turn)
|
|
258
|
+
lines.push(`${at} ASSISTANT${turn}`);
|
|
259
|
+
break;
|
|
260
|
+
case 'thinking':
|
|
261
|
+
if (turn)
|
|
262
|
+
lines.push(`${at} (thinking)${turn}`);
|
|
263
|
+
break;
|
|
264
|
+
case 'tool-use': {
|
|
265
|
+
const r = results.get(e.toolUseId);
|
|
266
|
+
f?.tool(lane.kind === 'main' ? 'main' : lane.label, e, r, count);
|
|
267
|
+
let status = 'no result';
|
|
268
|
+
if (r) {
|
|
269
|
+
const took = r.timestamp && e.timestamp ? ` ${dur(Date.parse(r.timestamp) - Date.parse(e.timestamp))}` : '';
|
|
270
|
+
const text = resultText(r);
|
|
271
|
+
if (r.denied)
|
|
272
|
+
status = `DENIED${took}: ${R(oneLine(r.denied, lv.err))}`;
|
|
273
|
+
else if (r.isError)
|
|
274
|
+
status = `ERROR${took}: ${R(oneLine(text, lv.err))}`;
|
|
275
|
+
else
|
|
276
|
+
status = `ok${took}${text.length > 200 ? `, ${text.length} chars` : ''}${lv.ok ? `: ${R(oneLine(text, lv.ok))}` : ''}`;
|
|
277
|
+
}
|
|
278
|
+
const child = childOf.get(e.toolUseId);
|
|
279
|
+
lines.push(`${at} TOOL ${e.name}${turn}: ${R(toolDetail(e, lv))} → ${status}${child ? ` ⇒ started ${child}` : ''}`);
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
case 'system':
|
|
283
|
+
if (e.subtype === 'compact_boundary') {
|
|
284
|
+
lines.push(`${at} [context compacted]`);
|
|
285
|
+
if (f)
|
|
286
|
+
f.compactions++;
|
|
287
|
+
}
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const span = lane.start && lane.end ? `, ${dur(lane.end - lane.start)}` : '';
|
|
292
|
+
const head = `## Agent: ${name}${lane.sublabel ? ` – ${R(oneLine(lane.sublabel, 160))}` : ''}\n(${lane.model ?? 'unknown model'}, ${lane.toolCalls} tool calls, ${kTok(lane.outputTokens)} output tokens, est. ${usd(dl.costUSD)}${span}, status ${lane.status})`;
|
|
293
|
+
return { head, lines };
|
|
294
|
+
}
|
|
295
|
+
export function buildDigest(input) {
|
|
296
|
+
const childOf = new Map();
|
|
297
|
+
const labelOf = new Map(input.lanes.map((l) => [l.lane.id, l.lane.kind === 'main' ? 'main agent' : `${l.lane.kind === 'spawn' ? 'claude session' : 'subagent'} "${l.lane.label}"`]));
|
|
298
|
+
for (const { lane } of input.lanes)
|
|
299
|
+
if (lane.viaToolUseId)
|
|
300
|
+
childOf.set(lane.viaToolUseId, labelOf.get(lane.id));
|
|
301
|
+
const tree = input.lanes
|
|
302
|
+
.map(({ lane, costUSD }) => `${' '.repeat(lane.depth)}- ${labelOf.get(lane.id)}${lane.sublabel ? ` (${oneLine(lane.sublabel, 80)})` : ''}: ${lane.model ?? '?'}, ${lane.toolCalls} tool calls, est. ${usd(costUSD)}, ${lane.status}`)
|
|
303
|
+
.join('\n');
|
|
304
|
+
const starts = input.lanes.map((l) => l.lane.start).filter((x) => !!x);
|
|
305
|
+
const ends = input.lanes.map((l) => l.lane.end).filter((x) => !!x);
|
|
306
|
+
const from = starts.length ? Math.min(...starts) : undefined;
|
|
307
|
+
const to = ends.length ? Math.max(...ends) : undefined;
|
|
308
|
+
let truncated = false;
|
|
309
|
+
let result;
|
|
310
|
+
for (let li = 0; li < LEVELS.length; li++) {
|
|
311
|
+
const count = { n: 0 };
|
|
312
|
+
const f = new Findings();
|
|
313
|
+
const lanes = input.lanes.map((l) => renderLane(l, LEVELS[li], childOf, f, count));
|
|
314
|
+
let body = lanes.map((l) => `${l.head}\n\n${l.lines.join('\n')}`).join('\n\n');
|
|
315
|
+
const last = li === LEVELS.length - 1;
|
|
316
|
+
if (body.length > MAX_CHARS && !last) {
|
|
317
|
+
truncated = true;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (body.length > MAX_CHARS) {
|
|
321
|
+
// Notfalls die Mitte langer Lanes auslassen: Anfang und Ende sind meist am aufschlussreichsten
|
|
322
|
+
const total = lanes.reduce((a, l) => a + l.lines.length, 0);
|
|
323
|
+
const avg = body.length / Math.max(1, total);
|
|
324
|
+
const keep = Math.floor(MAX_CHARS / avg);
|
|
325
|
+
body = lanes
|
|
326
|
+
.map((l) => {
|
|
327
|
+
const k = Math.max(20, Math.floor((keep * l.lines.length) / total));
|
|
328
|
+
if (l.lines.length <= k)
|
|
329
|
+
return `${l.head}\n\n${l.lines.join('\n')}`;
|
|
330
|
+
const head = Math.ceil(k * 0.4);
|
|
331
|
+
const tail = k - head;
|
|
332
|
+
return `${l.head}\n\n${[...l.lines.slice(0, head), `… [${l.lines.length - k} entries omitted for length] …`, ...l.lines.slice(-tail)].join('\n')}`;
|
|
333
|
+
})
|
|
334
|
+
.join('\n\n');
|
|
335
|
+
}
|
|
336
|
+
const meta = [
|
|
337
|
+
`# Claude Code session digest`,
|
|
338
|
+
'',
|
|
339
|
+
`- Title: ${redact(input.title ?? '(untitled)', count)}`,
|
|
340
|
+
`- Project: ${input.project}${input.cwd ? ` (${tildify(input.cwd)})` : ''}${input.gitBranch ? `, branch ${input.gitBranch}` : ''}`,
|
|
341
|
+
from ? `- Time: ${new Date(from).toLocaleString('en-GB')} – ${to ? new Date(to).toLocaleString('en-GB') : 'now'}${to ? ` (${dur(to - from)})` : ''}` : undefined,
|
|
342
|
+
`- Estimated cost of the whole tree (API list prices): ${usd(input.costUSD)}`,
|
|
343
|
+
`- Format: one line per event. [hh:mm:ss] USER / ASSISTANT / TOOL <name>: <input> → <result status, duration>. {$cost, ctx N} = cost and context size of that model response (shown from $0.02). Texts and outputs are shortened${truncated ? ' strongly, because the session is long' : ''}.`,
|
|
344
|
+
'',
|
|
345
|
+
'## Agent tree',
|
|
346
|
+
tree,
|
|
347
|
+
'',
|
|
348
|
+
'## Automatic observations (computed, not judged)',
|
|
349
|
+
f.render(count.n),
|
|
350
|
+
'',
|
|
351
|
+
]
|
|
352
|
+
.filter((x) => x !== undefined)
|
|
353
|
+
.join('\n');
|
|
354
|
+
const text = `${meta}\n\n${body}\n`;
|
|
355
|
+
result = {
|
|
356
|
+
text,
|
|
357
|
+
stats: { chars: text.length, tokens: Math.round(text.length / 3.5), agents: input.lanes.length, toolCalls: f.toolCalls, redactions: count.n, truncated: truncated || text.includes('entries omitted for length') },
|
|
358
|
+
};
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
return result;
|
|
362
|
+
}
|