hoi4-agent-tools 3.5.1 → 3.7.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 +18 -0
- package/README.md +137 -116
- package/dist/hoi4_agent_tools/core/engine.js +7 -7
- package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
- package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
- package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +27 -11
- package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
- package/dist/hoi4_agent_tools/core/scanner.js +133 -47
- package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
- package/dist/hoi4_agent_tools/gui/layout.js +237 -11
- package/dist/hoi4_agent_tools/gui/renderer.js +36 -3
- package/dist/hoi4_agent_tools/gui/scenario.js +18 -0
- package/dist/hoi4_agent_tools/gui/studio.js +73 -21
- package/dist/hoi4_agent_tools/map/service.js +2 -2
- package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
- package/dist/hoi4_agent_tools/reference/lines.js +12 -0
- package/dist/hoi4_agent_tools/reference/operations.js +45 -2
- package/dist/hoi4_agent_tools/reference/script-validation.js +277 -0
- package/dist/hoi4_agent_tools/reference/service.js +139 -67
- package/dist/hoi4_agent_tools/reference/source-lookup.js +91 -15
- package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
- package/dist/hoi4_agent_tools/schemas/reference.js +55 -0
- package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +34 -18
- package/docs/examples.md +75 -0
- package/docs/gui.md +23 -0
- package/docs/reference.md +43 -2
- package/docs/research/reference-benchmark.md +41 -0
- package/docs/setup.md +50 -26
- package/docs/tools.md +65 -0
- package/npm-shrinkwrap.json +7088 -0
- package/package.json +10 -7
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.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 +139 -89
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- 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/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +9 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +92 -0
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +68 -2
- package/server.json +3 -3
|
@@ -343,14 +343,20 @@ function localisationIconSpriteCandidates(token) {
|
|
|
343
343
|
const normalized = token.startsWith('GFX_') ? token : `GFX_${token}`;
|
|
344
344
|
return [...new Set([normalized, `${normalized}_texticon`, `${normalized}_text_icon`])];
|
|
345
345
|
}
|
|
346
|
-
export function referencedAssetPatternsForWindow(graph, windowName, additionalSpriteNames = [], languages = ['l_english'], additionalTemplateNames = []) {
|
|
346
|
+
export function referencedAssetPatternsForWindow(graph, windowName, additionalSpriteNames = [], languages = ['l_english'], additionalTemplateNames = [], includeImmediateTooltip = false) {
|
|
347
347
|
const spriteNames = new Set(additionalSpriteNames);
|
|
348
348
|
const fontNames = new Set();
|
|
349
349
|
const textValues = new Set();
|
|
350
|
+
if (includeImmediateTooltip) {
|
|
351
|
+
spriteNames.add('ToolTip');
|
|
352
|
+
fontNames.add('cg_16b');
|
|
353
|
+
}
|
|
350
354
|
for (const element of selectedElements(graph, windowName, additionalTemplateNames)) {
|
|
351
355
|
collectNamedAttributes(element.attributes, new Set(['spriteType', 'quadTextureSprite']), spriteNames);
|
|
352
356
|
collectNamedAttributes(element.attributes, new Set(['font', 'buttonFont']), fontNames);
|
|
353
357
|
collectNamedAttributes(element.attributes, new Set(['text', 'buttonText', 'context_aware_text']), textValues);
|
|
358
|
+
if (includeImmediateTooltip)
|
|
359
|
+
collectNamedAttributes(element.attributes, new Set(['pdx_tooltip']), textValues);
|
|
354
360
|
}
|
|
355
361
|
for (const localisation of graph.localisation) {
|
|
356
362
|
if (textValues.has(localisation.key))
|
|
@@ -619,7 +625,7 @@ function assertUniqueScenarioIds(scenarios) {
|
|
|
619
625
|
seen.add(scenario.id);
|
|
620
626
|
}
|
|
621
627
|
}
|
|
622
|
-
function scenarioMatrixEvidence(graph, scenes) {
|
|
628
|
+
export function scenarioMatrixEvidence(graph, scenes) {
|
|
623
629
|
const primary = scenes[0];
|
|
624
630
|
if (primary === undefined)
|
|
625
631
|
return { offline: true, scenarios: [], changes: [] };
|
|
@@ -664,16 +670,23 @@ function scenarioMatrixEvidence(graph, scenes) {
|
|
|
664
670
|
if (matches.length === 0)
|
|
665
671
|
return [];
|
|
666
672
|
const shown = matches
|
|
667
|
-
.filter(({ elements }) => elements.
|
|
673
|
+
.filter(({ elements }) => elements.every(({ visibilityStatus }) => visibilityStatus !== 'unresolved') &&
|
|
674
|
+
elements.some(({ visibilityStatus }) => visibilityStatus === 'shown'))
|
|
668
675
|
.map(({ scene }) => scene.scenario.id);
|
|
669
676
|
const hidden = matches
|
|
670
|
-
.filter(({ elements }) => elements.every(({
|
|
677
|
+
.filter(({ elements }) => elements.every(({ visibilityStatus }) => visibilityStatus === 'hidden'))
|
|
678
|
+
.map(({ scene }) => scene.scenario.id);
|
|
679
|
+
const unresolvedVisibility = matches
|
|
680
|
+
.filter(({ elements }) => elements.some(({ visibilityStatus }) => visibilityStatus === 'unresolved'))
|
|
671
681
|
.map(({ scene }) => scene.scenario.id);
|
|
672
682
|
const enabled = matches
|
|
673
|
-
.filter(({ elements }) => elements.some(({
|
|
683
|
+
.filter(({ elements }) => elements.some(({ enablement }) => enablement === 'enabled'))
|
|
674
684
|
.map(({ scene }) => scene.scenario.id);
|
|
675
685
|
const disabled = matches
|
|
676
|
-
.filter(({ elements }) => elements.every(({
|
|
686
|
+
.filter(({ elements }) => elements.every(({ enablement }) => enablement === 'disabled'))
|
|
687
|
+
.map(({ scene }) => scene.scenario.id);
|
|
688
|
+
const unresolved = matches
|
|
689
|
+
.filter(({ elements }) => elements.some(({ enablement }) => enablement === 'unresolved'))
|
|
677
690
|
.map(({ scene }) => scene.scenario.id);
|
|
678
691
|
return [
|
|
679
692
|
{
|
|
@@ -682,10 +695,24 @@ function scenarioMatrixEvidence(graph, scenes) {
|
|
|
682
695
|
...new Set(matches.flatMap(({ elements }) => elements.map(({ sourcePath }) => sourcePath))),
|
|
683
696
|
].sort(compareCodeUnits),
|
|
684
697
|
...(visibilityNames.has(name)
|
|
685
|
-
? {
|
|
698
|
+
? {
|
|
699
|
+
visibility: {
|
|
700
|
+
shown,
|
|
701
|
+
hidden,
|
|
702
|
+
unresolved: unresolvedVisibility,
|
|
703
|
+
covered: shown.length > 0 && hidden.length > 0,
|
|
704
|
+
},
|
|
705
|
+
}
|
|
686
706
|
: {}),
|
|
687
707
|
...(enabledNames.has(name)
|
|
688
|
-
? {
|
|
708
|
+
? {
|
|
709
|
+
enabled: {
|
|
710
|
+
enabled,
|
|
711
|
+
disabled,
|
|
712
|
+
unresolved,
|
|
713
|
+
covered: enabled.length > 0 && disabled.length > 0,
|
|
714
|
+
},
|
|
715
|
+
}
|
|
689
716
|
: {}),
|
|
690
717
|
},
|
|
691
718
|
];
|
|
@@ -736,13 +763,34 @@ function scenarioMatrixEvidence(graph, scenes) {
|
|
|
736
763
|
})),
|
|
737
764
|
visibleElements: scene.elements
|
|
738
765
|
.filter(({ visible }) => visible)
|
|
739
|
-
.map(({ id, name }) => ({ id, name })),
|
|
766
|
+
.map(({ id, name, visibilityStatus }) => ({ id, name, visibilityStatus })),
|
|
740
767
|
hiddenElements: scene.elements
|
|
741
768
|
.filter(({ visible }) => !visible)
|
|
742
|
-
.map(({ id, name, visibilityReason }) => ({
|
|
769
|
+
.map(({ id, name, visibilityReason, visibilityStatus }) => ({
|
|
770
|
+
id,
|
|
771
|
+
name,
|
|
772
|
+
reason: visibilityReason,
|
|
773
|
+
visibilityStatus,
|
|
774
|
+
})),
|
|
775
|
+
unresolvedVisibility: scene.elements
|
|
776
|
+
.filter(({ visibilityStatus }) => visibilityStatus === 'unresolved')
|
|
777
|
+
.map(({ id, name, visible, visibilityStatusReason }) => ({
|
|
778
|
+
id,
|
|
779
|
+
name,
|
|
780
|
+
previewVisible: visible,
|
|
781
|
+
reason: visibilityStatusReason,
|
|
782
|
+
})),
|
|
743
783
|
disabledElements: scene.elements
|
|
744
|
-
.filter(({ disabledReason }) => disabledReason !== undefined)
|
|
784
|
+
.filter(({ disabledReason, enablement }) => disabledReason !== undefined && enablement === 'disabled')
|
|
745
785
|
.map(({ id, name, disabledReason }) => ({ id, name, reason: disabledReason })),
|
|
786
|
+
unresolvedClickRegions: scene.elements
|
|
787
|
+
.filter(({ visible, enablement }) => visible && enablement === 'unresolved')
|
|
788
|
+
.map(({ id, name, rect, enablementReason }) => ({
|
|
789
|
+
id,
|
|
790
|
+
name,
|
|
791
|
+
rect,
|
|
792
|
+
reason: enablementReason,
|
|
793
|
+
})),
|
|
746
794
|
})),
|
|
747
795
|
changes: scenes.slice(1).map((scene) => {
|
|
748
796
|
const currentByKey = new Map(scene.elements.map((element) => [elementKey(element), element]));
|
|
@@ -758,13 +806,13 @@ function scenarioMatrixEvidence(graph, scenes) {
|
|
|
758
806
|
for (const key of keys) {
|
|
759
807
|
const before = primaryByKey.get(key);
|
|
760
808
|
const after = currentByKey.get(key);
|
|
761
|
-
if (before?.
|
|
809
|
+
if (before?.visibilityStatus === 'hidden' && after?.visibilityStatus === 'shown')
|
|
762
810
|
shown.push(after.name);
|
|
763
|
-
if (before?.
|
|
811
|
+
if (before?.visibilityStatus === 'shown' && after?.visibilityStatus === 'hidden')
|
|
764
812
|
hidden.push(before.name);
|
|
765
|
-
if (before?.
|
|
813
|
+
if (before?.enablement === 'enabled' && after?.enablement === 'disabled')
|
|
766
814
|
disabled.push(after.name);
|
|
767
|
-
if (before?.
|
|
815
|
+
if (before?.enablement === 'disabled' && after?.enablement === 'enabled')
|
|
768
816
|
enabled.push(after.name);
|
|
769
817
|
if (before === undefined || after === undefined)
|
|
770
818
|
continue;
|
|
@@ -924,7 +972,7 @@ export class ScriptedGuiStudio {
|
|
|
924
972
|
const files = mergeScannedFiles(snapshot.files, localisationFiles);
|
|
925
973
|
return this.graphForFiles(files, `workspace:${workspaceId}`);
|
|
926
974
|
}
|
|
927
|
-
async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english'], additionalSpriteNames = [], additionalTemplateNames = [], additionalAssetPatterns = [], additionalLocalisationKeys = []) {
|
|
975
|
+
async scanWindow(workspaceId, windowName, principal, signal, languages = ['l_english'], additionalSpriteNames = [], additionalTemplateNames = [], additionalAssetPatterns = [], additionalLocalisationKeys = [], includeImmediateTooltip = false) {
|
|
928
976
|
const workspace = this.resolver.get(workspaceId, principal);
|
|
929
977
|
const layoutPatterns = guiLayoutPatterns(workspace);
|
|
930
978
|
const overlayLayouts = await this.engine.scan(workspaceId, {
|
|
@@ -955,7 +1003,7 @@ export class ScriptedGuiStudio {
|
|
|
955
1003
|
const definitions = this.graphForFiles(definitionFiles, `definitions:${workspaceId}`, additionalLocalisationKeys);
|
|
956
1004
|
assertTargetWindowAvailable(definitions.graph, windowName);
|
|
957
1005
|
const referencedPatterns = [
|
|
958
|
-
...referencedAssetPatternsForWindow(definitions.graph, windowName, additionalSpriteNames, languages, additionalTemplateNames),
|
|
1006
|
+
...referencedAssetPatternsForWindow(definitions.graph, windowName, additionalSpriteNames, languages, additionalTemplateNames, includeImmediateTooltip),
|
|
959
1007
|
...additionalAssetPatterns,
|
|
960
1008
|
];
|
|
961
1009
|
const exactReferenced = referencedPatterns.length === 0
|
|
@@ -1000,7 +1048,7 @@ export class ScriptedGuiStudio {
|
|
|
1000
1048
|
const generatedOptions = input.generatedScenarios === undefined
|
|
1001
1049
|
? undefined
|
|
1002
1050
|
: parseGeneratedScenarioOptions(input.generatedScenarios);
|
|
1003
|
-
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples), scenarioTemplateNames([placeholderScenario, ...explicitRelatedScenarios]), scenarioCountryFlagPatterns([placeholderScenario, ...explicitRelatedScenarios]), scenarioLocalisationKeys([placeholderScenario, ...explicitRelatedScenarios]));
|
|
1051
|
+
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [placeholderScenario.language, ...explicitRelatedScenarios.map(({ language }) => language)], scenarioSpriteNames([placeholderScenario, ...explicitRelatedScenarios], generatedOptions?.textSamples), scenarioTemplateNames([placeholderScenario, ...explicitRelatedScenarios]), scenarioCountryFlagPatterns([placeholderScenario, ...explicitRelatedScenarios]), scenarioLocalisationKeys([placeholderScenario, ...explicitRelatedScenarios]), [placeholderScenario, ...explicitRelatedScenarios].some(({ tooltip }) => tooltip !== undefined));
|
|
1004
1052
|
const generatedScenarios = generatedOptions === undefined
|
|
1005
1053
|
? []
|
|
1006
1054
|
: generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, ClausewitzEvaluationDefinitions.build(scanned));
|
|
@@ -1030,7 +1078,7 @@ export class ScriptedGuiStudio {
|
|
|
1030
1078
|
const budget = new RenderBudget();
|
|
1031
1079
|
const beforeScenario = parsePreviewScenario(input.before);
|
|
1032
1080
|
const afterScenario = parsePreviewScenario(input.after);
|
|
1033
|
-
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [beforeScenario.language, afterScenario.language], scenarioSpriteNames([beforeScenario, afterScenario]), scenarioTemplateNames([beforeScenario, afterScenario]), scenarioCountryFlagPatterns([beforeScenario, afterScenario]), scenarioLocalisationKeys([beforeScenario, afterScenario]));
|
|
1081
|
+
const scanned = await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [beforeScenario.language, afterScenario.language], scenarioSpriteNames([beforeScenario, afterScenario]), scenarioTemplateNames([beforeScenario, afterScenario]), scenarioCountryFlagPatterns([beforeScenario, afterScenario]), scenarioLocalisationKeys([beforeScenario, afterScenario]), [beforeScenario, afterScenario].some(({ tooltip }) => tooltip !== undefined));
|
|
1034
1082
|
const beforeCatalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, beforeScenario.language);
|
|
1035
1083
|
const afterCatalog = new GuiAssetCatalog(scanned.graph, scanned.files, budget, afterScenario.language);
|
|
1036
1084
|
const before = await buildGuiScene(scanned.graph, scanned.files, input.windowName, beforeScenario, beforeCatalog);
|
|
@@ -1091,7 +1139,11 @@ export class ScriptedGuiStudio {
|
|
|
1091
1139
|
placeholderScenario,
|
|
1092
1140
|
...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
|
|
1093
1141
|
...explicitRelatedScenarios,
|
|
1094
|
-
])
|
|
1142
|
+
]), [
|
|
1143
|
+
placeholderScenario,
|
|
1144
|
+
...(requestedBaselineScenario === undefined ? [] : [requestedBaselineScenario]),
|
|
1145
|
+
...explicitRelatedScenarios,
|
|
1146
|
+
].some(({ tooltip }) => tooltip !== undefined));
|
|
1095
1147
|
const generatedScenarios = generatedOptions === undefined
|
|
1096
1148
|
? []
|
|
1097
1149
|
: generateGuiPreviewScenarios(scanned.graph, input.windowName, placeholderScenario, generatedOptions, ClausewitzEvaluationDefinitions.build(scanned));
|
|
@@ -1438,7 +1490,7 @@ export class ScriptedGuiStudio {
|
|
|
1438
1490
|
const previewScenario = input.scenario === undefined ? undefined : parsePreviewScenario(input.scenario);
|
|
1439
1491
|
const initiallyScanned = input.windowName === undefined
|
|
1440
1492
|
? await this.scan(input.workspaceId, input.principal, input.signal)
|
|
1441
|
-
: await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [previewScenario.language], scenarioSpriteNames([previewScenario]), scenarioTemplateNames([previewScenario]), scenarioCountryFlagPatterns([previewScenario]), scenarioLocalisationKeys([previewScenario]));
|
|
1493
|
+
: await this.scanWindow(input.workspaceId, input.windowName, input.principal, input.signal, [previewScenario.language], scenarioSpriteNames([previewScenario]), scenarioTemplateNames([previewScenario]), scenarioCountryFlagPatterns([previewScenario]), scenarioLocalisationKeys([previewScenario]), previewScenario.tooltip !== undefined);
|
|
1442
1494
|
const exactTargets = await this.scanner.scan(workspace, {
|
|
1443
1495
|
patterns: prepared.map(({ relativePath }) => relativePath),
|
|
1444
1496
|
...(input.signal === undefined ? {} : { signal: input.signal }),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import
|
|
2
|
+
import { escapeFileGlob } from '../core/file-glob.js';
|
|
3
3
|
import { ArtifactStore, boundedSourceHashEvidence, publicArtifactLink, } from '../core/artifacts.js';
|
|
4
4
|
import { canonicalJson, compareCodeUnits, hashCanonical, sha256Bytes } from '../core/canonical.js';
|
|
5
5
|
import { sortDiagnostics } from '../core/diagnostics.js';
|
|
@@ -426,7 +426,7 @@ function provinceBitmapPatterns(defaultMaps, mapRoots) {
|
|
|
426
426
|
const selectedPath = path.posix.normalize(path.posix.join(normalizedRoot, normalizedName));
|
|
427
427
|
if (selectedPath !== normalizedRoot && !selectedPath.startsWith(`${normalizedRoot}/`))
|
|
428
428
|
continue;
|
|
429
|
-
patterns.add(
|
|
429
|
+
patterns.add(escapeFileGlob(selectedPath));
|
|
430
430
|
}
|
|
431
431
|
}
|
|
432
432
|
return [...patterns].sort((left, right) => compareCodeUnits(left, right));
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Shared agent-facing guidance for both MCP protocol generations. */
|
|
2
|
-
export const SERVER_INSTRUCTIONS = '
|
|
2
|
+
export const SERVER_INSTRUCTIONS = 'Start with the matching inspect tool; use hoi4.probability_inspect for weights and timing. Find local documentation with hoi4.reference_context, hoi4.reference_search and hoi4.reference_read, and source definitions with hoi4.source_lookup. Inspection and rendering are read-only; rewrite tools edit mod source. Results describe offline source and scenario models, not observed game behavior. Unresolved states are not confirmed coverage. Read linked artifacts when summaries omit detail. Long operations support negotiated MCP tasks and hoi4.job_inspect/hoi4.job_cancel; background rewrites need a stable requestKey.';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { errorResult, strictOperationResultSchema } from '../../core/operation-result.js';
|
|
2
2
|
import { ReferenceToolService } from '../../reference/operations.js';
|
|
3
3
|
import { referenceContextRequestSchema, referenceReadRequestSchema, referenceSearchRequestSchema, sourceLookupRequestSchema, referenceContextDataSchema, referenceReadDataSchema, referenceSearchDataSchema, sourceLookupDataSchema, } from '../../schemas/reference.js';
|
|
4
|
+
import { scriptValidateDataSchema, scriptValidateRequestSchema, } from '../../schemas/script-validation.js';
|
|
4
5
|
const readOnly = {
|
|
5
6
|
readOnlyHint: true,
|
|
6
7
|
destructiveHint: false,
|
|
@@ -35,11 +36,19 @@ export const referenceTools = [
|
|
|
35
36
|
{
|
|
36
37
|
name: 'hoi4.source_lookup',
|
|
37
38
|
title: 'Look up HOI4 source symbol',
|
|
38
|
-
description: 'Find exact Clausewitz definitions
|
|
39
|
+
description: 'Find exact Clausewitz definitions and usages; navigate nested keyPath blocks or list bounded child structure with view=structure.',
|
|
39
40
|
inputSchema: sourceLookupRequestSchema,
|
|
40
41
|
outputSchema: strictOperationResultSchema(sourceLookupDataSchema),
|
|
41
42
|
annotations: readOnly,
|
|
42
43
|
},
|
|
44
|
+
{
|
|
45
|
+
name: 'hoi4.script_validate',
|
|
46
|
+
title: 'Check HOI4 script commands and scopes',
|
|
47
|
+
description: 'Check effect or trigger snippet syntax, native command kinds and declared scopes against cited installed documentation. Unknown helpers and native argument blocks remain explicit.',
|
|
48
|
+
inputSchema: scriptValidateRequestSchema,
|
|
49
|
+
outputSchema: strictOperationResultSchema(scriptValidateDataSchema),
|
|
50
|
+
annotations: readOnly,
|
|
51
|
+
},
|
|
43
52
|
];
|
|
44
53
|
export function registerReferenceTools(server, engine, context) {
|
|
45
54
|
const service = new ReferenceToolService(engine);
|
|
@@ -50,3 +50,15 @@ export function readBoundedLines(lines, firstLine, startLine, startColumn, maxLi
|
|
|
50
50
|
nextColumn: lineIndex < lines.length ? columnIndex + 1 : null,
|
|
51
51
|
};
|
|
52
52
|
}
|
|
53
|
+
/** Preserve continuation while bounding JSON escaping as well as raw UTF-8 text. */
|
|
54
|
+
export function readBoundedJsonLines(lines, firstLine, startLine, startColumn, maxLines, maxBytes, maxJsonBytes) {
|
|
55
|
+
let budget = maxBytes;
|
|
56
|
+
for (;;) {
|
|
57
|
+
const result = readBoundedLines(lines, firstLine, startLine, startColumn, maxLines, budget);
|
|
58
|
+
if (Buffer.byteLength(JSON.stringify(result.text), 'utf8') <= maxJsonBytes)
|
|
59
|
+
return result;
|
|
60
|
+
budget = Math.max(4, Math.floor(budget / 2));
|
|
61
|
+
if (budget === 4 && maxJsonBytes < 26)
|
|
62
|
+
throw new RangeError('JSON text budget cannot retain a source character');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -4,11 +4,14 @@ import { toolResult } from '../core/operation-result.js';
|
|
|
4
4
|
import { referenceContextRequestSchema, referenceReadRequestSchema, referenceSearchRequestSchema, sourceLookupRequestSchema, } from '../schemas/reference.js';
|
|
5
5
|
import { ReferenceService } from './service.js';
|
|
6
6
|
import { sourceLookup } from './source-lookup.js';
|
|
7
|
+
import { scriptValidateDataSchema, scriptValidateRequestSchema, } from '../schemas/script-validation.js';
|
|
8
|
+
import { validateScript } from './script-validation.js';
|
|
7
9
|
const schemas = {
|
|
8
10
|
'hoi4.reference_search': referenceSearchRequestSchema,
|
|
9
11
|
'hoi4.reference_read': referenceReadRequestSchema,
|
|
10
12
|
'hoi4.reference_context': referenceContextRequestSchema,
|
|
11
13
|
'hoi4.source_lookup': sourceLookupRequestSchema,
|
|
14
|
+
'hoi4.script_validate': scriptValidateRequestSchema,
|
|
12
15
|
};
|
|
13
16
|
export function isReferenceTool(name) {
|
|
14
17
|
return Object.hasOwn(schemas, name);
|
|
@@ -35,7 +38,47 @@ export class ReferenceToolService {
|
|
|
35
38
|
? await this.references.read(workspace, referenceReadRequestSchema.parse(parsed), signal)
|
|
36
39
|
: name === 'hoi4.reference_context'
|
|
37
40
|
? await this.references.context(workspace, referenceContextRequestSchema.parse(parsed), signal)
|
|
38
|
-
:
|
|
39
|
-
|
|
41
|
+
: name === 'hoi4.script_validate'
|
|
42
|
+
? await validateScript(this.references, workspace, scriptValidateRequestSchema.parse(parsed), signal)
|
|
43
|
+
: await sourceLookup(this.engine, workspaceId, sourceLookupRequestSchema.parse(parsed), context.principal, signal);
|
|
44
|
+
const result = emptyServiceResult(workspaceId, data);
|
|
45
|
+
if (name === 'hoi4.script_validate') {
|
|
46
|
+
const checked = scriptValidateDataSchema.parse(data);
|
|
47
|
+
result.code = checked.valid === null ? 'SCRIPT_CHECK_PARTIAL' : 'SCRIPT_CHECKED';
|
|
48
|
+
result.validation = {
|
|
49
|
+
passed: checked.valid === true,
|
|
50
|
+
checks: [
|
|
51
|
+
{
|
|
52
|
+
id: 'script-command-checks',
|
|
53
|
+
passed: checked.valid === true,
|
|
54
|
+
message: checked.valid === true
|
|
55
|
+
? 'Syntax, native command kinds and declared scopes match the selected documentation'
|
|
56
|
+
: checked.valid === false
|
|
57
|
+
? 'Script checks found an error'
|
|
58
|
+
: 'Script checks have unresolved coverage',
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
result.diagnostics = checked.findings
|
|
63
|
+
.filter(({ status }) => status !== 'supported')
|
|
64
|
+
.slice(0, 20)
|
|
65
|
+
.map((finding) => ({
|
|
66
|
+
code: finding.code,
|
|
67
|
+
severity: finding.status === 'error' ? 'error' : 'warning',
|
|
68
|
+
category: 'validation',
|
|
69
|
+
message: finding.message,
|
|
70
|
+
details: {
|
|
71
|
+
command: finding.command,
|
|
72
|
+
kind: finding.kind,
|
|
73
|
+
scope: finding.scope,
|
|
74
|
+
line: finding.line,
|
|
75
|
+
column: finding.column,
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
while (Buffer.byteLength(JSON.stringify(result.diagnostics), 'utf8') > 4096 &&
|
|
79
|
+
result.diagnostics.length > 1)
|
|
80
|
+
result.diagnostics.pop();
|
|
81
|
+
}
|
|
82
|
+
return toolResult(result);
|
|
40
83
|
}
|
|
41
84
|
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { hashCanonical } from '../core/canonical.js';
|
|
3
|
+
import { ServiceError } from '../core/result.js';
|
|
4
|
+
import { locationFor, parseClausewitz, } from '../core/source/index.js';
|
|
5
|
+
const MAX_COMMANDS = 256;
|
|
6
|
+
const booleanCommands = new Set(['AND', 'OR', 'NOT', 'XOR', 'NAND', 'NOR']);
|
|
7
|
+
const containers = new Set([
|
|
8
|
+
'and',
|
|
9
|
+
'or',
|
|
10
|
+
'not',
|
|
11
|
+
'xor',
|
|
12
|
+
'nand',
|
|
13
|
+
'nor',
|
|
14
|
+
'hidden_trigger',
|
|
15
|
+
'hidden_effect',
|
|
16
|
+
]);
|
|
17
|
+
const conditionals = new Set(['if', 'else_if', 'else']);
|
|
18
|
+
const iteratorParameters = new Set([
|
|
19
|
+
'tooltip',
|
|
20
|
+
'random_select_amount',
|
|
21
|
+
'display_individual_scopes',
|
|
22
|
+
'count',
|
|
23
|
+
]);
|
|
24
|
+
function editDistance(a, b) {
|
|
25
|
+
let row = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
26
|
+
for (let i = 0; i < a.length; i++) {
|
|
27
|
+
const next = [i + 1];
|
|
28
|
+
for (let j = 0; j < b.length; j++)
|
|
29
|
+
next.push(Math.min(next[j] + 1, row[j + 1] + 1, row[j] + Number(a[i] !== b[j])));
|
|
30
|
+
if (Math.min(...next) > 3)
|
|
31
|
+
return 4;
|
|
32
|
+
row = next;
|
|
33
|
+
}
|
|
34
|
+
return row[b.length];
|
|
35
|
+
}
|
|
36
|
+
/** Check only rules explicitly available from one selected documentation authority. */
|
|
37
|
+
export async function validateScript(references, workspace, input, signal) {
|
|
38
|
+
if (Buffer.byteLength(input.source, 'utf8') > 64_000)
|
|
39
|
+
throw new ServiceError('SCRIPT_SOURCE_TOO_LARGE', 'Snippet exceeds 64000 UTF-8 bytes');
|
|
40
|
+
const inventory = await references.inventory(workspace, signal, [input.documentation]);
|
|
41
|
+
const catalogs = { effect: new Map(), trigger: new Map() };
|
|
42
|
+
const sources = new Map();
|
|
43
|
+
for (const section of inventory.sections) {
|
|
44
|
+
const filename = path.basename(section.path).toLowerCase();
|
|
45
|
+
const match = /^(effects?|triggers?)_(?:documentation|docs)\.(?:md|log)$/u.exec(filename);
|
|
46
|
+
if (match === null)
|
|
47
|
+
continue;
|
|
48
|
+
sources.set(section.path, section.revision);
|
|
49
|
+
if (!/^[a-z_][a-z0-9_]{0,255}$/u.test(section.heading))
|
|
50
|
+
continue;
|
|
51
|
+
const scopeLine = /^\s*(?:\*\s*)?Supported Scopes:\s*(.+?)\s*$/imu.exec(section.lines.join('\n'));
|
|
52
|
+
if (scopeLine === null || !/^[a-z_,\s]+$/iu.test(scopeLine[1]))
|
|
53
|
+
continue;
|
|
54
|
+
const scopes = [
|
|
55
|
+
...new Set(scopeLine[1]
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.split(/[,\s]+/u)
|
|
58
|
+
.filter(Boolean)),
|
|
59
|
+
].sort();
|
|
60
|
+
const kind = match[1].startsWith('effect') ? 'effect' : 'trigger';
|
|
61
|
+
const previous = catalogs[kind].get(section.heading);
|
|
62
|
+
const { lines: _lines, ...reference } = section;
|
|
63
|
+
if (previous !== undefined) {
|
|
64
|
+
previous.conflict ||= previous.scopes.join(',') !== scopes.join(',');
|
|
65
|
+
}
|
|
66
|
+
else
|
|
67
|
+
catalogs[kind].set(section.heading, { reference, scopes, conflict: false });
|
|
68
|
+
}
|
|
69
|
+
const document = parseClausewitz(Buffer.from(input.source, 'utf8'), 'snippet');
|
|
70
|
+
const findings = [];
|
|
71
|
+
let visited = 0;
|
|
72
|
+
let checkedCommands = 0;
|
|
73
|
+
let argumentBlocksUnchecked = 0;
|
|
74
|
+
let suggestionBudget = 4000;
|
|
75
|
+
const bindings = new Map();
|
|
76
|
+
for (const binding of input.bindings) {
|
|
77
|
+
if (bindings.has(binding.name))
|
|
78
|
+
throw new ServiceError('SCRIPT_BINDING_DUPLICATE', 'Scope bindings must have unique names');
|
|
79
|
+
if (binding.name === 'ROOT' || binding.name === 'THIS' || binding.name === 'PREV')
|
|
80
|
+
throw new ServiceError('SCRIPT_BINDING_RESERVED', 'ROOT, THIS and PREV follow the supplied scope and traversal stack');
|
|
81
|
+
bindings.set(binding.name, binding.scope);
|
|
82
|
+
}
|
|
83
|
+
const report = (entry, kind, scope, details) => {
|
|
84
|
+
const position = locationFor('snippet', document.lineIndex, entry.start, entry.end).start;
|
|
85
|
+
findings.push({
|
|
86
|
+
...details,
|
|
87
|
+
command: entry.key.value.slice(0, 256),
|
|
88
|
+
kind,
|
|
89
|
+
scope,
|
|
90
|
+
line: position.line,
|
|
91
|
+
column: position.column,
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
for (const diagnostic of document.diagnostics)
|
|
95
|
+
findings.push({
|
|
96
|
+
code: diagnostic.code,
|
|
97
|
+
status: 'error',
|
|
98
|
+
command: '',
|
|
99
|
+
kind: input.kind,
|
|
100
|
+
scope: input.scope,
|
|
101
|
+
line: diagnostic.location?.start.line ?? 1,
|
|
102
|
+
column: diagnostic.location?.start.column ?? 1,
|
|
103
|
+
message: diagnostic.message.slice(0, 500),
|
|
104
|
+
suggestions: [],
|
|
105
|
+
});
|
|
106
|
+
const walk = (block, kind, scope, previous) => {
|
|
107
|
+
for (const entry of block.entries) {
|
|
108
|
+
signal?.throwIfAborted();
|
|
109
|
+
if (++visited > MAX_COMMANDS) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (entry.type !== 'assignment') {
|
|
113
|
+
const position = locationFor('snippet', document.lineIndex, entry.start, entry.end).start;
|
|
114
|
+
findings.push({
|
|
115
|
+
code: 'SCRIPT_EXPECTED_COMMAND',
|
|
116
|
+
status: 'error',
|
|
117
|
+
command: '',
|
|
118
|
+
kind,
|
|
119
|
+
scope,
|
|
120
|
+
line: position.line,
|
|
121
|
+
column: position.column,
|
|
122
|
+
message: 'Expected a command assignment in this effect or trigger body',
|
|
123
|
+
suggestions: [],
|
|
124
|
+
});
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const raw = entry.key.value;
|
|
128
|
+
if (raw.startsWith('@'))
|
|
129
|
+
continue;
|
|
130
|
+
const name = booleanCommands.has(raw) ? raw.toLowerCase() : raw;
|
|
131
|
+
const explicitScope = raw === 'ROOT'
|
|
132
|
+
? input.scope
|
|
133
|
+
: raw === 'THIS'
|
|
134
|
+
? scope
|
|
135
|
+
: raw === 'PREV'
|
|
136
|
+
? previous
|
|
137
|
+
: bindings.get(raw);
|
|
138
|
+
const fixedScope = /^[A-Z][A-Z0-9]{2}$/u.test(raw) &&
|
|
139
|
+
!['ROOT', 'THIS', 'PREV', 'FROM', 'AND', 'NOT', 'XOR', 'NOR'].includes(raw)
|
|
140
|
+
? 'country'
|
|
141
|
+
: /^\d+$/u.test(raw)
|
|
142
|
+
? 'state'
|
|
143
|
+
: undefined;
|
|
144
|
+
if (entry.value.type === 'block' &&
|
|
145
|
+
(explicitScope !== undefined || fixedScope !== undefined)) {
|
|
146
|
+
walk(entry.value, kind, explicitScope ?? fixedScope, scope);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const signature = catalogs[kind].get(name);
|
|
150
|
+
if (signature === undefined) {
|
|
151
|
+
const other = catalogs[kind === 'effect' ? 'trigger' : 'effect'].get(name);
|
|
152
|
+
const suggestions = name.length > 128
|
|
153
|
+
? []
|
|
154
|
+
: [...catalogs[kind].keys()]
|
|
155
|
+
.filter((candidate) => Math.abs(candidate.length - name.length) <= 3)
|
|
156
|
+
.filter(() => suggestionBudget-- > 0)
|
|
157
|
+
.map((candidate) => ({ candidate, distance: editDistance(name, candidate) }))
|
|
158
|
+
.filter(({ distance }) => distance <= 3)
|
|
159
|
+
.sort((a, b) => a.distance - b.distance || a.candidate.localeCompare(b.candidate, 'en'))
|
|
160
|
+
.slice(0, 3)
|
|
161
|
+
.map(({ candidate }) => candidate);
|
|
162
|
+
report(entry, kind, scope, {
|
|
163
|
+
code: other === undefined ? 'SCRIPT_COMMAND_UNRESOLVED' : 'SCRIPT_COMMAND_WRONG_KIND',
|
|
164
|
+
status: other === undefined ? 'unresolved' : 'error',
|
|
165
|
+
message: other === undefined
|
|
166
|
+
? 'Not documented as a native command in the selected source; inspect scripted helpers or declare the scope binding'
|
|
167
|
+
: `Documented as a ${kind === 'effect' ? 'trigger' : 'effect'}, not a ${kind}`,
|
|
168
|
+
suggestions,
|
|
169
|
+
...(other === undefined ? {} : { reference: other.reference }),
|
|
170
|
+
});
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
checkedCommands++;
|
|
174
|
+
const wrongScope = !signature.scopes.includes('any') && !signature.scopes.includes(scope);
|
|
175
|
+
report(entry, kind, scope, {
|
|
176
|
+
code: signature.conflict
|
|
177
|
+
? 'SCRIPT_DOCUMENTATION_CONFLICT'
|
|
178
|
+
: wrongScope
|
|
179
|
+
? 'SCRIPT_COMMAND_WRONG_SCOPE'
|
|
180
|
+
: 'SCRIPT_COMMAND_SUPPORTED',
|
|
181
|
+
status: signature.conflict ? 'unresolved' : wrongScope ? 'error' : 'supported',
|
|
182
|
+
suggestions: [],
|
|
183
|
+
reference: signature.reference,
|
|
184
|
+
message: signature.conflict
|
|
185
|
+
? 'Conflicting supported scopes in the selected documentation'
|
|
186
|
+
: wrongScope
|
|
187
|
+
? `Supported scopes: ${signature.scopes.join(', ')}`
|
|
188
|
+
: 'Command kind and declared scope match the selected documentation',
|
|
189
|
+
});
|
|
190
|
+
const iterator = /^(?:every|random|any|all)_(?:(?:owned|controlled|neighbor)_)?(state|country)$/u.exec(name);
|
|
191
|
+
if (entry.value.type !== 'block') {
|
|
192
|
+
if (containers.has(name) || conditionals.has(name) || iterator !== null)
|
|
193
|
+
report(entry, kind, scope, {
|
|
194
|
+
code: 'SCRIPT_EXPECTED_BLOCK',
|
|
195
|
+
status: 'error',
|
|
196
|
+
message: 'This control command requires a block',
|
|
197
|
+
suggestions: [],
|
|
198
|
+
reference: signature.reference,
|
|
199
|
+
});
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (containers.has(name)) {
|
|
203
|
+
walk(entry.value, kind, scope, previous);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (conditionals.has(name) || iterator !== null) {
|
|
207
|
+
const childScope = iterator?.[1] ?? scope;
|
|
208
|
+
for (const child of entry.value.entries) {
|
|
209
|
+
if (child.type === 'assignment' && child.key.value === 'limit') {
|
|
210
|
+
if (child.value.type === 'block')
|
|
211
|
+
walk(child.value, 'trigger', childScope, iterator === null ? previous : scope);
|
|
212
|
+
else
|
|
213
|
+
report(child, 'trigger', childScope, {
|
|
214
|
+
code: 'SCRIPT_EXPECTED_BLOCK',
|
|
215
|
+
status: 'error',
|
|
216
|
+
message: 'limit requires a trigger block',
|
|
217
|
+
suggestions: [],
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
else if (iterator !== null &&
|
|
221
|
+
child.type === 'assignment' &&
|
|
222
|
+
iteratorParameters.has(child.key.value)) {
|
|
223
|
+
argumentBlocksUnchecked++;
|
|
224
|
+
}
|
|
225
|
+
else
|
|
226
|
+
walk({ type: 'block', entries: [child], start: child.start, end: child.end }, kind, childScope, iterator === null ? previous : scope);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
argumentBlocksUnchecked++;
|
|
231
|
+
report(entry, kind, scope, {
|
|
232
|
+
code: 'SCRIPT_ARGUMENT_BLOCK_UNCHECKED',
|
|
233
|
+
status: 'unresolved',
|
|
234
|
+
message: 'Native argument block is not validated from prose documentation; read the cited syntax before use',
|
|
235
|
+
suggestions: [],
|
|
236
|
+
reference: signature.reference,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
if (document.diagnostics.every(({ severity }) => severity !== 'error' && severity !== 'blocker'))
|
|
242
|
+
walk(document.root, input.kind, input.scope);
|
|
243
|
+
const unresolvedCount = findings.filter(({ status }) => status === 'unresolved').length;
|
|
244
|
+
const invalid = findings.some(({ status }) => status === 'error');
|
|
245
|
+
const truncated = visited > MAX_COMMANDS;
|
|
246
|
+
const incomplete = unresolvedCount > 0 || truncated || inventory.skipped > 0 || catalogs[input.kind].size === 0;
|
|
247
|
+
const order = { error: 0, unresolved: 1, supported: 2 };
|
|
248
|
+
findings.sort((a, b) => order[a.status] - order[b.status] ||
|
|
249
|
+
a.line - b.line ||
|
|
250
|
+
a.column - b.column ||
|
|
251
|
+
a.code.localeCompare(b.code, 'en'));
|
|
252
|
+
const result = {
|
|
253
|
+
valid: invalid ? false : incomplete ? null : true,
|
|
254
|
+
checksPerformed: ['syntax', 'command_kind', 'declared_scope'],
|
|
255
|
+
parametersChecked: false,
|
|
256
|
+
documentation: {
|
|
257
|
+
source: input.documentation,
|
|
258
|
+
revision: hashCanonical([...sources].sort()),
|
|
259
|
+
effectCount: catalogs.effect.size,
|
|
260
|
+
triggerCount: catalogs.trigger.size,
|
|
261
|
+
skippedSources: inventory.skipped,
|
|
262
|
+
},
|
|
263
|
+
findings: findings.slice(0, input.limit),
|
|
264
|
+
totalFindings: findings.length,
|
|
265
|
+
omittedFindings: Math.max(0, findings.length - input.limit),
|
|
266
|
+
checkedCommands,
|
|
267
|
+
unresolvedCount,
|
|
268
|
+
truncated,
|
|
269
|
+
argumentBlocksUnchecked,
|
|
270
|
+
};
|
|
271
|
+
while (Buffer.byteLength(JSON.stringify(result), 'utf8') > 16_000 && result.findings.length > 1)
|
|
272
|
+
result.findings.pop();
|
|
273
|
+
result.omittedFindings = result.totalFindings - result.findings.length;
|
|
274
|
+
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > 16_000)
|
|
275
|
+
throw new ServiceError('REFERENCE_RESPONSE_TOO_LARGE', 'Command documentation metadata exceeds the bounded reply');
|
|
276
|
+
return result;
|
|
277
|
+
}
|