hoi4-agent-tools 2.8.11 → 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 (53) hide show
  1. package/CHANGELOG.md +10 -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/event/service.js +30 -23
  9. package/dist/hoi4_agent_tools/gui/assets.js +218 -0
  10. package/dist/hoi4_agent_tools/gui/layout.js +70 -9
  11. package/dist/hoi4_agent_tools/gui/renderer.js +80 -11
  12. package/dist/hoi4_agent_tools/gui/scenario-generator.js +36 -0
  13. package/dist/hoi4_agent_tools/gui/studio.js +90 -25
  14. package/dist/hoi4_agent_tools/mcp/tools/gui.js +1 -1
  15. package/dist/hoi4_agent_tools/probability/service.js +33 -8
  16. package/dist/hoi4_agent_tools/schemas/probability.js +2 -0
  17. package/dist/hoi4_agent_tools/version.js +1 -1
  18. package/docs/development.md +2 -0
  19. package/docs/gui.md +14 -3
  20. package/docs/images/readme/chaos-meter-ingame.png +0 -0
  21. package/docs/images/readme/chaos-meter-mcp.png +0 -0
  22. package/docs/images/readme/communist-insurgency-ingame.png +0 -0
  23. package/docs/images/readme/communist-insurgency-mcp.png +0 -0
  24. package/docs/images/readme/event-chain-mcp.png +0 -0
  25. package/docs/images/readme/focus-tree-mcp.png +0 -0
  26. package/docs/images/readme/map-mcp.png +0 -0
  27. package/docs/images/readme/probability-mcp.png +0 -0
  28. package/docs/images/readme/settings-advanced-mcp.png +0 -0
  29. package/docs/images/readme/settings-events-mcp.png +0 -0
  30. package/docs/images/readme/technology-tree-mcp.png +0 -0
  31. package/package.json +3 -1
  32. package/schemas/configuration.schema.json +3 -3
  33. package/schemas/continuous-focus-palette.schema.json +1 -1
  34. package/schemas/custom-weighted-pool.schema.json +1 -1
  35. package/schemas/event-feature-manifest.schema.json +1 -1
  36. package/schemas/focus-plan.schema.json +1 -1
  37. package/schemas/focus-planning-sidecar.schema.json +1 -1
  38. package/schemas/gui-animation-source.schema.json +1 -1
  39. package/schemas/gui-helper.schema.json +1 -1
  40. package/schemas/gui-scenario.schema.json +1 -1
  41. package/schemas/map-operation.schema.json +1 -1
  42. package/schemas/operation-result.schema.json +1 -1
  43. package/schemas/probability-analysis-result.schema.json +58 -39
  44. package/schemas/probability-compare-input.schema.json +1 -1
  45. package/schemas/probability-evaluate-input.schema.json +1 -1
  46. package/schemas/probability-inspect-input.schema.json +1 -1
  47. package/schemas/probability-render-input.schema.json +1 -1
  48. package/schemas/probability-scenario-set.schema.json +1 -1
  49. package/schemas/probability-sequence-input.schema.json +1 -1
  50. package/schemas/probability-simulate-input.schema.json +1 -1
  51. package/schemas/probability-sweep-input.schema.json +1 -1
  52. package/server.json +2 -2
  53. package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +0 -483
package/CHANGELOG.md CHANGED
@@ -4,6 +4,16 @@
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
+
7
17
  ## 2.8.11 - 2026-08-30
8
18
 
9
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.
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();
@@ -175,19 +175,21 @@ function eventScanPatterns(workspace, focused = false) {
175
175
  ...under(roots.localisation, '*.{yml,yaml}'),
176
176
  ].sort(compareCodeUnits);
177
177
  }
178
- async function scanEventSources(engine, workspaceId, workspace, focused = false, principal, signal) {
178
+ async function scanEventSources(engine, workspaceId, workspace, focused = false, signal) {
179
179
  const patterns = eventScanPatterns(workspace, focused);
180
180
  const gamePatterns = eventScanPatterns(workspace, focused);
181
181
  const sourceKinds = ['mod', 'dependency', 'fixture'];
182
- const scans = await Promise.all([
183
- engine.scan(workspaceId, { patterns, rootKinds: sourceKinds }, principal, signal),
184
- ...(workspace.roots.some(({ kind }) => kind === 'game')
185
- ? [
186
- engine.scan(workspaceId, { patterns: gamePatterns, rootKinds: ['game'] }, principal, signal),
187
- ]
188
- : []),
189
- ]);
190
- 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
+ })));
191
193
  recomputeShadowing(files);
192
194
  files.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
193
195
  const index = engine.indexFiles(files);
@@ -196,10 +198,10 @@ async function scanEventSources(engine, workspaceId, workspace, focused = false,
196
198
  revision: snapshotRevision(files),
197
199
  files,
198
200
  index,
199
- complete: scans.every(({ complete }) => complete) && index.complete,
201
+ complete: index.complete,
200
202
  skippedSourceCount: index.skippedSourceCount,
201
203
  skippedSources: index.skippedSources,
202
- diagnostics: [...scans.flatMap(({ diagnostics }) => diagnostics), ...index.diagnostics],
204
+ diagnostics: index.diagnostics,
203
205
  };
204
206
  }
205
207
  function rememberGraph(state, workspaceId, graph) {
@@ -707,18 +709,17 @@ export class EventChainViewer {
707
709
  if (options.refresh !== true && cached?.generation === generation) {
708
710
  return cached.graph;
709
711
  }
710
- const sibling = this.#state.current.get(eventGraphCacheKey(workspaceId, !cacheProjectHelpers, cacheAnalysisMode)) ??
711
- this.#state.current.get(eventGraphCacheKey(workspaceId, requestedProjectHelpers, requestedAnalysisMode)) ??
712
- this.#state.current.get(eventGraphCacheKey(workspaceId, !requestedProjectHelpers)) ??
713
- this.#state.current.get(eventGraphCacheKey(workspaceId, requestedProjectHelpers));
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
+ }
714
718
  const scanFocused = requestedAnalysisMode === 'focused';
715
- const snapshot = options.refresh !== true && sibling?.generation === generation
716
- ? sibling.snapshot
717
- : await scanEventSources(this.engine, workspaceId, workspace, scanFocused, options.principal, options.signal);
719
+ const snapshot = await scanEventSources(this.engine, workspaceId, workspace, scanFocused, options.signal);
718
720
  options.signal?.throwIfAborted();
719
- if (cached?.snapshot.revision === snapshot.revision && cached.generation === generation) {
721
+ if (cached?.graph.revision === snapshot.revision && cached.generation === generation)
720
722
  return cached.graph;
721
- }
722
723
  const buildAnalysisMode = requestedAnalysisMode;
723
724
  const projectHelpers = buildAnalysisMode === 'focused' ? false : requestedProjectHelpers;
724
725
  const graph = buildEventGraph(snapshot, {
@@ -728,13 +729,17 @@ export class EventChainViewer {
728
729
  projectHelpers,
729
730
  analysisMode: buildAnalysisMode,
730
731
  });
731
- this.#state.current.set(cacheKey, { generation, snapshot, graph });
732
+ this.#state.current.set(cacheKey, { generation, files: snapshot.files, graph });
732
733
  if (buildAnalysisMode === 'full')
733
734
  rememberGraph(this.#state, workspaceId, graph);
734
735
  return graph;
735
736
  }
736
737
  async inspect(input) {
737
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'));
738
743
  const scannedGraph = await this.scan(input.workspaceId, {
739
744
  refresh: input.refresh ?? input.mode === 'scan',
740
745
  // Focused routes traverse helper nodes directly and collapse only the
@@ -743,6 +748,7 @@ export class EventChainViewer {
743
748
  input.mode !== 'roots' &&
744
749
  input.mode !== 'trace' &&
745
750
  input.mode !== 'explain_path',
751
+ analysisMode: focused ? 'focused' : 'full',
746
752
  ...(input.principal === undefined ? {} : { principal: input.principal }),
747
753
  ...(input.signal === undefined ? {} : { signal: input.signal }),
748
754
  });
@@ -923,6 +929,7 @@ export class EventChainViewer {
923
929
  const graph = await this.scan(input.workspaceId, {
924
930
  ...(input.refresh === undefined ? {} : { refresh: input.refresh }),
925
931
  projectHelpers: true,
932
+ analysisMode: input.selector === undefined ? 'full' : 'focused',
926
933
  ...(input.principal === undefined ? {} : { principal: input.principal }),
927
934
  ...(input.signal === undefined ? {} : { signal: input.signal }),
928
935
  });
@@ -1135,7 +1142,7 @@ export class EventChainViewer {
1135
1142
  if (new Set(paths.map((value) => value.toLowerCase())).size !== paths.length) {
1136
1143
  throw new ServiceError('EVENT_PROPOSED_PATH_DUPLICATE', 'Proposed source paths must be unique');
1137
1144
  }
1138
- const files = cached.snapshot.files.map((file) => ({
1145
+ const files = cached.files.map((file) => ({
1139
1146
  ...file,
1140
1147
  bytes: Buffer.from(file.bytes),
1141
1148
  }));