claude-inspect 0.7.1 → 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.
@@ -12,6 +12,10 @@ import { readFileSync } from 'node:fs';
12
12
  import { dirname, join } from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { parseArgs } from 'node:util';
15
+ import v8 from 'node:v8';
16
+ // Läuft dauerhaft im Hintergrund: kleiner Heap statt Höchsttempo. Gemessen mit ~1.600 Transcripts
17
+ // 140 statt 700 MB Speicher, der erste Scan dauert dafür ~20 % länger. Keine harte Grenze.
18
+ v8.setFlagsFromString('--optimize-for-size');
15
19
  const env = process.env;
16
20
  function version() {
17
21
  const here = dirname(fileURLToPath(import.meta.url));
@@ -5,6 +5,22 @@ import { toolSummary } from '../../shared/tools.js';
5
5
  import { registry } from '../formats/index.js';
6
6
  import { subagentDir } from '../sources/subagents.js';
7
7
  import { JsonlTail } from '../util/jsonl.js';
8
+ /** Wiederkehrende Texte (Modell- und Tool-Namen) nur einmal im Speicher halten. */
9
+ const interned = new Map();
10
+ function intern(s) {
11
+ let v = interned.get(s);
12
+ if (v === undefined)
13
+ interned.set(s, (v = s));
14
+ return v;
15
+ }
16
+ /**
17
+ * Eigene Kopie eines gekürzten Strings. V8 legt Teilstrings als Verweis auf den Ausgangstext an –
18
+ * eine 200-Zeichen-Zusammenfassung hielte sonst den kompletten (evtl. mehrere KB großen) Befehl fest.
19
+ */
20
+ function own(s) {
21
+ return s.length < 13 ? s : Buffer.from(s, 'utf8').toString('utf8');
22
+ }
23
+ const CHUNK_BYTES = 2 * 1024 * 1024;
8
24
  const FILE_TOOLS = {
9
25
  Read: 'reads',
10
26
  Edit: 'edits',
@@ -110,19 +126,22 @@ export class HistoryIndex {
110
126
  }
111
127
  agg.size = size;
112
128
  agg.project = project;
113
- let lines;
114
- try {
115
- ({ lines } = await agg.tail.readNew());
116
- }
117
- catch {
118
- return;
119
- }
120
- for (const l of lines) {
121
- if (l.value === undefined)
122
- continue;
123
- const { out } = registry.decode('transcript', l.value, path, l.line, { silent: true });
124
- for (const e of out)
125
- this.fold(agg, e);
129
+ // in Stücken lesen: große Transcripts (50–100 MB) nicht komplett geparst im Speicher halten
130
+ for (let more = true; more;) {
131
+ let lines;
132
+ try {
133
+ ({ lines, more } = await agg.tail.readNew(undefined, CHUNK_BYTES));
134
+ }
135
+ catch {
136
+ return;
137
+ }
138
+ for (const l of lines) {
139
+ if (l.value === undefined)
140
+ continue;
141
+ const { out } = registry.decode('transcript', l.value, path, l.line, { silent: true });
142
+ for (const e of out)
143
+ this.fold(agg, e);
144
+ }
126
145
  }
127
146
  }
128
147
  fold(agg, e) {
@@ -144,8 +163,8 @@ export class HistoryIndex {
144
163
  agentId: agg.agentId,
145
164
  project: agg.project,
146
165
  toolUseId: e.toolUseId,
147
- name: e.name,
148
- summary: toolSummary(e.name, e.input).slice(0, 200),
166
+ name: intern(e.name),
167
+ summary: own(toolSummary(e.name, e.input).slice(0, 200)),
149
168
  ts: Number.isNaN(ts) ? 0 : ts,
150
169
  file,
151
170
  });
@@ -168,7 +187,9 @@ export class HistoryIndex {
168
187
  break;
169
188
  }
170
189
  if ((e.kind === 'assistant-text' || e.kind === 'thinking' || e.kind === 'tool-use') && e.usage && e.messageId && e.model && e.model !== '<synthetic>') {
171
- agg.usage.set(e.messageId, { model: e.model, ts: Number.isNaN(ts) ? 0 : ts, usage: e.usage });
190
+ const tokens = emptyTotals();
191
+ addUsage(tokens, e.usage);
192
+ agg.usage.set(e.messageId, { model: intern(e.model), ts: Number.isNaN(ts) ? 0 : ts, tokens });
172
193
  }
173
194
  }
174
195
  // ---------------------------------------------------------------------------
@@ -209,8 +230,7 @@ export class HistoryIndex {
209
230
  for (const u of a.usage.values()) {
210
231
  if (u.ts < since)
211
232
  continue;
212
- const one = emptyTotals();
213
- addUsage(one, u.usage);
233
+ const one = u.tokens;
214
234
  let m = byModel.get(u.model);
215
235
  if (!m)
216
236
  byModel.set(u.model, (m = emptyTotals()));
@@ -307,12 +327,9 @@ export class HistoryIndex {
307
327
  let t = out.get(u.model);
308
328
  if (!t)
309
329
  out.set(u.model, (t = emptyTotals()));
310
- addUsage(t, u.usage);
311
- if (each) {
312
- const one = emptyTotals();
313
- addUsage(one, u.usage);
314
- each(u.model, u.ts, one);
315
- }
330
+ mergeTotals(t, u.tokens);
331
+ if (each)
332
+ each(u.model, u.ts, { ...u.tokens });
316
333
  }
317
334
  }
318
335
  return out;
@@ -1,8 +1,11 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
2
3
  import { toolSummary } from '../../shared/tools.js';
3
4
  import { paths } from '../config.js';
4
5
  import { registry } from '../formats/index.js';
5
6
  import { pathTail } from '../sources/projects.js';
7
+ const home = homedir();
8
+ const tildify = (p) => (home && p.startsWith(home) ? '~' + p.slice(home.length) : p);
6
9
  function snippet(text, needle, radius = 90) {
7
10
  const i = text.toLowerCase().indexOf(needle);
8
11
  if (i < 0)
@@ -33,7 +36,7 @@ function searchable(e) {
33
36
  * nach dem Suchbegriff durchsucht, nur Trefferzeilen werden dekodiert.
34
37
  * Neueste Dateien zuerst; bricht bei `limit` Treffern oder nach `budgetMs` ab.
35
38
  */
36
- export async function search(query, files, knownSessions, limit = 200, budgetMs = 6000, perFile = 5) {
39
+ export async function search(query, files, knownSessions, limit = 200, budgetMs = 15_000, perFile = 5) {
37
40
  const t0 = Date.now();
38
41
  const needle = query.trim().toLowerCase();
39
42
  const hits = [];
@@ -41,6 +44,16 @@ export async function search(query, files, knownSessions, limit = 200, budgetMs
41
44
  let truncated = false;
42
45
  if (needle.length < 2)
43
46
  return { query, hits, scannedFiles: 0, totalFiles: files.length, truncated, ms: 0 };
47
+ // Dateinamen und Pfade der Transcripts (z. B. "agent-a1b2….jsonl", Session-Id, Pfadteile)
48
+ const pathNeedle = (needle.startsWith('~') && home ? home.toLowerCase() + needle.slice(1) : needle).replace(/\\/g, '/');
49
+ for (const f of files) {
50
+ const p = f.path.replace(/\\/g, '/');
51
+ if (!p.toLowerCase().includes(pathNeedle))
52
+ continue;
53
+ hits.push({ sessionId: f.sessionId, agentId: f.agentId, project: f.project, title: f.title, line: 0, kind: 'file', snippet: tildify(f.path) });
54
+ if (hits.length >= 50)
55
+ break;
56
+ }
44
57
  outer: for (const f of files) {
45
58
  if (Date.now() - t0 > budgetMs) {
46
59
  truncated = true;
@@ -1,6 +1,7 @@
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
6
  import { CLAUDE_DIR, DATA_DIR, relToClaude } from './config.js';
6
7
  import { buildDigest } from './review/digest.js';
@@ -483,14 +484,62 @@ export class Inspector extends EventEmitter {
483
484
  }
484
485
  async search(q) {
485
486
  const files = [];
487
+ const subMeta = new Map();
486
488
  for (const f of this.index.list()) {
487
489
  const sum = await this.index.summary(f);
488
490
  files.push({ path: f.path, sessionId: f.sessionId, project: sum.project, title: sum.title });
489
491
  for (const sf of await listSubagents(f.path)) {
490
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 });
491
494
  }
492
495
  }
493
- 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 };
494
543
  }
495
544
  /**
496
545
  * Ablauf einer Session für Zeitleiste und Graph: Hauptagent, Subagenten
@@ -189,19 +189,23 @@ export class SpawnIndex {
189
189
  }
190
190
  st.size = size;
191
191
  try {
192
- const { lines, reset } = await st.tail.readNew((t) => t.includes('"Bash"') && t.includes('claude'));
193
- if (reset)
194
- st.calls = [];
195
- for (const l of lines) {
196
- const raw = l.value;
197
- if (!isObj(raw) || raw.type !== 'assistant')
198
- continue;
199
- const content = obj(raw.message)?.content;
200
- if (!Array.isArray(content))
201
- continue;
202
- for (const b of content) {
203
- if (isObj(b) && b.type === 'tool_use' && b.name === 'Bash')
204
- st.calls.push(...parseCall(raw, b, sessionId, agentId));
192
+ // in Stücken lesen, damit große Dateien nicht komplett im Speicher landen
193
+ for (let more = true; more;) {
194
+ const r = await st.tail.readNew((t) => t.includes('"Bash"') && t.includes('claude'), 8 * 1024 * 1024);
195
+ more = r.more;
196
+ if (r.reset)
197
+ st.calls = [];
198
+ for (const l of r.lines) {
199
+ const raw = l.value;
200
+ if (!isObj(raw) || raw.type !== 'assistant')
201
+ continue;
202
+ const content = obj(raw.message)?.content;
203
+ if (!Array.isArray(content))
204
+ continue;
205
+ for (const b of content) {
206
+ if (isObj(b) && b.type === 'tool_use' && b.name === 'Bash')
207
+ st.calls.push(...parseCall(raw, b, sessionId, agentId));
208
+ }
205
209
  }
206
210
  }
207
211
  }
@@ -22,8 +22,10 @@ export class JsonlTail {
22
22
  /**
23
23
  * @param prefilter Optional: nur Zeilen parsen, deren Rohtext passt (spart bei
24
24
  * großen Dateien den Großteil der JSON.parse-Arbeit).
25
+ * @param maxBytes Optional: höchstens so viel lesen; `more` sagt, ob noch etwas folgt.
26
+ * Hält den Speicherbedarf bei großen Dateien klein (statt alles auf einmal).
25
27
  */
26
- async readNew(prefilter) {
28
+ async readNew(prefilter, maxBytes = Infinity) {
27
29
  const st = await stat(this.path);
28
30
  let reset = false;
29
31
  if (st.size < this.offset) {
@@ -34,10 +36,10 @@ export class JsonlTail {
34
36
  reset = true;
35
37
  }
36
38
  if (st.size === this.offset)
37
- return { lines: [], reset };
39
+ return { lines: [], reset, more: false };
38
40
  const fh = await open(this.path, 'r');
39
41
  try {
40
- const len = st.size - this.offset;
42
+ const len = Math.min(st.size - this.offset, maxBytes);
41
43
  const buf = Buffer.alloc(len);
42
44
  const { bytesRead } = await fh.read(buf, 0, len, this.offset);
43
45
  const chunk = Buffer.concat([this.leftover, buf.subarray(0, bytesRead)]);
@@ -53,7 +55,7 @@ export class JsonlTail {
53
55
  start = i + 1;
54
56
  }
55
57
  this.leftover = Buffer.from(chunk.subarray(start));
56
- return { lines, reset };
58
+ return { lines, reset, more: this.offset < st.size };
57
59
  }
58
60
  finally {
59
61
  await fh.close();