hoi4-agent-tools 2.3.0 → 2.3.3

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/bin/stdio.js +4 -0
  3. package/dist/hoi4_agent_tools/event/algorithms.js +178 -1
  4. package/dist/hoi4_agent_tools/event/graph.js +24 -9
  5. package/dist/hoi4_agent_tools/event/limits.js +4 -1
  6. package/dist/hoi4_agent_tools/event/service.js +182 -45
  7. package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +480 -0
  8. package/dist/hoi4_agent_tools/mcp/tools/technology.js +4 -1
  9. package/dist/hoi4_agent_tools/runtime.js +109 -7
  10. package/dist/hoi4_agent_tools/technology/graph.js +30 -3
  11. package/dist/hoi4_agent_tools/technology/queries.js +70 -3
  12. package/dist/hoi4_agent_tools/technology/service.js +38 -2
  13. package/dist/hoi4_agent_tools/version.js +1 -1
  14. package/docs/events.md +2 -2
  15. package/docs/setup.md +2 -0
  16. package/docs/technology.md +4 -2
  17. package/package.json +3 -1
  18. package/schemas/configuration.schema.json +1 -1
  19. package/schemas/continuous-focus-palette.schema.json +1 -1
  20. package/schemas/custom-weighted-pool.schema.json +1 -1
  21. package/schemas/event-feature-manifest.schema.json +1 -1
  22. package/schemas/focus-plan.schema.json +1 -1
  23. package/schemas/focus-planning-sidecar.schema.json +1 -1
  24. package/schemas/gui-animation-source.schema.json +1 -1
  25. package/schemas/gui-helper.schema.json +1 -1
  26. package/schemas/gui-scenario.schema.json +1 -1
  27. package/schemas/map-operation.schema.json +1 -1
  28. package/schemas/operation-result.schema.json +1 -1
  29. package/schemas/probability-analysis-result.schema.json +1 -1
  30. package/schemas/probability-compare-input.schema.json +1 -1
  31. package/schemas/probability-evaluate-input.schema.json +1 -1
  32. package/schemas/probability-inspect-input.schema.json +1 -1
  33. package/schemas/probability-render-input.schema.json +1 -1
  34. package/schemas/probability-scenario-set.schema.json +1 -1
  35. package/schemas/probability-sequence-input.schema.json +1 -1
  36. package/schemas/probability-simulate-input.schema.json +1 -1
  37. package/schemas/probability-sweep-input.schema.json +1 -1
  38. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.3 - 2026-07-26
4
+
5
+ - Regenerated the npm lockfile with the pinned release npm so clean installs include the patched optional native runtime packages.
6
+
7
+ ## 2.3.2 - 2026-07-26
8
+
9
+ - Pinned patched transitive `brace-expansion` and `postcss` releases so the CI and release audit remain clean without changing the MCP Inspector surface.
10
+
11
+ ## 2.3.1 - 2026-07-26
12
+
13
+ - Fixed large vanilla-plus-mod technology scans that could consume multiple gigabytes and leave the MCP transport open until a client timeout closed it.
14
+ - Added automatic focused technology analysis for game-backed and large workspaces, retaining direct source evidence and helper-call indexing while deferring workspace-wide scripted-effect projections.
15
+
3
16
  ## 2.3.0 - 2026-07-22
4
17
 
5
18
  - Added a read-only AI and MTTH Scenario Analyzer with inspect, evaluate, sweep, simulate, sequence, compare, and render tools.
package/dist/bin/stdio.js CHANGED
@@ -8,6 +8,10 @@ async function main() {
8
8
  const engine = await createEngine();
9
9
  const context = {};
10
10
  const server = createMcpServer(engine, context);
11
+ if (process.env.HOI4_AGENT_TOOLS_CHAOSX === '1') {
12
+ const { registerChaosxTools } = await import('../hoi4_agent_tools/mcp/tools/chaosx.js');
13
+ registerChaosxTools(server, engine, context);
14
+ }
11
15
  server.server.onerror = (error) => {
12
16
  const fatalInputError = error instanceof StdioFrameLimitError || error instanceof StdioInvalidUtf8Error;
13
17
  const rejectedMessage = error instanceof StdioInvalidMessageError;
@@ -1,4 +1,4 @@
1
- import { compareCodeUnits } from '../core/canonical.js';
1
+ import { compareCodeUnits, hashCanonical } from '../core/canonical.js';
2
2
  import { EventAnalysisBudget } from './limits.js';
3
3
  import { iterativeStronglyConnectedComponents } from './scc.js';
4
4
  function sortedUnique(values) {
@@ -41,6 +41,178 @@ function edgeMaps(edges) {
41
41
  }
42
42
  return { outgoing, incoming };
43
43
  }
44
+ function helperName(node) {
45
+ const name = node.metadata.name;
46
+ if (typeof name === 'string')
47
+ return name;
48
+ return node.label || node.id.replace(/^helper:/u, '');
49
+ }
50
+ function collapsedHelperEdge(path, helperStack, from, to) {
51
+ const first = path[0];
52
+ const last = path.at(-1);
53
+ if (helperStack.length === 0)
54
+ return first;
55
+ return {
56
+ id: `edge:lazy-helper-projection:${hashCanonical({
57
+ from,
58
+ to,
59
+ edges: path.map(({ id }) => id),
60
+ }).slice(0, 32)}`,
61
+ from,
62
+ to,
63
+ reason: 'scripted_effect_expansion',
64
+ conditions: path.flatMap(({ conditions }) => conditions),
65
+ helperStack: [...helperStack],
66
+ location: first.location,
67
+ provenance: path.flatMap(({ provenance }) => provenance),
68
+ confidence: last.confidence,
69
+ derived: true,
70
+ ...(last.timing === undefined ? {} : { timing: last.timing }),
71
+ ...(last.weight === undefined ? {} : { weight: last.weight }),
72
+ ...(last.scope === undefined ? {} : { scope: last.scope }),
73
+ metadata: { structuralEdgeIds: path.map(({ id }) => id) },
74
+ };
75
+ }
76
+ /**
77
+ * Build only the helper projections reached by one bounded trace. Full-workspace
78
+ * helper materialisation is useful for broad reports, but it is unnecessarily
79
+ * expensive when an agent asks for one route in a large installed game.
80
+ */
81
+ function lazyCollapsedEdges(graph, nodeId, direction, maps, maximumPaths, signal) {
82
+ const nodes = nodeMap(graph);
83
+ const retained = new Map();
84
+ let pathVisits = 0;
85
+ const add = (edge) => {
86
+ retained.set(edge.id, edge);
87
+ };
88
+ const downstream = (edge, path, stack, visited) => {
89
+ signal?.throwIfAborted();
90
+ if (++pathVisits > maximumPaths)
91
+ return;
92
+ if (path.length > 64)
93
+ return;
94
+ const target = nodes.get(edge.to);
95
+ if (target?.kind !== 'helper') {
96
+ add(collapsedHelperEdge(path, stack, nodeId, edge.to));
97
+ return;
98
+ }
99
+ if (visited.has(target.id))
100
+ return;
101
+ for (const next of maps.outgoing.get(target.id) ?? []) {
102
+ downstream(next, [...path, next], [...stack, helperName(target)], new Set([...visited, target.id]));
103
+ }
104
+ };
105
+ const upstream = (edge, path, stack, visited) => {
106
+ signal?.throwIfAborted();
107
+ if (++pathVisits > maximumPaths)
108
+ return;
109
+ if (path.length > 64)
110
+ return;
111
+ const source = nodes.get(edge.from);
112
+ if (source?.kind !== 'helper') {
113
+ const forwardPath = [...path].reverse();
114
+ add(collapsedHelperEdge(forwardPath, [...stack].reverse(), edge.from, nodeId));
115
+ return;
116
+ }
117
+ if (visited.has(source.id))
118
+ return;
119
+ for (const previous of maps.incoming.get(source.id) ?? []) {
120
+ upstream(previous, [...path, previous], [...stack, helperName(source)], new Set([...visited, source.id]));
121
+ }
122
+ };
123
+ if (direction !== 'upstream') {
124
+ for (const edge of maps.outgoing.get(nodeId) ?? [])
125
+ downstream(edge, [edge], [], new Set());
126
+ }
127
+ if (direction !== 'downstream') {
128
+ for (const edge of maps.incoming.get(nodeId) ?? [])
129
+ upstream(edge, [edge], [], new Set());
130
+ }
131
+ return [...retained.values()].sort((left, right) => compareCodeUnits(left.id, right.id));
132
+ }
133
+ function traceCollapsedHelpers(graph, starts, boundary, signal) {
134
+ const byId = nodeMap(graph);
135
+ const maps = edgeMaps(graph.edges.filter(({ derived }) => !derived));
136
+ const edgeCache = new Map();
137
+ const edgesFor = (nodeId) => {
138
+ const key = `${nodeId}\0${boundary.direction}`;
139
+ const cached = edgeCache.get(key);
140
+ if (cached !== undefined)
141
+ return cached;
142
+ const edges = lazyCollapsedEdges(graph, nodeId, boundary.direction, maps, Math.max(256, boundary.maxEdges * 2), signal);
143
+ edgeCache.set(key, edges);
144
+ return edges;
145
+ };
146
+ const depthByNode = new Map(starts.map((id) => [id, 0]));
147
+ const queue = [...starts];
148
+ const retainedEdges = new Map();
149
+ const boundaryNodes = new Set();
150
+ let truncated = false;
151
+ let cursor = 0;
152
+ while (cursor < queue.length) {
153
+ if ((cursor & 255) === 0)
154
+ signal?.throwIfAborted();
155
+ const current = queue[cursor++];
156
+ const depth = depthByNode.get(current) ?? 0;
157
+ const nextEdges = edgesFor(current);
158
+ if (depth >= boundary.maxDepth) {
159
+ if (nextEdges.length > 0) {
160
+ boundaryNodes.add(current);
161
+ truncated = true;
162
+ }
163
+ continue;
164
+ }
165
+ for (const edge of nextEdges) {
166
+ if (retainedEdges.size >= boundary.maxEdges) {
167
+ boundaryNodes.add(current);
168
+ truncated = true;
169
+ break;
170
+ }
171
+ const next = boundary.direction === 'upstream'
172
+ ? edge.from
173
+ : boundary.direction === 'downstream'
174
+ ? edge.to
175
+ : edge.from === current
176
+ ? edge.to
177
+ : edge.from;
178
+ const known = depthByNode.has(next);
179
+ if (!known && depthByNode.size >= boundary.maxNodes) {
180
+ boundaryNodes.add(current);
181
+ truncated = true;
182
+ continue;
183
+ }
184
+ retainedEdges.set(edge.id, edge);
185
+ if (!known) {
186
+ depthByNode.set(next, depth + 1);
187
+ queue.push(next);
188
+ }
189
+ }
190
+ }
191
+ const retainedNodes = [...depthByNode.keys()]
192
+ .flatMap((id) => {
193
+ const node = byId.get(id);
194
+ return node === undefined ? [] : [node];
195
+ })
196
+ .sort((left, right) => compareCodeUnits(left.id, right.id));
197
+ const layers = [...new Set(depthByNode.values())]
198
+ .sort((left, right) => left - right)
199
+ .map((depth) => ({
200
+ depth,
201
+ nodeIds: [...depthByNode.entries()]
202
+ .filter(([, value]) => value === depth)
203
+ .map(([id]) => id)
204
+ .sort(compareCodeUnits),
205
+ }));
206
+ return {
207
+ startNodeIds: [...starts],
208
+ nodes: retainedNodes,
209
+ edges: [...retainedEdges.values()].sort((left, right) => compareCodeUnits(left.id, right.id)),
210
+ layers,
211
+ boundary,
212
+ boundaryNodeIds: [...boundaryNodes].sort(compareCodeUnits),
213
+ truncated,
214
+ };
215
+ }
44
216
  function edgesForDirection(nodeId, direction, maps) {
45
217
  const results = [];
46
218
  if (direction !== 'upstream') {
@@ -58,6 +230,11 @@ export function traceEventGraph(graph, startNodeIds, boundary, signal) {
58
230
  const byId = nodeMap(graph);
59
231
  const resolvedStarts = sortedUnique(startNodeIds).filter((id) => byId.has(id));
60
232
  const starts = resolvedStarts.slice(0, Math.max(0, boundary.maxNodes));
233
+ if (!boundary.expandHelpers &&
234
+ !graph.edges.some(({ derived }) => derived) &&
235
+ starts.every((id) => byId.get(id)?.kind !== 'helper')) {
236
+ return traceCollapsedHelpers(graph, starts, boundary, signal);
237
+ }
61
238
  const edges = eventFlowEdges(graph, boundary.expandHelpers);
62
239
  const maps = edgeMaps(edges);
63
240
  const depthByNode = new Map(starts.map((id) => [id, 0]));
@@ -29,6 +29,14 @@ function activeFiles(snapshot) {
29
29
  !/(?:^|\/)(?:supply_nodes|railways|buildings|unitstacks|weatherpositions)\.txt$/iu.test(relativePath.replaceAll('\\', '/')))
30
30
  .sort((left, right) => compareCodeUnits(left.displayPath, right.displayPath));
31
31
  }
32
+ const EVENT_SETUP_CALL_PATTERN = /\b(?:country_event|news_event|state_event|unit_leader_event|operative_leader_event|fire_event|event)\s*=/iu;
33
+ function shouldAnalyzeEventFile(file) {
34
+ const sourcePath = file.relativePath.replaceAll('\\', '/').toLowerCase();
35
+ if (!sourcePath.startsWith('history/countries/') && !sourcePath.startsWith('history/states/')) {
36
+ return true;
37
+ }
38
+ return EVENT_SETUP_CALL_PATTERN.test(file.bytes.toString('utf8'));
39
+ }
32
40
  function catalogFor(snapshot) {
33
41
  const unshadowedEventSymbols = snapshot.index
34
42
  .findAll('event')
@@ -1444,6 +1452,8 @@ function analyzeFragments(snapshot, files, catalog, options, budget) {
1444
1452
  const fragments = [];
1445
1453
  for (const file of files) {
1446
1454
  budget.spend('source_fragment');
1455
+ if (!shouldAnalyzeEventFile(file))
1456
+ continue;
1447
1457
  if (snapshot.index.isSourceSkipped(file.displayPath))
1448
1458
  continue;
1449
1459
  const cacheKey = eventSemanticFragmentCacheKey(file, catalog.fingerprint);
@@ -1560,14 +1570,17 @@ export function buildEventGraph(snapshot, options = {}) {
1560
1570
  graph.edges = stableUnique(graph.edges);
1561
1571
  graph.stateAccesses = stableUnique(graph.stateAccesses);
1562
1572
  graph.unresolved = stableUnique(graph.unresolved);
1563
- const stateLinks = stateLinksFor(graph, budget);
1564
- duplicateAndReferenceIssues(graph);
1565
- unresolvedIssues(graph);
1566
- structuralCallIssues(graph, budget);
1567
- localisationIssues(graph);
1568
- stateLifecycleIssues(graph, stateLinks, budget);
1569
- unreachableIssues(graph, budget);
1570
- normalizeIssueSubjects(graph);
1573
+ const focused = options.analysisMode === 'focused';
1574
+ const stateLinks = focused ? [] : stateLinksFor(graph, budget);
1575
+ if (!focused) {
1576
+ duplicateAndReferenceIssues(graph);
1577
+ unresolvedIssues(graph);
1578
+ structuralCallIssues(graph, budget);
1579
+ localisationIssues(graph);
1580
+ stateLifecycleIssues(graph, stateLinks, budget);
1581
+ unreachableIssues(graph, budget);
1582
+ normalizeIssueSubjects(graph);
1583
+ }
1571
1584
  graph.issues = stableIssues(graph.issues);
1572
1585
  assertGraphLimit(graph.nodes.length, EVENT_GRAPH_MAX_NODES, 'EVENT_NODE_LIMIT', 'node count');
1573
1586
  assertGraphLimit(graph.edges.length, EVENT_GRAPH_MAX_EDGES, 'EVENT_EDGE_LIMIT', 'edge count');
@@ -1577,7 +1590,8 @@ export function buildEventGraph(snapshot, options = {}) {
1577
1590
  assertGraphLimit(graph.unresolved.length, EVENT_GRAPH_MAX_UNRESOLVED, 'EVENT_UNRESOLVED_LIMIT', 'unresolved-analysis count');
1578
1591
  budget.check();
1579
1592
  const diagnostics = sortDiagnostics([...snapshot.diagnostics, ...partial.diagnostics]);
1580
- const complete = snapshot.complete &&
1593
+ const complete = !focused &&
1594
+ snapshot.complete &&
1581
1595
  graph.unresolved.length === 0 &&
1582
1596
  !graph.issues.some(({ classification, severity }) => classification === 'unresolved_analysis' || severity === 'blocker');
1583
1597
  const nodes = sortNodes(graph.nodes);
@@ -1588,6 +1602,7 @@ export function buildEventGraph(snapshot, options = {}) {
1588
1602
  return {
1589
1603
  schemaVersion: EVENT_GRAPH_SCHEMA_VERSION,
1590
1604
  parserVersion: EVENT_GRAPH_PARSER_VERSION,
1605
+ analysisMode: options.analysisMode ?? 'full',
1591
1606
  workspaceId: snapshot.workspaceId,
1592
1607
  workspaceIdentity: options.workspaceIdentity ?? hashCanonical({ workspaceId: snapshot.workspaceId }),
1593
1608
  revision: snapshot.revision,
@@ -4,7 +4,10 @@ export const EVENT_GRAPH_MAX_EDGES = 500_000;
4
4
  export const EVENT_GRAPH_MAX_STATE_ACCESSES = 250_000;
5
5
  export const EVENT_GRAPH_MAX_STATE_LINKS = 250_000;
6
6
  export const EVENT_GRAPH_MAX_STATE_LINK_CANDIDATES = 500_000;
7
- export const EVENT_GRAPH_MAX_ISSUES = 20_000;
7
+ // Installed-game scans can legitimately produce more than twenty thousand source-linked
8
+ // findings before an agent narrows the query. Keep the graph bounded, but do not reject a
9
+ // complete large workspace merely because its diagnostics are numerous.
10
+ export const EVENT_GRAPH_MAX_ISSUES = 100_000;
8
11
  export const EVENT_GRAPH_MAX_UNRESOLVED = 50_000;
9
12
  export const EVENT_GRAPH_MAX_HELPER_DEPTH = 64;
10
13
  export const EVENT_GRAPH_MAX_HELPER_PROJECTIONS = 200_000;
@@ -42,14 +42,16 @@ class BoundedEventFragmentCache {
42
42
  }
43
43
  }
44
44
  }
45
- function eventGraphCacheKey(workspaceId, projectHelpers) {
46
- return `${workspaceId}\0${projectHelpers ? 'expanded' : 'structural'}`;
45
+ function eventGraphCacheKey(workspaceId, projectHelpers, analysisMode = 'full') {
46
+ return `${workspaceId}\0${projectHelpers ? 'expanded' : 'structural'}\0${analysisMode}`;
47
47
  }
48
48
  const serviceState = new WeakMap();
49
49
  const EVENT_GRAPH_ARTIFACT_MAX_BYTES = 67_108_864;
50
50
  const EVENT_GRAPH_ARTIFACT_MAX_CHUNKS = 1_024;
51
51
  const EVENT_SCAN_FULL_GRAPH_RECORD_LIMIT = 100_000;
52
52
  const EVENT_SCAN_SUMMARY_SAMPLE_LIMIT = 100;
53
+ const EVENT_FOCUSED_EVIDENCE_LIMIT = 2_000;
54
+ const EVENT_FOCUSED_GRAPH_FILE_THRESHOLD = 1_000;
53
55
  const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
54
56
  const graphHashCache = new WeakMap();
55
57
  function groupedCounts(values, key) {
@@ -116,7 +118,16 @@ function cachedEventGraphHash(graph, signal) {
116
118
  const cached = graphHashCache.get(graph);
117
119
  if (cached !== undefined)
118
120
  return cached;
119
- const computed = eventGraphHash(graph, signal);
121
+ const computed = graph.analysisMode === 'focused'
122
+ ? hashCanonical({
123
+ schemaVersion: graph.schemaVersion,
124
+ parserVersion: graph.parserVersion,
125
+ workspaceIdentity: graph.workspaceIdentity,
126
+ revision: graph.revision,
127
+ statistics: graph.statistics,
128
+ sourceHashes: graph.sourceHashes,
129
+ })
130
+ : eventGraphHash(graph, signal);
120
131
  graphHashCache.set(graph, computed);
121
132
  return computed;
122
133
  }
@@ -135,6 +146,55 @@ function sharedState(engine) {
135
146
  function engineGeneration(engine, workspaceId) {
136
147
  return engine.generation(workspaceId);
137
148
  }
149
+ function eventScanPatterns(workspace) {
150
+ const roots = workspace.registration.roots;
151
+ const under = (values, glob) => values.map((root) => `${root.replaceAll('\\', '/').replace(/\/$/u, '')}/${glob}`);
152
+ return [
153
+ 'events/**/*.txt',
154
+ 'history/countries/**/*.txt',
155
+ ...under(roots.states, '**/*.txt'),
156
+ 'common/continuous_focus/**/*.txt',
157
+ 'common/decisions/**/*.txt',
158
+ 'common/ideas/**/*.txt',
159
+ 'common/characters/**/*.txt',
160
+ 'common/operations/**/*.txt',
161
+ 'common/raids/**/*.txt',
162
+ 'common/bop/**/*.txt',
163
+ 'common/resistance_compliance_modifiers/**/*.txt',
164
+ 'common/abilities/**/*.txt',
165
+ 'common/on_actions/**/*.txt',
166
+ 'common/special_projects/**/*.txt',
167
+ 'common/scripted_effects/**/*.txt',
168
+ ...under(roots.focus, '**/*.txt'),
169
+ ...under(roots.scriptedGui, '**/*.txt'),
170
+ ...under(roots.localisation, 'english/**/*.{yml,yaml}'),
171
+ ...under(roots.localisation, '*.{yml,yaml}'),
172
+ ].sort(compareCodeUnits);
173
+ }
174
+ async function scanEventSources(engine, workspaceId, workspace, principal, signal) {
175
+ const patterns = eventScanPatterns(workspace);
176
+ const sourceKinds = ['mod', 'dependency', 'fixture'];
177
+ const scans = await Promise.all([
178
+ engine.scan(workspaceId, { patterns, rootKinds: sourceKinds }, principal, signal),
179
+ ...(workspace.roots.some(({ kind }) => kind === 'game')
180
+ ? [engine.scan(workspaceId, { patterns, rootKinds: ['game'] }, principal, signal)]
181
+ : []),
182
+ ]);
183
+ const files = scans.flatMap(({ files }) => files.map((file) => ({ ...file })));
184
+ recomputeShadowing(files);
185
+ files.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
186
+ const index = engine.indexFiles(files);
187
+ return {
188
+ workspaceId,
189
+ revision: snapshotRevision(files),
190
+ files,
191
+ index,
192
+ complete: scans.every(({ complete }) => complete) && index.complete,
193
+ skippedSourceCount: index.skippedSourceCount,
194
+ skippedSources: index.skippedSources,
195
+ diagnostics: [...scans.flatMap(({ diagnostics }) => diagnostics), ...index.diagnostics],
196
+ };
197
+ }
138
198
  function rememberGraph(state, workspaceId, graph) {
139
199
  const history = state.history.get(workspaceId) ?? new Map();
140
200
  history.delete(graph.revision);
@@ -163,6 +223,20 @@ function eventIssueDiagnostic(issue) {
163
223
  },
164
224
  };
165
225
  }
226
+ function boundedEvidence(values, limit = EVENT_FOCUSED_EVIDENCE_LIMIT) {
227
+ if (values.length <= limit)
228
+ return { values: [...values] };
229
+ return {
230
+ values: values.slice(0, limit),
231
+ inventory: { count: values.length, retainedCount: limit, truncated: true },
232
+ };
233
+ }
234
+ function boundedUnresolved(graph, selectedNodeIds) {
235
+ const relevant = selectedNodeIds === undefined
236
+ ? graph.unresolved
237
+ : graph.unresolved.filter(({ id, ownerId }) => selectedNodeIds.has(id) || (ownerId !== undefined && selectedNodeIds.has(ownerId)));
238
+ return boundedEvidence(relevant);
239
+ }
166
240
  export function eventDiagnostics(graph) {
167
241
  const existing = new Set(graph.diagnostics.map(({ code, location }) => `${code}:${location?.path ?? ''}:${location?.start.offset ?? -1}`));
168
242
  return [
@@ -618,37 +692,56 @@ export class EventChainViewer {
618
692
  const workspace = this.engine.resolver.get(workspaceId, options.principal);
619
693
  const generation = engineGeneration(this.engine, workspaceId);
620
694
  const projectHelpers = options.projectHelpers ?? true;
621
- const cacheKey = eventGraphCacheKey(workspaceId, projectHelpers);
695
+ const analysisMode = options.analysisMode ?? 'full';
696
+ const cacheKey = eventGraphCacheKey(workspaceId, projectHelpers, analysisMode);
622
697
  const cached = this.#state.current.get(cacheKey);
623
698
  if (options.refresh !== true && cached?.generation === generation) {
624
699
  return cached.graph;
625
700
  }
626
- const sibling = this.#state.current.get(eventGraphCacheKey(workspaceId, !projectHelpers));
701
+ const sibling = this.#state.current.get(eventGraphCacheKey(workspaceId, !projectHelpers, analysisMode)) ??
702
+ this.#state.current.get(eventGraphCacheKey(workspaceId, !projectHelpers)) ??
703
+ this.#state.current.get(eventGraphCacheKey(workspaceId, projectHelpers));
627
704
  const snapshot = options.refresh !== true && sibling?.generation === generation
628
705
  ? sibling.snapshot
629
- : await this.engine.scan(workspaceId, {}, options.principal, options.signal);
706
+ : await scanEventSources(this.engine, workspaceId, workspace, options.principal, options.signal);
630
707
  options.signal?.throwIfAborted();
631
708
  if (cached?.snapshot.revision === snapshot.revision && cached.generation === generation) {
632
709
  return cached.graph;
633
710
  }
711
+ const buildAnalysisMode = analysisMode === 'focused' && snapshot.files.length > EVENT_FOCUSED_GRAPH_FILE_THRESHOLD
712
+ ? 'focused'
713
+ : 'full';
634
714
  const graph = buildEventGraph(snapshot, {
635
715
  ...(options.signal === undefined ? {} : { signal: options.signal }),
636
716
  cache: this.#state.fragments,
637
717
  workspaceIdentity: workspace.workspaceIdentity,
638
718
  projectHelpers,
719
+ analysisMode: buildAnalysisMode,
639
720
  });
640
721
  this.#state.current.set(cacheKey, { generation, snapshot, graph });
641
- rememberGraph(this.#state, workspaceId, graph);
722
+ if (analysisMode === 'full')
723
+ rememberGraph(this.#state, workspaceId, graph);
642
724
  return graph;
643
725
  }
644
726
  async inspect(input) {
645
727
  input.signal?.throwIfAborted();
646
- const graph = await this.scan(input.workspaceId, {
728
+ const scannedGraph = await this.scan(input.workspaceId, {
647
729
  refresh: input.refresh ?? input.mode === 'scan',
648
- projectHelpers: input.mode !== 'scan' && input.mode !== 'roots',
730
+ // Focused routes traverse helper nodes directly and collapse only the
731
+ // selected path. Avoid materialising the full workspace projection first.
732
+ projectHelpers: input.mode !== 'scan' &&
733
+ input.mode !== 'roots' &&
734
+ input.mode !== 'trace' &&
735
+ input.mode !== 'explain_path',
736
+ ...(input.mode === 'trace' || input.mode === 'explain_path'
737
+ ? { analysisMode: 'focused' }
738
+ : {}),
649
739
  ...(input.principal === undefined ? {} : { principal: input.principal }),
650
740
  ...(input.signal === undefined ? {} : { signal: input.signal }),
651
741
  });
742
+ // Keep the service result's graph identity distinct from a prior scan while
743
+ // reusing its structural cache and avoiding a second large graph build.
744
+ const graph = input.mode === 'trace' || input.mode === 'explain_path' ? { ...scannedGraph } : scannedGraph;
652
745
  const maxDepth = input.maxDepth ?? 8;
653
746
  const maxNodes = input.maxNodes ?? 500;
654
747
  const maxEdges = input.maxEdges ?? 2_000;
@@ -671,28 +764,41 @@ export class EventChainViewer {
671
764
  report = eventScanReport(graph);
672
765
  break;
673
766
  case 'roots':
674
- report = {
675
- graphRevision: graph.revision,
676
- sourceHashes: graph.sourceHashes,
677
- roots: discoverEventRoots(graph, input.signal),
678
- unresolved: graph.unresolved,
679
- };
767
+ {
768
+ const unresolved = boundedUnresolved(graph);
769
+ report = {
770
+ graphRevision: graph.revision,
771
+ sourceHashes: graph.sourceHashes,
772
+ roots: discoverEventRoots(graph, input.signal),
773
+ unresolved: unresolved.values,
774
+ ...(unresolved.inventory === undefined
775
+ ? {}
776
+ : { unresolvedInventory: unresolved.inventory }),
777
+ };
778
+ }
680
779
  break;
681
780
  case 'trace':
682
781
  if (input.selector === undefined)
683
782
  throw new ServiceError('EVENT_SELECTOR_REQUIRED', 'Trace requires a selector');
684
- report = {
685
- graphRevision: graph.revision,
686
- sourceHashes: graph.sourceHashes,
687
- trace: traceSelectedEvents(graph, input.selector, {
783
+ {
784
+ const trace = traceSelectedEvents(graph, input.selector, {
688
785
  maxDepth,
689
786
  maxNodes,
690
787
  maxEdges,
691
788
  direction: input.direction ?? 'both',
692
789
  expandHelpers: input.expandHelpers ?? false,
693
- }, input.signal),
694
- unresolved: graph.unresolved,
695
- };
790
+ }, input.signal);
791
+ const unresolved = boundedUnresolved(graph, new Set(trace.nodes.map(({ id }) => id)));
792
+ report = {
793
+ graphRevision: graph.revision,
794
+ sourceHashes: graph.sourceHashes,
795
+ trace,
796
+ unresolved: unresolved.values,
797
+ ...(unresolved.inventory === undefined
798
+ ? {}
799
+ : { unresolvedInventory: unresolved.inventory }),
800
+ };
801
+ }
696
802
  break;
697
803
  case 'explain_path':
698
804
  if (input.from === undefined || input.to === undefined)
@@ -704,31 +810,54 @@ export class EventChainViewer {
704
810
  };
705
811
  break;
706
812
  case 'state_flow':
707
- report = {
708
- graphRevision: graph.revision,
709
- sourceHashes: graph.sourceHashes,
710
- flow: inspectEventStateFlow(graph, input.selector, input.stateSubject, input.signal),
711
- issues: lintEventGraph(graph, input.selector, input.signal).filter(({ code }) => /(?:STATE|FLAG|VARIABLE|ARRAY|TARGET|SCOPE)/u.test(code)),
712
- };
813
+ {
814
+ const unresolved = boundedUnresolved(graph);
815
+ const issues = boundedEvidence(lintEventGraph(graph, input.selector, input.signal).filter(({ code }) => /(?:STATE|FLAG|VARIABLE|ARRAY|TARGET|SCOPE)/u.test(code)));
816
+ report = {
817
+ graphRevision: graph.revision,
818
+ sourceHashes: graph.sourceHashes,
819
+ flow: inspectEventStateFlow(graph, input.selector, input.stateSubject, input.signal),
820
+ issues: issues.values,
821
+ unresolved: unresolved.values,
822
+ ...(issues.inventory === undefined ? {} : { issueInventory: issues.inventory }),
823
+ ...(unresolved.inventory === undefined
824
+ ? {}
825
+ : { unresolvedInventory: unresolved.inventory }),
826
+ };
827
+ }
713
828
  break;
714
829
  case 'lint':
715
- report = {
716
- graphRevision: graph.revision,
717
- sourceHashes: graph.sourceHashes,
718
- issues: lintEventGraph(graph, input.selector, input.signal),
719
- complete: graph.complete,
720
- unresolved: graph.unresolved,
721
- };
830
+ {
831
+ const issues = boundedEvidence(lintEventGraph(graph, input.selector, input.signal));
832
+ const unresolved = boundedUnresolved(graph);
833
+ report = {
834
+ graphRevision: graph.revision,
835
+ sourceHashes: graph.sourceHashes,
836
+ issues: issues.values,
837
+ complete: graph.complete,
838
+ unresolved: unresolved.values,
839
+ ...(issues.inventory === undefined ? {} : { issueInventory: issues.inventory }),
840
+ ...(unresolved.inventory === undefined
841
+ ? {}
842
+ : { unresolvedInventory: unresolved.inventory }),
843
+ };
844
+ }
722
845
  break;
723
846
  case 'impact':
724
847
  if (input.impactSubject === undefined)
725
848
  throw new ServiceError('EVENT_IMPACT_SUBJECT_REQUIRED', 'Impact analysis requires a subject');
726
- report = {
727
- graphRevision: graph.revision,
728
- sourceHashes: graph.sourceHashes,
729
- impact: analyzeEventImpact(graph, input.impactSubject, input.signal),
730
- unresolved: graph.unresolved,
731
- };
849
+ {
850
+ const unresolved = boundedUnresolved(graph);
851
+ report = {
852
+ graphRevision: graph.revision,
853
+ sourceHashes: graph.sourceHashes,
854
+ impact: analyzeEventImpact(graph, input.impactSubject, input.signal),
855
+ unresolved: unresolved.values,
856
+ ...(unresolved.inventory === undefined
857
+ ? {}
858
+ : { unresolvedInventory: unresolved.inventory }),
859
+ };
860
+ }
732
861
  break;
733
862
  }
734
863
  const graphHash = cachedEventGraphHash(graph, input.signal);
@@ -742,7 +871,7 @@ export class EventChainViewer {
742
871
  : undefined;
743
872
  const artifactUnresolved = projectedScan
744
873
  ? graph.unresolved.slice(0, EVENT_SCAN_SUMMARY_SAMPLE_LIMIT)
745
- : graph.unresolved;
874
+ : boundedUnresolved(graph).values;
746
875
  const reportJson = `${canonicalJson({
747
876
  schemaVersion: 'event-analysis.v1',
748
877
  graphSchemaVersion: graph.schemaVersion,
@@ -759,12 +888,12 @@ export class EventChainViewer {
759
888
  complete: graph.complete,
760
889
  filters: analysisFilters,
761
890
  unresolved: artifactUnresolved,
762
- ...(projectedScan
891
+ ...(artifactUnresolved.length !== graph.unresolved.length
763
892
  ? {
764
893
  unresolvedInventory: {
765
894
  count: graph.unresolved.length,
766
895
  retainedCount: artifactUnresolved.length,
767
- truncated: artifactUnresolved.length !== graph.unresolved.length,
896
+ truncated: true,
768
897
  },
769
898
  }
770
899
  : {}),
@@ -950,7 +1079,15 @@ export class EventChainViewer {
950
1079
  includeHtml: input.includeHtml ?? false,
951
1080
  refresh: input.refresh ?? false,
952
1081
  },
953
- unresolved: graph.unresolved,
1082
+ ...(() => {
1083
+ const unresolved = boundedUnresolved(graph, new Set(render.selectedNodeIds));
1084
+ return {
1085
+ unresolved: unresolved.values,
1086
+ ...(unresolved.inventory === undefined
1087
+ ? {}
1088
+ : { unresolvedInventory: unresolved.inventory }),
1089
+ };
1090
+ })(),
954
1091
  resources: { ...resourceNames, artifacts: resourceLinks },
955
1092
  overview: {
956
1093
  selectedNodeIds: render.selectedNodeIds,