hoi4-agent-tools 2.0.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 +14 -1
- package/README.md +4 -3
- 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/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 +7 -3
- 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 -1
- package/dist/hoi4_agent_tools/mcp/server/output-schemas.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/focus.js +145 -223
- package/dist/hoi4_agent_tools/mcp/tools/gui.js +29 -22
- 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 +4 -2
- package/docs/map.md +1 -1
- package/docs/setup.md +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,11 +1,24 @@
|
|
|
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
|
+
|
|
3
16
|
## 2.0.0 - 2026-07-15
|
|
4
17
|
|
|
5
18
|
- Removed the workspace inventory tool and made the current mod implicit for local MCP calls.
|
|
6
19
|
- Starting the server inside any mod now works without a config file or per-mod setup command.
|
|
7
20
|
- Updated the published client examples, Registry metadata, and installation verification for the twelve-tool surface.
|
|
8
|
-
-
|
|
21
|
+
- Distinguished unresolved numeric values (`[X]`) from unresolved text-returning dynamic localisation (`[dynamic_loc]`) and applied supported HOI4 `§` text colours in offline GUI previews.
|
|
9
22
|
|
|
10
23
|
## 1.2.0 - 2026-07-13
|
|
11
24
|
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ 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@
|
|
17
|
+
npm install --global hoi4-agent-tools@latest
|
|
18
18
|
```
|
|
19
19
|
|
|
20
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).
|
|
@@ -32,7 +32,8 @@ Paste the printed Codex or generic global-install entry into your MCP client, th
|
|
|
32
32
|
| Tool | Purpose |
|
|
33
33
|
| -------------------- | --------------------------------------------------------------------------------------- |
|
|
34
34
|
| `hoi4.focus_inspect` | Read focus trees and report structural or reference problems. |
|
|
35
|
-
| `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. |
|
|
36
37
|
| `hoi4.focus_rewrite` | Create or update a focus tree. |
|
|
37
38
|
| `hoi4.gui_inspect` | Read a scripted GUI and its linked assets and logic. |
|
|
38
39
|
| `hoi4.gui_render` | Render GUI states and resolutions offline. |
|
|
@@ -54,7 +55,7 @@ Connecting and listing tools does not scan mod source. Compact tool schemas and
|
|
|
54
55
|
|
|
55
56
|
## Create or clean content
|
|
56
57
|
|
|
57
|
-
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.
|
|
58
59
|
|
|
59
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).
|
|
60
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
|
/**
|
|
@@ -451,6 +451,26 @@ function helperProjections(graph, budget) {
|
|
|
451
451
|
if (projectedKeys.has(projectionKey))
|
|
452
452
|
continue;
|
|
453
453
|
projectedKeys.add(projectionKey);
|
|
454
|
+
if (projected.length >= EVENT_GRAPH_MAX_HELPER_PROJECTIONS) {
|
|
455
|
+
addIssue(graph.issues, {
|
|
456
|
+
code: 'EVENT_HELPER_PROJECTION_LIMIT',
|
|
457
|
+
classification: 'unresolved_analysis',
|
|
458
|
+
severity: 'blocker',
|
|
459
|
+
message: 'Collapsed helper projection reached its materialization ceiling; structural helper calls remain available.',
|
|
460
|
+
confidence: 'unresolved',
|
|
461
|
+
location: start.location,
|
|
462
|
+
blockers: [
|
|
463
|
+
{
|
|
464
|
+
code: 'HELPER_PROJECTION_LIMIT',
|
|
465
|
+
message: 'Use a bounded helper-expanded trace for paths omitted from the collapsed workspace graph.',
|
|
466
|
+
location: start.location,
|
|
467
|
+
details: { maximum: EVENT_GRAPH_MAX_HELPER_PROJECTIONS },
|
|
468
|
+
},
|
|
469
|
+
],
|
|
470
|
+
subjectIds: [start.from, start.to],
|
|
471
|
+
});
|
|
472
|
+
return stableUnique(projected);
|
|
473
|
+
}
|
|
454
474
|
projected.push({
|
|
455
475
|
id: `edge:helper-projection:${hashCanonical({
|
|
456
476
|
from: start.from,
|
|
@@ -480,9 +500,6 @@ function helperProjections(graph, budget) {
|
|
|
480
500
|
terminalDispatchLocation: locationKey(final.location),
|
|
481
501
|
},
|
|
482
502
|
});
|
|
483
|
-
if (projected.length > EVENT_GRAPH_MAX_HELPER_PROJECTIONS) {
|
|
484
|
-
throw new ServiceError('EVENT_HELPER_PROJECTION_LIMIT', 'Event-chain helper expansion exceeds the fixed projection ceiling', { maximum: EVENT_GRAPH_MAX_HELPER_PROJECTIONS });
|
|
485
|
-
}
|
|
486
503
|
}
|
|
487
504
|
}
|
|
488
505
|
}
|
|
@@ -1523,7 +1540,8 @@ export function buildEventGraph(snapshot, options = {}) {
|
|
|
1523
1540
|
const fragments = analyzeFragments(snapshot, files, catalog, options, budget);
|
|
1524
1541
|
const graph = mergeFragments(fragments);
|
|
1525
1542
|
const partial = partialInventoryEvidence(snapshot);
|
|
1526
|
-
|
|
1543
|
+
for (const unresolved of partial.unresolved)
|
|
1544
|
+
graph.unresolved.push(unresolved);
|
|
1527
1545
|
assertGraphLimit(graph.nodes.length, EVENT_GRAPH_MAX_NODES, 'EVENT_NODE_LIMIT', 'node count');
|
|
1528
1546
|
assertGraphLimit(graph.edges.length, EVENT_GRAPH_MAX_EDGES, 'EVENT_EDGE_LIMIT', 'edge count');
|
|
1529
1547
|
assertGraphLimit(graph.stateAccesses.length, EVENT_GRAPH_MAX_STATE_ACCESSES, 'EVENT_STATE_ACCESS_LIMIT', 'state-access count');
|
|
@@ -1531,8 +1549,12 @@ export function buildEventGraph(snapshot, options = {}) {
|
|
|
1531
1549
|
normalizePublicEventNodeIds(graph);
|
|
1532
1550
|
normalizeCrossFileEdges(graph);
|
|
1533
1551
|
ensureReferencedNodes(graph);
|
|
1534
|
-
|
|
1535
|
-
|
|
1552
|
+
if (options.projectHelpers !== false) {
|
|
1553
|
+
for (const edge of helperProjections(graph, budget))
|
|
1554
|
+
graph.edges.push(edge);
|
|
1555
|
+
for (const access of projectHelperStateAccesses(graph, budget))
|
|
1556
|
+
graph.stateAccesses.push(access);
|
|
1557
|
+
}
|
|
1536
1558
|
addTerminalNodes(graph);
|
|
1537
1559
|
graph.nodes = sortNodes(graph.nodes);
|
|
1538
1560
|
graph.edges = stableUnique(graph.edges);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ServiceError } from '../core/result.js';
|
|
2
2
|
export const EVENT_GRAPH_MAX_NODES = 100_000;
|
|
3
|
-
export const EVENT_GRAPH_MAX_EDGES =
|
|
3
|
+
export const EVENT_GRAPH_MAX_EDGES = 500_000;
|
|
4
4
|
export const EVENT_GRAPH_MAX_STATE_ACCESSES = 250_000;
|
|
5
5
|
export const EVENT_GRAPH_MAX_STATE_LINKS = 250_000;
|
|
6
6
|
export const EVENT_GRAPH_MAX_STATE_LINK_CANDIDATES = 500_000;
|