hoi4-agent-tools 2.8.10 → 3.0.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +35 -10
  3. package/dist/bin/stdio.js +0 -4
  4. package/dist/hoi4_agent_tools/core/configuration.js +16 -3
  5. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +10 -13
  6. package/dist/hoi4_agent_tools/core/engine.js +3 -0
  7. package/dist/hoi4_agent_tools/core/scanner.js +63 -7
  8. package/dist/hoi4_agent_tools/core/source/limits.js +6 -7
  9. package/dist/hoi4_agent_tools/event/graph.js +3 -4
  10. package/dist/hoi4_agent_tools/event/limits.js +4 -4
  11. package/dist/hoi4_agent_tools/event/service.js +34 -39
  12. package/dist/hoi4_agent_tools/gui/assets.js +280 -12
  13. package/dist/hoi4_agent_tools/gui/layout.js +70 -9
  14. package/dist/hoi4_agent_tools/gui/renderer.js +81 -12
  15. package/dist/hoi4_agent_tools/gui/scenario-generator.js +36 -0
  16. package/dist/hoi4_agent_tools/gui/studio.js +90 -25
  17. package/dist/hoi4_agent_tools/mcp/tools/gui.js +1 -1
  18. package/dist/hoi4_agent_tools/mcp/tools/probability.js +72 -14
  19. package/dist/hoi4_agent_tools/probability/sequence.js +43 -18
  20. package/dist/hoi4_agent_tools/probability/service.js +187 -24
  21. package/dist/hoi4_agent_tools/schemas/probability.js +2 -0
  22. package/dist/hoi4_agent_tools/technology/service.js +41 -8
  23. package/dist/hoi4_agent_tools/version.js +1 -1
  24. package/docs/development.md +2 -0
  25. package/docs/gui.md +14 -3
  26. package/docs/images/readme/chaos-meter-ingame.png +0 -0
  27. package/docs/images/readme/chaos-meter-mcp.png +0 -0
  28. package/docs/images/readme/communist-insurgency-ingame.png +0 -0
  29. package/docs/images/readme/communist-insurgency-mcp.png +0 -0
  30. package/docs/images/readme/event-chain-mcp.png +0 -0
  31. package/docs/images/readme/focus-tree-mcp.png +0 -0
  32. package/docs/images/readme/map-mcp.png +0 -0
  33. package/docs/images/readme/probability-mcp.png +0 -0
  34. package/docs/images/readme/settings-advanced-mcp.png +0 -0
  35. package/docs/images/readme/settings-events-mcp.png +0 -0
  36. package/docs/images/readme/technology-tree-mcp.png +0 -0
  37. package/package.json +3 -1
  38. package/schemas/configuration.schema.json +3 -3
  39. package/schemas/continuous-focus-palette.schema.json +1 -1
  40. package/schemas/custom-weighted-pool.schema.json +1 -1
  41. package/schemas/event-feature-manifest.schema.json +1 -1
  42. package/schemas/focus-plan.schema.json +1 -1
  43. package/schemas/focus-planning-sidecar.schema.json +1 -1
  44. package/schemas/gui-animation-source.schema.json +1 -1
  45. package/schemas/gui-helper.schema.json +1 -1
  46. package/schemas/gui-scenario.schema.json +1 -1
  47. package/schemas/map-operation.schema.json +1 -1
  48. package/schemas/operation-result.schema.json +1 -1
  49. package/schemas/probability-analysis-result.schema.json +58 -39
  50. package/schemas/probability-compare-input.schema.json +1 -1
  51. package/schemas/probability-evaluate-input.schema.json +1 -1
  52. package/schemas/probability-inspect-input.schema.json +1 -1
  53. package/schemas/probability-render-input.schema.json +1 -1
  54. package/schemas/probability-scenario-set.schema.json +1 -1
  55. package/schemas/probability-sequence-input.schema.json +1 -1
  56. package/schemas/probability-simulate-input.schema.json +1 -1
  57. package/schemas/probability-sweep-input.schema.json +1 -1
  58. package/server.json +2 -2
  59. package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +0 -483
package/CHANGELOG.md CHANGED
@@ -4,6 +4,23 @@
4
4
 
5
5
  No unreleased changes.
6
6
 
7
+ ## 3.0.0 - 2026-08-31
8
+
9
+ - Promote the complete focus, GUI, map, event, technology, and probability MCP surface to the stable 3.0 release line after full synthetic, installed-game, external-workspace, transport, package, Inspector, and Registry qualification.
10
+ - Render installed HOI4 bitmap fonts and language overrides from their native atlases with deterministic metrics, borders, crisp scaling, and localisation colours; keep the exhaustive installed-font audit as a release regression gate.
11
+ - Resolve scenario and dynamic-list country scopes into real ideology-aware flags using each masked-shield definition's own asset dimensions, and render inline HOI4 text icons that appear only after dynamic text substitution.
12
+ - Keep large legitimate event and technology workspaces complete and support scenario-resolved dynamic weighted pools without automatic partial-analysis fallbacks.
13
+ - Keep explicit-file probability inspection narrow, bound retained source and immutable-game scan caches, and avoid full-workspace rescans when rendering inline or declared custom-pool analyses.
14
+ - Build event graphs from one merged source index, retain raw comparison sources instead of parsed scan snapshots, and route selector-scoped inspection and rendering through the focused event graph.
15
+ - Keep the public repository focused on the supported release by using one maintained branch and manually reviewed dependency updates.
16
+
17
+ ## 2.8.11 - 2026-08-30
18
+
19
+ - Render vanilla HOI4 bitmap fonts with their native atlas masks, legacy page fallbacks, inverted-mask handling, declared glyph channels, crisp scaled sampling, borders, and localisation colours; qualify every shipped descriptor and base font family.
20
+ - Keep large event and technology workspaces complete by separating game and mod scans, accepting large legitimate Clausewitz sources, retaining large state-access inventories, and removing automatic focused-analysis fallbacks.
21
+ - Inspect, evaluate, and compare complete declared dynamic weighted pools with scenario-resolved candidate weights, eligibility, exact probabilities, and complete candidate inventories.
22
+ - Authenticate GitHub release-state API checks so release publication does not fail on anonymous API limits.
23
+
7
24
  ## 2.8.10 - 2026-08-30
8
25
 
9
26
  - Fix vanilla BMFont corruption by following declared face and outline channel coverage directly instead of guessing polarity independently for each glyph.
package/README.md CHANGED
@@ -1,17 +1,45 @@
1
1
  # HOI4 Agent Tools
2
2
 
3
- HOI4 Agent Tools is an MCP server for coding agents to understand Hearts of Iron IV event chains, technology systems, AI weights, and MTTH timing and to inspect, create, or clean up focus trees, scripted GUIs, and maps. Start it in a mod folder and it works there immediately, with offline renders for review.
3
+ HOI4 Agent Tools is an MCP server for coding agents to understand Hearts of Iron IV event chains, technology systems, AI weights, and MTTH timing and to inspect, create, or clean up focus trees, scripted GUIs, and maps. It produces source-linked structural and visual evidence that agents can use inside a larger modding workflow.
4
4
 
5
5
  ## What it does
6
6
 
7
7
  - Focus trees: inspect structure and references, render layouts, create trees, and reorganize existing branches.
8
- - Scripted GUIs: trace GUI, GFX, scripted-GUI, and localisation links; render value-driven variants, states, resolutions, hierarchy, and click regions; diagnose alignment, visibility, clipping, panel containment, and button-label centering; create or repair interface source.
8
+ - Scripted GUIs: trace GUI, GFX, scripted-GUI, and localisation links; render value-driven variants, dynamic country flags, inline text icons, states, resolutions, hierarchy, and click regions; diagnose alignment, visibility, clipping, panel containment, and button-label centering; create or repair interface source.
9
9
  - Maps: navigate the complete rendered map by ID or localised name; inspect provinces, states, regions, adjacency, supply, railways, and positions; create states and provinces; change IDs; and repair connected map data.
10
10
  - Event chains: scan definitions and call sites, trace routes and state flow, lint references, render graphs, and compare revisions without editing event source.
11
11
  - Technology trees: reconstruct technology and doctrine paths, folder layouts, unlocks, bonuses, grants, metadata, assets, and structural changes.
12
12
  - AI and MTTH: evaluate weighted choices and timing across explicit scenarios, bind special scope chains, enumerate dynamic target pools, sweep uncertain inputs, simulate distributions, compare patches, and analyze declared stateful pools.
13
13
 
14
- ## Setup
14
+ ## Real results
15
+
16
+ The GUI renderer resolves the mod and installed-game sources, fonts, localisation colours, sprites, scripted visibility, dynamic values, lists, controls, and panel composition. These pairs show the in-game surface beside the MCP result.
17
+
18
+ | Communist Insurgency in game | Communist Insurgency from MCP |
19
+ | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
20
+ | ![Communist Insurgency in game](docs/images/readme/communist-insurgency-ingame.png) | ![Communist Insurgency rendered by the MCP](docs/images/readme/communist-insurgency-mcp.png) |
21
+
22
+ | Chaos Meter in game | Chaos Meter from MCP |
23
+ | ----------------------------------------------------------------- | -------------------------------------------------------------------------- |
24
+ | ![Chaos Meter in game](docs/images/readme/chaos-meter-ingame.png) | ![Chaos Meter rendered by the MCP](docs/images/readme/chaos-meter-mcp.png) |
25
+
26
+ The same scenario-aware renderer can select mutually exclusive settings panes and populate plausible runtime values.
27
+
28
+ | Trigger Events settings | Advanced settings |
29
+ | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------- |
30
+ | ![Trigger Events settings rendered by the MCP](docs/images/readme/settings-events-mcp.png) | ![Advanced settings rendered by the MCP](docs/images/readme/settings-advanced-mcp.png) |
31
+
32
+ The other workbenches return complete source-linked layouts and catalogs rather than prose-only summaries.
33
+
34
+ | Focus tree | Technology tree |
35
+ | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------- |
36
+ | ![Large focus tree rendered by the MCP](docs/images/readme/focus-tree-mcp.png) | ![Technology tree rendered by the MCP](docs/images/readme/technology-tree-mcp.png) |
37
+
38
+ | Map | Event chain | Probability ranking |
39
+ | --------------------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
40
+ | ![Full map rendered by the MCP](docs/images/readme/map-mcp.png) | ![Event chain rendered by the MCP](docs/images/readme/event-chain-mcp.png) | ![Probability ranking rendered by the MCP](docs/images/readme/probability-mcp.png) |
41
+
42
+ ## Use from a coding agent
15
43
 
16
44
  Requires Node.js 22.19 or later in the Node 22 line, or Node.js 24.
17
45
 
@@ -19,15 +47,12 @@ Requires Node.js 22.19 or later in the Node 22 line, or Node.js 24.
19
47
  npm install --global hoi4-agent-tools@latest
20
48
  ```
21
49
 
22
- Start the MCP with its working directory set to the mod you are editing. No config or per-mod registration is required: the server finds that mod automatically. Run `hoi4-agent-tools-setup --init` only when you want persistent discovery for several mod roots, a custom game path, or a remote deployment; see [Setup](docs/setup.md).
23
-
24
- ## Connect your agent
25
-
26
- ```bash
27
- hoi4-agent-tools-setup --print-client-config
50
+ ```toml
51
+ [mcp_servers.hoi4_agent_tools]
52
+ command = "hoi4-agent-tools.cmd"
28
53
  ```
29
54
 
30
- Paste the printed Codex or generic global-install entry into your MCP client, then restart it. The utility prints the correct command for the current operating system.
55
+ On non-Windows systems, use `hoi4-agent-tools` as the command. Agentic HOI4 repositories can include these steps in their agent template so the coding agent installs and registers the server itself; manual installation is not required in that workflow. Once connected, the server follows the active mod supplied by the client and the agent can call its HOI4 tools directly.
31
56
 
32
57
  ## Tools
33
58
 
package/dist/bin/stdio.js CHANGED
@@ -8,10 +8,6 @@ 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
- }
15
11
  server.server.onerror = (error) => {
16
12
  const fatalInputError = error instanceof StdioFrameLimitError || error instanceof StdioInvalidUtf8Error;
17
13
  const rejectedMessage = error instanceof StdioInvalidMessageError;
@@ -10,6 +10,9 @@ export const HTTP_DEFAULT_SESSION_EVENT_BYTES = 2_097_152;
10
10
  export const WORKSPACE_MAX_REGISTRATIONS = 1_000;
11
11
  const WORKSPACE_MAX_SOURCE_ROOTS = 16;
12
12
  const WORKSPACE_MAX_PATHS = 1_000;
13
+ export const DEFAULT_SCAN_MAX_FILES = 100_000;
14
+ export const DEFAULT_SCAN_MAX_BYTES = 268_435_456;
15
+ export const DEFAULT_SCAN_MAX_FILE_BYTES = 67_108_864;
13
16
  const allowedOriginSchema = z.url().refine((value) => {
14
17
  const parsed = new URL(value);
15
18
  return ((parsed.protocol === 'http:' || parsed.protocol === 'https:') &&
@@ -128,9 +131,19 @@ export const serverConfigurationSchema = z
128
131
  message: 'Server state root must be an absolute private path',
129
132
  })
130
133
  .optional(),
131
- scanMaxFiles: z.number().int().min(1).max(1_000_000).default(20_000),
132
- scanMaxBytes: z.number().int().min(1_048_576).max(Number.MAX_SAFE_INTEGER).default(134_217_728),
133
- scanMaxFileBytes: z.number().int().min(65_536).max(Number.MAX_SAFE_INTEGER).default(67_108_864),
134
+ scanMaxFiles: z.number().int().min(1).max(1_000_000).default(DEFAULT_SCAN_MAX_FILES),
135
+ scanMaxBytes: z
136
+ .number()
137
+ .int()
138
+ .min(1_048_576)
139
+ .max(Number.MAX_SAFE_INTEGER)
140
+ .default(DEFAULT_SCAN_MAX_BYTES),
141
+ scanMaxFileBytes: z
142
+ .number()
143
+ .int()
144
+ .min(65_536)
145
+ .max(Number.MAX_SAFE_INTEGER)
146
+ .default(DEFAULT_SCAN_MAX_FILE_BYTES),
134
147
  artifactMaxBytes: z
135
148
  .number()
136
149
  .int()
@@ -8,6 +8,14 @@ function uniquePatterns(patterns) {
8
8
  function englishLocalisationPatterns(roots) {
9
9
  return [...under(roots, 'english/**/*.{yml,yaml}'), ...under(roots, '*.{yml,yaml}')];
10
10
  }
11
+ export function probabilitySharedDefinitionPatterns() {
12
+ return uniquePatterns([
13
+ 'common/scripted_effects/**/*.txt',
14
+ 'common/scripted_triggers/**/*.txt',
15
+ 'common/script_constants/**/*.txt',
16
+ 'common/mtth/**/*.txt',
17
+ ]);
18
+ }
11
19
  /** Sources required to import, link, lint, render, and rewrite focus content. */
12
20
  export function focusDomainScanPatterns(workspace) {
13
21
  const roots = workspace.registration.roots;
@@ -32,8 +40,9 @@ export function focusDomainScanPatterns(workspace) {
32
40
  export function probabilityDomainScanPatterns(workspace, sourcePaths = []) {
33
41
  const roots = workspace.registration.roots;
34
42
  const normalizedSourcePaths = sourcePaths.map((sourcePath) => sourcePath.replace(/^.*?:/u, '').replaceAll('\\', '/').replace(/^\.\//u, ''));
43
+ if (normalizedSourcePaths.length > 0)
44
+ return uniquePatterns([...normalizedSourcePaths, ...probabilitySharedDefinitionPatterns()]);
35
45
  return uniquePatterns([
36
- ...normalizedSourcePaths,
37
46
  ...under(roots.focus, '**/*.txt'),
38
47
  'common/decisions/**/*.txt',
39
48
  'common/technologies/**/*.txt',
@@ -49,18 +58,6 @@ export function probabilityDomainScanPatterns(workspace, sourcePaths = []) {
49
58
  'events/**/*.txt',
50
59
  ]);
51
60
  }
52
- /** Text sources needed to discover country flags and leader portraits. */
53
- export function countryAssetDiscoveryPatterns(workspace) {
54
- const roots = workspace.registration.roots;
55
- return uniquePatterns([
56
- 'history/countries/**/*.txt',
57
- 'common/characters/**/*.txt',
58
- 'common/scripted_effects/**/*.txt',
59
- 'events/**/*.txt',
60
- ...under(roots.interface, '**/*.gfx'),
61
- ...under(roots.gfx, '**/*.gfx'),
62
- ]);
63
- }
64
61
  export function exactChangedFilePatterns(relativePaths) {
65
62
  return uniquePatterns(relativePaths.map((relativePath) => relativePath.replaceAll('\\', '/').replace(/^\.\//u, '')));
66
63
  }
@@ -139,6 +139,9 @@ export class CoreEngine {
139
139
  })}`;
140
140
  let flight = this.#scanFlights.get(requestKey);
141
141
  if (flight === undefined) {
142
+ for (const key of this.#scanCache.keys())
143
+ if (key.startsWith(`${workspaceId}:`) && !key.startsWith(`${requestKey}:`))
144
+ this.#scanCache.delete(key);
142
145
  const controller = new AbortController();
143
146
  const promise = (async () => {
144
147
  return this.withScanAdmission(controller.signal, async () => {
@@ -1,7 +1,8 @@
1
1
  import { open } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import fg from 'fast-glob';
4
- import { compareCodeUnits, sha256Bytes } from './canonical.js';
4
+ import { compareCodeUnits, hashCanonical, sha256Bytes } from './canonical.js';
5
+ import { DEFAULT_SCAN_MAX_BYTES, DEFAULT_SCAN_MAX_FILE_BYTES, DEFAULT_SCAN_MAX_FILES, } from './configuration.js';
5
6
  import { ServiceError } from './result.js';
6
7
  function normalizeRelative(value) {
7
8
  return value.replaceAll('\\', '/').replace(/^\.\//u, '');
@@ -26,13 +27,14 @@ export class WorkspaceScanner {
26
27
  serverMaxBytes;
27
28
  serverMaxFileBytes;
28
29
  #sourceCache = new Map();
30
+ #gameScanCache = new Map();
29
31
  #sourceCacheBytes = 0;
30
32
  #sourceCacheMaxBytes;
31
- constructor(serverMaxFiles = 20_000, serverMaxBytes = 134_217_728, serverMaxFileBytes = 67_108_864) {
33
+ constructor(serverMaxFiles = DEFAULT_SCAN_MAX_FILES, serverMaxBytes = DEFAULT_SCAN_MAX_BYTES, serverMaxFileBytes = DEFAULT_SCAN_MAX_FILE_BYTES) {
32
34
  this.serverMaxFiles = serverMaxFiles;
33
35
  this.serverMaxBytes = serverMaxBytes;
34
36
  this.serverMaxFileBytes = serverMaxFileBytes;
35
- this.#sourceCacheMaxBytes = Math.max(serverMaxFileBytes, Math.min(536_870_912, serverMaxBytes * 2));
37
+ this.#sourceCacheMaxBytes = Math.max(serverMaxFileBytes, Math.min(134_217_728, serverMaxBytes));
36
38
  }
37
39
  cacheKey(absolutePath) {
38
40
  const resolved = path.resolve(absolutePath);
@@ -53,6 +55,24 @@ export class WorkspaceScanner {
53
55
  this.#sourceCacheBytes -= oldest[1].bytes.length;
54
56
  }
55
57
  }
58
+ gameScanKey(workspace, root, options) {
59
+ return hashCanonical({
60
+ workspaceId: workspace.id,
61
+ root: root.path,
62
+ patterns: [...options.patterns].sort(compareCodeUnits),
63
+ ignore: [...(options.ignore ?? ['**/.hoi4-agent/**'])].sort(compareCodeUnits),
64
+ });
65
+ }
66
+ retainGameScan(key, files) {
67
+ this.#gameScanCache.delete(key);
68
+ this.#gameScanCache.set(key, files.map((file) => ({ ...file })));
69
+ while (this.#gameScanCache.size > 8) {
70
+ const oldest = this.#gameScanCache.keys().next().value;
71
+ if (oldest === undefined)
72
+ break;
73
+ this.#gameScanCache.delete(oldest);
74
+ }
75
+ }
56
76
  async scan(workspace, options) {
57
77
  options.signal?.throwIfAborted();
58
78
  const maxFiles = options.maxFiles ?? this.serverMaxFiles;
@@ -77,6 +97,29 @@ export class WorkspaceScanner {
77
97
  let enumeratedFiles = 0;
78
98
  for (const root of roots) {
79
99
  options.signal?.throwIfAborted();
100
+ const gameScanKey = root.kind === 'game' ? this.gameScanKey(workspace, root, options) : undefined;
101
+ const cachedGameFiles = gameScanKey === undefined ? undefined : this.#gameScanCache.get(gameScanKey);
102
+ if (cachedGameFiles !== undefined && gameScanKey !== undefined) {
103
+ this.#gameScanCache.delete(gameScanKey);
104
+ this.#gameScanCache.set(gameScanKey, cachedGameFiles);
105
+ enumeratedFiles += cachedGameFiles.length;
106
+ if (enumeratedFiles > maxFiles)
107
+ throw new ServiceError('SCAN_FILE_LIMIT', 'Scan exceeds the configured file limit', {
108
+ files: enumeratedFiles,
109
+ limit: maxFiles,
110
+ });
111
+ for (const file of cachedGameFiles) {
112
+ totalBytes += file.size;
113
+ if (totalBytes > maxBytes)
114
+ throw new ServiceError('SCAN_BYTE_LIMIT', 'Scan exceeds the configured byte limit', {
115
+ bytes: totalBytes,
116
+ limit: maxBytes,
117
+ });
118
+ result.push({ ...file });
119
+ }
120
+ continue;
121
+ }
122
+ const rootFiles = [];
80
123
  const matches = fg.stream(options.patterns, {
81
124
  cwd: root.path,
82
125
  onlyFiles: true,
@@ -89,7 +132,10 @@ export class WorkspaceScanner {
89
132
  options.signal?.throwIfAborted();
90
133
  enumeratedFiles += 1;
91
134
  if (enumeratedFiles > maxFiles) {
92
- throw new ServiceError('SCAN_FILE_LIMIT', 'Scan exceeds the configured file limit');
135
+ throw new ServiceError('SCAN_FILE_LIMIT', 'Scan exceeds the configured file limit', {
136
+ files: enumeratedFiles,
137
+ limit: maxFiles,
138
+ });
93
139
  }
94
140
  const relativePath = normalizeRelative(String(match));
95
141
  if (hiddenByReplacePath(workspace, root, relativePath))
@@ -103,7 +149,13 @@ export class WorkspaceScanner {
103
149
  continue;
104
150
  const remaining = maxBytes - totalBytes;
105
151
  if (metadata.size > remaining || metadata.size > this.serverMaxFileBytes) {
106
- throw new ServiceError('SCAN_BYTE_LIMIT', 'Scan exceeds the configured byte limit');
152
+ throw new ServiceError('SCAN_BYTE_LIMIT', 'Scan exceeds the configured byte limit', {
153
+ file: relativePath,
154
+ fileBytes: metadata.size,
155
+ bytes: totalBytes,
156
+ limit: maxBytes,
157
+ perFileLimit: this.serverMaxFileBytes,
158
+ });
107
159
  }
108
160
  const cached = this.#sourceCache.get(cacheKey);
109
161
  const retained = cached?.size === metadata.size &&
@@ -124,7 +176,7 @@ export class WorkspaceScanner {
124
176
  bytes,
125
177
  });
126
178
  totalBytes += bytes.length;
127
- result.push({
179
+ const scanned = {
128
180
  absolutePath,
129
181
  displayPath: `${rootLabel(root)}:${relativePath}`,
130
182
  relativePath,
@@ -134,12 +186,16 @@ export class WorkspaceScanner {
134
186
  modifiedMs: metadata.mtimeMs,
135
187
  sha256,
136
188
  bytes,
137
- });
189
+ };
190
+ result.push(scanned);
191
+ rootFiles.push(scanned);
138
192
  }
139
193
  finally {
140
194
  await handle.close();
141
195
  }
142
196
  }
197
+ if (gameScanKey !== undefined)
198
+ this.retainGameScan(gameScanKey, rootFiles);
143
199
  }
144
200
  result.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.relativePath, right.relativePath));
145
201
  const groups = new Map();
@@ -1,12 +1,11 @@
1
1
  export const SOURCE_DIAGNOSTIC_LIMIT = 100;
2
2
  export const SOURCE_MAX_NESTING = 256;
3
- export const SOURCE_MAX_BYTES = 8_388_608;
4
- // Large authored HOI4 focus and scripted-effect files can exceed 250k tokens while remaining well
5
- // below the byte and nesting ceilings. Keep enough room for those files without allowing an
6
- // unbounded token inventory.
7
- export const SOURCE_TOKEN_LIMIT = 1_000_000;
8
- export const SOURCE_ENTRY_LIMIT = 100_000;
9
- export const SOURCE_LINE_LIMIT = 250_000;
3
+ // This is a parser ceiling, not the aggregate workspace-scan or remote request ceiling. Large
4
+ // generated focus, event, and scripted-effect sources routinely exceed 8 MiB in real agentic mods.
5
+ export const SOURCE_MAX_BYTES = 33_554_432;
6
+ export const SOURCE_TOKEN_LIMIT = 4_000_000;
7
+ export const SOURCE_ENTRY_LIMIT = 1_000_000;
8
+ export const SOURCE_LINE_LIMIT = 2_000_000;
10
9
  /**
11
10
  * Parser ceilings that intentionally return no document or a structurally partial document.
12
11
  * Broad inventories may skip these sources, but targeted reads and rewrites must continue to
@@ -1604,10 +1604,9 @@ export function buildEventGraph(snapshot, options = {}) {
1604
1604
  assertGraphLimit(graph.unresolved.length, EVENT_GRAPH_MAX_UNRESOLVED, 'EVENT_UNRESOLVED_LIMIT', 'unresolved-analysis count');
1605
1605
  budget.check();
1606
1606
  const diagnostics = sortDiagnostics([...snapshot.diagnostics, ...partial.diagnostics]);
1607
- const complete = !focused &&
1608
- snapshot.complete &&
1609
- graph.unresolved.length === 0 &&
1610
- !graph.issues.some(({ classification, severity }) => classification === 'unresolved_analysis' || severity === 'blocker');
1607
+ // Coverage completeness is independent from defects discovered in the source. A full,
1608
+ // untruncated analysis remains complete when it correctly reports missing or dynamic links.
1609
+ const complete = !focused && snapshot.complete;
1611
1610
  const nodes = sortNodes(graph.nodes);
1612
1611
  const edges = stableUnique(graph.edges);
1613
1612
  const stateAccesses = stableUnique(graph.stateAccesses);
@@ -1,9 +1,9 @@
1
1
  import { ServiceError } from '../core/result.js';
2
2
  export const EVENT_GRAPH_MAX_NODES = 250_000;
3
3
  export const EVENT_GRAPH_MAX_EDGES = 500_000;
4
- export const EVENT_GRAPH_MAX_STATE_ACCESSES = 250_000;
5
- export const EVENT_GRAPH_MAX_STATE_LINKS = 250_000;
6
- export const EVENT_GRAPH_MAX_STATE_LINK_CANDIDATES = 500_000;
4
+ export const EVENT_GRAPH_MAX_STATE_ACCESSES = 1_000_000;
5
+ export const EVENT_GRAPH_MAX_STATE_LINKS = 1_000_000;
6
+ export const EVENT_GRAPH_MAX_STATE_LINK_CANDIDATES = 2_000_000;
7
7
  // Installed-game scans can legitimately produce more than twenty thousand source-linked
8
8
  // findings before an agent narrows the query. Keep the graph bounded, but do not reject a
9
9
  // complete large workspace merely because its diagnostics are numerous.
@@ -13,7 +13,7 @@ export const EVENT_GRAPH_MAX_HELPER_DEPTH = 64;
13
13
  export const EVENT_GRAPH_MAX_HELPER_PROJECTIONS = 200_000;
14
14
  export const EVENT_GRAPH_MAX_HELPER_STATE_PROJECTIONS = 50_000;
15
15
  export const EVENT_GRAPH_MAX_CONDITION_TEXT = 16_384;
16
- export const EVENT_GRAPH_WORK_LIMIT = 5_000_000;
16
+ export const EVENT_GRAPH_WORK_LIMIT = 10_000_000;
17
17
  export const EVENT_FRAGMENT_CACHE_MAX_ENTRIES = 10_000;
18
18
  export const EVENT_FRAGMENT_CACHE_MAX_SOURCE_BYTES = 134_217_728;
19
19
  export const EVENT_PROPOSED_SOURCE_MAX_FILES = 64;
@@ -45,16 +45,12 @@ class BoundedEventFragmentCache {
45
45
  function eventGraphCacheKey(workspaceId, projectHelpers, analysisMode = 'full') {
46
46
  return `${workspaceId}\0${projectHelpers ? 'expanded' : 'structural'}\0${analysisMode}`;
47
47
  }
48
- function isGameBackedWorkspace(workspace) {
49
- return workspace.roots.some(({ kind }) => kind === 'game');
50
- }
51
48
  const serviceState = new WeakMap();
52
49
  const EVENT_GRAPH_ARTIFACT_MAX_BYTES = 67_108_864;
53
50
  const EVENT_GRAPH_ARTIFACT_MAX_CHUNKS = 1_024;
54
51
  const EVENT_SCAN_FULL_GRAPH_RECORD_LIMIT = 100_000;
55
52
  const EVENT_SCAN_SUMMARY_SAMPLE_LIMIT = 100;
56
53
  const EVENT_FOCUSED_EVIDENCE_LIMIT = 2_000;
57
- const EVENT_FOCUSED_GRAPH_FILE_THRESHOLD = 1_000;
58
54
  const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
59
55
  const graphHashCache = new WeakMap();
60
56
  function groupedCounts(values, key) {
@@ -179,19 +175,21 @@ function eventScanPatterns(workspace, focused = false) {
179
175
  ...under(roots.localisation, '*.{yml,yaml}'),
180
176
  ].sort(compareCodeUnits);
181
177
  }
182
- async function scanEventSources(engine, workspaceId, workspace, focused = false, principal, signal) {
178
+ async function scanEventSources(engine, workspaceId, workspace, focused = false, signal) {
183
179
  const patterns = eventScanPatterns(workspace, focused);
184
180
  const gamePatterns = eventScanPatterns(workspace, focused);
185
181
  const sourceKinds = ['mod', 'dependency', 'fixture'];
186
- const scans = await Promise.all([
187
- engine.scan(workspaceId, { patterns, rootKinds: sourceKinds }, principal, signal),
188
- ...(workspace.roots.some(({ kind }) => kind === 'game')
189
- ? [
190
- engine.scan(workspaceId, { patterns: gamePatterns, rootKinds: ['game'] }, principal, signal),
191
- ]
192
- : []),
193
- ]);
194
- const files = scans.flatMap(({ files }) => files.map((file) => ({ ...file })));
182
+ const files = await engine.scanner.scan(workspace, {
183
+ patterns,
184
+ rootKinds: sourceKinds,
185
+ ...(signal === undefined ? {} : { signal }),
186
+ });
187
+ if (workspace.roots.some(({ kind }) => kind === 'game'))
188
+ files.push(...(await engine.scanner.scan(workspace, {
189
+ patterns: gamePatterns,
190
+ rootKinds: ['game'],
191
+ ...(signal === undefined ? {} : { signal }),
192
+ })));
195
193
  recomputeShadowing(files);
196
194
  files.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
197
195
  const index = engine.indexFiles(files);
@@ -200,10 +198,10 @@ async function scanEventSources(engine, workspaceId, workspace, focused = false,
200
198
  revision: snapshotRevision(files),
201
199
  files,
202
200
  index,
203
- complete: scans.every(({ complete }) => complete) && index.complete,
201
+ complete: index.complete,
204
202
  skippedSourceCount: index.skippedSourceCount,
205
203
  skippedSources: index.skippedSources,
206
- diagnostics: [...scans.flatMap(({ diagnostics }) => diagnostics), ...index.diagnostics],
204
+ diagnostics: index.diagnostics,
207
205
  };
208
206
  }
209
207
  function rememberGraph(state, workspaceId, graph) {
@@ -704,29 +702,25 @@ export class EventChainViewer {
704
702
  const generation = engineGeneration(this.engine, workspaceId);
705
703
  const requestedProjectHelpers = options.projectHelpers ?? true;
706
704
  const requestedAnalysisMode = options.analysisMode ?? 'full';
707
- const autoFocused = isGameBackedWorkspace(workspace) && requestedAnalysisMode === 'full';
708
- const cacheAnalysisMode = autoFocused ? 'focused' : requestedAnalysisMode;
705
+ const cacheAnalysisMode = requestedAnalysisMode;
709
706
  const cacheProjectHelpers = cacheAnalysisMode === 'focused' ? false : requestedProjectHelpers;
710
707
  const cacheKey = eventGraphCacheKey(workspaceId, cacheProjectHelpers, cacheAnalysisMode);
711
708
  const cached = this.#state.current.get(cacheKey);
712
709
  if (options.refresh !== true && cached?.generation === generation) {
713
710
  return cached.graph;
714
711
  }
715
- const sibling = this.#state.current.get(eventGraphCacheKey(workspaceId, !cacheProjectHelpers, cacheAnalysisMode)) ??
716
- this.#state.current.get(eventGraphCacheKey(workspaceId, requestedProjectHelpers, requestedAnalysisMode)) ??
717
- this.#state.current.get(eventGraphCacheKey(workspaceId, !requestedProjectHelpers)) ??
718
- this.#state.current.get(eventGraphCacheKey(workspaceId, requestedProjectHelpers));
719
- const scanFocused = autoFocused || requestedAnalysisMode === 'focused';
720
- const snapshot = options.refresh !== true && sibling?.generation === generation
721
- ? sibling.snapshot
722
- : await scanEventSources(this.engine, workspaceId, workspace, scanFocused, options.principal, options.signal);
712
+ if (options.refresh !== true && requestedAnalysisMode === 'focused') {
713
+ const fullSibling = this.#state.current.get(eventGraphCacheKey(workspaceId, cacheProjectHelpers, 'full')) ??
714
+ this.#state.current.get(eventGraphCacheKey(workspaceId, !cacheProjectHelpers, 'full'));
715
+ if (fullSibling?.generation === generation)
716
+ return fullSibling.graph;
717
+ }
718
+ const scanFocused = requestedAnalysisMode === 'focused';
719
+ const snapshot = await scanEventSources(this.engine, workspaceId, workspace, scanFocused, options.signal);
723
720
  options.signal?.throwIfAborted();
724
- if (cached?.snapshot.revision === snapshot.revision && cached.generation === generation) {
721
+ if (cached?.graph.revision === snapshot.revision && cached.generation === generation)
725
722
  return cached.graph;
726
- }
727
- const buildAnalysisMode = autoFocused || snapshot.files.length > EVENT_FOCUSED_GRAPH_FILE_THRESHOLD
728
- ? 'focused'
729
- : 'full';
723
+ const buildAnalysisMode = requestedAnalysisMode;
730
724
  const projectHelpers = buildAnalysisMode === 'focused' ? false : requestedProjectHelpers;
731
725
  const graph = buildEventGraph(snapshot, {
732
726
  ...(options.signal === undefined ? {} : { signal: options.signal }),
@@ -735,13 +729,17 @@ export class EventChainViewer {
735
729
  projectHelpers,
736
730
  analysisMode: buildAnalysisMode,
737
731
  });
738
- this.#state.current.set(cacheKey, { generation, snapshot, graph });
732
+ this.#state.current.set(cacheKey, { generation, files: snapshot.files, graph });
739
733
  if (buildAnalysisMode === 'full')
740
734
  rememberGraph(this.#state, workspaceId, graph);
741
735
  return graph;
742
736
  }
743
737
  async inspect(input) {
744
738
  input.signal?.throwIfAborted();
739
+ const focused = input.mode === 'trace' ||
740
+ input.mode === 'explain_path' ||
741
+ (input.selector !== undefined &&
742
+ (input.mode === 'scan' || input.mode === 'roots' || input.mode === 'lint'));
745
743
  const scannedGraph = await this.scan(input.workspaceId, {
746
744
  refresh: input.refresh ?? input.mode === 'scan',
747
745
  // Focused routes traverse helper nodes directly and collapse only the
@@ -750,15 +748,11 @@ export class EventChainViewer {
750
748
  input.mode !== 'roots' &&
751
749
  input.mode !== 'trace' &&
752
750
  input.mode !== 'explain_path',
753
- ...(input.mode === 'trace' || input.mode === 'explain_path'
754
- ? { analysisMode: 'focused' }
755
- : {}),
751
+ analysisMode: focused ? 'focused' : 'full',
756
752
  ...(input.principal === undefined ? {} : { principal: input.principal }),
757
753
  ...(input.signal === undefined ? {} : { signal: input.signal }),
758
754
  });
759
- // Keep the service result's graph identity distinct from a prior scan while
760
- // reusing its structural cache and avoiding a second large graph build.
761
- const graph = input.mode === 'trace' || input.mode === 'explain_path' ? { ...scannedGraph } : scannedGraph;
755
+ const graph = scannedGraph;
762
756
  const maxDepth = input.maxDepth ?? 8;
763
757
  const maxNodes = input.maxNodes ?? 500;
764
758
  const maxEdges = input.maxEdges ?? 2_000;
@@ -935,6 +929,7 @@ export class EventChainViewer {
935
929
  const graph = await this.scan(input.workspaceId, {
936
930
  ...(input.refresh === undefined ? {} : { refresh: input.refresh }),
937
931
  projectHelpers: true,
932
+ analysisMode: input.selector === undefined ? 'full' : 'focused',
938
933
  ...(input.principal === undefined ? {} : { principal: input.principal }),
939
934
  ...(input.signal === undefined ? {} : { signal: input.signal }),
940
935
  });
@@ -1147,7 +1142,7 @@ export class EventChainViewer {
1147
1142
  if (new Set(paths.map((value) => value.toLowerCase())).size !== paths.length) {
1148
1143
  throw new ServiceError('EVENT_PROPOSED_PATH_DUPLICATE', 'Proposed source paths must be unique');
1149
1144
  }
1150
- const files = cached.snapshot.files.map((file) => ({
1145
+ const files = cached.files.map((file) => ({
1151
1146
  ...file,
1152
1147
  bytes: Buffer.from(file.bytes),
1153
1148
  }));