hoi4-agent-tools 2.3.0 → 2.3.4

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 (39) hide show
  1. package/CHANGELOG.md +18 -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 +38 -9
  5. package/dist/hoi4_agent_tools/event/limits.js +4 -1
  6. package/dist/hoi4_agent_tools/event/service.js +200 -46
  7. package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +480 -0
  8. package/dist/hoi4_agent_tools/mcp/tools/event.js +10 -3
  9. package/dist/hoi4_agent_tools/mcp/tools/technology.js +4 -1
  10. package/dist/hoi4_agent_tools/runtime.js +109 -7
  11. package/dist/hoi4_agent_tools/technology/graph.js +30 -3
  12. package/dist/hoi4_agent_tools/technology/queries.js +70 -3
  13. package/dist/hoi4_agent_tools/technology/service.js +38 -2
  14. package/dist/hoi4_agent_tools/version.js +1 -1
  15. package/docs/events.md +2 -2
  16. package/docs/setup.md +2 -0
  17. package/docs/technology.md +4 -2
  18. package/package.json +3 -1
  19. package/schemas/configuration.schema.json +1 -1
  20. package/schemas/continuous-focus-palette.schema.json +1 -1
  21. package/schemas/custom-weighted-pool.schema.json +1 -1
  22. package/schemas/event-feature-manifest.schema.json +1 -1
  23. package/schemas/focus-plan.schema.json +1 -1
  24. package/schemas/focus-planning-sidecar.schema.json +1 -1
  25. package/schemas/gui-animation-source.schema.json +1 -1
  26. package/schemas/gui-helper.schema.json +1 -1
  27. package/schemas/gui-scenario.schema.json +1 -1
  28. package/schemas/map-operation.schema.json +1 -1
  29. package/schemas/operation-result.schema.json +1 -1
  30. package/schemas/probability-analysis-result.schema.json +1 -1
  31. package/schemas/probability-compare-input.schema.json +1 -1
  32. package/schemas/probability-evaluate-input.schema.json +1 -1
  33. package/schemas/probability-inspect-input.schema.json +1 -1
  34. package/schemas/probability-render-input.schema.json +1 -1
  35. package/schemas/probability-scenario-set.schema.json +1 -1
  36. package/schemas/probability-sequence-input.schema.json +1 -1
  37. package/schemas/probability-simulate-input.schema.json +1 -1
  38. package/schemas/probability-sweep-input.schema.json +1 -1
  39. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.3.4 - 2026-07-28
4
+
5
+ - Bounded large game-backed event scans before workspace-wide lifecycle and helper analysis could exhaust the MCP process and close the stdio transport.
6
+ - Added automatic focused event analysis for large workspaces, explicit deferred-analysis diagnostics, source-profile reporting, and deterministic regression coverage.
7
+
8
+ ## 2.3.3 - 2026-07-26
9
+
10
+ - Regenerated the npm lockfile with the pinned release npm so clean installs include the patched optional native runtime packages.
11
+
12
+ ## 2.3.2 - 2026-07-26
13
+
14
+ - Pinned patched transitive `brace-expansion` and `postcss` releases so the CI and release audit remain clean without changing the MCP Inspector surface.
15
+
16
+ ## 2.3.1 - 2026-07-26
17
+
18
+ - Fixed large vanilla-plus-mod technology scans that could consume multiple gigabytes and leave the MCP transport open until a client timeout closed it.
19
+ - 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.
20
+
3
21
  ## 2.3.0 - 2026-07-22
4
22
 
5
23
  - 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,31 @@ 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
+ if (focused) {
1575
+ graph.unresolved.push({
1576
+ id: 'event-analysis-focused-boundary',
1577
+ kind: 'partial_source',
1578
+ expression: 'workspace-wide event helper projections and lifecycle diagnostics',
1579
+ confidence: 'unresolved',
1580
+ blockers: [
1581
+ {
1582
+ code: 'EVENT_FOCUSED_ANALYSIS_DEFERRED',
1583
+ message: 'Large event workspaces defer workspace-wide helper projections and lifecycle passes; direct source evidence remains available.',
1584
+ },
1585
+ ],
1586
+ });
1587
+ }
1588
+ const stateLinks = focused ? [] : stateLinksFor(graph, budget);
1589
+ if (!focused) {
1590
+ duplicateAndReferenceIssues(graph);
1591
+ unresolvedIssues(graph);
1592
+ structuralCallIssues(graph, budget);
1593
+ localisationIssues(graph);
1594
+ stateLifecycleIssues(graph, stateLinks, budget);
1595
+ unreachableIssues(graph, budget);
1596
+ normalizeIssueSubjects(graph);
1597
+ }
1571
1598
  graph.issues = stableIssues(graph.issues);
1572
1599
  assertGraphLimit(graph.nodes.length, EVENT_GRAPH_MAX_NODES, 'EVENT_NODE_LIMIT', 'node count');
1573
1600
  assertGraphLimit(graph.edges.length, EVENT_GRAPH_MAX_EDGES, 'EVENT_EDGE_LIMIT', 'edge count');
@@ -1577,7 +1604,8 @@ export function buildEventGraph(snapshot, options = {}) {
1577
1604
  assertGraphLimit(graph.unresolved.length, EVENT_GRAPH_MAX_UNRESOLVED, 'EVENT_UNRESOLVED_LIMIT', 'unresolved-analysis count');
1578
1605
  budget.check();
1579
1606
  const diagnostics = sortDiagnostics([...snapshot.diagnostics, ...partial.diagnostics]);
1580
- const complete = snapshot.complete &&
1607
+ const complete = !focused &&
1608
+ snapshot.complete &&
1581
1609
  graph.unresolved.length === 0 &&
1582
1610
  !graph.issues.some(({ classification, severity }) => classification === 'unresolved_analysis' || severity === 'blocker');
1583
1611
  const nodes = sortNodes(graph.nodes);
@@ -1588,6 +1616,7 @@ export function buildEventGraph(snapshot, options = {}) {
1588
1616
  return {
1589
1617
  schemaVersion: EVENT_GRAPH_SCHEMA_VERSION,
1590
1618
  parserVersion: EVENT_GRAPH_PARSER_VERSION,
1619
+ analysisMode: options.analysisMode ?? 'full',
1591
1620
  workspaceId: snapshot.workspaceId,
1592
1621
  workspaceIdentity: options.workspaceIdentity ?? hashCanonical({ workspaceId: snapshot.workspaceId }),
1593
1622
  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;