hoi4-agent-tools 1.2.0 → 2.1.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 +21 -1
- package/README.md +6 -7
- package/dist/hoi4_agent_tools/core/artifacts.js +109 -4
- package/dist/hoi4_agent_tools/core/diagnostic-collector.js +28 -8
- package/dist/hoi4_agent_tools/core/diff.js +162 -4
- package/dist/hoi4_agent_tools/core/engine.js +2 -0
- package/dist/hoi4_agent_tools/core/index.js +17 -6
- package/dist/hoi4_agent_tools/core/render-budget.js +5 -3
- package/dist/hoi4_agent_tools/core/scanner.js +3 -2
- package/dist/hoi4_agent_tools/core/source/limits.js +4 -1
- package/dist/hoi4_agent_tools/core/workspace.js +31 -3
- package/dist/hoi4_agent_tools/event/graph.js +28 -6
- package/dist/hoi4_agent_tools/event/limits.js +1 -1
- package/dist/hoi4_agent_tools/event/service.js +103 -7
- package/dist/hoi4_agent_tools/focus/continuous-render.js +17 -8
- package/dist/hoi4_agent_tools/focus/presentation.js +96 -41
- package/dist/hoi4_agent_tools/focus/render.js +27 -16
- package/dist/hoi4_agent_tools/gui/assets.js +9 -2
- package/dist/hoi4_agent_tools/gui/inspection-artifact.js +99 -0
- package/dist/hoi4_agent_tools/gui/layout.js +126 -27
- package/dist/hoi4_agent_tools/gui/renderer.js +16 -0
- package/dist/hoi4_agent_tools/gui/source-graph.js +61 -28
- package/dist/hoi4_agent_tools/gui/studio.js +23 -8
- package/dist/hoi4_agent_tools/map/diagnostic-limit.js +90 -9
- package/dist/hoi4_agent_tools/map/model.js +22 -4
- package/dist/hoi4_agent_tools/map/service.js +4 -3
- package/dist/hoi4_agent_tools/map/validation.js +3 -2
- package/dist/hoi4_agent_tools/mcp/server/create.js +1 -3
- package/dist/hoi4_agent_tools/mcp/server/output-schemas.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/event.js +6 -6
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +149 -224
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +35 -25
- package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -3
- package/dist/hoi4_agent_tools/runtime.js +112 -2
- package/dist/hoi4_agent_tools/schemas/common.js +5 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/events.md +4 -5
- package/docs/focus.md +5 -4
- package/docs/gui.md +5 -1
- package/docs/map.md +1 -1
- package/docs/setup.md +9 -6
- package/package.json +1 -1
- package/server.json +2 -2
- package/dist/hoi4_agent_tools/mcp/tools/mods.js +0 -54
package/CHANGELOG.md
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.1.0 - 2026-07-18
|
|
4
|
+
|
|
5
|
+
- Added `hoi4.focus_raster` for decoded focus icons and deterministic PNG reviews while keeping inspection, vector rendering, and rewrites responsive on very large trees.
|
|
6
|
+
- Added public MCP regressions that create, compact, inspect, render, and rasterize 1,024-focus trees, including a separate 1,024-distinct-icon raster workload.
|
|
7
|
+
- Replaced the quadratic large-source diff ceiling with a deterministic linear-memory patience diff so large rewrites still return exact review artifacts.
|
|
8
|
+
- Raised distinct raster-operation capacity for large icon sets and deduplicated GUI sprite-frame raster work by texture and frame.
|
|
9
|
+
- Separated full localisation inventories from connected map topology limits and raised the shared index capacity for installed-game scale.
|
|
10
|
+
- Added automatic generated-artifact retention so long-running agent workflows reclaim older artifacts instead of stopping at the storage ceiling.
|
|
11
|
+
- Made broad GUI inspection index only connected localisation, reuse decoded textures, and return bounded workspace projections for very large source graphs.
|
|
12
|
+
- Made broad event scans structural by default, with helper expansion in focused queries and compact indexed resources for very large graphs.
|
|
13
|
+
- Suppressed unrelated vanilla parser noise while retaining mod and dependency diagnostics that affect the requested content.
|
|
14
|
+
- Added regressions for 500,100 localisation records, 1,024 distinct focus textures, large GUI and event graphs, raster discovery, package installation, and agent workflows.
|
|
15
|
+
|
|
16
|
+
## 2.0.0 - 2026-07-15
|
|
17
|
+
|
|
18
|
+
- Removed the workspace inventory tool and made the current mod implicit for local MCP calls.
|
|
19
|
+
- Starting the server inside any mod now works without a config file or per-mod setup command.
|
|
20
|
+
- Updated the published client examples, Registry metadata, and installation verification for the twelve-tool surface.
|
|
21
|
+
- Distinguished unresolved numeric values (`[X]`) from unresolved text-returning dynamic localisation (`[dynamic_loc]`) and applied supported HOI4 `§` text colours in offline GUI previews.
|
|
22
|
+
|
|
3
23
|
## 1.2.0 - 2026-07-13
|
|
4
24
|
|
|
5
25
|
- Added a read-only Event Chain Viewer for scanning, tracing, explaining, linting, rendering, and comparing large HOI4 event chains.
|
|
6
|
-
- Kept the event surface to three tools with seven inspect modes and linked authoritative graph artifacts,
|
|
26
|
+
- Kept the event surface to three tools with seven inspect modes and linked authoritative graph artifacts, keeping the public surface to twelve tools within the fixed discovery budget. Local calls now resolve the mod containing the MCP working directory.
|
|
7
27
|
- Added event workflow documentation, package and Registry metadata, and project-owned acceptance coverage for more than 300 event definitions.
|
|
8
28
|
- Reconfirmed MCP protocol `2025-11-25`, TypeScript SDK 1.29.0, Registry schema `2025-12-11`, and Registry publisher 1.7.9 for this release.
|
|
9
29
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# HOI4 Agent Tools
|
|
2
2
|
|
|
3
|
-
HOI4 Agent Tools is an MCP server for coding agents to analyze Hearts of Iron IV event chains and inspect, create, or clean up focus trees, scripted GUIs, and maps.
|
|
3
|
+
HOI4 Agent Tools is an MCP server for coding agents to analyze Hearts of Iron IV event chains and 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.
|
|
4
4
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
@@ -14,11 +14,10 @@ HOI4 Agent Tools is an MCP server for coding agents to analyze Hearts of Iron IV
|
|
|
14
14
|
Requires Node.js 22 or 24.
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
|
-
npm install --global hoi4-agent-tools@
|
|
18
|
-
hoi4-agent-tools-setup --init
|
|
17
|
+
npm install --global hoi4-agent-tools@latest
|
|
19
18
|
```
|
|
20
19
|
|
|
21
|
-
|
|
20
|
+
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).
|
|
22
21
|
|
|
23
22
|
## Connect your agent
|
|
24
23
|
|
|
@@ -32,9 +31,9 @@ Paste the printed Codex or generic global-install entry into your MCP client, th
|
|
|
32
31
|
|
|
33
32
|
| Tool | Purpose |
|
|
34
33
|
| -------------------- | --------------------------------------------------------------------------------------- |
|
|
35
|
-
| `hoi4.mods` | List the writable mods available to the coding agent. |
|
|
36
34
|
| `hoi4.focus_inspect` | Read focus trees and report structural or reference problems. |
|
|
37
|
-
| `hoi4.focus_render` |
|
|
35
|
+
| `hoi4.focus_render` | Produce fast HTML, SVG, JSON, and source-linked layout artifacts. |
|
|
36
|
+
| `hoi4.focus_raster` | Produce a high-fidelity PNG review with decoded source icons. |
|
|
38
37
|
| `hoi4.focus_rewrite` | Create or update a focus tree. |
|
|
39
38
|
| `hoi4.gui_inspect` | Read a scripted GUI and its linked assets and logic. |
|
|
40
39
|
| `hoi4.gui_render` | Render GUI states and resolutions offline. |
|
|
@@ -56,7 +55,7 @@ Connecting and listing tools does not scan mod source. Compact tool schemas and
|
|
|
56
55
|
|
|
57
56
|
## Create or clean content
|
|
58
57
|
|
|
59
|
-
Ask your agent in normal task language. A typical workflow is inspect, render, rewrite, then inspect the result.
|
|
58
|
+
Ask your agent in normal task language. A typical workflow is inspect, render, rewrite, then inspect the result. The agent can call a raster tool when a pixel review is useful without paying that cost during every structural operation.
|
|
60
59
|
|
|
61
60
|
- Focus trees: "Create a complete national focus tree for this route specification," or "Compact this existing tree into a balanced, readable layout." Existing trees can use a plan-free compact reflow; new trees use a complete plan. See [Focus trees](docs/focus.md).
|
|
62
61
|
- Scripted GUIs: "Create a scripted GUI for this mechanic," or "Render this window at common resolutions and fix clipping, missing assets, and click-region conflicts." See [Scripted GUIs](docs/gui.md).
|
|
@@ -462,6 +462,114 @@ export class ArtifactStore {
|
|
|
462
462
|
this.maxEntries = maxEntries;
|
|
463
463
|
this.maxSingleBytes = maxSingleBytes;
|
|
464
464
|
}
|
|
465
|
+
async pruneForAdmission(workspace, usage, additionalBytes, additionalEntries, protectedManifests, protectedTargets, signal) {
|
|
466
|
+
if (usage.entries + additionalEntries <= this.maxEntries &&
|
|
467
|
+
usage.bytes <= this.maxBytes - additionalBytes) {
|
|
468
|
+
return usage;
|
|
469
|
+
}
|
|
470
|
+
// Reclaim to a low-water mark so a busy agent workflow does not rescan and evict one artifact
|
|
471
|
+
// for every subsequent tool call. The incoming batch itself remains protected and atomic.
|
|
472
|
+
const targetBytes = Math.max(0, Math.min(this.maxBytes - additionalBytes, Math.floor(this.maxBytes * 0.75)));
|
|
473
|
+
const targetEntries = Math.max(0, Math.min(this.maxEntries - additionalEntries, Math.floor(this.maxEntries * 0.75)));
|
|
474
|
+
const retained = [];
|
|
475
|
+
const walk = async (directory) => {
|
|
476
|
+
signal?.throwIfAborted();
|
|
477
|
+
let children;
|
|
478
|
+
try {
|
|
479
|
+
children = await readdir(directory, { withFileTypes: true });
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
if (error.code === 'ENOENT')
|
|
483
|
+
return;
|
|
484
|
+
throw error;
|
|
485
|
+
}
|
|
486
|
+
for (const child of children) {
|
|
487
|
+
signal?.throwIfAborted();
|
|
488
|
+
if (child.isSymbolicLink()) {
|
|
489
|
+
throw new ServiceError('ARTIFACT_STORAGE_UNSAFE', 'Artifact storage contains a symbolic link or junction');
|
|
490
|
+
}
|
|
491
|
+
const candidate = await containedGeneratedPath(workspace.artifactRoot, path.relative(workspace.artifactRoot, path.join(directory, child.name)));
|
|
492
|
+
if (child.isDirectory()) {
|
|
493
|
+
await walk(candidate);
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
if (!child.isFile() || !child.name.endsWith('.manifest.json'))
|
|
497
|
+
continue;
|
|
498
|
+
const metadata = await lstat(candidate);
|
|
499
|
+
const manifest = await readArtifactManifest(candidate, signal);
|
|
500
|
+
if (child.name !== `${manifest.name}.${manifest.provenanceHash}.manifest.json`) {
|
|
501
|
+
throw new ServiceError('ARTIFACT_MANIFEST_INTEGRITY_FAILED', 'Artifact provenance manifest filename does not match its contents');
|
|
502
|
+
}
|
|
503
|
+
assertManifestIntegrity(manifest, {
|
|
504
|
+
sha256: path.basename(directory),
|
|
505
|
+
provenanceHash: manifest.provenanceHash,
|
|
506
|
+
name: manifest.name,
|
|
507
|
+
}, workspace);
|
|
508
|
+
const targetPath = await containedGeneratedPath(workspace.artifactRoot, path.relative(workspace.artifactRoot, path.join(directory, manifest.name)));
|
|
509
|
+
let targetBytes = 0;
|
|
510
|
+
try {
|
|
511
|
+
targetBytes = (await stat(targetPath)).size;
|
|
512
|
+
}
|
|
513
|
+
catch (error) {
|
|
514
|
+
if (error.code !== 'ENOENT')
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
retained.push({
|
|
518
|
+
manifest,
|
|
519
|
+
manifestPath: candidate,
|
|
520
|
+
manifestBytes: metadata.size,
|
|
521
|
+
modifiedAt: metadata.mtimeMs,
|
|
522
|
+
targetPath,
|
|
523
|
+
targetBytes,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
await walk(await containedGeneratedPath(workspace.artifactRoot));
|
|
528
|
+
retained.sort((left, right) => left.modifiedAt - right.modifiedAt ||
|
|
529
|
+
compareCodeUnits(left.manifestPath, right.manifestPath));
|
|
530
|
+
const current = { ...usage };
|
|
531
|
+
for (const candidate of retained) {
|
|
532
|
+
signal?.throwIfAborted();
|
|
533
|
+
if (current.bytes <= targetBytes && current.entries <= targetEntries)
|
|
534
|
+
break;
|
|
535
|
+
if (protectedManifests.has(candidate.manifestPath))
|
|
536
|
+
continue;
|
|
537
|
+
try {
|
|
538
|
+
await unlink(candidate.manifestPath);
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
if (error.code === 'ENOENT')
|
|
542
|
+
continue;
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
current.entries = Math.max(0, current.entries - 1);
|
|
546
|
+
current.bytes = Math.max(0, current.bytes - candidate.manifestBytes);
|
|
547
|
+
if (protectedTargets.has(candidate.targetPath))
|
|
548
|
+
continue;
|
|
549
|
+
const prefix = `${candidate.manifest.name}.`;
|
|
550
|
+
const hasManifest = (await readdir(path.dirname(candidate.targetPath), { withFileTypes: true })).some((entry) => entry.isFile() && entry.name.startsWith(prefix) && entry.name.endsWith('.manifest.json'));
|
|
551
|
+
if (hasManifest)
|
|
552
|
+
continue;
|
|
553
|
+
try {
|
|
554
|
+
await unlink(candidate.targetPath);
|
|
555
|
+
current.bytes = Math.max(0, current.bytes - candidate.targetBytes);
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
if (error.code !== 'ENOENT')
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
const verificationPrefix = `${workspace.workspaceIdentity}\0${workspace.ownerIdentity}\0${candidate.targetPath}\0`;
|
|
562
|
+
for (const key of this.#verifiedContent.keys()) {
|
|
563
|
+
if (key.startsWith(verificationPrefix))
|
|
564
|
+
this.#verifiedContent.delete(key);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if (current.entries + additionalEntries > this.maxEntries ||
|
|
568
|
+
current.bytes > this.maxBytes - additionalBytes) {
|
|
569
|
+
throw new ServiceError('ARTIFACT_STORAGE_LIMIT', 'Artifact batch cannot fit after reclaiming expired artifacts');
|
|
570
|
+
}
|
|
571
|
+
return current;
|
|
572
|
+
}
|
|
465
573
|
async list(workspace, signal) {
|
|
466
574
|
signal?.throwIfAborted();
|
|
467
575
|
const usage = await artifactUsage(workspace.artifactRoot, signal);
|
|
@@ -902,10 +1010,7 @@ export class ArtifactStore {
|
|
|
902
1010
|
}
|
|
903
1011
|
const usage = await artifactUsage(workspace.artifactRoot, signal);
|
|
904
1012
|
const additionalEntries = [...uniqueManifests.values()].filter((artifact) => !artifact.exists).length;
|
|
905
|
-
|
|
906
|
-
usage.bytes > this.maxBytes - additionalBytes) {
|
|
907
|
-
throw new ServiceError('ARTIFACT_STORAGE_LIMIT', 'Artifact storage retention limit has been reached');
|
|
908
|
-
}
|
|
1013
|
+
await this.pruneForAdmission(workspace, usage, additionalBytes, additionalEntries, new Set(uniqueManifests.keys()), new Set(uniqueTargets.keys()), signal);
|
|
909
1014
|
const createdTargets = [];
|
|
910
1015
|
const createdManifests = [];
|
|
911
1016
|
try {
|
|
@@ -4,6 +4,8 @@ export class DiagnosticCollector {
|
|
|
4
4
|
truncation;
|
|
5
5
|
items = [];
|
|
6
6
|
dropped = 0;
|
|
7
|
+
droppedBySeverity = {};
|
|
8
|
+
droppedByCode = {};
|
|
7
9
|
constructor(maximum, truncation) {
|
|
8
10
|
this.maximum = maximum;
|
|
9
11
|
this.truncation = truncation;
|
|
@@ -17,27 +19,45 @@ export class DiagnosticCollector {
|
|
|
17
19
|
if (this.items.length < Math.max(0, this.maximum - 1))
|
|
18
20
|
this.items.push(diagnostic);
|
|
19
21
|
else {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
const hard = diagnostic.severity === 'error' || diagnostic.severity === 'blocker';
|
|
23
|
+
const replacement = hard
|
|
24
|
+
? this.items.findLastIndex(({ severity }) => severity !== 'error' && severity !== 'blocker')
|
|
25
|
+
: -1;
|
|
26
|
+
if (replacement >= 0) {
|
|
27
|
+
const replaced = this.items[replacement];
|
|
28
|
+
this.items[replacement] = diagnostic;
|
|
29
|
+
this.recordDropped(replaced);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
this.recordDropped(diagnostic);
|
|
26
33
|
}
|
|
27
34
|
}
|
|
28
35
|
}
|
|
29
36
|
}
|
|
37
|
+
recordDropped(diagnostic) {
|
|
38
|
+
this.dropped += 1;
|
|
39
|
+
this.droppedBySeverity[diagnostic.severity] =
|
|
40
|
+
(this.droppedBySeverity[diagnostic.severity] ?? 0) + 1;
|
|
41
|
+
this.droppedByCode[diagnostic.code] = (this.droppedByCode[diagnostic.code] ?? 0) + 1;
|
|
42
|
+
}
|
|
30
43
|
values() {
|
|
31
44
|
if (this.dropped === 0)
|
|
32
45
|
return [...this.items];
|
|
46
|
+
const hardDropped = (this.droppedBySeverity.error ?? 0) + (this.droppedBySeverity.blocker ?? 0) > 0;
|
|
33
47
|
return [
|
|
34
48
|
...this.items,
|
|
35
49
|
{
|
|
36
50
|
code: this.truncation.code,
|
|
37
|
-
severity: 'blocker',
|
|
51
|
+
severity: hardDropped ? 'blocker' : 'info',
|
|
38
52
|
category: this.truncation.category,
|
|
39
53
|
message: this.truncation.message,
|
|
40
|
-
details: {
|
|
54
|
+
details: {
|
|
55
|
+
retained: this.items.length,
|
|
56
|
+
dropped: this.dropped,
|
|
57
|
+
maximum: this.maximum,
|
|
58
|
+
droppedBySeverity: this.droppedBySeverity,
|
|
59
|
+
droppedByCode: this.droppedByCode,
|
|
60
|
+
},
|
|
41
61
|
},
|
|
42
62
|
];
|
|
43
63
|
}
|
|
@@ -25,9 +25,13 @@ function lcsDiff(oldLines, newLines, options) {
|
|
|
25
25
|
const columns = newLines.length + 1;
|
|
26
26
|
const cells = (oldLines.length + 1) * columns;
|
|
27
27
|
const maxMatrixCells = options.maxMatrixCells ?? defaultMaxMatrixCells;
|
|
28
|
-
if (!Number.isSafeInteger(cells) || cells > maxMatrixCells)
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
if (!Number.isSafeInteger(cells) || cells > maxMatrixCells)
|
|
29
|
+
return patienceDiff(oldLines, newLines, maxMatrixCells, options.signal);
|
|
30
|
+
return matrixDiff(oldLines, newLines, options);
|
|
31
|
+
}
|
|
32
|
+
function matrixDiff(oldLines, newLines, options) {
|
|
33
|
+
const columns = newLines.length + 1;
|
|
34
|
+
const cells = (oldLines.length + 1) * columns;
|
|
31
35
|
const table = new Uint32Array(cells);
|
|
32
36
|
for (let oldIndex = oldLines.length - 1; oldIndex >= 0; oldIndex -= 1) {
|
|
33
37
|
if ((oldIndex & 0x3f) === 0)
|
|
@@ -68,6 +72,161 @@ function lcsDiff(oldLines, newLines, options) {
|
|
|
68
72
|
}
|
|
69
73
|
return result;
|
|
70
74
|
}
|
|
75
|
+
function uniqueLineIndexes(lines, start, end) {
|
|
76
|
+
const indexes = new Map();
|
|
77
|
+
const repeated = new Set();
|
|
78
|
+
for (let index = start; index < end; index += 1) {
|
|
79
|
+
const line = lines[index];
|
|
80
|
+
if (repeated.has(line))
|
|
81
|
+
continue;
|
|
82
|
+
if (indexes.has(line)) {
|
|
83
|
+
indexes.delete(line);
|
|
84
|
+
repeated.add(line);
|
|
85
|
+
}
|
|
86
|
+
else
|
|
87
|
+
indexes.set(line, index);
|
|
88
|
+
}
|
|
89
|
+
return indexes;
|
|
90
|
+
}
|
|
91
|
+
function patienceAnchors(oldLines, newLines, range, signal) {
|
|
92
|
+
const oldUnique = uniqueLineIndexes(oldLines, range.oldStart, range.oldEnd);
|
|
93
|
+
const newUnique = uniqueLineIndexes(newLines, range.newStart, range.newEnd);
|
|
94
|
+
const candidates = [];
|
|
95
|
+
let visited = 0;
|
|
96
|
+
for (const [line, oldIndex] of oldUnique) {
|
|
97
|
+
if ((visited & 0x3ff) === 0)
|
|
98
|
+
signal?.throwIfAborted();
|
|
99
|
+
visited += 1;
|
|
100
|
+
const newIndex = newUnique.get(line);
|
|
101
|
+
if (newIndex !== undefined)
|
|
102
|
+
candidates.push({ oldIndex, newIndex });
|
|
103
|
+
}
|
|
104
|
+
candidates.sort((left, right) => left.oldIndex - right.oldIndex);
|
|
105
|
+
if (candidates.length <= 1)
|
|
106
|
+
return candidates;
|
|
107
|
+
const tails = [];
|
|
108
|
+
const previous = new Int32Array(candidates.length);
|
|
109
|
+
previous.fill(-1);
|
|
110
|
+
for (let index = 0; index < candidates.length; index += 1) {
|
|
111
|
+
if ((index & 0x3ff) === 0)
|
|
112
|
+
signal?.throwIfAborted();
|
|
113
|
+
const newIndex = candidates[index].newIndex;
|
|
114
|
+
let low = 0;
|
|
115
|
+
let high = tails.length;
|
|
116
|
+
while (low < high) {
|
|
117
|
+
const middle = (low + high) >>> 1;
|
|
118
|
+
if (candidates[tails[middle]].newIndex < newIndex)
|
|
119
|
+
low = middle + 1;
|
|
120
|
+
else
|
|
121
|
+
high = middle;
|
|
122
|
+
}
|
|
123
|
+
if (low > 0)
|
|
124
|
+
previous[index] = tails[low - 1];
|
|
125
|
+
tails[low] = index;
|
|
126
|
+
}
|
|
127
|
+
const anchors = new Array(tails.length);
|
|
128
|
+
let current = tails.at(-1);
|
|
129
|
+
for (let index = anchors.length - 1; index >= 0; index -= 1) {
|
|
130
|
+
anchors[index] = candidates[current];
|
|
131
|
+
current = previous[current];
|
|
132
|
+
}
|
|
133
|
+
return anchors;
|
|
134
|
+
}
|
|
135
|
+
function patienceDiff(oldLines, newLines, matrixThreshold, signal) {
|
|
136
|
+
const result = [];
|
|
137
|
+
const tasks = [
|
|
138
|
+
{
|
|
139
|
+
kind: 'range',
|
|
140
|
+
oldStart: 0,
|
|
141
|
+
oldEnd: oldLines.length,
|
|
142
|
+
newStart: 0,
|
|
143
|
+
newEnd: newLines.length,
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
let iterations = 0;
|
|
147
|
+
while (tasks.length > 0) {
|
|
148
|
+
if ((iterations & 0xff) === 0)
|
|
149
|
+
signal?.throwIfAborted();
|
|
150
|
+
iterations += 1;
|
|
151
|
+
const task = tasks.pop();
|
|
152
|
+
if (task.kind !== 'range') {
|
|
153
|
+
const lines = task.kind === 'add' ? newLines : oldLines;
|
|
154
|
+
const prefix = task.kind === 'same' ? ' ' : task.kind === 'add' ? '+' : '-';
|
|
155
|
+
for (let index = task.start; index < task.end; index += 1)
|
|
156
|
+
result.push(`${prefix}${lineContent(lines[index])}`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
let { oldStart, oldEnd, newStart, newEnd } = task;
|
|
160
|
+
while (oldStart < oldEnd && newStart < newEnd && oldLines[oldStart] === newLines[newStart]) {
|
|
161
|
+
result.push(` ${lineContent(oldLines[oldStart])}`);
|
|
162
|
+
oldStart += 1;
|
|
163
|
+
newStart += 1;
|
|
164
|
+
}
|
|
165
|
+
let commonSuffix = 0;
|
|
166
|
+
while (oldStart < oldEnd - commonSuffix &&
|
|
167
|
+
newStart < newEnd - commonSuffix &&
|
|
168
|
+
oldLines[oldEnd - commonSuffix - 1] === newLines[newEnd - commonSuffix - 1])
|
|
169
|
+
commonSuffix += 1;
|
|
170
|
+
oldEnd -= commonSuffix;
|
|
171
|
+
newEnd -= commonSuffix;
|
|
172
|
+
if (commonSuffix > 0)
|
|
173
|
+
tasks.push({ kind: 'same', start: oldEnd, end: oldEnd + commonSuffix });
|
|
174
|
+
if (oldStart === oldEnd) {
|
|
175
|
+
if (newStart < newEnd)
|
|
176
|
+
tasks.push({ kind: 'add', start: newStart, end: newEnd });
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
if (newStart === newEnd) {
|
|
180
|
+
tasks.push({ kind: 'delete', start: oldStart, end: oldEnd });
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
const rangeCells = (oldEnd - oldStart + 1) * (newEnd - newStart + 1);
|
|
184
|
+
if (Number.isSafeInteger(rangeCells) && rangeCells <= matrixThreshold) {
|
|
185
|
+
const rows = matrixDiff(oldLines.slice(oldStart, oldEnd), newLines.slice(newStart, newEnd), {
|
|
186
|
+
maxMatrixCells: matrixThreshold,
|
|
187
|
+
...(signal === undefined ? {} : { signal }),
|
|
188
|
+
});
|
|
189
|
+
result.push(...rows);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const range = { kind: 'range', oldStart, oldEnd, newStart, newEnd };
|
|
193
|
+
const anchors = patienceAnchors(oldLines, newLines, range, signal);
|
|
194
|
+
if (anchors.length === 0) {
|
|
195
|
+
tasks.push({ kind: 'add', start: newStart, end: newEnd });
|
|
196
|
+
tasks.push({ kind: 'delete', start: oldStart, end: oldEnd });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const ordered = [];
|
|
200
|
+
let previousOld = oldStart;
|
|
201
|
+
let previousNew = newStart;
|
|
202
|
+
for (const anchor of anchors) {
|
|
203
|
+
if (previousOld < anchor.oldIndex || previousNew < anchor.newIndex) {
|
|
204
|
+
ordered.push({
|
|
205
|
+
kind: 'range',
|
|
206
|
+
oldStart: previousOld,
|
|
207
|
+
oldEnd: anchor.oldIndex,
|
|
208
|
+
newStart: previousNew,
|
|
209
|
+
newEnd: anchor.newIndex,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
ordered.push({ kind: 'same', start: anchor.oldIndex, end: anchor.oldIndex + 1 });
|
|
213
|
+
previousOld = anchor.oldIndex + 1;
|
|
214
|
+
previousNew = anchor.newIndex + 1;
|
|
215
|
+
}
|
|
216
|
+
if (previousOld < oldEnd || previousNew < newEnd) {
|
|
217
|
+
ordered.push({
|
|
218
|
+
kind: 'range',
|
|
219
|
+
oldStart: previousOld,
|
|
220
|
+
oldEnd,
|
|
221
|
+
newStart: previousNew,
|
|
222
|
+
newEnd,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
for (let index = ordered.length - 1; index >= 0; index -= 1)
|
|
226
|
+
tasks.push(ordered[index]);
|
|
227
|
+
}
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
71
230
|
export function binaryDiff(oldBytes, newBytes) {
|
|
72
231
|
const length = Math.max(oldBytes.length, newBytes.length);
|
|
73
232
|
let changedBytes = 0;
|
|
@@ -89,4 +248,3 @@ export function binaryDiff(oldBytes, newBytes) {
|
|
|
89
248
|
: { firstChangedOffset, lastChangedOffset: lastChangedOffset }),
|
|
90
249
|
};
|
|
91
250
|
}
|
|
92
|
-
import { ServiceError } from './result.js';
|
|
@@ -119,6 +119,7 @@ export class CoreEngine {
|
|
|
119
119
|
const requestKey = `${workspaceId}:${generation}:${hashCanonical({
|
|
120
120
|
patterns: [...patterns].sort(),
|
|
121
121
|
ignore: [...(ignore ?? [])].sort(),
|
|
122
|
+
rootKinds: [...(options.rootKinds ?? [])].sort(),
|
|
122
123
|
maxFiles: options.maxFiles ?? this.resolver.config().scanMaxFiles,
|
|
123
124
|
maxBytes: options.maxBytes ?? this.resolver.config().scanMaxBytes,
|
|
124
125
|
})}`;
|
|
@@ -130,6 +131,7 @@ export class CoreEngine {
|
|
|
130
131
|
const files = await this.scanner.scan(workspace, {
|
|
131
132
|
patterns,
|
|
132
133
|
...(ignore === undefined ? {} : { ignore }),
|
|
134
|
+
...(options.rootKinds === undefined ? {} : { rootKinds: options.rootKinds }),
|
|
133
135
|
...(options.maxFiles === undefined ? {} : { maxFiles: options.maxFiles }),
|
|
134
136
|
...(options.maxBytes === undefined ? {} : { maxBytes: options.maxBytes }),
|
|
135
137
|
signal: controller.signal,
|
|
@@ -39,7 +39,7 @@ const decisionCategoryFields = new Set([
|
|
|
39
39
|
// before the higher-precedence mod roots are reached. Keep the inventory
|
|
40
40
|
// bounded, but leave enough headroom for the supported game build and a large
|
|
41
41
|
// external workspace so valid mod symbols are never discarded behind vanilla.
|
|
42
|
-
const INDEX_RECORD_LIMIT =
|
|
42
|
+
const INDEX_RECORD_LIMIT = 2_000_000;
|
|
43
43
|
const INDEX_DIAGNOSTIC_LIMIT = 10_000;
|
|
44
44
|
const INDEX_RELATED_LOCATION_LIMIT = 100;
|
|
45
45
|
const INDEX_TABLE_RECORD_LIMIT = 100_000;
|
|
@@ -125,6 +125,9 @@ function normalizedRelative(value) {
|
|
|
125
125
|
function sourceIdentity(file) {
|
|
126
126
|
return `${file.rootKind}:${file.loadOrder}`;
|
|
127
127
|
}
|
|
128
|
+
function isWorkspaceOwnedSource(file) {
|
|
129
|
+
return file?.rootKind === 'mod' || file?.rootKind === 'fixture';
|
|
130
|
+
}
|
|
128
131
|
function selectedDefinitionFiles(files) {
|
|
129
132
|
const defaults = files.filter(({ relativePath }) => path.posix.basename(normalizedRelative(relativePath)) === 'default.map');
|
|
130
133
|
const directories = [
|
|
@@ -257,11 +260,15 @@ export class SymbolIndex {
|
|
|
257
260
|
recordSkippedSource(file, diagnostics) {
|
|
258
261
|
if (file.shadowedBy !== undefined || this.#skippedSourcePaths.has(file.displayPath))
|
|
259
262
|
return;
|
|
263
|
+
const candidates = possibleSymbolKinds(file, this.#definitionFiles);
|
|
264
|
+
// Files outside indexed HOI4 domains may still match broad text globs. A parsing ceiling in one
|
|
265
|
+
// of those files does not make the symbol inventory partial.
|
|
266
|
+
if (candidates.length === 0)
|
|
267
|
+
return;
|
|
260
268
|
this.#skippedSourcePaths.add(file.displayPath);
|
|
261
269
|
this.#complete = false;
|
|
262
270
|
this.#skippedSourceCount += 1;
|
|
263
271
|
const reasonCodes = [...new Set(diagnostics.map(({ code }) => code))].sort((left, right) => compareCodeUnits(left, right));
|
|
264
|
-
const candidates = possibleSymbolKinds(file, this.#definitionFiles);
|
|
265
272
|
for (const kind of candidates)
|
|
266
273
|
this.#skippedPossibleSymbolKinds.add(kind);
|
|
267
274
|
if (this.skippedSources.length >= INDEX_SKIPPED_SOURCE_SAMPLE_LIMIT)
|
|
@@ -361,7 +368,8 @@ export class SymbolIndex {
|
|
|
361
368
|
this.recordSkippedSource(file, limitDiagnostics);
|
|
362
369
|
return;
|
|
363
370
|
}
|
|
364
|
-
|
|
371
|
+
if (isWorkspaceOwnedSource(file))
|
|
372
|
+
this.addDiagnostics(document.diagnostics);
|
|
365
373
|
for (const entry of document.entries) {
|
|
366
374
|
this.addSymbol({
|
|
367
375
|
kind: 'localisation',
|
|
@@ -401,7 +409,8 @@ export class SymbolIndex {
|
|
|
401
409
|
this.recordSkippedSource(file, limitDiagnostics);
|
|
402
410
|
return;
|
|
403
411
|
}
|
|
404
|
-
|
|
412
|
+
if (isWorkspaceOwnedSource(file))
|
|
413
|
+
this.addDiagnostics(document.diagnostics);
|
|
405
414
|
this.walkBlock(document, document.root, file, []);
|
|
406
415
|
if (this.#currentFileShadowed) {
|
|
407
416
|
this.diagnostics.splice(diagnosticStart);
|
|
@@ -932,7 +941,9 @@ export class SymbolIndex {
|
|
|
932
941
|
candidate.overridden = additiveCategory ? candidate.sourceShadowed : candidate !== active;
|
|
933
942
|
}
|
|
934
943
|
const sameLevel = candidates.filter(({ loadOrder }) => loadOrder === active.loadOrder);
|
|
935
|
-
if (!additiveCategory &&
|
|
944
|
+
if (!additiveCategory &&
|
|
945
|
+
sameLevel.length > 1 &&
|
|
946
|
+
sameLevel.some((candidate) => isWorkspaceOwnedSource(this.files.get(candidate.path)))) {
|
|
936
947
|
this.addDiagnostic({
|
|
937
948
|
code: 'INDEX_SYMBOL_COLLISION',
|
|
938
949
|
severity: 'error',
|
|
@@ -945,7 +956,7 @@ export class SymbolIndex {
|
|
|
945
956
|
});
|
|
946
957
|
}
|
|
947
958
|
}
|
|
948
|
-
for (const reference of this.unresolvedReferences()) {
|
|
959
|
+
for (const reference of this.unresolvedReferences().filter((candidate) => isWorkspaceOwnedSource(this.files.get(candidate.path)))) {
|
|
949
960
|
const mayResolveFromSkippedSource = this.#skippedPossibleSymbolKinds.has(reference.toKind);
|
|
950
961
|
this.addDiagnostic({
|
|
951
962
|
code: mayResolveFromSkippedSource
|
|
@@ -3,7 +3,7 @@ import { ServiceError } from './result.js';
|
|
|
3
3
|
* Fixed process-safety ceilings for decoded rasters and offline render artifacts.
|
|
4
4
|
*
|
|
5
5
|
* The per-artifact ceiling permits the vanilla 5632x2048 map at scale 2 and the
|
|
6
|
-
* committed
|
|
6
|
+
* committed 1,024-focus benchmarks. Aggregate reservations account for every
|
|
7
7
|
* variant in one service request and cap their combined RGBA-equivalent area.
|
|
8
8
|
*/
|
|
9
9
|
export const RENDER_MAX_DIMENSION = 16_384;
|
|
@@ -14,8 +14,10 @@ export const RENDER_MAX_DECODED_PIXELS = 16_777_216;
|
|
|
14
14
|
export const RENDER_MAX_ENCODED_IMAGE_BYTES = 33_554_432;
|
|
15
15
|
// 64 Mi pixels preserves one vanilla scale-1 before/proposed/diff map request.
|
|
16
16
|
export const RENDER_MAX_AGGREGATE_PIXELS = 67_108_864;
|
|
17
|
-
//
|
|
18
|
-
|
|
17
|
+
// Large focus and GUI surfaces can legitimately contain hundreds of distinct textures and frames.
|
|
18
|
+
// The aggregate pixel budget remains the primary memory bound while this ceiling prevents runaway
|
|
19
|
+
// decoder fan-out from malformed graphs.
|
|
20
|
+
export const RENDER_MAX_DISTINCT_RASTER_OPERATIONS = 4_096;
|
|
19
21
|
/** Return a deterministic violation without allocating or throwing. */
|
|
20
22
|
export function renderDimensionViolation(width, height, label = 'render artifact', limits = {}) {
|
|
21
23
|
const maximumDimension = Math.min(RENDER_MAX_DIMENSION, limits.maximumDimension ?? RENDER_MAX_DIMENSION);
|
|
@@ -43,10 +43,11 @@ export class WorkspaceScanner {
|
|
|
43
43
|
throw new ServiceError('SCAN_LIMIT_EXCEEDS_POLICY', 'Requested scan limits exceed the configured server ceiling');
|
|
44
44
|
}
|
|
45
45
|
const roots = workspace.roots
|
|
46
|
-
.filter((root) => root.kind === 'game' ||
|
|
46
|
+
.filter((root) => (root.kind === 'game' ||
|
|
47
47
|
root.kind === 'dependency' ||
|
|
48
48
|
root.kind === 'mod' ||
|
|
49
|
-
root.kind === 'fixture')
|
|
49
|
+
root.kind === 'fixture') &&
|
|
50
|
+
(options.rootKinds === undefined || options.rootKinds.includes(root.kind)))
|
|
50
51
|
.sort((a, b) => a.loadOrder - b.loadOrder || compareCodeUnits(a.path, b.path));
|
|
51
52
|
const result = [];
|
|
52
53
|
let totalBytes = 0;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
export const SOURCE_DIAGNOSTIC_LIMIT = 100;
|
|
2
2
|
export const SOURCE_MAX_NESTING = 256;
|
|
3
3
|
export const SOURCE_MAX_BYTES = 8_388_608;
|
|
4
|
-
|
|
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;
|
|
5
8
|
export const SOURCE_ENTRY_LIMIT = 100_000;
|
|
6
9
|
export const SOURCE_LINE_LIMIT = 250_000;
|
|
7
10
|
/**
|
|
@@ -4,6 +4,8 @@ import { WORKSPACE_MAX_REGISTRATIONS, workspaceRegistrationSchema, } from './con
|
|
|
4
4
|
import { compareCodeUnits, hashCanonical, sha256Bytes } from './canonical.js';
|
|
5
5
|
import { ServiceError } from './result.js';
|
|
6
6
|
import { ServerState } from './server-state.js';
|
|
7
|
+
/** Omitted MCP workspace arguments resolve to the mod containing the server cwd. */
|
|
8
|
+
export const CURRENT_WORKSPACE_ID = 'current';
|
|
7
9
|
async function exists(value, signal) {
|
|
8
10
|
signal?.throwIfAborted();
|
|
9
11
|
try {
|
|
@@ -185,6 +187,9 @@ export class WorkspaceResolver {
|
|
|
185
187
|
}
|
|
186
188
|
}
|
|
187
189
|
for (const configured of configuration.workspaces) {
|
|
190
|
+
if (configured.id === CURRENT_WORKSPACE_ID) {
|
|
191
|
+
throw new ServiceError('WORKSPACE_ID_RESERVED', `Workspace ID is reserved for the current working mod: ${CURRENT_WORKSPACE_ID}`);
|
|
192
|
+
}
|
|
188
193
|
const registration = resolver.withGlobalWorkspaceDefaults(configured);
|
|
189
194
|
const workspace = await resolver.resolveRegistration(registration);
|
|
190
195
|
if (resolver.#byId.has(workspace.id)) {
|
|
@@ -203,6 +208,28 @@ export class WorkspaceResolver {
|
|
|
203
208
|
config() {
|
|
204
209
|
return this.configuration;
|
|
205
210
|
}
|
|
211
|
+
/** Resolve the implicit workspace used by local coding-agent calls. */
|
|
212
|
+
resolveCurrentWorkspaceId(principal) {
|
|
213
|
+
const workspaces = this.list(principal).filter(({ registration }) => registration.kind === 'mod');
|
|
214
|
+
if (workspaces.length === 0) {
|
|
215
|
+
throw new ServiceError('WORKSPACE_CONTEXT_NOT_FOUND', 'No writable mod workspace is configured for the MCP server');
|
|
216
|
+
}
|
|
217
|
+
const currentDirectory = path.normalize(process.cwd());
|
|
218
|
+
const containing = workspaces.filter((workspace) => isWithin(workspace.modRoot, currentDirectory));
|
|
219
|
+
if (containing.length === 1)
|
|
220
|
+
return containing[0].id;
|
|
221
|
+
if (containing.length > 1) {
|
|
222
|
+
throw new ServiceError('WORKSPACE_CONTEXT_AMBIGUOUS', 'The MCP working directory is inside more than one writable mod workspace', { currentDirectory });
|
|
223
|
+
}
|
|
224
|
+
if (workspaces.length === 1)
|
|
225
|
+
return workspaces[0].id;
|
|
226
|
+
throw new ServiceError('WORKSPACE_CONTEXT_REQUIRED', 'The MCP working directory does not identify one writable mod workspace', { currentDirectory, workspaceIds: workspaces.map(({ id }) => id) });
|
|
227
|
+
}
|
|
228
|
+
resolveWorkspaceId(workspaceId, principal) {
|
|
229
|
+
return workspaceId === CURRENT_WORKSPACE_ID
|
|
230
|
+
? this.resolveCurrentWorkspaceId(principal)
|
|
231
|
+
: workspaceId;
|
|
232
|
+
}
|
|
206
233
|
serverState() {
|
|
207
234
|
return this.#serverState;
|
|
208
235
|
}
|
|
@@ -276,15 +303,16 @@ export class WorkspaceResolver {
|
|
|
276
303
|
.sort((a, b) => compareCodeUnits(a.id, b.id));
|
|
277
304
|
}
|
|
278
305
|
get(workspaceId, principal) {
|
|
279
|
-
|
|
306
|
+
const resolvedWorkspaceId = this.resolveWorkspaceId(workspaceId, principal);
|
|
307
|
+
if (principal !== undefined && !this.allowedWorkspaceIds(principal).has(resolvedWorkspaceId)) {
|
|
280
308
|
throw new ServiceError('WORKSPACE_INACCESSIBLE', 'Workspace is unavailable to the authenticated principal');
|
|
281
309
|
}
|
|
282
|
-
const workspace = this.#byId.get(
|
|
310
|
+
const workspace = this.#byId.get(resolvedWorkspaceId);
|
|
283
311
|
if (workspace === undefined) {
|
|
284
312
|
if (principal !== undefined) {
|
|
285
313
|
throw new ServiceError('WORKSPACE_INACCESSIBLE', 'Workspace is unavailable to the authenticated principal');
|
|
286
314
|
}
|
|
287
|
-
throw new ServiceError('WORKSPACE_NOT_REGISTERED', `Workspace is not registered: ${
|
|
315
|
+
throw new ServiceError('WORKSPACE_NOT_REGISTERED', `Workspace is not registered: ${resolvedWorkspaceId}`);
|
|
288
316
|
}
|
|
289
317
|
return workspace;
|
|
290
318
|
}
|