archgraph-argo 0.26.2 → 0.28.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,407 +1,410 @@
1
- 'use strict';
2
- /**
3
- * Agent search diagnosis (framework, zero-dependency).
4
- *
5
- * Turns ONE agent session into a self-contained diagnostic bundle so the user
6
- * can hand it back for analysis:
7
- *
8
- * <workspace>/.argo/temp/diagnosis/<session-id>/
9
- * ├─ diagnosis.md human-readable summary (incl. heuristic hints)
10
- * ├─ metrics.json the numbers behind the summary
11
- * ├─ session.ndjson the raw host session (full tool input/output)
12
- * ├─ cost-log.slice.ndjson the matching slice of the background cost log
13
- * └─ manifest.json file list + sizes + versions
14
- *
15
- * Data sources (user's choice: both):
16
- * - PRIMARY: the host session NDJSON (opencode export / run --format json),
17
- * which carries every tool's full input/output + timestamps + tokens.
18
- * - SUPPLEMENT: the framework cost log <workspace>/.argo/temp/agent-cost-log.ndjson.
19
- *
20
- * Usage:
21
- * node agentSearchDiagnose.js --session <session.ndjson> [--session-id ID]
22
- * [--cost-log <path>] [--workspace <root>] [--out <dir>] [--json]
23
- *
24
- * Read-only except for writing the bundle dir. Never mutates the graph or repo.
25
- */
26
- const fs = require('node:fs');
27
- const path = require('node:path');
28
- const crypto = require('node:crypto');
29
-
30
- const BUNDLE_VERSION = 'temp2.2';
31
- const SCHEMA_VERSION = 1;
32
- const GRAPH_TOOLS = ['getSystemArchitecture', 'getIntentElementContext', 'getArchitectureViewContext', 'queryNeo4jGraph', 'memory_search'];
33
- const GRAPH_WRITE_TOOLS = ['previewSystemArchitectureMutation', 'applySystemArchitectureMutation', 'addArchitectureElement', 'updateArchitectureElement', 'removeArchitectureElement', 'addArchitectureRelationship', 'updateArchitectureRelationship', 'removeArchitectureRelationship', 'addArchitectureView', 'updateArchitectureView', 'removeArchitectureView'];
34
- const VALIDATOR_TOOLS = ['validateSystemArchitecture', 'runArchitectureTests', 'initializeWorkspace'];
35
- const REPO_TOOLS = ['read', 'grep', 'glob', 'list', 'bash', 'webfetch', 'edit', 'write'];
36
-
37
- function backendOf(tool) {
38
- const n = String(tool || '');
39
- const hit = (l) => l.some(t => n.includes(t));
40
- if (hit(GRAPH_WRITE_TOOLS)) return 'graph';
41
- if (hit(GRAPH_TOOLS)) return 'graph';
42
- if (hit(VALIDATOR_TOOLS)) return 'framework';
43
- if (hit(REPO_TOOLS)) return 'repo';
44
- return 'other';
45
- }
46
-
47
- function classifyQuery(tool, input) {
48
- const n = String(tool || '');
49
- if (n.includes('question')) return 'human-wait';
50
- if (n.includes('task')) return 'subagent';
51
- if (n.includes('bash')) return 'bash';
52
- if (n.includes('getSystemArchitecture') || n.includes('memory_search')) return 'semantic';
53
- if (n.includes('queryNeo4jGraph')) return 'structured-cypher';
54
- if (n.includes('getIntentElementContext') || n.includes('getArchitectureViewContext')) return 'structured-context';
55
- if (n.includes('validateSystemArchitecture') || n.includes('runArchitectureTests')) return 'validate';
56
- if (n.includes('initializeWorkspace')) return 'init';
57
- if (n.includes('previewSystemArchitectureMutation') || GRAPH_WRITE_TOOLS.some(t => n.includes(t))) return 'mutation';
58
- if (n.includes('read')) return 'file-read';
59
- if (n.includes('grep')) return 'grep';
60
- if (n.includes('glob') || n.includes('list')) return 'list';
61
- if (n.includes('edit') || n.includes('write')) return 'edit';
62
- if (n.includes('todowrite')) return 'todo';
63
- return 'other';
64
- }
65
-
66
- // Tools that block on a HUMAN (time spent waiting for the person, not the agent).
67
- const HUMAN_WAIT_TOOLS = ['question'];
68
- const CONTEXT_BLOWUP_TOKENS = 100000;
69
-
70
- function estimateTokens(text) {
71
- if (!text) return 0;
72
- const s = String(text);
73
- const cjk = (s.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]/g) || []).length;
74
- return cjk + Math.ceil((s.length - cjk) / 4);
75
- }
76
-
77
- function stableStringify(value) {
78
- if (value === null || typeof value !== 'object') return JSON.stringify(value);
79
- if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
80
- return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
81
- }
82
-
83
- function signature(tool, input) {
84
- try { return crypto.createHash('sha1').update(String(tool) + '|' + stableStringify(input || {})).digest('hex').slice(0, 12); }
85
- catch (_) { return 'unknown'; }
86
- }
87
-
88
- function inputPreview(tool, input) {
89
- if (!input || typeof input !== 'object') return '';
90
- const pick = input.query && (input.query.intent || input.query) || input.pattern || input.elementId || input.elementName || input.view_id || input.path || input.filePath || input.cypher || input.intent;
91
- const s = typeof pick === 'string' ? pick : (pick ? JSON.stringify(pick) : JSON.stringify(input));
92
- return String(s).slice(0, 160);
93
- }
94
-
95
- function eventTime(e) {
96
- const cands = [e.time, e.part && e.part.time, e.part && e.part.state && e.part.state.time];
97
- for (const t of cands) {
98
- if (!t) continue;
99
- if (typeof t === 'number') return t;
100
- if (typeof t.start === 'number' && typeof t.end === 'number') return t.end;
101
- if (typeof t.start === 'number') return t.start;
102
- if (typeof t.end === 'number') return t.end;
103
- }
104
- return typeof e.timestamp === 'number' ? e.timestamp : null;
105
- }
106
-
107
- function isExportJson(text) {
108
- const t = String(text || '').trim();
109
- if (!t.startsWith('{')) return false;
110
- try { const j = JSON.parse(t); return !!(j && Array.isArray(j.messages)); } catch (_) { return false; }
111
- }
112
-
113
- // Accept BOTH the live event stream (opencode run --format json, NDJSON) and the
114
- // session export JSON (opencode export -> { info, messages:[{info, parts:[...]}] }).
115
- function exportToNdjson(text) {
116
- const j = JSON.parse(text);
117
- const ev = [];
118
- for (const m of j.messages || []) {
119
- const info = m.info || {};
120
- const ts = info.time && (info.time.created || info.time.completed);
121
- for (const p of m.parts || []) {
122
- if (!p || typeof p !== 'object') continue;
123
- if (p.type === 'tool') ev.push({ type: 'tool', timestamp: (p.state && p.state.time && p.state.time.end) || ts, part: p });
124
- else if (p.type === 'step-start') ev.push({ type: 'step_start', timestamp: ts, part: p });
125
- else if (p.type === 'text') ev.push({ type: 'text', timestamp: ts, part: p });
126
- }
127
- if (info.role === 'assistant' && info.tokens) ev.push({ type: 'step_finish', timestamp: info.time && info.time.completed, part: { tokens: info.tokens, cost: info.cost } });
128
- }
129
- return ev.map(e => JSON.stringify(e)).join('\n') + '\n';
130
- }
131
-
132
- function parseSession(text) {
133
- const raw = String(text || '');
134
- const toolCalls = [];
135
- const usages = [];
136
- let steps = 0; let tMin = null; let tMax = null; let cost = 0;
137
- const texts = [];
138
- for (const line of raw.split('\n')) {
139
- const t = line.trim();
140
- if (!t.startsWith('{')) continue;
141
- let e; try { e = JSON.parse(t); } catch (_) { continue; }
142
- const part = e.part || e;
143
- if (part && part.type === 'text' && part.text) texts.push(part.text);
144
- if (part && part.type === 'tool') {
145
- const tool = part.tool || part.name || (part.state && part.state.tool) || 'tool';
146
- const st = part.state || {};
147
- const tm = st.time || {};
148
- const out = st.output != null ? String(st.output) : '';
149
- toolCalls.push({
150
- tool, backend: backendOf(tool), queryClass: classifyQuery(tool, st.input),
151
- durationMs: (typeof tm.start === 'number' && typeof tm.end === 'number') ? Math.max(0, tm.end - tm.start) : null,
152
- ok: st.status ? st.status !== 'error' : true,
153
- error: st.error ? String(st.error).slice(0, 200) : null,
154
- signature: signature(tool, st.input), inputPreview: inputPreview(tool, st.input),
155
- outputBytes: out.length, outputTokens: estimateTokens(out),
156
- path: (st.input && (st.input.filePath || st.input.path || st.input.file)) || null,
157
- });
158
- }
159
- if (e.type === 'step_start') steps += 1;
160
- if (e.type === 'step_finish') {
161
- const fin = (e.part && e.part.finish) ? e.part.finish : (e.part || e);
162
- if (fin && typeof fin.cost === 'number') cost += fin.cost;
163
- const tk = fin && (fin.tokens || (fin.finish && fin.finish.tokens));
164
- if (tk) usages.push({ input: tk.input || 0, output: tk.output || 0, reasoning: tk.reasoning || 0, cacheRead: (tk.cache && tk.cache.read) || 0, cacheWrite: (tk.cache && tk.cache.write) || 0 });
165
- }
166
- const time = eventTime(e);
167
- if (time !== null) { tMin = tMin === null ? time : Math.min(tMin, time); tMax = tMax === null ? time : Math.max(tMax, time); }
168
- }
169
- const tokensIn = usages.reduce((a, u) => a + u.input, 0);
170
- const tokensOut = usages.reduce((a, u) => a + u.output, 0);
171
- const tokensReasoning = usages.reduce((a, u) => a + u.reasoning, 0);
172
- return { toolCalls, usages, texts, steps, cost, wallMs: (tMin !== null && tMax !== null && tMax >= tMin) ? tMax - tMin : null, tokensIn, tokensOut, tokensReasoning, tokens: tokensIn + tokensOut + tokensReasoning };
173
- }
174
-
175
- function countRoundTrips(toolCalls) {
176
- const seq = toolCalls.map(c => c.backend).filter(b => b === 'graph' || b === 'repo');
177
- let n = 0;
178
- for (let i = 1; i < seq.length; i++) if (seq[i] !== seq[i - 1]) n += 1;
179
- return n;
180
- }
181
-
182
- function diagnose(session, opts = {}) {
183
- const tc = session.toolCalls;
184
- const byTool = {};
185
- const byBackend = { graph: { calls: 0, ms: 0 }, repo: { calls: 0, ms: 0 }, framework: { calls: 0, ms: 0 }, other: { calls: 0, ms: 0 } };
186
- const byQueryClass = {};
187
- const sigCount = {};
188
- const pathReads = {};
189
- let toolMs = 0; let humanWaitMs = 0; let errors = 0; let empty = 0;
190
- for (const c of tc) {
191
- const isHuman = c.queryClass === 'human-wait' || HUMAN_WAIT_TOOLS.some(t => String(c.tool).includes(t));
192
- const bt = byTool[c.tool] || (byTool[c.tool] = { calls: 0, ms: 0, tokens: 0, errors: 0 });
193
- bt.calls += 1; bt.ms += c.durationMs || 0; bt.tokens += c.outputTokens || 0;
194
- if (isHuman) {
195
- humanWaitMs += c.durationMs || 0; // human time is NOT agent/tool work
196
- } else {
197
- const b = byBackend[c.backend] || (byBackend[c.backend] = { calls: 0, ms: 0 });
198
- b.calls += 1; b.ms += c.durationMs || 0;
199
- toolMs += c.durationMs || 0;
200
- }
201
- if (c.ok === false) { bt.errors += 1; errors += 1; }
202
- if ((c.outputBytes || 0) < 2) empty += 1;
203
- byQueryClass[c.queryClass] = (byQueryClass[c.queryClass] || 0) + 1;
204
- sigCount[c.signature] = (sigCount[c.signature] || 0) + 1;
205
- if (c.queryClass === 'file-read' && c.path) pathReads[c.path] = (pathReads[c.path] || 0) + 1;
206
- }
207
- const duplicates = [];
208
- const seen = new Set();
209
- for (const c of tc) {
210
- if (seen.has(c.signature)) continue;
211
- seen.add(c.signature);
212
- if (sigCount[c.signature] > 1) duplicates.push({ tool: c.tool, queryClass: c.queryClass, count: sigCount[c.signature], preview: c.inputPreview });
213
- }
214
- const repeatedReads = Object.entries(pathReads).filter(([, n]) => n > 1).map(([p, n]) => ({ path: p, count: n }));
215
- // longest no-progress streak: consecutive calls that were empty/failed/duplicate
216
- let streak = 0; let best = 0;
217
- for (const c of tc) {
218
- const bad = c.ok === false || (c.outputBytes || 0) < 2 || sigCount[c.signature] > 1;
219
- streak = bad ? streak + 1 : 0;
220
- if (streak > best) best = streak;
221
- }
222
- const cumulativeInput = [];
223
- let acc = 0;
224
- for (const u of session.usages) { acc += u.input; cumulativeInput.push(u.input); }
225
- const topByMs = [...tc].sort((a, b) => (b.durationMs || 0) - (a.durationMs || 0)).slice(0, 5).map(c => ({ tool: c.tool, ms: c.durationMs, ok: c.ok, preview: c.inputPreview }));
226
- const topByTokens = [...tc].sort((a, b) => (b.outputTokens || 0) - (a.outputTokens || 0)).slice(0, 5).map(c => ({ tool: c.tool, tokens: c.outputTokens, preview: c.inputPreview }));
227
-
228
- const wallMs = opts.wallMs != null ? opts.wallMs : session.wallMs;
229
- const modelMs = wallMs != null ? Math.max(0, wallMs - toolMs - humanWaitMs) : null;
230
- const blowups = cumulativeInput.map((v, i) => ({ step: i, input: v })).filter(x => x.input >= CONTEXT_BLOWUP_TOKENS).sort((a, b) => b.input - a.input);
231
-
232
- return {
233
- schemaVersion: SCHEMA_VERSION, bundleVersion: BUNDLE_VERSION, generatedAt: new Date().toISOString(),
234
- workspace: opts.workspace || null, sessionId: opts.sessionId || null,
235
- overview: {
236
- steps: session.steps, toolCalls: tc.length, roundTrips: countRoundTrips(tc),
237
- wallMs, modelMs, mcpMs: byBackend.graph.ms, repoToolMs: byBackend.repo.ms, toolMs, humanWaitMs,
238
- tokensIn: session.tokensIn, tokensOut: session.tokensOut, tokensReasoning: session.tokensReasoning, tokens: session.tokens,
239
- cost: session.cost, toolErrors: errors, emptyResults: empty,
240
- distinctSignatures: new Set(tc.map(c => c.signature)).size,
241
- contextBlowup: { thresholdTokens: CONTEXT_BLOWUP_TOKENS, count: blowups.length, max: blowups.length ? blowups[0].input : 0, top: blowups.slice(0, 5) },
242
- },
243
- byTool, byBackend, byQueryClass,
244
- tokenGrowth: { perStepInput: cumulativeInput, steps: cumulativeInput.length },
245
- overSearch: {
246
- duplicateCalls: duplicates,
247
- repeatedReads,
248
- emptyOrError: errors + empty,
249
- noProgressStreak: best,
250
- },
251
- topOffendersByTime: topByMs,
252
- topOffendersByTokens: topByTokens,
253
- hints: buildHints({ toolCalls: tc.length, duplicates: duplicates.length, emptyOrError: errors + empty, repeatedReads: repeatedReads.length, noProgressStreak: best, modelMs, mcpMs: byBackend.graph.ms, repoToolMs: byBackend.repo.ms, roundTrips: countRoundTrips(tc), blowups: blowups.length, blowupMax: blowups.length ? blowups[0].input : 0 }),
254
- };
255
- }
256
-
257
- function buildHints(m) {
258
- const hints = [];
259
- if (m.blowups > 0) hints.push(`上下文尖峰 ${m.blowups} 个 step(最大 ${m.blowupMax} tokens)→ 多由大工具输出造成;优先考虑截断/分页/摘要工具输出(observation masking)、先摘要后精读(对所有场景统一生效,勿只针对单会话)。`);
260
- if (m.duplicates > 0) hints.push(`重复/近似重复调用 ${m.duplicates} 组 → 考虑结果缓存或查询归一(同参不重搜)。`);
261
- if (m.emptyOrError > 0) hints.push(`空结果/失败 ${m.emptyOrError} 次 → 考虑改进查询构造/回退策略,避免"空手→换词→再搜"的循环。`);
262
- if (m.repeatedReads > 0) hints.push(`同一文件被重复读 ${m.repeatedReads} 处 → 考虑读取缓存或先摘要后精读。`);
263
- if (m.noProgressStreak >= 3) hints.push(`最长"无进展"连续 ${m.noProgressStreak} 次 → 缺停止判据/预算,建议显式设停止条件。`);
264
- if (m.roundTrips >= 2) hints.push(`图↔仓往返 ${m.roundTrips} 次 → 检查是否可在一次规划内并发取数。`);
265
- if (m.modelMs != null && m.modelMs > (m.mcpMs + m.repoToolMs)) hints.push(`模型耗时主导(${m.modelMs}ms > 工具 ${(m.mcpMs + m.repoToolMs)}ms)→ 考虑上下文压缩/减少轮次。`);
266
- if (hints.length === 0) hints.push('未发现明显过度搜索信号。');
267
- return hints;
268
- }
269
-
270
- function renderDiagnosis(m, meta) {
271
- const L = [];
272
- L.push(`# Agent 搜索诊断报告`);
273
- L.push('');
274
- L.push(`- 生成时间:${m.generatedAt}`);
275
- L.push(`- 会话:${m.sessionId || '(unknown)'} 工作区:${m.workspace || '(unknown)'}`);
276
- L.push(`- bundle 版本:${m.bundleVersion} schema:${m.schemaVersion}`);
277
- L.push('');
278
- L.push(`## 概览`);
279
- L.push('');
280
- L.push(`| 指标 | 值 |`);
281
- L.push(`|---|---|`);
282
- L.push(`| 轮次 steps | ${m.overview.steps} |`);
283
- L.push(`| 工具调用 | ${m.overview.toolCalls}(去重签名 ${m.overview.distinctSignatures}) |`);
284
- L.push(`| 图↔仓往返 | ${m.overview.roundTrips} |`);
285
- L.push(`| 墙钟 | ${fmtMs(m.overview.wallMs)} = 模型 ${fmtMs(m.overview.modelMs)} + MCP ${fmtMs(m.overview.mcpMs)} + 仓 ${fmtMs(m.overview.repoToolMs)} + 人类等待 ${fmtMs(m.overview.humanWaitMs)} |`);
286
- L.push(`| tokens | 总 ${m.overview.tokens}(in ${m.overview.tokensIn} / out ${m.overview.tokensOut} / reason ${m.overview.tokensReasoning}) |`);
287
- L.push(`| 工具失败 / 空结果 | ${m.overview.toolErrors} / ${m.overview.emptyResults} |`);
288
- L.push('');
289
- L.push(`## 过度搜索信号`);
290
- L.push('');
291
- L.push(`- 重复调用组:${m.overSearch.duplicateCalls.length}`);
292
- for (const d of m.overSearch.duplicateCalls.slice(0, 10)) L.push(` - ×${d.count} ${d.tool} [${d.queryClass}] ${d.preview}`);
293
- L.push(`- 重复读同一文件:${m.overSearch.repeatedReads.length}`);
294
- for (const r of m.overSearch.repeatedReads.slice(0, 10)) L.push(` - ×${r.count} ${r.path}`);
295
- L.push(`- 空结果/失败合计:${m.overSearch.emptyOrError}`);
296
- L.push(`- 最长无进展连续:${m.overSearch.noProgressStreak}`);
297
- L.push('');
298
- L.push(`## 后端 / 查询类分布`);
299
- L.push('');
300
- L.push(`- 后端:` + Object.entries(m.byBackend).map(([k, v]) => `${k}=${v.calls}`).join(' '));
301
- L.push(`- 查询类:` + Object.entries(m.byQueryClass).map(([k, v]) => `${k}=${v}`).join(' '));
302
- L.push('');
303
- L.push(`## 最贵调用(按耗时)`);
304
- L.push('');
305
- for (const c of m.topOffendersByTime) L.push(`- ${fmtMs(c.ms)} ${c.tool}${c.ok ? '' : ' (error)'} ${c.preview}`);
306
- L.push('');
307
- L.push(`## token 随轮次`);
308
- L.push('');
309
- L.push(`每步 input tokens(累计上下文规模):${m.tokenGrowth.perStepInput.join(', ') || '(无)'}`);
310
- L.push('');
311
- const cb = m.overview.contextBlowup || { count: 0, max: 0, top: [] };
312
- L.push(`## 上下文尖峰(≥ ${cb.thresholdTokens} tokens 的 step)`);
313
- L.push('');
314
- L.push(`- 尖峰数:${cb.count} 最大:${cb.max} tokens`);
315
- for (const b of cb.top) L.push(` - step ${b.step}: ${b.input} input tokens`);
316
- L.push('');
317
- L.push(`## 诊断建议(启发式,供 Agent 复核)`);
318
- L.push('');
319
- for (const h of m.hints) L.push(`- ${h}`);
320
- L.push('');
321
- L.push(`---`);
322
- L.push(`> 本报告由 agentSearchDiagnose 自动生成;请 Agent 结合 metrics.json 与会话片段复核并补充解读。`);
323
- L.push('');
324
- return L.join('\n');
325
- }
326
-
327
- function fmtMs(ms) {
328
- if (ms == null) return 'n/a';
329
- return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
330
- }
331
-
332
- function writeBundle(opts) {
333
- const workspace = opts.workspace || process.cwd();
334
- const rawInput = fs.readFileSync(opts.session, 'utf8');
335
- const sessionText = isExportJson(rawInput) ? exportToNdjson(rawInput) : rawInput;
336
- const session = parseSession(sessionText);
337
- const sessionId = opts.sessionId || inferSessionId(sessionText) || 'session';
338
- const outDir = opts.out || path.join(workspace, '.argo', 'temp', 'diagnosis', sessionId);
339
- fs.mkdirSync(outDir, { recursive: true });
340
-
341
- const costLogPath = opts.costLog || path.join(workspace, '.argo', 'temp', 'agent-cost-log.ndjson');
342
- const costLogText = readMaybe(costLogPath);
343
-
344
- const metrics = diagnose(session, { workspace, sessionId, wallMs: opts.wallMs });
345
- const files = [];
346
- const write = (name, body) => { const p = path.join(outDir, name); fs.writeFileSync(p, body); files.push({ path: name, bytes: Buffer.byteLength(body) }); };
347
-
348
- write('session.ndjson', sessionText);
349
- write('cost-log.slice.ndjson', sliceCostLog(costLogText, sessionId));
350
- write('metrics.json', JSON.stringify(metrics, null, 2) + '\n');
351
- write('diagnosis.md', renderDiagnosis(metrics, { costLogPath }));
352
-
353
- const manifest = {
354
- schemaVersion: SCHEMA_VERSION, bundleVersion: BUNDLE_VERSION, generatedAt: metrics.generatedAt,
355
- workspace, sessionId, sources: { session: path.resolve(opts.session), costLog: costLogPath },
356
- files,
357
- };
358
- fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
359
- return { outDir, metrics, manifest };
360
- }
361
-
362
- function readMaybe(file) { try { return fs.readFileSync(file, 'utf8'); } catch (_) { return ''; } }
363
-
364
- function inferSessionId(text) {
365
- const m = String(text).match(/"sessionID"\s*:\s*"([^"]+)"/);
366
- return m ? m[1] : null;
367
- }
368
-
369
- function sliceCostLog(text, sessionId) {
370
- if (!text) return '';
371
- const lines = text.split('\n').filter(Boolean);
372
- const hit = lines.filter(l => !sessionId || l.includes(sessionId));
373
- return (hit.length ? hit : []).join('\n') + (hit.length ? '\n' : '');
374
- }
375
-
376
- function parseArgs(argv) {
377
- const a = { session: null, sessionId: null, costLog: null, workspace: process.cwd(), out: null, json: false };
378
- for (let i = 0; i < argv.length; i++) {
379
- const k = argv[i];
380
- if (k === '--session') a.session = argv[++i];
381
- else if (k === '--session-id') a.sessionId = argv[++i];
382
- else if (k === '--cost-log') a.costLog = argv[++i];
383
- else if (k === '--workspace') a.workspace = argv[++i];
384
- else if (k === '--out') a.out = argv[++i];
385
- else if (k === '--json') a.json = true;
386
- }
387
- return a;
388
- }
389
-
390
- function main(argv) {
391
- const args = parseArgs(argv || process.argv.slice(2));
392
- if (!args.session || !fs.existsSync(args.session)) {
393
- console.error('agentSearchDiagnose: --session <session.ndjson> is required');
394
- return 2;
395
- }
396
- const { outDir, metrics, manifest } = writeBundle(args);
397
- if (args.json) { console.log(JSON.stringify({ outDir, overview: metrics.overview, hints: metrics.hints }, null, 2)); return 0; }
398
- console.log(`diagnosis bundle -> ${outDir}`);
399
- console.log(`files: ${manifest.files.map(f => f.path).join(', ')}`);
400
- console.log(`overview: steps=${metrics.overview.steps} tools=${metrics.overview.toolCalls} roundTrips=${metrics.overview.roundTrips} tokens=${metrics.overview.tokens} model=${fmtMs(metrics.overview.modelMs)} mcp=${fmtMs(metrics.overview.mcpMs)} repo=${fmtMs(metrics.overview.repoToolMs)}`);
401
- for (const h of metrics.hints) console.log(`hint: ${h}`);
402
- return 0;
403
- }
404
-
405
- module.exports = { BUNDLE_VERSION, SCHEMA_VERSION, parseSession, classifyQuery, backendOf, signature, diagnose, renderDiagnosis, writeBundle, sliceCostLog, isExportJson, exportToNdjson, main };
406
-
407
- if (require.main === module) process.exit(main());
1
+ 'use strict';
2
+ /**
3
+ * Agent search diagnosis (framework, zero-dependency).
4
+ *
5
+ * Turns ONE agent session into a self-contained diagnostic bundle so the user
6
+ * can hand it back for analysis:
7
+ *
8
+ * <workspace>/.argo/temp/diagnosis/<session-id>/
9
+ * ├─ diagnosis.md human-readable summary (incl. heuristic hints)
10
+ * ├─ metrics.json the numbers behind the summary
11
+ * ├─ session.ndjson the raw host session (full tool input/output)
12
+ * ├─ cost-log.slice.ndjson the matching slice of the background cost log
13
+ * └─ manifest.json file list + sizes + versions
14
+ *
15
+ * Data sources (user's choice: both):
16
+ * - PRIMARY: the host session NDJSON (opencode export / run --format json),
17
+ * which carries every tool's full input/output + timestamps + tokens.
18
+ * - SUPPLEMENT: the framework cost log <workspace>/.argo/temp/agent-cost-log.ndjson.
19
+ *
20
+ * Usage:
21
+ * node agentSearchDiagnose.js --session <session.ndjson> [--session-id ID]
22
+ * [--cost-log <path>] [--workspace <root>] [--out <dir>] [--json]
23
+ *
24
+ * Read-only except for writing the bundle dir. Never mutates the graph or repo.
25
+ */
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+ const crypto = require('node:crypto');
29
+
30
+ const BUNDLE_VERSION = 'temp2.2';
31
+ const SCHEMA_VERSION = 1;
32
+ const GRAPH_TOOLS = ['getSystemArchitecture', 'getIntentElementContext', 'getArchitectureViewContext', 'queryNeo4jGraph', 'memory_search'];
33
+ const GRAPH_WRITE_TOOLS = ['previewSystemArchitectureMutation', 'applySystemArchitectureMutation', 'addArchitectureElement', 'updateArchitectureElement', 'removeArchitectureElement', 'addArchitectureRelationship', 'updateArchitectureRelationship', 'removeArchitectureRelationship', 'addArchitectureView', 'updateArchitectureView', 'removeArchitectureView'];
34
+ const VALIDATOR_TOOLS = ['validateSystemArchitecture', 'runArchitectureTests', 'initializeWorkspace'];
35
+ const REPO_TOOLS = ['read', 'grep', 'glob', 'list', 'bash', 'webfetch', 'edit', 'write'];
36
+
37
+ function backendOf(tool) {
38
+ const n = String(tool || '');
39
+ const hit = (l) => l.some(t => n.includes(t));
40
+ if (hit(GRAPH_WRITE_TOOLS)) return 'graph';
41
+ if (hit(GRAPH_TOOLS)) return 'graph';
42
+ if (hit(VALIDATOR_TOOLS)) return 'framework';
43
+ if (hit(REPO_TOOLS)) return 'repo';
44
+ return 'other';
45
+ }
46
+
47
+ function classifyQuery(tool, input) {
48
+ const n = String(tool || '');
49
+ if (n.includes('question')) return 'human-wait';
50
+ if (n.includes('task')) return 'subagent';
51
+ if (n.includes('bash')) return 'bash';
52
+ if (n.includes('getSystemArchitecture') || n.includes('memory_search')) return 'semantic';
53
+ if (n.includes('queryNeo4jGraph')) return 'structured-cypher';
54
+ if (n.includes('getIntentElementContext') || n.includes('getArchitectureViewContext')) return 'structured-context';
55
+ if (n.includes('validateSystemArchitecture') || n.includes('runArchitectureTests')) return 'validate';
56
+ if (n.includes('initializeWorkspace')) return 'init';
57
+ if (n.includes('previewSystemArchitectureMutation') || GRAPH_WRITE_TOOLS.some(t => n.includes(t))) return 'mutation';
58
+ if (n.includes('read')) return 'file-read';
59
+ if (n.includes('grep')) return 'grep';
60
+ if (n.includes('glob') || n.includes('list')) return 'list';
61
+ if (n.includes('edit') || n.includes('write')) return 'edit';
62
+ if (n.includes('todowrite')) return 'todo';
63
+ return 'other';
64
+ }
65
+
66
+ // Tools that block on a HUMAN (time spent waiting for the person, not the agent).
67
+ const HUMAN_WAIT_TOOLS = ['question'];
68
+ const CONTEXT_BLOWUP_TOKENS = 100000;
69
+
70
+ function estimateTokens(text) {
71
+ if (!text) return 0;
72
+ const s = String(text);
73
+ const cjk = (s.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]/g) || []).length;
74
+ return cjk + Math.ceil((s.length - cjk) / 4);
75
+ }
76
+
77
+ function stableStringify(value) {
78
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
79
+ if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
80
+ return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
81
+ }
82
+
83
+ function signature(tool, input) {
84
+ try { return crypto.createHash('sha1').update(String(tool) + '|' + stableStringify(input || {})).digest('hex').slice(0, 12); }
85
+ catch (_) { return 'unknown'; }
86
+ }
87
+
88
+ function inputPreview(tool, input) {
89
+ if (!input || typeof input !== 'object') return '';
90
+ const pick = input.query && (input.query.intent || input.query) || input.pattern || input.elementId || input.elementName || input.view_id || input.path || input.filePath || input.cypher || input.intent;
91
+ const s = typeof pick === 'string' ? pick : (pick ? JSON.stringify(pick) : JSON.stringify(input));
92
+ return String(s).slice(0, 160);
93
+ }
94
+
95
+ function eventTime(e) {
96
+ const cands = [e.time, e.part && e.part.time, e.part && e.part.state && e.part.state.time];
97
+ for (const t of cands) {
98
+ if (!t) continue;
99
+ if (typeof t === 'number') return t;
100
+ if (typeof t.start === 'number' && typeof t.end === 'number') return t.end;
101
+ if (typeof t.start === 'number') return t.start;
102
+ if (typeof t.end === 'number') return t.end;
103
+ }
104
+ return typeof e.timestamp === 'number' ? e.timestamp : null;
105
+ }
106
+
107
+ function isExportJson(text) {
108
+ const t = String(text || '').trim();
109
+ if (!t.startsWith('{')) return false;
110
+ try { const j = JSON.parse(t); return !!(j && Array.isArray(j.messages)); } catch (_) { return false; }
111
+ }
112
+
113
+ // Accept BOTH the live event stream (opencode run --format json, NDJSON) and the
114
+ // session export JSON (opencode export -> { info, messages:[{info, parts:[...]}] }).
115
+ function exportToNdjson(text) {
116
+ const j = JSON.parse(text);
117
+ const ev = [];
118
+ for (const m of j.messages || []) {
119
+ const info = m.info || {};
120
+ const ts = info.time && (info.time.created || info.time.completed);
121
+ for (const p of m.parts || []) {
122
+ if (!p || typeof p !== 'object') continue;
123
+ if (p.type === 'tool') ev.push({ type: 'tool', timestamp: (p.state && p.state.time && p.state.time.end) || ts, part: p });
124
+ else if (p.type === 'step-start') ev.push({ type: 'step_start', timestamp: ts, part: p });
125
+ else if (p.type === 'text') ev.push({ type: 'text', timestamp: ts, part: p });
126
+ }
127
+ if (info.role === 'assistant' && info.tokens) ev.push({ type: 'step_finish', timestamp: info.time && info.time.completed, part: { tokens: info.tokens, cost: info.cost } });
128
+ }
129
+ return ev.map(e => JSON.stringify(e)).join('\n') + '\n';
130
+ }
131
+
132
+ function parseSession(text) {
133
+ const raw = String(text || '');
134
+ const toolCalls = [];
135
+ const usages = [];
136
+ let steps = 0; let tMin = null; let tMax = null; let cost = 0;
137
+ const texts = [];
138
+ for (const line of raw.split('\n')) {
139
+ const t = line.trim();
140
+ if (!t.startsWith('{')) continue;
141
+ let e; try { e = JSON.parse(t); } catch (_) { continue; }
142
+ const part = e.part || e;
143
+ if (part && part.type === 'text' && part.text) texts.push(part.text);
144
+ if (part && part.type === 'tool') {
145
+ const tool = part.tool || part.name || (part.state && part.state.tool) || 'tool';
146
+ const st = part.state || {};
147
+ const tm = st.time || {};
148
+ const out = st.output != null ? String(st.output) : '';
149
+ toolCalls.push({
150
+ tool, backend: backendOf(tool), queryClass: classifyQuery(tool, st.input),
151
+ durationMs: (typeof tm.start === 'number' && typeof tm.end === 'number') ? Math.max(0, tm.end - tm.start) : null,
152
+ ok: st.status ? st.status !== 'error' : true,
153
+ error: st.error ? String(st.error).slice(0, 200) : null,
154
+ signature: signature(tool, st.input), inputPreview: inputPreview(tool, st.input),
155
+ outputBytes: out.length, outputTokens: estimateTokens(out),
156
+ path: (st.input && (st.input.filePath || st.input.path || st.input.file)) || null,
157
+ });
158
+ }
159
+ if (e.type === 'step_start') steps += 1;
160
+ if (e.type === 'step_finish') {
161
+ const fin = (e.part && e.part.finish) ? e.part.finish : (e.part || e);
162
+ if (fin && typeof fin.cost === 'number') cost += fin.cost;
163
+ const tk = fin && (fin.tokens || (fin.finish && fin.finish.tokens));
164
+ if (tk) usages.push({ input: tk.input || 0, output: tk.output || 0, reasoning: tk.reasoning || 0, cacheRead: (tk.cache && tk.cache.read) || 0, cacheWrite: (tk.cache && tk.cache.write) || 0 });
165
+ }
166
+ const time = eventTime(e);
167
+ if (time !== null) { tMin = tMin === null ? time : Math.min(tMin, time); tMax = tMax === null ? time : Math.max(tMax, time); }
168
+ }
169
+ const tokensIn = usages.reduce((a, u) => a + u.input, 0);
170
+ const tokensOut = usages.reduce((a, u) => a + u.output, 0);
171
+ const tokensReasoning = usages.reduce((a, u) => a + u.reasoning, 0);
172
+ return { toolCalls, usages, texts, steps, cost, wallMs: (tMin !== null && tMax !== null && tMax >= tMin) ? tMax - tMin : null, tokensIn, tokensOut, tokensReasoning, tokens: tokensIn + tokensOut + tokensReasoning };
173
+ }
174
+
175
+ function countRoundTrips(toolCalls) {
176
+ const seq = toolCalls.map(c => c.backend).filter(b => b === 'graph' || b === 'repo');
177
+ let n = 0;
178
+ for (let i = 1; i < seq.length; i++) if (seq[i] !== seq[i - 1]) n += 1;
179
+ return n;
180
+ }
181
+
182
+ function diagnose(session, opts = {}) {
183
+ const tc = session.toolCalls;
184
+ const byTool = {};
185
+ const byBackend = { graph: { calls: 0, ms: 0 }, repo: { calls: 0, ms: 0 }, framework: { calls: 0, ms: 0 }, other: { calls: 0, ms: 0 } };
186
+ const byQueryClass = {};
187
+ const sigCount = {};
188
+ const pathReads = {};
189
+ let toolMs = 0; let humanWaitMs = 0; let errors = 0; let empty = 0; let emptyOrError = 0;
190
+ for (const c of tc) {
191
+ const isHuman = c.queryClass === 'human-wait' || HUMAN_WAIT_TOOLS.some(t => String(c.tool).includes(t));
192
+ const bt = byTool[c.tool] || (byTool[c.tool] = { calls: 0, ms: 0, tokens: 0, errors: 0 });
193
+ bt.calls += 1; bt.ms += c.durationMs || 0; bt.tokens += c.outputTokens || 0;
194
+ if (isHuman) {
195
+ humanWaitMs += c.durationMs || 0; // human time is NOT agent/tool work
196
+ } else {
197
+ const b = byBackend[c.backend] || (byBackend[c.backend] = { calls: 0, ms: 0 });
198
+ b.calls += 1; b.ms += c.durationMs || 0;
199
+ toolMs += c.durationMs || 0;
200
+ }
201
+ if (c.ok === false) { bt.errors += 1; errors += 1; }
202
+ if ((c.outputBytes || 0) < 2) empty += 1;
203
+ // A failed call with empty output is ONE unproductive call, not two — count the
204
+ // union so the hint does not overstate "empty/error" (issue #8 口径).
205
+ if (c.ok === false || (c.outputBytes || 0) < 2) emptyOrError += 1;
206
+ byQueryClass[c.queryClass] = (byQueryClass[c.queryClass] || 0) + 1;
207
+ sigCount[c.signature] = (sigCount[c.signature] || 0) + 1;
208
+ if (c.queryClass === 'file-read' && c.path) pathReads[c.path] = (pathReads[c.path] || 0) + 1;
209
+ }
210
+ const duplicates = [];
211
+ const seen = new Set();
212
+ for (const c of tc) {
213
+ if (seen.has(c.signature)) continue;
214
+ seen.add(c.signature);
215
+ if (sigCount[c.signature] > 1) duplicates.push({ tool: c.tool, queryClass: c.queryClass, count: sigCount[c.signature], preview: c.inputPreview });
216
+ }
217
+ const repeatedReads = Object.entries(pathReads).filter(([, n]) => n > 1).map(([p, n]) => ({ path: p, count: n }));
218
+ // longest no-progress streak: consecutive calls that were empty/failed/duplicate
219
+ let streak = 0; let best = 0;
220
+ for (const c of tc) {
221
+ const bad = c.ok === false || (c.outputBytes || 0) < 2 || sigCount[c.signature] > 1;
222
+ streak = bad ? streak + 1 : 0;
223
+ if (streak > best) best = streak;
224
+ }
225
+ const cumulativeInput = [];
226
+ let acc = 0;
227
+ for (const u of session.usages) { acc += u.input; cumulativeInput.push(u.input); }
228
+ const topByMs = [...tc].sort((a, b) => (b.durationMs || 0) - (a.durationMs || 0)).slice(0, 5).map(c => ({ tool: c.tool, ms: c.durationMs, ok: c.ok, preview: c.inputPreview }));
229
+ const topByTokens = [...tc].sort((a, b) => (b.outputTokens || 0) - (a.outputTokens || 0)).slice(0, 5).map(c => ({ tool: c.tool, tokens: c.outputTokens, preview: c.inputPreview }));
230
+
231
+ const wallMs = opts.wallMs != null ? opts.wallMs : session.wallMs;
232
+ const modelMs = wallMs != null ? Math.max(0, wallMs - toolMs - humanWaitMs) : null;
233
+ const blowups = cumulativeInput.map((v, i) => ({ step: i, input: v })).filter(x => x.input >= CONTEXT_BLOWUP_TOKENS).sort((a, b) => b.input - a.input);
234
+
235
+ return {
236
+ schemaVersion: SCHEMA_VERSION, bundleVersion: BUNDLE_VERSION, generatedAt: new Date().toISOString(),
237
+ workspace: opts.workspace || null, sessionId: opts.sessionId || null,
238
+ overview: {
239
+ steps: session.steps, toolCalls: tc.length, roundTrips: countRoundTrips(tc),
240
+ wallMs, modelMs, mcpMs: byBackend.graph.ms, repoToolMs: byBackend.repo.ms, toolMs, humanWaitMs,
241
+ tokensIn: session.tokensIn, tokensOut: session.tokensOut, tokensReasoning: session.tokensReasoning, tokens: session.tokens,
242
+ cost: session.cost, toolErrors: errors, emptyResults: empty,
243
+ distinctSignatures: new Set(tc.map(c => c.signature)).size,
244
+ contextBlowup: { thresholdTokens: CONTEXT_BLOWUP_TOKENS, count: blowups.length, max: blowups.length ? blowups[0].input : 0, top: blowups.slice(0, 5) },
245
+ },
246
+ byTool, byBackend, byQueryClass,
247
+ tokenGrowth: { perStepInput: cumulativeInput, steps: cumulativeInput.length },
248
+ overSearch: {
249
+ duplicateCalls: duplicates,
250
+ repeatedReads,
251
+ emptyOrError,
252
+ noProgressStreak: best,
253
+ },
254
+ topOffendersByTime: topByMs,
255
+ topOffendersByTokens: topByTokens,
256
+ hints: buildHints({ toolCalls: tc.length, duplicates: duplicates.length, emptyOrError, repeatedReads: repeatedReads.length, noProgressStreak: best, modelMs, mcpMs: byBackend.graph.ms, repoToolMs: byBackend.repo.ms, roundTrips: countRoundTrips(tc), blowups: blowups.length, blowupMax: blowups.length ? blowups[0].input : 0 }),
257
+ };
258
+ }
259
+
260
+ function buildHints(m) {
261
+ const hints = [];
262
+ if (m.blowups > 0) hints.push(`上下文尖峰 ${m.blowups} 个 step(最大 ${m.blowupMax} tokens)→ 多由大工具输出造成;优先考虑截断/分页/摘要工具输出(observation masking)、先摘要后精读(对所有场景统一生效,勿只针对单会话)。`);
263
+ if (m.duplicates > 0) hints.push(`重复/近似重复调用 ${m.duplicates} 组 → 考虑结果缓存或查询归一(同参不重搜)。`);
264
+ if (m.emptyOrError > 0) hints.push(`空结果/失败 ${m.emptyOrError} 次 → 考虑改进查询构造/回退策略,避免"空手→换词→再搜"的循环。`);
265
+ if (m.repeatedReads > 0) hints.push(`同一文件被重复读 ${m.repeatedReads} 处 → 考虑读取缓存或先摘要后精读。`);
266
+ if (m.noProgressStreak >= 3) hints.push(`最长"无进展"连续 ${m.noProgressStreak} 次 → 缺停止判据/预算,建议显式设停止条件。`);
267
+ if (m.roundTrips >= 2) hints.push(`图↔仓往返 ${m.roundTrips} 次 → 检查是否可在一次规划内并发取数。`);
268
+ if (m.modelMs != null && m.modelMs > (m.mcpMs + m.repoToolMs)) hints.push(`模型耗时主导(${m.modelMs}ms > 工具 ${(m.mcpMs + m.repoToolMs)}ms)→ 考虑上下文压缩/减少轮次。`);
269
+ if (hints.length === 0) hints.push('未发现明显过度搜索信号。');
270
+ return hints;
271
+ }
272
+
273
+ function renderDiagnosis(m, meta) {
274
+ const L = [];
275
+ L.push(`# Agent 搜索诊断报告`);
276
+ L.push('');
277
+ L.push(`- 生成时间:${m.generatedAt}`);
278
+ L.push(`- 会话:${m.sessionId || '(unknown)'} 工作区:${m.workspace || '(unknown)'}`);
279
+ L.push(`- bundle 版本:${m.bundleVersion} schema:${m.schemaVersion}`);
280
+ L.push('');
281
+ L.push(`## 概览`);
282
+ L.push('');
283
+ L.push(`| 指标 | 值 |`);
284
+ L.push(`|---|---|`);
285
+ L.push(`| 轮次 steps | ${m.overview.steps} |`);
286
+ L.push(`| 工具调用 | ${m.overview.toolCalls}(去重签名 ${m.overview.distinctSignatures}) |`);
287
+ L.push(`| 图↔仓往返 | ${m.overview.roundTrips} |`);
288
+ L.push(`| 墙钟 | ${fmtMs(m.overview.wallMs)} = 模型 ${fmtMs(m.overview.modelMs)} + MCP ${fmtMs(m.overview.mcpMs)} + 仓 ${fmtMs(m.overview.repoToolMs)} + 人类等待 ${fmtMs(m.overview.humanWaitMs)} |`);
289
+ L.push(`| tokens | 总 ${m.overview.tokens}(in ${m.overview.tokensIn} / out ${m.overview.tokensOut} / reason ${m.overview.tokensReasoning}) |`);
290
+ L.push(`| 工具失败 / 空结果 | ${m.overview.toolErrors} / ${m.overview.emptyResults} |`);
291
+ L.push('');
292
+ L.push(`## 过度搜索信号`);
293
+ L.push('');
294
+ L.push(`- 重复调用组:${m.overSearch.duplicateCalls.length}`);
295
+ for (const d of m.overSearch.duplicateCalls.slice(0, 10)) L.push(` - ×${d.count} ${d.tool} [${d.queryClass}] ${d.preview}`);
296
+ L.push(`- 重复读同一文件:${m.overSearch.repeatedReads.length}`);
297
+ for (const r of m.overSearch.repeatedReads.slice(0, 10)) L.push(` - ×${r.count} ${r.path}`);
298
+ L.push(`- 空结果/失败合计:${m.overSearch.emptyOrError}`);
299
+ L.push(`- 最长无进展连续:${m.overSearch.noProgressStreak}`);
300
+ L.push('');
301
+ L.push(`## 后端 / 查询类分布`);
302
+ L.push('');
303
+ L.push(`- 后端:` + Object.entries(m.byBackend).map(([k, v]) => `${k}=${v.calls}`).join(' '));
304
+ L.push(`- 查询类:` + Object.entries(m.byQueryClass).map(([k, v]) => `${k}=${v}`).join(' '));
305
+ L.push('');
306
+ L.push(`## 最贵调用(按耗时)`);
307
+ L.push('');
308
+ for (const c of m.topOffendersByTime) L.push(`- ${fmtMs(c.ms)} ${c.tool}${c.ok ? '' : ' (error)'} ${c.preview}`);
309
+ L.push('');
310
+ L.push(`## token 随轮次`);
311
+ L.push('');
312
+ L.push(`每步 input tokens(累计上下文规模):${m.tokenGrowth.perStepInput.join(', ') || '(无)'}`);
313
+ L.push('');
314
+ const cb = m.overview.contextBlowup || { count: 0, max: 0, top: [] };
315
+ L.push(`## 上下文尖峰(≥ ${cb.thresholdTokens} tokens 的 step)`);
316
+ L.push('');
317
+ L.push(`- 尖峰数:${cb.count} 最大:${cb.max} tokens`);
318
+ for (const b of cb.top) L.push(` - step ${b.step}: ${b.input} input tokens`);
319
+ L.push('');
320
+ L.push(`## 诊断建议(启发式,供 Agent 复核)`);
321
+ L.push('');
322
+ for (const h of m.hints) L.push(`- ${h}`);
323
+ L.push('');
324
+ L.push(`---`);
325
+ L.push(`> 本报告由 agentSearchDiagnose 自动生成;请 Agent 结合 metrics.json 与会话片段复核并补充解读。`);
326
+ L.push('');
327
+ return L.join('\n');
328
+ }
329
+
330
+ function fmtMs(ms) {
331
+ if (ms == null) return 'n/a';
332
+ return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
333
+ }
334
+
335
+ function writeBundle(opts) {
336
+ const workspace = opts.workspace || process.cwd();
337
+ const rawInput = fs.readFileSync(opts.session, 'utf8');
338
+ const sessionText = isExportJson(rawInput) ? exportToNdjson(rawInput) : rawInput;
339
+ const session = parseSession(sessionText);
340
+ const sessionId = opts.sessionId || inferSessionId(sessionText) || 'session';
341
+ const outDir = opts.out || path.join(workspace, '.argo', 'temp', 'diagnosis', sessionId);
342
+ fs.mkdirSync(outDir, { recursive: true });
343
+
344
+ const costLogPath = opts.costLog || path.join(workspace, '.argo', 'temp', 'agent-cost-log.ndjson');
345
+ const costLogText = readMaybe(costLogPath);
346
+
347
+ const metrics = diagnose(session, { workspace, sessionId, wallMs: opts.wallMs });
348
+ const files = [];
349
+ const write = (name, body) => { const p = path.join(outDir, name); fs.writeFileSync(p, body); files.push({ path: name, bytes: Buffer.byteLength(body) }); };
350
+
351
+ write('session.ndjson', sessionText);
352
+ write('cost-log.slice.ndjson', sliceCostLog(costLogText, sessionId));
353
+ write('metrics.json', JSON.stringify(metrics, null, 2) + '\n');
354
+ write('diagnosis.md', renderDiagnosis(metrics, { costLogPath }));
355
+
356
+ const manifest = {
357
+ schemaVersion: SCHEMA_VERSION, bundleVersion: BUNDLE_VERSION, generatedAt: metrics.generatedAt,
358
+ workspace, sessionId, sources: { session: path.resolve(opts.session), costLog: costLogPath },
359
+ files,
360
+ };
361
+ fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
362
+ return { outDir, metrics, manifest };
363
+ }
364
+
365
+ function readMaybe(file) { try { return fs.readFileSync(file, 'utf8'); } catch (_) { return ''; } }
366
+
367
+ function inferSessionId(text) {
368
+ const m = String(text).match(/"sessionID"\s*:\s*"([^"]+)"/);
369
+ return m ? m[1] : null;
370
+ }
371
+
372
+ function sliceCostLog(text, sessionId) {
373
+ if (!text) return '';
374
+ const lines = text.split('\n').filter(Boolean);
375
+ const hit = lines.filter(l => !sessionId || l.includes(sessionId));
376
+ return (hit.length ? hit : []).join('\n') + (hit.length ? '\n' : '');
377
+ }
378
+
379
+ function parseArgs(argv) {
380
+ const a = { session: null, sessionId: null, costLog: null, workspace: process.cwd(), out: null, json: false };
381
+ for (let i = 0; i < argv.length; i++) {
382
+ const k = argv[i];
383
+ if (k === '--session') a.session = argv[++i];
384
+ else if (k === '--session-id') a.sessionId = argv[++i];
385
+ else if (k === '--cost-log') a.costLog = argv[++i];
386
+ else if (k === '--workspace') a.workspace = argv[++i];
387
+ else if (k === '--out') a.out = argv[++i];
388
+ else if (k === '--json') a.json = true;
389
+ }
390
+ return a;
391
+ }
392
+
393
+ function main(argv) {
394
+ const args = parseArgs(argv || process.argv.slice(2));
395
+ if (!args.session || !fs.existsSync(args.session)) {
396
+ console.error('agentSearchDiagnose: --session <session.ndjson> is required');
397
+ return 2;
398
+ }
399
+ const { outDir, metrics, manifest } = writeBundle(args);
400
+ if (args.json) { console.log(JSON.stringify({ outDir, overview: metrics.overview, hints: metrics.hints }, null, 2)); return 0; }
401
+ console.log(`diagnosis bundle -> ${outDir}`);
402
+ console.log(`files: ${manifest.files.map(f => f.path).join(', ')}`);
403
+ console.log(`overview: steps=${metrics.overview.steps} tools=${metrics.overview.toolCalls} roundTrips=${metrics.overview.roundTrips} tokens=${metrics.overview.tokens} model=${fmtMs(metrics.overview.modelMs)} mcp=${fmtMs(metrics.overview.mcpMs)} repo=${fmtMs(metrics.overview.repoToolMs)}`);
404
+ for (const h of metrics.hints) console.log(`hint: ${h}`);
405
+ return 0;
406
+ }
407
+
408
+ module.exports = { BUNDLE_VERSION, SCHEMA_VERSION, parseSession, classifyQuery, backendOf, signature, diagnose, renderDiagnosis, writeBundle, sliceCostLog, isExportJson, exportToNdjson, main };
409
+
410
+ if (require.main === module) process.exit(main());