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.
@@ -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.poller = setInterval(() => void this.poll(), 3000);
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
- void this.onTranscriptChanged(abs);
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
- this.schedule();
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
- if (this.historyReady)
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
- if (!this.dashboard)
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
- return search(q, files, new Set(this.index.list().map((f) => f.sessionId)));
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
+ }