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.
- package/CHANGELOG.md +17 -0
- package/README.md +35 -10
- package/dist/bin/stdio.js +0 -4
- package/dist/hoi4_agent_tools/core/configuration.js +16 -3
- package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +10 -13
- package/dist/hoi4_agent_tools/core/engine.js +3 -0
- package/dist/hoi4_agent_tools/core/scanner.js +63 -7
- package/dist/hoi4_agent_tools/core/source/limits.js +6 -7
- package/dist/hoi4_agent_tools/event/graph.js +3 -4
- package/dist/hoi4_agent_tools/event/limits.js +4 -4
- package/dist/hoi4_agent_tools/event/service.js +34 -39
- package/dist/hoi4_agent_tools/gui/assets.js +280 -12
- package/dist/hoi4_agent_tools/gui/layout.js +70 -9
- package/dist/hoi4_agent_tools/gui/renderer.js +81 -12
- package/dist/hoi4_agent_tools/gui/scenario-generator.js +36 -0
- package/dist/hoi4_agent_tools/gui/studio.js +90 -25
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/probability.js +72 -14
- package/dist/hoi4_agent_tools/probability/sequence.js +43 -18
- package/dist/hoi4_agent_tools/probability/service.js +187 -24
- package/dist/hoi4_agent_tools/schemas/probability.js +2 -0
- package/dist/hoi4_agent_tools/technology/service.js +41 -8
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/development.md +2 -0
- package/docs/gui.md +14 -3
- package/docs/images/readme/chaos-meter-ingame.png +0 -0
- package/docs/images/readme/chaos-meter-mcp.png +0 -0
- package/docs/images/readme/communist-insurgency-ingame.png +0 -0
- package/docs/images/readme/communist-insurgency-mcp.png +0 -0
- package/docs/images/readme/event-chain-mcp.png +0 -0
- package/docs/images/readme/focus-tree-mcp.png +0 -0
- package/docs/images/readme/map-mcp.png +0 -0
- package/docs/images/readme/probability-mcp.png +0 -0
- package/docs/images/readme/settings-advanced-mcp.png +0 -0
- package/docs/images/readme/settings-events-mcp.png +0 -0
- package/docs/images/readme/technology-tree-mcp.png +0 -0
- package/package.json +3 -1
- package/schemas/configuration.schema.json +3 -3
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +58 -39
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/server.json +2 -2
- 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.
|
|
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
|
-
##
|
|
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
|
+
|  |  |
|
|
21
|
+
|
|
22
|
+
| Chaos Meter in game | Chaos Meter from MCP |
|
|
23
|
+
| ----------------------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
24
|
+
|  |  |
|
|
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
|
+
|  |  |
|
|
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
|
+
|  |  |
|
|
37
|
+
|
|
38
|
+
| Map | Event chain | Probability ranking |
|
|
39
|
+
| --------------------------------------------------------------- | -------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
|
40
|
+
|  |  |  |
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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(
|
|
132
|
-
scanMaxBytes: z
|
|
133
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export const
|
|
8
|
-
export const
|
|
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
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
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 =
|
|
5
|
-
export const EVENT_GRAPH_MAX_STATE_LINKS =
|
|
6
|
-
export const EVENT_GRAPH_MAX_STATE_LINK_CANDIDATES =
|
|
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 =
|
|
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,
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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:
|
|
201
|
+
complete: index.complete,
|
|
204
202
|
skippedSourceCount: index.skippedSourceCount,
|
|
205
203
|
skippedSources: index.skippedSources,
|
|
206
|
-
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
|
|
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
|
-
|
|
716
|
-
this.#state.current.get(eventGraphCacheKey(workspaceId,
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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?.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1145
|
+
const files = cached.files.map((file) => ({
|
|
1151
1146
|
...file,
|
|
1152
1147
|
bytes: Buffer.from(file.bytes),
|
|
1153
1148
|
}));
|