archgraph-argo 0.24.3 → 0.26.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.
@@ -0,0 +1,35 @@
1
+ // Argo agent-cost collector plugin for opencode.
2
+ //
3
+ // The framework records ONE consolidated agent-cost log per workspace at
4
+ // <workspace>/.argo/temp/agent-cost-log.ndjson, so the user fetches a single
5
+ // file instead of stitching logs from several places. This plugin is the ONLY
6
+ // collector: it sees EVERY tool call the agent makes — MCP interface calls
7
+ // (e.g. getSystemArchitecture), graph writes (e.g. applySystemArchitectureMutation)
8
+ // and repository calls (read / grep / glob) alike — plus assistant token/cost
9
+ // usage, which an MCP-side hook could never observe completely.
10
+ //
11
+ // Recording only observes; it never changes retrieval. Disable with
12
+ // ARGO_COST_PROFILER=0.
13
+
14
+ import { createRequire } from 'node:module';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ let log = null;
18
+ try {
19
+ log = require('../scripts/graph-rag/agentCostLog.js');
20
+ } catch {
21
+ // The shared log module ships next to the plugins under ~/.argo; if it is
22
+ // missing this plugin is a no-op so it can never break a session.
23
+ log = null;
24
+ }
25
+
26
+ export default async function argoCostCollector(input) {
27
+ if (!log) return {};
28
+ const workspaceRoot = (input && (input.directory || input.worktree)) || process.cwd();
29
+ const hooks = log.createHostCollectorHooks(workspaceRoot);
30
+ return {
31
+ "tool.execute.before": async (i) => { try { hooks.before(i); } catch { /* best-effort */ } },
32
+ "tool.execute.after": async (i, o) => { try { hooks.after(i, o); } catch { /* best-effort */ } },
33
+ event: async (payload) => { try { hooks.event(payload); } catch { /* best-effort */ } },
34
+ };
35
+ }
@@ -0,0 +1,356 @@
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('getSystemArchitecture') || n.includes('memory_search')) return 'semantic';
50
+ if (n.includes('queryNeo4jGraph')) return 'structured-cypher';
51
+ if (n.includes('getIntentElementContext') || n.includes('getArchitectureViewContext')) return 'structured-context';
52
+ if (n.includes('read')) return 'file-read';
53
+ if (n.includes('grep')) return 'grep';
54
+ if (n.includes('glob') || n.includes('list')) return 'list';
55
+ if (GRAPH_WRITE_TOOLS.some(t => n.includes(t))) return 'write';
56
+ return 'other';
57
+ }
58
+
59
+ function estimateTokens(text) {
60
+ if (!text) return 0;
61
+ const s = String(text);
62
+ const cjk = (s.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]/g) || []).length;
63
+ return cjk + Math.ceil((s.length - cjk) / 4);
64
+ }
65
+
66
+ function stableStringify(value) {
67
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
68
+ if (Array.isArray(value)) return '[' + value.map(stableStringify).join(',') + ']';
69
+ return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + stableStringify(value[k])).join(',') + '}';
70
+ }
71
+
72
+ function signature(tool, input) {
73
+ try { return crypto.createHash('sha1').update(String(tool) + '|' + stableStringify(input || {})).digest('hex').slice(0, 12); }
74
+ catch (_) { return 'unknown'; }
75
+ }
76
+
77
+ function inputPreview(tool, input) {
78
+ if (!input || typeof input !== 'object') return '';
79
+ 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;
80
+ const s = typeof pick === 'string' ? pick : (pick ? JSON.stringify(pick) : JSON.stringify(input));
81
+ return String(s).slice(0, 160);
82
+ }
83
+
84
+ function eventTime(e) {
85
+ const cands = [e.time, e.part && e.part.time, e.part && e.part.state && e.part.state.time];
86
+ for (const t of cands) {
87
+ if (!t) continue;
88
+ if (typeof t === 'number') return t;
89
+ if (typeof t.start === 'number' && typeof t.end === 'number') return t.end;
90
+ if (typeof t.start === 'number') return t.start;
91
+ if (typeof t.end === 'number') return t.end;
92
+ }
93
+ return typeof e.timestamp === 'number' ? e.timestamp : null;
94
+ }
95
+
96
+ function parseSession(text) {
97
+ const raw = String(text || '');
98
+ const toolCalls = [];
99
+ const usages = [];
100
+ let steps = 0; let tMin = null; let tMax = null; let cost = 0;
101
+ const texts = [];
102
+ for (const line of raw.split('\n')) {
103
+ const t = line.trim();
104
+ if (!t.startsWith('{')) continue;
105
+ let e; try { e = JSON.parse(t); } catch (_) { continue; }
106
+ const part = e.part || e;
107
+ if (part && part.type === 'text' && part.text) texts.push(part.text);
108
+ if (part && part.type === 'tool') {
109
+ const tool = part.tool || part.name || (part.state && part.state.tool) || 'tool';
110
+ const st = part.state || {};
111
+ const tm = st.time || {};
112
+ const out = st.output != null ? String(st.output) : '';
113
+ toolCalls.push({
114
+ tool, backend: backendOf(tool), queryClass: classifyQuery(tool, st.input),
115
+ durationMs: (typeof tm.start === 'number' && typeof tm.end === 'number') ? Math.max(0, tm.end - tm.start) : null,
116
+ ok: st.status ? st.status !== 'error' : true,
117
+ error: st.error ? String(st.error).slice(0, 200) : null,
118
+ signature: signature(tool, st.input), inputPreview: inputPreview(tool, st.input),
119
+ outputBytes: out.length, outputTokens: estimateTokens(out),
120
+ path: (st.input && (st.input.filePath || st.input.path || st.input.file)) || null,
121
+ });
122
+ }
123
+ if (e.type === 'step_start') steps += 1;
124
+ if (e.type === 'step_finish') {
125
+ const fin = (e.part && e.part.finish) ? e.part.finish : (e.part || e);
126
+ if (fin && typeof fin.cost === 'number') cost += fin.cost;
127
+ const tk = fin && (fin.tokens || (fin.finish && fin.finish.tokens));
128
+ 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 });
129
+ }
130
+ const time = eventTime(e);
131
+ if (time !== null) { tMin = tMin === null ? time : Math.min(tMin, time); tMax = tMax === null ? time : Math.max(tMax, time); }
132
+ }
133
+ const tokensIn = usages.reduce((a, u) => a + u.input, 0);
134
+ const tokensOut = usages.reduce((a, u) => a + u.output, 0);
135
+ const tokensReasoning = usages.reduce((a, u) => a + u.reasoning, 0);
136
+ return { toolCalls, usages, texts, steps, cost, wallMs: (tMin !== null && tMax !== null && tMax >= tMin) ? tMax - tMin : null, tokensIn, tokensOut, tokensReasoning, tokens: tokensIn + tokensOut + tokensReasoning };
137
+ }
138
+
139
+ function countRoundTrips(toolCalls) {
140
+ const seq = toolCalls.map(c => c.backend).filter(b => b === 'graph' || b === 'repo');
141
+ let n = 0;
142
+ for (let i = 1; i < seq.length; i++) if (seq[i] !== seq[i - 1]) n += 1;
143
+ return n;
144
+ }
145
+
146
+ function diagnose(session, opts = {}) {
147
+ const tc = session.toolCalls;
148
+ const byTool = {};
149
+ const byBackend = { graph: { calls: 0, ms: 0 }, repo: { calls: 0, ms: 0 }, framework: { calls: 0, ms: 0 }, other: { calls: 0, ms: 0 } };
150
+ const byQueryClass = {};
151
+ const sigCount = {};
152
+ const pathReads = {};
153
+ let toolMs = 0; let errors = 0; let empty = 0;
154
+ for (const c of tc) {
155
+ const b = byBackend[c.backend] || (byBackend[c.backend] = { calls: 0, ms: 0 });
156
+ b.calls += 1; b.ms += c.durationMs || 0;
157
+ toolMs += c.durationMs || 0;
158
+ const bt = byTool[c.tool] || (byTool[c.tool] = { calls: 0, ms: 0, tokens: 0, errors: 0 });
159
+ bt.calls += 1; bt.ms += c.durationMs || 0; bt.tokens += c.outputTokens || 0;
160
+ if (c.ok === false) { bt.errors += 1; errors += 1; }
161
+ if ((c.outputBytes || 0) < 2) empty += 1;
162
+ byQueryClass[c.queryClass] = (byQueryClass[c.queryClass] || 0) + 1;
163
+ sigCount[c.signature] = (sigCount[c.signature] || 0) + 1;
164
+ if (c.queryClass === 'file-read' && c.path) pathReads[c.path] = (pathReads[c.path] || 0) + 1;
165
+ }
166
+ const duplicates = [];
167
+ const seen = new Set();
168
+ for (const c of tc) {
169
+ if (seen.has(c.signature)) continue;
170
+ seen.add(c.signature);
171
+ if (sigCount[c.signature] > 1) duplicates.push({ tool: c.tool, queryClass: c.queryClass, count: sigCount[c.signature], preview: c.inputPreview });
172
+ }
173
+ const repeatedReads = Object.entries(pathReads).filter(([, n]) => n > 1).map(([p, n]) => ({ path: p, count: n }));
174
+ // longest no-progress streak: consecutive calls that were empty/failed/duplicate
175
+ let streak = 0; let best = 0;
176
+ for (const c of tc) {
177
+ const bad = c.ok === false || (c.outputBytes || 0) < 2 || sigCount[c.signature] > 1;
178
+ streak = bad ? streak + 1 : 0;
179
+ if (streak > best) best = streak;
180
+ }
181
+ const cumulativeInput = [];
182
+ let acc = 0;
183
+ for (const u of session.usages) { acc += u.input; cumulativeInput.push(u.input); }
184
+ 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 }));
185
+ 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 }));
186
+
187
+ const wallMs = opts.wallMs != null ? opts.wallMs : session.wallMs;
188
+ const modelMs = wallMs != null ? Math.max(0, wallMs - toolMs) : null;
189
+
190
+ return {
191
+ schemaVersion: SCHEMA_VERSION, bundleVersion: BUNDLE_VERSION, generatedAt: new Date().toISOString(),
192
+ workspace: opts.workspace || null, sessionId: opts.sessionId || null,
193
+ overview: {
194
+ steps: session.steps, toolCalls: tc.length, roundTrips: countRoundTrips(tc),
195
+ wallMs, modelMs, mcpMs: byBackend.graph.ms, repoToolMs: byBackend.repo.ms, toolMs,
196
+ tokensIn: session.tokensIn, tokensOut: session.tokensOut, tokensReasoning: session.tokensReasoning, tokens: session.tokens,
197
+ cost: session.cost, toolErrors: errors, emptyResults: empty,
198
+ distinctSignatures: new Set(tc.map(c => c.signature)).size,
199
+ },
200
+ byTool, byBackend, byQueryClass,
201
+ tokenGrowth: { perStepInput: cumulativeInput, steps: cumulativeInput.length },
202
+ overSearch: {
203
+ duplicateCalls: duplicates,
204
+ repeatedReads,
205
+ emptyOrError: errors + empty,
206
+ noProgressStreak: best,
207
+ },
208
+ topOffendersByTime: topByMs,
209
+ topOffendersByTokens: topByTokens,
210
+ 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) }),
211
+ };
212
+ }
213
+
214
+ function buildHints(m) {
215
+ const hints = [];
216
+ if (m.duplicates > 0) hints.push(`重复/近似重复调用 ${m.duplicates} 组 → 考虑结果缓存或查询归一(同参不重搜)。`);
217
+ if (m.emptyOrError > 0) hints.push(`空结果/失败 ${m.emptyOrError} 次 → 考虑改进查询构造/回退策略,避免"空手→换词→再搜"的循环。`);
218
+ if (m.repeatedReads > 0) hints.push(`同一文件被重复读 ${m.repeatedReads} 处 → 考虑读取缓存或先摘要后精读。`);
219
+ if (m.noProgressStreak >= 3) hints.push(`最长"无进展"连续 ${m.noProgressStreak} 次 → 缺停止判据/预算,建议显式设停止条件。`);
220
+ if (m.roundTrips >= 2) hints.push(`图↔仓往返 ${m.roundTrips} 次 → 检查是否可在一次规划内并发取数。`);
221
+ if (m.modelMs != null && m.modelMs > (m.mcpMs + m.repoToolMs)) hints.push(`模型耗时主导(${m.modelMs}ms > 工具 ${(m.mcpMs + m.repoToolMs)}ms)→ 考虑上下文压缩/减少轮次。`);
222
+ if (hints.length === 0) hints.push('未发现明显过度搜索信号。');
223
+ return hints;
224
+ }
225
+
226
+ function renderDiagnosis(m, meta) {
227
+ const L = [];
228
+ L.push(`# Agent 搜索诊断报告`);
229
+ L.push('');
230
+ L.push(`- 生成时间:${m.generatedAt}`);
231
+ L.push(`- 会话:${m.sessionId || '(unknown)'} 工作区:${m.workspace || '(unknown)'}`);
232
+ L.push(`- bundle 版本:${m.bundleVersion} schema:${m.schemaVersion}`);
233
+ L.push('');
234
+ L.push(`## 概览`);
235
+ L.push('');
236
+ L.push(`| 指标 | 值 |`);
237
+ L.push(`|---|---|`);
238
+ L.push(`| 轮次 steps | ${m.overview.steps} |`);
239
+ L.push(`| 工具调用 | ${m.overview.toolCalls}(去重签名 ${m.overview.distinctSignatures}) |`);
240
+ L.push(`| 图↔仓往返 | ${m.overview.roundTrips} |`);
241
+ L.push(`| 墙钟 | ${fmtMs(m.overview.wallMs)} = 模型 ${fmtMs(m.overview.modelMs)} + MCP ${fmtMs(m.overview.mcpMs)} + 仓 ${fmtMs(m.overview.repoToolMs)} |`);
242
+ L.push(`| tokens | 总 ${m.overview.tokens}(in ${m.overview.tokensIn} / out ${m.overview.tokensOut} / reason ${m.overview.tokensReasoning}) |`);
243
+ L.push(`| 工具失败 / 空结果 | ${m.overview.toolErrors} / ${m.overview.emptyResults} |`);
244
+ L.push('');
245
+ L.push(`## 过度搜索信号`);
246
+ L.push('');
247
+ L.push(`- 重复调用组:${m.overSearch.duplicateCalls.length}`);
248
+ for (const d of m.overSearch.duplicateCalls.slice(0, 10)) L.push(` - ×${d.count} ${d.tool} [${d.queryClass}] ${d.preview}`);
249
+ L.push(`- 重复读同一文件:${m.overSearch.repeatedReads.length}`);
250
+ for (const r of m.overSearch.repeatedReads.slice(0, 10)) L.push(` - ×${r.count} ${r.path}`);
251
+ L.push(`- 空结果/失败合计:${m.overSearch.emptyOrError}`);
252
+ L.push(`- 最长无进展连续:${m.overSearch.noProgressStreak}`);
253
+ L.push('');
254
+ L.push(`## 后端 / 查询类分布`);
255
+ L.push('');
256
+ L.push(`- 后端:` + Object.entries(m.byBackend).map(([k, v]) => `${k}=${v.calls}`).join(' '));
257
+ L.push(`- 查询类:` + Object.entries(m.byQueryClass).map(([k, v]) => `${k}=${v}`).join(' '));
258
+ L.push('');
259
+ L.push(`## 最贵调用(按耗时)`);
260
+ L.push('');
261
+ for (const c of m.topOffendersByTime) L.push(`- ${fmtMs(c.ms)} ${c.tool}${c.ok ? '' : ' (error)'} ${c.preview}`);
262
+ L.push('');
263
+ L.push(`## token 随轮次`);
264
+ L.push('');
265
+ L.push(`每步 input tokens(累计上下文规模):${m.tokenGrowth.perStepInput.join(', ') || '(无)'}`);
266
+ L.push('');
267
+ L.push(`## 诊断建议(启发式,供 Agent 复核)`);
268
+ L.push('');
269
+ for (const h of m.hints) L.push(`- ${h}`);
270
+ L.push('');
271
+ L.push(`---`);
272
+ L.push(`> 本报告由 agentSearchDiagnose 自动生成;请 Agent 结合 metrics.json 与会话片段复核并补充解读。`);
273
+ L.push('');
274
+ return L.join('\n');
275
+ }
276
+
277
+ function fmtMs(ms) {
278
+ if (ms == null) return 'n/a';
279
+ return ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms}ms`;
280
+ }
281
+
282
+ function writeBundle(opts) {
283
+ const workspace = opts.workspace || process.cwd();
284
+ const sessionText = fs.readFileSync(opts.session, 'utf8');
285
+ const session = parseSession(sessionText);
286
+ const sessionId = opts.sessionId || inferSessionId(sessionText) || 'session';
287
+ const outDir = opts.out || path.join(workspace, '.argo', 'temp', 'diagnosis', sessionId);
288
+ fs.mkdirSync(outDir, { recursive: true });
289
+
290
+ const costLogPath = opts.costLog || path.join(workspace, '.argo', 'temp', 'agent-cost-log.ndjson');
291
+ const costLogText = readMaybe(costLogPath);
292
+
293
+ const metrics = diagnose(session, { workspace, sessionId, wallMs: opts.wallMs });
294
+ const files = [];
295
+ const write = (name, body) => { const p = path.join(outDir, name); fs.writeFileSync(p, body); files.push({ path: name, bytes: Buffer.byteLength(body) }); };
296
+
297
+ write('session.ndjson', sessionText);
298
+ write('cost-log.slice.ndjson', sliceCostLog(costLogText, sessionId));
299
+ write('metrics.json', JSON.stringify(metrics, null, 2) + '\n');
300
+ write('diagnosis.md', renderDiagnosis(metrics, { costLogPath }));
301
+
302
+ const manifest = {
303
+ schemaVersion: SCHEMA_VERSION, bundleVersion: BUNDLE_VERSION, generatedAt: metrics.generatedAt,
304
+ workspace, sessionId, sources: { session: path.resolve(opts.session), costLog: costLogPath },
305
+ files,
306
+ };
307
+ fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
308
+ return { outDir, metrics, manifest };
309
+ }
310
+
311
+ function readMaybe(file) { try { return fs.readFileSync(file, 'utf8'); } catch (_) { return ''; } }
312
+
313
+ function inferSessionId(text) {
314
+ const m = String(text).match(/"sessionID"\s*:\s*"([^"]+)"/);
315
+ return m ? m[1] : null;
316
+ }
317
+
318
+ function sliceCostLog(text, sessionId) {
319
+ if (!text) return '';
320
+ const lines = text.split('\n').filter(Boolean);
321
+ const hit = lines.filter(l => !sessionId || l.includes(sessionId));
322
+ return (hit.length ? hit : []).join('\n') + (hit.length ? '\n' : '');
323
+ }
324
+
325
+ function parseArgs(argv) {
326
+ const a = { session: null, sessionId: null, costLog: null, workspace: process.cwd(), out: null, json: false };
327
+ for (let i = 0; i < argv.length; i++) {
328
+ const k = argv[i];
329
+ if (k === '--session') a.session = argv[++i];
330
+ else if (k === '--session-id') a.sessionId = argv[++i];
331
+ else if (k === '--cost-log') a.costLog = argv[++i];
332
+ else if (k === '--workspace') a.workspace = argv[++i];
333
+ else if (k === '--out') a.out = argv[++i];
334
+ else if (k === '--json') a.json = true;
335
+ }
336
+ return a;
337
+ }
338
+
339
+ function main(argv) {
340
+ const args = parseArgs(argv || process.argv.slice(2));
341
+ if (!args.session || !fs.existsSync(args.session)) {
342
+ console.error('agentSearchDiagnose: --session <session.ndjson> is required');
343
+ return 2;
344
+ }
345
+ const { outDir, metrics, manifest } = writeBundle(args);
346
+ if (args.json) { console.log(JSON.stringify({ outDir, overview: metrics.overview, hints: metrics.hints }, null, 2)); return 0; }
347
+ console.log(`diagnosis bundle -> ${outDir}`);
348
+ console.log(`files: ${manifest.files.map(f => f.path).join(', ')}`);
349
+ 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)}`);
350
+ for (const h of metrics.hints) console.log(`hint: ${h}`);
351
+ return 0;
352
+ }
353
+
354
+ module.exports = { BUNDLE_VERSION, SCHEMA_VERSION, parseSession, classifyQuery, backendOf, signature, diagnose, renderDiagnosis, writeBundle, sliceCostLog, main };
355
+
356
+ if (require.main === module) process.exit(main());
@@ -0,0 +1,179 @@
1
+ 'use strict';
2
+
3
+ // Agent cost LOG (framework, zero-config, background) — the single consolidated
4
+ // store for everything needed to reason about an agent's retrieval cost.
5
+ //
6
+ // WHY ONE FILE: in a real project the agent's scope is the whole repository (an
7
+ // unbounded content source) plus the curated intent graph (KG, only a semantic
8
+ // index / routing layer). Cost is dominated by the agent's round-trips BETWEEN
9
+ // the two backends (graph: getSystemArchitecture / getIntentElementContext /
10
+ // getArchitectureViewContext / queryNeo4jGraph / memory_search; repository:
11
+ // read / grep / glob), not by the size of either backend. To optimise without
12
+ // hurting recall we must measure that — in ONE place the user fetches once:
13
+ //
14
+ // <workspace>/.argo/temp/agent-cost-log.ndjson
15
+ //
16
+ // WHO WRITES: the OpenCode plugin argo/plugins/argo-cost-collector.js. Every
17
+ // tool call the agent makes — MCP interface calls, graph writes, and repository
18
+ // calls alike — is a host tool, so the plugin records ALL of them, plus the
19
+ // assistant's token/cost usage. There is deliberately NO MCP-side instrumentation
20
+ // (a record without the host side would be incomplete and thus misleading).
21
+ //
22
+ // Posture (mirrors mcpCrashDiagnostics): best-effort, never throws, never logs
23
+ // secret values, and NEVER changes retrieval (it observes results only).
24
+
25
+ const fs = require('node:fs');
26
+ const path = require('node:path');
27
+
28
+ const LOG_FILE_NAME = 'agent-cost-log.ndjson';
29
+ const DEFAULT_MAX_BYTES = 5 * 1024 * 1024;
30
+
31
+ const GRAPH_TOOLS = ['getSystemArchitecture', 'getIntentElementContext', 'getArchitectureViewContext', 'queryNeo4jGraph', 'memory_search'];
32
+ const GRAPH_WRITE_TOOLS = [
33
+ 'previewSystemArchitectureMutation', 'applySystemArchitectureMutation',
34
+ 'addArchitectureElement', 'updateArchitectureElement', 'removeArchitectureElement',
35
+ 'addArchitectureRelationship', 'updateArchitectureRelationship', 'removeArchitectureRelationship',
36
+ 'addArchitectureView', 'updateArchitectureView', 'removeArchitectureView',
37
+ ];
38
+ const VALIDATOR_TOOLS = ['validateSystemArchitecture', 'runArchitectureTests', 'initializeWorkspace'];
39
+ const REPO_TOOLS = ['read', 'grep', 'glob', 'list', 'bash', 'webfetch', 'edit', 'write'];
40
+
41
+ function enabled() {
42
+ return process.env.ARGO_COST_PROFILER !== '0';
43
+ }
44
+
45
+ function maxBytes() {
46
+ const n = Number(process.env.ARGO_COST_TRACE_MAX_BYTES);
47
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_BYTES;
48
+ }
49
+
50
+ function tempDir(workspaceRoot) {
51
+ return path.join(workspaceRoot, '.argo', 'temp');
52
+ }
53
+
54
+ function logFilePath(workspaceRoot) {
55
+ return path.join(tempDir(workspaceRoot), LOG_FILE_NAME);
56
+ }
57
+
58
+ // Pure, total classification. Host tool names may be prefixed (e.g.
59
+ // mcp__argo__getSystemArchitecture), so match by substring.
60
+ function classifyTool(tool) {
61
+ const name = String(tool || '');
62
+ const hit = (list) => list.some(t => name.includes(t));
63
+ if (hit(GRAPH_WRITE_TOOLS)) return { backend: 'graph', kind: 'write' };
64
+ if (hit(GRAPH_TOOLS)) return { backend: 'graph', kind: 'read' };
65
+ if (hit(VALIDATOR_TOOLS)) return { backend: 'framework', kind: 'framework' };
66
+ if (hit(REPO_TOOLS)) return { backend: 'repo', kind: 'read' };
67
+ return { backend: 'other', kind: 'other' };
68
+ }
69
+
70
+ // Deterministic coarse token estimate: CJK ≈ 1 token/char, else ≈ 1/4 chars.
71
+ function estimateTokens(text) {
72
+ if (!text) return 0;
73
+ const s = String(text);
74
+ const cjk = (s.match(/[\u4e00-\u9fff\u3400-\u4dbf\u3000-\u303f\uff00-\uffef]/g) || []).length;
75
+ return cjk + Math.ceil((s.length - cjk) / 4);
76
+ }
77
+
78
+ function rotateIfNeeded(file, limit) {
79
+ try {
80
+ if (fs.statSync(file).size >= limit) fs.renameSync(file, `${file}.1`);
81
+ } catch (_) { /* no existing log */ }
82
+ }
83
+
84
+ // Append one record to the single consolidated log. Best-effort: any failure is
85
+ // swallowed so logging can never affect a tool call.
86
+ function appendRecord(workspaceRoot, record) {
87
+ if (!enabled() || !workspaceRoot || !record || typeof record !== 'object') return;
88
+ try {
89
+ const file = logFilePath(workspaceRoot);
90
+ fs.mkdirSync(path.dirname(file), { recursive: true });
91
+ rotateIfNeeded(file, maxBytes());
92
+ const line = JSON.stringify({ at: new Date().toISOString(), pid: process.pid, ...record }) + '\n';
93
+ fs.appendFileSync(file, line, 'utf8');
94
+ } catch (_) {
95
+ // best-effort only
96
+ }
97
+ }
98
+
99
+ function readLog(workspaceRoot) {
100
+ const file = logFilePath(workspaceRoot);
101
+ let raw = '';
102
+ try { raw = fs.readFileSync(file, 'utf8'); } catch (_) { return { file, records: [] }; }
103
+ const records = [];
104
+ for (const line of raw.split('\n')) {
105
+ const t = line.trim();
106
+ if (!t.startsWith('{')) continue;
107
+ try { records.push(JSON.parse(t)); } catch (_) { /* skip malformed */ }
108
+ }
109
+ return { file, records };
110
+ }
111
+
112
+ function byteLen(value) {
113
+ try {
114
+ const s = JSON.stringify(value);
115
+ return (typeof Buffer !== 'undefined') ? Buffer.byteLength(s) : s.length;
116
+ } catch (_) { return 0; }
117
+ }
118
+
119
+ function keysOf(value) {
120
+ return (value && typeof value === 'object' && !Array.isArray(value)) ? Object.keys(value).sort() : [];
121
+ }
122
+
123
+ // Host-collector hook logic (used by the OpenCode plugin; unit-tested in CJS).
124
+ // It records EVERY host tool call — MCP interface calls, graph writes, and
125
+ // repository calls alike — plus assistant token/cost usage into the single log.
126
+ function createHostCollectorHooks(workspaceRoot) {
127
+ const starts = new Map();
128
+ const seenUsage = new Set();
129
+ return {
130
+ before(input) {
131
+ if (!input || !input.callID) return;
132
+ const args = input.args || {};
133
+ starts.set(input.callID, { t: Date.now(), keys: keysOf(args), bytes: byteLen(args) });
134
+ },
135
+ after(input, output) {
136
+ if (!input) return;
137
+ const s = starts.get(input.callID) || {};
138
+ const cls = classifyTool(input.tool);
139
+ const out = (output && output.output) || '';
140
+ appendRecord(workspaceRoot, {
141
+ source: 'host',
142
+ type: 'tool',
143
+ sessionID: input.sessionID,
144
+ callID: input.callID,
145
+ tool: input.tool,
146
+ backend: cls.backend,
147
+ kind: cls.kind,
148
+ durationMs: s.t ? (Date.now() - s.t) : 0,
149
+ ok: true,
150
+ args: { keys: s.keys || keysOf(input.args), bytes: s.bytes != null ? s.bytes : byteLen(input.args) },
151
+ resultBytes: String(out).length,
152
+ resultTokens: estimateTokens(out),
153
+ });
154
+ starts.delete(input.callID);
155
+ },
156
+ event(payload) {
157
+ const event = payload && payload.event;
158
+ const props = (event && event.properties) || {};
159
+ const info = props.info || (event && event.info) || null;
160
+ if (info && info.role === 'assistant' && info.tokens && info.id && !seenUsage.has(info.id)) {
161
+ seenUsage.add(info.id);
162
+ appendRecord(workspaceRoot, {
163
+ source: 'host',
164
+ type: 'usage',
165
+ sessionID: info.sessionID,
166
+ messageID: info.id,
167
+ tokens: info.tokens,
168
+ cost: info.cost,
169
+ });
170
+ }
171
+ },
172
+ };
173
+ }
174
+
175
+ module.exports = {
176
+ LOG_FILE_NAME,
177
+ enabled, tempDir, logFilePath, classifyTool, estimateTokens,
178
+ appendRecord, readLog, createHostCollectorHooks,
179
+ };
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: agent-search-diagnosis
3
+ description: "诊断一次 Agent 会话的检索/搜索行为(是否过度搜索、轮次/token/耗时花在哪、是否重复或空手、图↔仓往返),产出一份自包含的『诊断包』(diagnosis.md 总结 + metrics.json + 会话 NDJSON + 插件日志切片 + manifest.json),供人类伙伴打包回传以便进一步分析优化。Use when the user says an agent session searched too many rounds / wants to diagnose retrieval cost or over-search, or asks to produce a diagnostic bundle to send back. Keywords: 过度搜索, 诊断, over-search, search diagnosis, retrieval cost, diagnostic bundle, agent-search-diagnosis."
4
+ ---
5
+
6
+ # Agent Search Diagnosis
7
+
8
+ 把**一次 Agent 会话**变成一份自包含的**诊断包**:现场出诊断总结 + 聚合关键数据到一个目录,用户打包回传即可。
9
+
10
+ ## 数据来源
11
+
12
+ - **主**:宿主会话 NDJSON(含每个工具的**完整入参/输出**、时间戳、tokens)。
13
+ - **辅**:框架后台日志 `<workspace>/.argo/temp/agent-cost-log.ndjson`(跨会话成本形态)。
14
+
15
+ ## 输出(固定目录)
16
+
17
+ ```
18
+ <workspace>/.argo/temp/diagnosis/<session-id>/
19
+ ├─ diagnosis.md # 人读诊断总结(含启发式建议,供 Agent 复核)
20
+ ├─ metrics.json # 总结背后的数字
21
+ ├─ session.ndjson # 原始会话(全保真)
22
+ ├─ cost-log.slice.ndjson # 匹配该会话的后台日志切片
23
+ └─ manifest.json # 文件清单 + 大小 + 版本
24
+ ```
25
+
26
+ ## Workflow
27
+
28
+ ### 1. 定位并导出会话
29
+
30
+ - 若用户给出了 sessionID:`opencode export <sessionID> > <tmp>.ndjson`。
31
+ - 若未给出:`opencode session list` 取最近会话,或请用户指定。
32
+ - 宿主不支持导出时:退化为**仅用后台日志**(`agent-cost-log.ndjson`,诊断能力受限,须在报告中注明)。
33
+
34
+ ### 2. 运行诊断脚本(确定性)
35
+
36
+ ```
37
+ node ~/.argo/scripts/agentSearchDiagnose.js --session <session.ndjson> \
38
+ [--session-id <id>] [--workspace .] [--cost-log <path>] [--json]
39
+ ```
40
+
41
+ 脚本**只读**会话与日志,写出上面的 bundle 目录,输出概览与启发式 hints。
42
+
43
+ ### 3. 复核并补充诊断总结(Agent)
44
+
45
+ - 读 `diagnosis.md` 与 `metrics.json`。
46
+ - 在报告末尾追加一节 `## Agent 复核解读`:结合会话片段指出**最可能的过度搜索根因**与**建议的优化方向**(去重/缓存、查询构造、停止判据、上下文压缩、图↔仓往返合并等)。
47
+ - 不臆造:结论必须能从 metrics/会话片段找到依据。
48
+
49
+ ### 4. 交付给用户
50
+
51
+ - 报告 bundle 目录路径,请用户**打包该目录**回传。
52
+ - 给一段简短结论(轮次/token/耗时拆分 + 主要信号)。
53
+
54
+ ## Rules(MUST / MUST NOT)
55
+
56
+ - **MUST** 只用确定性脚本 + 会话/日志读取;**MUST NOT** 修改图谱、仓库或任何框架内容。
57
+ - **MUST NOT** 读取或打印 `.env`/凭据;若会话片段疑似含密钥,**MUST** 提醒用户回传前先审查 `session.ndjson`(其中是原始工具 I/O)。
58
+ - **MUST** 在结论中区分「事实(metrics)」与「推断(根因假设)」,推断须标注依据。
59
+ - 若无法导出会话或脚本不可用,**MUST** 如实说明并给出退化方案,不得臆测诊断结果。
package/install-argo.ps1 CHANGED
@@ -788,8 +788,11 @@ $skillDest = Join-Path $SkillsRoot 'argo-init'
788
788
  Write-Host "[4/22] argo\skills\argo-init -> $skillDest"
789
789
  Copy-Tree -Source $skillSrc -Destination $skillDest
790
790
  $reconcileSkillSrc = Join-Path (Join-Path $argoDir 'skills') 'ea-human-reconcile'
791
+ $diagSkillSrc = Join-Path (Join-Path $argoDir 'skills') 'agent-search-diagnosis'
791
792
  Write-Host ' argo\skills\ea-human-reconcile -> $SkillsRoot\ea-human-reconcile (EA human draft reconcile skill)'
792
793
  Copy-Tree -Source $reconcileSkillSrc -Destination (Join-Path $SkillsRoot 'ea-human-reconcile')
794
+ Write-Host ' argo\skills\agent-search-diagnosis -> $SkillsRoot\agent-search-diagnosis (agent search diagnosis skill)'
795
+ Copy-Tree -Source $diagSkillSrc -Destination (Join-Path $SkillsRoot 'agent-search-diagnosis')
793
796
 
794
797
  $ruleSrc = Join-Path (Join-Path $argoDir 'rules') 'archgraph.instructions.md'
795
798
  $ruleDest = Join-Path $PromptsRoot 'archgraph.instructions.md'
@@ -807,6 +810,8 @@ Write-Host "[7/22] argo\skills\argo-init -> $cursorSkillDest (Cursor)"
807
810
  Copy-Tree -Source $skillSrc -Destination $cursorSkillDest
808
811
  Write-Host ' argo\skills\ea-human-reconcile -> $CursorSkillsRoot\ea-human-reconcile (Cursor)'
809
812
  Copy-Tree -Source $reconcileSkillSrc -Destination (Join-Path $CursorSkillsRoot 'ea-human-reconcile')
813
+ Write-Host ' argo\skills\agent-search-diagnosis -> $CursorSkillsRoot\agent-search-diagnosis (Cursor)'
814
+ Copy-Tree -Source $diagSkillSrc -Destination (Join-Path $CursorSkillsRoot 'agent-search-diagnosis')
810
815
 
811
816
  $mcpBridgeSrc = Join-Path $argoDir 'mcp-bridges'
812
817
  $mcpBridgeDest = Join-Path $CursorMcpBridgesRoot ''
@@ -818,6 +823,8 @@ Write-Host "[8/22] argo\skills\argo-init -> $openCodeSkillDest (OpenCode)"
818
823
  Copy-Tree -Source $skillSrc -Destination $openCodeSkillDest
819
824
  Write-Host ' argo\skills\ea-human-reconcile -> $OpenCodeSkillsRoot\ea-human-reconcile (OpenCode)'
820
825
  Copy-Tree -Source $reconcileSkillSrc -Destination (Join-Path $OpenCodeSkillsRoot 'ea-human-reconcile')
826
+ Write-Host ' argo\skills\agent-search-diagnosis -> $OpenCodeSkillsRoot\agent-search-diagnosis (OpenCode)'
827
+ Copy-Tree -Source $diagSkillSrc -Destination (Join-Path $OpenCodeSkillsRoot 'agent-search-diagnosis')
821
828
 
822
829
  Write-Host "[9/22] argo\rules\archgraph.instructions.md -> $OpenCodeAgentsPath (OpenCode global AGENTS.md)"
823
830
  Add-AgentsRule -AgentsPath $OpenCodeAgentsPath -RulePath $ruleSrc
@@ -857,6 +864,8 @@ if ($SkipDsh) {
857
864
  Copy-Tree -Source (Join-Path $argoDir 'skills\argo-init') -Destination $dshSkillDest
858
865
  Write-Host ' argo\skills\ea-human-reconcile -> $DshHome\skills\ea-human-reconcile (DeepSeek Harness skill)'
859
866
  Copy-Tree -Source (Join-Path $argoDir 'skills\ea-human-reconcile') -Destination (Join-Path (Join-Path $DshHome 'skills') 'ea-human-reconcile')
867
+ Write-Host ' argo\skills\agent-search-diagnosis -> $DshHome\skills\agent-search-diagnosis (DeepSeek Harness skill)'
868
+ Copy-Tree -Source $diagSkillSrc -Destination (Join-Path (Join-Path $DshHome 'skills') 'agent-search-diagnosis')
860
869
 
861
870
  Write-Host "[17/22] argo\rules\<WakeupGuideline> -> $DshHome\plugins\dsh-argo-wakeup\index.js (DeepSeek Harness wakeup plugin)"
862
871
  $wakeupDshPath = New-DshWakeupPlugin -DshHome $DshHome -RuleText $ruleSrcContent
@@ -918,8 +927,10 @@ if ($SkipOpenClaw) {
918
927
 
919
928
  Write-Host "[21/22] argo\skills\argo-init -> $openClawSkillDest (OpenClaw managed skill, all agents)"
920
929
  Copy-Tree -Source (Join-Path $argoDir 'skills\argo-init') -Destination $openClawSkillDest
921
- Write-Host ' argo\skills\ea-human-reconcile -> $OpenClawHome\skills\ea-human-reconcile (OpenClaw managed skill, all agents)'
922
- Copy-Tree -Source (Join-Path $argoDir 'skills\ea-human-reconcile') -Destination (Join-Path (Join-Path $OpenClawHome 'skills') 'ea-human-reconcile')
930
+ Write-Host ' argo\skills\ea-human-reconcile -> $OpenClawHome\skills\ea-human-reconcile (OpenClaw managed skill, all agents)'
931
+ Copy-Tree -Source (Join-Path $argoDir 'skills\ea-human-reconcile') -Destination (Join-Path (Join-Path $OpenClawHome 'skills') 'ea-human-reconcile')
932
+ Write-Host ' argo\skills\agent-search-diagnosis -> $OpenClawHome\skills\agent-search-diagnosis (OpenClaw managed skill, all agents)'
933
+ Copy-Tree -Source $diagSkillSrc -Destination (Join-Path (Join-Path $OpenClawHome 'skills') 'agent-search-diagnosis')
923
934
 
924
935
  Write-Host ' OpenClaw injects AGENTS.md into Project Context on every session, so the wakeup'
925
936
  Write-Host ' gate (UNCONDITIONAL STARTUP GATE) is active on the next OpenClaw session; restart'
@@ -1109,5 +1120,14 @@ if (Test-Path $wakeupPluginPath) {
1109
1120
  Write-Host "argo-wakeup plugin registered -> $OpenCodeConfigPath"
1110
1121
  }
1111
1122
 
1123
+ # Agent-cost collector: the complete host-side writer of the single consolidated
1124
+ # agent-cost log (<workspace>/.argo/temp/agent-cost-log.ndjson).
1125
+ $costCollectorPath = Join-Path $PluginsRoot 'argo-cost-collector.js'
1126
+ if (Test-Path $costCollectorPath) {
1127
+ Write-Host '==> Registering argo-cost-collector plugin in OpenCode'
1128
+ Register-OpenCodePlugin -ConfigPath $OpenCodeConfigPath -PluginFilePath $costCollectorPath
1129
+ Write-Host "argo-cost-collector plugin registered -> $OpenCodeConfigPath"
1130
+ }
1131
+
1112
1132
  Write-Host ''
1113
1133
  Write-Host 'Argo deployment complete.'
package/package.json CHANGED
@@ -1,52 +1,53 @@
1
- {
2
- "name": "archgraph-argo",
3
- "version": "0.24.3",
4
- "description": "Deploy the ArchGraph ARGO toolchain, skills, and rules (schema, scripts, argo-init skill, global rule) with one command.",
5
- "license": "MIT",
6
- "bin": {
7
- "argo-deploy": "bin/argo-deploy.js"
8
- },
9
- "files": [
10
- "argo/scripts",
11
- "argo/schema",
12
- "argo/defaults",
13
- "argo/agents",
14
- "argo/plugins",
15
- "argo/mcp-bridges",
16
- "argo/skills/argo-init",
17
- "argo/skills/ea-human-reconcile",
18
- "argo/rules",
19
- "argo/package.json",
20
- "argo/.env.example",
21
- "vendor",
22
- "install-argo.ps1",
23
- "bin",
24
- "cordis.patch.yml",
25
- "dsh-argo-workspace",
26
- "dsh-argo-wakeup"
27
- ],
28
- "exports": {
29
- "./dsh-argo-workspace": "./dsh-argo-workspace/index.js",
30
- "./dsh-argo-wakeup": "./dsh-argo-wakeup/index.js",
31
- "./cordis.patch.yml": "./cordis.patch.yml",
32
- "./package.json": "./package.json"
33
- },
34
- "dsh": {
35
- "bundle": {
36
- "patch": "./cordis.patch.yml"
37
- }
38
- },
39
- "dependencies": {
40
- "neo4j-driver": "^6.2.0"
41
- },
42
- "devDependencies": {
43
- "@resvg/resvg-js": "^2.6.2",
44
- "playwright": "1.61.1"
45
- },
46
- "scripts": {
47
- "test": "node --test \"tests/*.test.js\""
48
- },
49
- "engines": {
50
- "node": ">=18"
51
- }
52
- }
1
+ {
2
+ "name": "archgraph-argo",
3
+ "version": "0.26.0",
4
+ "description": "Deploy the ArchGraph ARGO toolchain, skills, and rules (schema, scripts, argo-init skill, global rule) with one command.",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "argo-deploy": "bin/argo-deploy.js"
8
+ },
9
+ "files": [
10
+ "argo/scripts",
11
+ "argo/schema",
12
+ "argo/defaults",
13
+ "argo/agents",
14
+ "argo/plugins",
15
+ "argo/mcp-bridges",
16
+ "argo/skills/argo-init",
17
+ "argo/skills/ea-human-reconcile",
18
+ "argo/skills/agent-search-diagnosis",
19
+ "argo/rules",
20
+ "argo/package.json",
21
+ "argo/.env.example",
22
+ "vendor",
23
+ "install-argo.ps1",
24
+ "bin",
25
+ "cordis.patch.yml",
26
+ "dsh-argo-workspace",
27
+ "dsh-argo-wakeup"
28
+ ],
29
+ "exports": {
30
+ "./dsh-argo-workspace": "./dsh-argo-workspace/index.js",
31
+ "./dsh-argo-wakeup": "./dsh-argo-wakeup/index.js",
32
+ "./cordis.patch.yml": "./cordis.patch.yml",
33
+ "./package.json": "./package.json"
34
+ },
35
+ "dsh": {
36
+ "bundle": {
37
+ "patch": "./cordis.patch.yml"
38
+ }
39
+ },
40
+ "dependencies": {
41
+ "neo4j-driver": "^6.2.0"
42
+ },
43
+ "devDependencies": {
44
+ "@resvg/resvg-js": "^2.6.2",
45
+ "playwright": "1.61.1"
46
+ },
47
+ "scripts": {
48
+ "test": "node --test \"tests/*.test.js\""
49
+ },
50
+ "engines": {
51
+ "node": ">=18"
52
+ }
53
+ }