archgraph-argo 0.26.0 → 0.26.1

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.
@@ -46,16 +46,27 @@ function backendOf(tool) {
46
46
 
47
47
  function classifyQuery(tool, input) {
48
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';
49
52
  if (n.includes('getSystemArchitecture') || n.includes('memory_search')) return 'semantic';
50
53
  if (n.includes('queryNeo4jGraph')) return 'structured-cypher';
51
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';
52
58
  if (n.includes('read')) return 'file-read';
53
59
  if (n.includes('grep')) return 'grep';
54
60
  if (n.includes('glob') || n.includes('list')) return 'list';
55
- if (GRAPH_WRITE_TOOLS.some(t => n.includes(t))) return 'write';
61
+ if (n.includes('edit') || n.includes('write')) return 'edit';
62
+ if (n.includes('todowrite')) return 'todo';
56
63
  return 'other';
57
64
  }
58
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
+
59
70
  function estimateTokens(text) {
60
71
  if (!text) return 0;
61
72
  const s = String(text);
@@ -93,6 +104,31 @@ function eventTime(e) {
93
104
  return typeof e.timestamp === 'number' ? e.timestamp : null;
94
105
  }
95
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
+
96
132
  function parseSession(text) {
97
133
  const raw = String(text || '');
98
134
  const toolCalls = [];
@@ -150,13 +186,18 @@ function diagnose(session, opts = {}) {
150
186
  const byQueryClass = {};
151
187
  const sigCount = {};
152
188
  const pathReads = {};
153
- let toolMs = 0; let errors = 0; let empty = 0;
189
+ let toolMs = 0; let humanWaitMs = 0; let errors = 0; let empty = 0;
154
190
  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;
191
+ const isHuman = c.queryClass === 'human-wait' || HUMAN_WAIT_TOOLS.some(t => String(c.tool).includes(t));
158
192
  const bt = byTool[c.tool] || (byTool[c.tool] = { calls: 0, ms: 0, tokens: 0, errors: 0 });
159
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
+ }
160
201
  if (c.ok === false) { bt.errors += 1; errors += 1; }
161
202
  if ((c.outputBytes || 0) < 2) empty += 1;
162
203
  byQueryClass[c.queryClass] = (byQueryClass[c.queryClass] || 0) + 1;
@@ -185,17 +226,19 @@ function diagnose(session, opts = {}) {
185
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 }));
186
227
 
187
228
  const wallMs = opts.wallMs != null ? opts.wallMs : session.wallMs;
188
- const modelMs = wallMs != null ? Math.max(0, wallMs - toolMs) : null;
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);
189
231
 
190
232
  return {
191
233
  schemaVersion: SCHEMA_VERSION, bundleVersion: BUNDLE_VERSION, generatedAt: new Date().toISOString(),
192
234
  workspace: opts.workspace || null, sessionId: opts.sessionId || null,
193
235
  overview: {
194
236
  steps: session.steps, toolCalls: tc.length, roundTrips: countRoundTrips(tc),
195
- wallMs, modelMs, mcpMs: byBackend.graph.ms, repoToolMs: byBackend.repo.ms, toolMs,
237
+ wallMs, modelMs, mcpMs: byBackend.graph.ms, repoToolMs: byBackend.repo.ms, toolMs, humanWaitMs,
196
238
  tokensIn: session.tokensIn, tokensOut: session.tokensOut, tokensReasoning: session.tokensReasoning, tokens: session.tokens,
197
239
  cost: session.cost, toolErrors: errors, emptyResults: empty,
198
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) },
199
242
  },
200
243
  byTool, byBackend, byQueryClass,
201
244
  tokenGrowth: { perStepInput: cumulativeInput, steps: cumulativeInput.length },
@@ -207,12 +250,13 @@ function diagnose(session, opts = {}) {
207
250
  },
208
251
  topOffendersByTime: topByMs,
209
252
  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) }),
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 }),
211
254
  };
212
255
  }
213
256
 
214
257
  function buildHints(m) {
215
258
  const hints = [];
259
+ if (m.blowups > 0) hints.push(`上下文尖峰 ${m.blowups} 个 step(最大 ${m.blowupMax} tokens)→ 多由大工具输出造成;优先考虑截断/分页/摘要工具输出(observation masking)、先摘要后精读(对所有场景统一生效,勿只针对单会话)。`);
216
260
  if (m.duplicates > 0) hints.push(`重复/近似重复调用 ${m.duplicates} 组 → 考虑结果缓存或查询归一(同参不重搜)。`);
217
261
  if (m.emptyOrError > 0) hints.push(`空结果/失败 ${m.emptyOrError} 次 → 考虑改进查询构造/回退策略,避免"空手→换词→再搜"的循环。`);
218
262
  if (m.repeatedReads > 0) hints.push(`同一文件被重复读 ${m.repeatedReads} 处 → 考虑读取缓存或先摘要后精读。`);
@@ -238,7 +282,7 @@ function renderDiagnosis(m, meta) {
238
282
  L.push(`| 轮次 steps | ${m.overview.steps} |`);
239
283
  L.push(`| 工具调用 | ${m.overview.toolCalls}(去重签名 ${m.overview.distinctSignatures}) |`);
240
284
  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)} |`);
285
+ L.push(`| 墙钟 | ${fmtMs(m.overview.wallMs)} = 模型 ${fmtMs(m.overview.modelMs)} + MCP ${fmtMs(m.overview.mcpMs)} + 仓 ${fmtMs(m.overview.repoToolMs)} + 人类等待 ${fmtMs(m.overview.humanWaitMs)} |`);
242
286
  L.push(`| tokens | 总 ${m.overview.tokens}(in ${m.overview.tokensIn} / out ${m.overview.tokensOut} / reason ${m.overview.tokensReasoning}) |`);
243
287
  L.push(`| 工具失败 / 空结果 | ${m.overview.toolErrors} / ${m.overview.emptyResults} |`);
244
288
  L.push('');
@@ -264,6 +308,12 @@ function renderDiagnosis(m, meta) {
264
308
  L.push('');
265
309
  L.push(`每步 input tokens(累计上下文规模):${m.tokenGrowth.perStepInput.join(', ') || '(无)'}`);
266
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('');
267
317
  L.push(`## 诊断建议(启发式,供 Agent 复核)`);
268
318
  L.push('');
269
319
  for (const h of m.hints) L.push(`- ${h}`);
@@ -281,7 +331,8 @@ function fmtMs(ms) {
281
331
 
282
332
  function writeBundle(opts) {
283
333
  const workspace = opts.workspace || process.cwd();
284
- const sessionText = fs.readFileSync(opts.session, 'utf8');
334
+ const rawInput = fs.readFileSync(opts.session, 'utf8');
335
+ const sessionText = isExportJson(rawInput) ? exportToNdjson(rawInput) : rawInput;
285
336
  const session = parseSession(sessionText);
286
337
  const sessionId = opts.sessionId || inferSessionId(sessionText) || 'session';
287
338
  const outDir = opts.out || path.join(workspace, '.argo', 'temp', 'diagnosis', sessionId);
@@ -351,6 +402,6 @@ function main(argv) {
351
402
  return 0;
352
403
  }
353
404
 
354
- module.exports = { BUNDLE_VERSION, SCHEMA_VERSION, parseSession, classifyQuery, backendOf, signature, diagnose, renderDiagnosis, writeBundle, sliceCostLog, main };
405
+ module.exports = { BUNDLE_VERSION, SCHEMA_VERSION, parseSession, classifyQuery, backendOf, signature, diagnose, renderDiagnosis, writeBundle, sliceCostLog, isExportJson, exportToNdjson, main };
355
406
 
356
407
  if (require.main === module) process.exit(main());
@@ -330,6 +330,8 @@ function intentElementContextInputSchema() {
330
330
  dependentDepth: { type: 'number', description: 'Default: 1. Semantic dependents that rely on the focus element.' },
331
331
  associationDepth: { type: 'number', description: 'Default: 1. Association neighbors are expanded at least one layer.' },
332
332
  associationNeighborDependencyDepth: { type: 'number', description: 'Default: 0. Optional dependency expansion from association neighbors.' },
333
+ includeAttributes: { type: 'boolean', description: 'Default: false. Include `attributes` (commit/session/release ledgers) verbatim; omitted by default from this structural read (the focus element always keeps its own).' },
334
+ includeTestcases: { type: 'boolean', description: 'Default: false. Include member `testcases` verbatim; omitted by default from this structural read.' },
333
335
  },
334
336
  additionalProperties: false,
335
337
  };
@@ -461,6 +461,8 @@ function intentElementContextInputSchema() {
461
461
  dependentDepth: { type: 'number', description: 'Default: 1. Semantic dependents that rely on the focus element.' },
462
462
  associationDepth: { type: 'number', description: 'Default: 1. Association neighbors are expanded at least one layer.' },
463
463
  associationNeighborDependencyDepth: { type: 'number', description: 'Default: 0. Optional dependency expansion from association neighbors.' },
464
+ includeAttributes: { type: 'boolean', description: 'Default: false. Include `attributes` (commit/session/release ledgers) verbatim. Omitted by default from this structural read; the focus element always keeps its own. Semantic retrieval embeds attributes, so semantic hits keep them.' },
465
+ includeTestcases: { type: 'boolean', description: 'Default: false. Include member `testcases` verbatim. Omitted by default from this structural read (bookkeeping); pass true when you need acceptance cases. Semantic retrieval embeds testcase descriptions, so semantic hits keep them.' },
464
466
  },
465
467
  additionalProperties: false,
466
468
  };
@@ -475,6 +477,8 @@ function viewContextInputSchema() {
475
477
  view_id: { type: 'string', description: 'The id of the view to resolve.' },
476
478
  includeParentElement: { type: 'boolean', description: 'Default: true. Resolve the parent element referenced by the view.' },
477
479
  includeChildViews: { type: 'boolean', description: 'Default: false. Include child views declared by member elements through subdiagram_views.' },
480
+ includeAttributes: { type: 'boolean', description: 'Default: false. Include member/relationship `attributes` (commit/session/release ledgers) verbatim. Omitted by default from this structural read (bookkeeping); pass true when you need provenance.' },
481
+ includeTestcases: { type: 'boolean', description: 'Default: false. Include member `testcases` verbatim. Omitted by default from this structural read; pass true for acceptance-case lookups.' },
478
482
  includeEaGeometry: { type: 'boolean', description: 'Default: false (opt-in). When true, additionally resolve the diagram GEOMETRY (element boxes + connector line routes) for this view from the workspace EA model (.qea) and return it under a `geometry` field aligned by schema id with the resolved members. Each geometry relationship carries: `path` (the EA route from t_diagramlinks.Path, "" when EA auto-routes), `points` (the parsed [{x,y}] waypoints), `edge` (the EDGE route-style token or null) and `geometry` (the raw SX/SY/EX/EY override string, which contains NO waypoints). By default the EA model is never touched and no `geometry` field is returned; a missing EA model/diagram yields geometry.present=false, never an error.' },
479
483
  },
480
484
  additionalProperties: false,
@@ -615,6 +619,42 @@ function validateDocument(document, schema, options = {}) {
615
619
  return errors;
616
620
  }
617
621
 
622
+ // Agent-facing projection of canonical records for STRUCTURAL reads (view
623
+ // membership / intent-element subgraph). `attributes` (commit/session/release
624
+ // ledgers) and `testcases` are bookkeeping: measured as ~52% of the element
625
+ // bytes but NOT needed to answer structural reads, so they are omitted by
626
+ // default and returned only on opt-in (includeAttributes / includeTestcases).
627
+ //
628
+ // This is NOT summarisation: every retained field is returned verbatim. It is
629
+ // also NOT the semantic match surface — semantic retrieval embeds attributes and
630
+ // testcase descriptions (semanticRecordText.js), so semantic results keep them;
631
+ // and the focus element of an intent-element read always keeps its own.
632
+ function projectAgentFields(record, opts = {}) {
633
+ const value = clone(record);
634
+ let omittedAttributes = 0;
635
+ let omittedTestcases = 0;
636
+ if (!opts.includeAttributes && Array.isArray(value.attributes) && value.attributes.length) {
637
+ omittedAttributes = value.attributes.length;
638
+ delete value.attributes;
639
+ }
640
+ if (!opts.includeTestcases && Array.isArray(value.testcases) && value.testcases.length) {
641
+ omittedTestcases = value.testcases.length;
642
+ delete value.testcases;
643
+ }
644
+ return { value, omittedAttributes, omittedTestcases };
645
+ }
646
+
647
+ function buildAgentProjection(omitted) {
648
+ if (!omitted || (omitted.attributes === 0 && omitted.testcases === 0)) {
649
+ return null;
650
+ }
651
+ return {
652
+ attributesOmitted: omitted.attributes,
653
+ testcasesOmitted: omitted.testcases,
654
+ note: 'Member attributes/testcases are bookkeeping (commit/session/release ledgers, acceptance cases) and are omitted from this structural read by default. Pass includeAttributes:true / includeTestcases:true to include them verbatim. Semantic retrieval already embeds their text, so semantic hits keep them.',
655
+ };
656
+ }
657
+
618
658
  function buildIntentElementContext(context, args = {}) {
619
659
  const profile = args.profile || 'generic-agent';
620
660
  const focusResult = resolveFocusElement(context.document, args);
@@ -697,7 +737,27 @@ function buildIntentElementContext(context, args = {}) {
697
737
  associationDepth,
698
738
  });
699
739
 
700
- return {
740
+ const subgraph = buildNativeSubgraph(context.document, includedElementIds, includedRelationshipIds);
741
+ const includeAttributes = args.includeAttributes === true;
742
+ const includeTestcases = args.includeTestcases === true;
743
+ const omitted = { attributes: 0, testcases: 0 };
744
+ if (!(includeAttributes && includeTestcases)) {
745
+ subgraph.elements = subgraph.elements.map((element) => {
746
+ if (element.id === focusElement.id) {
747
+ return element; // the focus element keeps its own bookkeeping
748
+ }
749
+ const projected = projectAgentFields(element, { includeAttributes, includeTestcases });
750
+ omitted.attributes += projected.omittedAttributes;
751
+ omitted.testcases += projected.omittedTestcases;
752
+ return projected.value;
753
+ });
754
+ subgraph.relationships = subgraph.relationships.map((relationship) => {
755
+ const projected = projectAgentFields(relationship, { includeAttributes });
756
+ omitted.attributes += projected.omittedAttributes;
757
+ return projected.value;
758
+ });
759
+ }
760
+ const result = {
701
761
  status: 'passed',
702
762
  query: {
703
763
  architecturePath: context.graphPath.relativePath,
@@ -711,12 +771,15 @@ function buildIntentElementContext(context, args = {}) {
711
771
  traversalMode: 'archimate-semantic',
712
772
  },
713
773
  focusElementId: focusElement.id,
714
- subgraph: buildNativeSubgraph(context.document, includedElementIds, includedRelationshipIds),
774
+ subgraph,
715
775
  boundary,
716
776
  explorationHints,
717
777
  workContext: {},
718
778
  diagnostics: [],
719
779
  };
780
+ const projection = buildAgentProjection(omitted);
781
+ if (projection) result.projection = projection;
782
+ return result;
720
783
  }
721
784
 
722
785
  // ---------------------------------------------------------------------------
@@ -786,12 +849,22 @@ function buildViewContext(context, args = {}) {
786
849
  const elementById = new Map((document.elements || []).map(element => [element.id, element]));
787
850
  const relationshipById = new Map((document.relationships || []).map(relationship => [relationship.id, relationship]));
788
851
 
852
+ const includeAttributes = args.includeAttributes === true;
853
+ const includeTestcases = args.includeTestcases === true;
854
+ const omitted = { attributes: 0, testcases: 0 };
855
+ const project = (record) => {
856
+ const projected = projectAgentFields(record, { includeAttributes, includeTestcases });
857
+ omitted.attributes += projected.omittedAttributes;
858
+ omitted.testcases += projected.omittedTestcases;
859
+ return projected.value;
860
+ };
861
+
789
862
  const elements = [];
790
863
  const missingElementIds = [];
791
864
  for (const elementId of view.included_elements || []) {
792
865
  const element = elementById.get(elementId);
793
866
  if (element) {
794
- elements.push(clone(element));
867
+ elements.push(project(element));
795
868
  } else {
796
869
  missingElementIds.push(elementId);
797
870
  }
@@ -802,7 +875,7 @@ function buildViewContext(context, args = {}) {
802
875
  for (const relationshipId of view.included_relationships || []) {
803
876
  const relationship = relationshipById.get(relationshipId);
804
877
  if (relationship) {
805
- relationships.push(clone(relationship));
878
+ relationships.push(project(relationship));
806
879
  } else {
807
880
  missingRelationshipIds.push(relationshipId);
808
881
  }
@@ -812,7 +885,7 @@ function buildViewContext(context, args = {}) {
812
885
  let parentElement = null;
813
886
  if (includeParentElement && view.parent_element_id) {
814
887
  const parent = elementById.get(view.parent_element_id);
815
- parentElement = parent ? clone(parent) : null;
888
+ parentElement = parent ? project(parent) : null;
816
889
  }
817
890
 
818
891
  const includeChildViews = args.includeChildViews === true;
@@ -847,6 +920,8 @@ function buildViewContext(context, args = {}) {
847
920
  if (args.includeEaGeometry === true) {
848
921
  result.geometry = readEaViewGeometry(context.workspaceRoot, viewId);
849
922
  }
923
+ const projection = buildAgentProjection(omitted);
924
+ if (projection) result.projection = projection;
850
925
  return result;
851
926
  }
852
927
 
@@ -2750,7 +2825,7 @@ async function memorySearchTool(args = {}, dependencies = undefined) {
2750
2825
  .filter(element => element && typeof element.semanticScore === 'number')
2751
2826
  .sort((left, right) => right.semanticScore - left.semanticScore)
2752
2827
  .slice(0, topK)
2753
- .map(element => Object.freeze(memoryHitCard(element, maxDescLen)));
2828
+ .map(element => Object.freeze(memoryHitCard(element, maxDescLen, query)));
2754
2829
  return {
2755
2830
  status: 'passed',
2756
2831
  query,
@@ -2763,7 +2838,7 @@ async function memorySearchTool(args = {}, dependencies = undefined) {
2763
2838
  // Build one compact memory hit: id/name/type/score + an excerpt of the
2764
2839
  // description bounded by maxDescLen, plus the full text length so the caller
2765
2840
  // knows how much content exists and can decide whether to expand.
2766
- function memoryHitCard(element, maxDescLen) {
2841
+ function memoryHitCard(element, maxDescLen, query) {
2767
2842
  const description = typeof element.description === 'string' ? element.description : '';
2768
2843
  const descriptionLength = description.length;
2769
2844
  let excerpt = '';
@@ -2783,6 +2858,13 @@ function memoryHitCard(element, maxDescLen) {
2783
2858
  card.truncated = true;
2784
2859
  }
2785
2860
  }
2861
+ // A memory match may be driven by an attribute/testcase (both are embedded);
2862
+ // surface the matching bookkeeping text so the agent sees WHY it hit without
2863
+ // a second call.
2864
+ const snippet = bookkeepingSnippet(element, 'Element', query);
2865
+ if (snippet) {
2866
+ card.matchedSnippet = snippet;
2867
+ }
2786
2868
  return card;
2787
2869
  }
2788
2870
 
@@ -3290,11 +3372,31 @@ function buildBusinessSemanticSummary(retrieved, query = {}) {
3290
3372
  supplementaryReasons: Array.isArray(item.supplementaryReasons) ? [...item.supplementaryReasons] : [],
3291
3373
  },
3292
3374
  ]));
3375
+ const recordById = new Map();
3376
+ const putRecord = (objectType, record, id) => { if (record && id) recordById.set(`${objectType}:${id}`, record); };
3377
+ for (const element of (source.closure && source.closure.elements) || []) putRecord('Element', element, element.id);
3378
+ for (const view of (source.viewClosure && source.viewClosure.views) || []) {
3379
+ putRecord('View', view, view.view_id);
3380
+ for (const member of view.memberElements || []) putRecord('Element', member, member.id);
3381
+ for (const member of view.memberRelationships || []) putRecord('ArchitectureRelationship', member, member.id);
3382
+ }
3383
+ for (const relationship of (source.endpointClosure && source.endpointClosure.relationships) || []) {
3384
+ putRecord('ArchitectureRelationship', relationship, relationship.id);
3385
+ if (relationship.source) putRecord('Element', relationship.source, relationship.source.id);
3386
+ if (relationship.target) putRecord('Element', relationship.target, relationship.target.id);
3387
+ }
3388
+ const ctx = {
3389
+ query,
3390
+ recordById,
3391
+ hitElementIds: buildHitIdSet(source, 'elements'),
3392
+ hitRelationshipIds: buildHitIdSet(source, 'relationships'),
3393
+ hitViewIds: buildHitIdSet(source, 'views'),
3394
+ };
3293
3395
  const seedLimit = businessSummaryLimit(query);
3294
- const semanticSeeds = summarizeSeeds(source.seedsByType, hitReasonByKey, seedLimit);
3295
- const elements = summarizeElements(source, hitReasonByKey, seedLimit * 2);
3296
- const relationships = summarizeRelationships(source, hitReasonByKey, seedLimit * 2);
3297
- const views = summarizeViews(source, hitReasonByKey, seedLimit);
3396
+ const semanticSeeds = summarizeSeeds(source.seedsByType, hitReasonByKey, seedLimit, ctx);
3397
+ const elements = summarizeElements(source, hitReasonByKey, seedLimit * 2, ctx);
3398
+ const relationships = summarizeRelationships(source, hitReasonByKey, seedLimit * 2, ctx);
3399
+ const views = summarizeViews(source, hitReasonByKey, seedLimit, ctx);
3298
3400
  const includedObjectIds = Object.freeze([
3299
3401
  ...elements.map(item => item.id),
3300
3402
  ...relationships.map(item => item.id),
@@ -3688,7 +3790,70 @@ function businessSummaryLimit(query) {
3688
3790
  return Number.isInteger(supplied) && supplied > 0 ? Math.min(supplied, 50) : 8;
3689
3791
  }
3690
3792
 
3691
- function summarizeSeeds(seedsByType = {}, hitReasonByKey, limit) {
3793
+ // A hit may be driven by an attribute/testcase (both are embedded), so surface
3794
+ // the matching bookkeeping text on HIT records only — so the agent sees WHY it
3795
+ // matched without a second lookup. Never attached to pruned neighbours.
3796
+ function tokenizeForMatch(text) {
3797
+ const tokens = new Set();
3798
+ const s = String(text || '');
3799
+ for (const word of s.toLowerCase().match(/[a-z0-9]{3,}/g) || []) tokens.add(word);
3800
+ for (const run of s.match(/[\u4e00-\u9fff]{2,}/g) || []) {
3801
+ tokens.add(run);
3802
+ for (let i = 0; i + 2 <= run.length; i += 1) tokens.add(run.slice(i, i + 2));
3803
+ }
3804
+ return [...tokens];
3805
+ }
3806
+
3807
+ function bookkeepingSnippet(record, objectType, query, maxLen = 240) {
3808
+ if (!record || typeof record !== 'object') return null;
3809
+ const candidates = [];
3810
+ if (objectType === 'ArchitectureRelationship' || objectType === 'Relationship') {
3811
+ if (typeof record.statement === 'string' && record.statement.trim()) candidates.push(record.statement.trim());
3812
+ if (typeof record.description === 'string' && record.description.trim()) candidates.push(record.description.trim());
3813
+ }
3814
+ for (const attribute of Array.isArray(record.attributes) ? record.attributes : []) {
3815
+ if (!attribute || typeof attribute.name !== 'string') continue;
3816
+ const text = (typeof attribute.value === 'string' && attribute.value.trim())
3817
+ || (typeof attribute.description === 'string' && attribute.description.trim()) || '';
3818
+ if (text) candidates.push(`${attribute.name}: ${text}`);
3819
+ }
3820
+ for (const testcase of Array.isArray(record.testcases) ? record.testcases : []) {
3821
+ if (!testcase) continue;
3822
+ const text = typeof testcase === 'string'
3823
+ ? testcase
3824
+ : (testcase.description || testcase.coverage || testcase.name || '');
3825
+ if (typeof text === 'string' && text.trim()) candidates.push(`AT ${testcase.name || ''}: ${text.trim()}`.trim());
3826
+ }
3827
+ if (!candidates.length) return null;
3828
+ const q = typeof query === 'string' ? query : (query && query.intent) || '';
3829
+ const tokens = tokenizeForMatch(q);
3830
+ const scored = candidates.map((text, index) => {
3831
+ const lower = text.toLowerCase();
3832
+ let overlap = 0;
3833
+ for (const token of tokens) if (lower.includes(token)) overlap += 1;
3834
+ return { text, overlap, index };
3835
+ }).sort((a, b) => (b.overlap - a.overlap) || (b.text.length - a.text.length) || (a.index - b.index));
3836
+ let out = '';
3837
+ for (const entry of scored) {
3838
+ if (out && out.length + entry.text.length + 2 > maxLen) break;
3839
+ out = out ? `${out} | ${entry.text}` : entry.text;
3840
+ if (out.length >= maxLen) break;
3841
+ }
3842
+ if (!out) return null;
3843
+ return out.length > maxLen ? `${out.slice(0, maxLen - 3)}...` : out;
3844
+ }
3845
+
3846
+ function buildHitIdSet(source, typeKey) {
3847
+ const set = new Set();
3848
+ const seeds = (source && source.seedsByType && source.seedsByType[typeKey]) || [];
3849
+ for (const seed of Array.isArray(seeds) ? seeds : []) {
3850
+ const raw = seed && (seed.id || seed.objectId || seed.canonicalIdentity);
3851
+ if (typeof raw === 'string' && raw) set.add(raw.includes(':') ? raw.split(':').pop() : raw);
3852
+ }
3853
+ return set;
3854
+ }
3855
+
3856
+ function summarizeSeeds(seedsByType = {}, hitReasonByKey, limit, ctx = {}) {
3692
3857
  return Object.freeze(Object.fromEntries(Object.entries(seedsByType).map(([type, seeds]) => [
3693
3858
  type,
3694
3859
  Object.freeze((Array.isArray(seeds) ? seeds : [])
@@ -3697,14 +3862,18 @@ function summarizeSeeds(seedsByType = {}, hitReasonByKey, limit) {
3697
3862
  .slice(0, limit)
3698
3863
  .map(seed => {
3699
3864
  const objectType = seed.objectType || seed.channel || inferObjectTypeFromSeedType(type);
3700
- const objectId = seed.id || seed.objectId || seed.canonicalIdentity;
3865
+ const rawId = seed.id || seed.objectId || seed.canonicalIdentity;
3866
+ const objectId = typeof rawId === 'string' && rawId.includes(':') ? rawId.split(':').pop() : rawId;
3701
3867
  const reasons = hitReasonByKey.get(`${objectType}:${objectId}`) || {};
3868
+ const record = ctx.recordById ? ctx.recordById.get(`${objectType}:${objectId}`) : null;
3869
+ const snippet = record ? bookkeepingSnippet(record, objectType, ctx.query) : null;
3702
3870
  return Object.freeze({
3703
3871
  objectId,
3704
3872
  objectType,
3705
3873
  score: typeof seed.score === 'number' ? seed.score : undefined,
3706
3874
  hitReason: reasons.firstInclusionReason || 'semantic-seed',
3707
3875
  supplementaryReasons: Object.freeze(reasons.supplementaryReasons || []),
3876
+ ...(snippet ? { matchedSnippet: snippet } : {}),
3708
3877
  });
3709
3878
  })),
3710
3879
  ])));
@@ -3716,19 +3885,25 @@ function inferObjectTypeFromSeedType(type) {
3716
3885
  return 'Element';
3717
3886
  }
3718
3887
 
3719
- function summarizeElements(source, hitReasonByKey, limit) {
3888
+ function summarizeElements(source, hitReasonByKey, limit, ctx = {}) {
3889
+ // Semantic subgraph rule: the attribute/testcase-DERIVED fields (status /
3890
+ // functionalPoints / testCoverage) and the matchedSnippet are the match
3891
+ // surface for the HIT elements only; closure neighbours drop them
3892
+ // (bookkeepingOmitted) — mirroring the structural-read projection.
3893
+ const hitIds = ctx.hitElementIds || buildHitIdSet(source, 'elements');
3720
3894
  return uniqueById([
3721
3895
  ...(((source.closure && source.closure.elements) || [])),
3722
3896
  ...((((source.viewClosure && source.viewClosure.views) || []).flatMap(view => view.memberElements || []))),
3723
3897
  ...((((source.endpointClosure && source.endpointClosure.relationships) || []).flatMap(relationship => [relationship.source, relationship.target]).filter(Boolean))),
3724
- ], 'id').slice(0, limit).map(element => summarizeElement(element, hitReasonByKey));
3898
+ ], 'id').slice(0, limit).map(element => summarizeElement(element, hitReasonByKey, hitIds, ctx));
3725
3899
  }
3726
3900
 
3727
- function summarizeRelationships(source, hitReasonByKey, limit) {
3901
+ function summarizeRelationships(source, hitReasonByKey, limit, ctx = {}) {
3902
+ const hitIds = ctx.hitRelationshipIds || buildHitIdSet(source, 'relationships');
3728
3903
  return uniqueById([
3729
3904
  ...(((source.endpointClosure && source.endpointClosure.relationships) || [])),
3730
3905
  ...((((source.viewClosure && source.viewClosure.views) || []).flatMap(view => view.memberRelationships || []))),
3731
- ], 'id').slice(0, limit).map(relationship => summarizeRelationship(relationship, hitReasonByKey));
3906
+ ], 'id').slice(0, limit).map(relationship => summarizeRelationship(relationship, hitReasonByKey, hitIds, ctx));
3732
3907
  }
3733
3908
 
3734
3909
  function summarizeViews(source, hitReasonByKey, limit) {
@@ -3737,27 +3912,38 @@ function summarizeViews(source, hitReasonByKey, limit) {
3737
3912
  .map(view => summarizeView(view, hitReasonByKey));
3738
3913
  }
3739
3914
 
3740
- function summarizeElement(element, hitReasonByKey) {
3915
+ function summarizeElement(element, hitReasonByKey, hitIds = null, ctx = {}) {
3741
3916
  const attributes = attributesMap(element);
3742
3917
  const reasons = hitReasonByKey.get(`Element:${element.id}`) || {};
3743
- return Object.freeze({
3918
+ const isHit = !hitIds || hitIds.size === 0 || hitIds.has(element.id);
3919
+ const base = {
3744
3920
  id: element.id,
3745
3921
  name: element.name,
3746
3922
  type: element.type,
3747
3923
  descriptionSummary: summarizeText(element.description),
3748
- status: attributes.deliveryStatus || attributes.status,
3749
- functionalPoints: Object.freeze(Object.entries(attributes)
3750
- .filter(([name]) => name.startsWith('functionalPoint'))
3751
- .map(([, value]) => value)),
3752
- testCoverage: summarizeTestcases(element.testcases),
3753
3924
  hitReason: reasons.firstInclusionReason,
3754
3925
  supplementaryReasons: Object.freeze(reasons.supplementaryReasons || []),
3755
- });
3926
+ };
3927
+ if (isHit) {
3928
+ const snippet = bookkeepingSnippet(element, 'Element', ctx.query);
3929
+ return Object.freeze({
3930
+ ...base,
3931
+ status: attributes.deliveryStatus || attributes.status,
3932
+ functionalPoints: Object.freeze(Object.entries(attributes)
3933
+ .filter(([name]) => name.startsWith('functionalPoint'))
3934
+ .map(([, value]) => value)),
3935
+ testCoverage: summarizeTestcases(element.testcases),
3936
+ ...(snippet ? { matchedSnippet: snippet } : {}),
3937
+ });
3938
+ }
3939
+ const hasBookkeeping = Object.keys(attributes).length > 0
3940
+ || (Array.isArray(element.testcases) && element.testcases.length > 0);
3941
+ return Object.freeze({ ...base, ...(hasBookkeeping ? { bookkeepingOmitted: true } : {}) });
3756
3942
  }
3757
3943
 
3758
- function summarizeRelationship(relationship, hitReasonByKey) {
3944
+ function summarizeRelationship(relationship, hitReasonByKey, hitIds = null, ctx = {}) {
3759
3945
  const reasons = hitReasonByKey.get(`ArchitectureRelationship:${relationship.id}`) || {};
3760
- return Object.freeze({
3946
+ const base = {
3761
3947
  id: relationship.id,
3762
3948
  name: relationship.name,
3763
3949
  type: relationship.type,
@@ -3765,7 +3951,13 @@ function summarizeRelationship(relationship, hitReasonByKey) {
3765
3951
  target_id: relationship.target_id,
3766
3952
  hitReason: reasons.firstInclusionReason,
3767
3953
  supplementaryReasons: Object.freeze(reasons.supplementaryReasons || []),
3768
- });
3954
+ };
3955
+ const isHit = !hitIds || hitIds.size === 0 || hitIds.has(relationship.id);
3956
+ if (!isHit) {
3957
+ return Object.freeze(base);
3958
+ }
3959
+ const snippet = bookkeepingSnippet(relationship, 'ArchitectureRelationship', ctx.query);
3960
+ return Object.freeze({ ...base, ...(snippet ? { matchedSnippet: snippet } : {}) });
3769
3961
  }
3770
3962
 
3771
3963
  function summarizeView(view, hitReasonByKey) {
@@ -4239,6 +4431,7 @@ module.exports = {
4239
4431
  GET_SYSTEM_ARCHITECTURE_OUTPUT_SCHEMA,
4240
4432
  TOOLS,
4241
4433
  applyMutations,
4434
+ buildBusinessSemanticSummary,
4242
4435
  buildSemanticDedupAdvisory,
4243
4436
  selectCreatedElementAdds,
4244
4437
  evaluateSemanticDedupGate,
@@ -39,6 +39,7 @@ node ~/.argo/scripts/agentSearchDiagnose.js --session <session.ndjson> \
39
39
  ```
40
40
 
41
41
  脚本**只读**会话与日志,写出上面的 bundle 目录,输出概览与启发式 hints。
42
+ 支持两种输入:`opencode export` 的会话 JSON(`{info, messages}`)与 `opencode run --format json` 的事件流 NDJSON。
42
43
 
43
44
  ### 3. 复核并补充诊断总结(Agent)
44
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archgraph-argo",
3
- "version": "0.26.0",
3
+ "version": "0.26.1",
4
4
  "description": "Deploy the ArchGraph ARGO toolchain, skills, and rules (schema, scripts, argo-init skill, global rule) with one command.",
5
5
  "license": "MIT",
6
6
  "bin": {