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.
Files changed (44) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/README.md +6 -7
  3. package/dist/hoi4_agent_tools/core/artifacts.js +109 -4
  4. package/dist/hoi4_agent_tools/core/diagnostic-collector.js +28 -8
  5. package/dist/hoi4_agent_tools/core/diff.js +162 -4
  6. package/dist/hoi4_agent_tools/core/engine.js +2 -0
  7. package/dist/hoi4_agent_tools/core/index.js +17 -6
  8. package/dist/hoi4_agent_tools/core/render-budget.js +5 -3
  9. package/dist/hoi4_agent_tools/core/scanner.js +3 -2
  10. package/dist/hoi4_agent_tools/core/source/limits.js +4 -1
  11. package/dist/hoi4_agent_tools/core/workspace.js +31 -3
  12. package/dist/hoi4_agent_tools/event/graph.js +28 -6
  13. package/dist/hoi4_agent_tools/event/limits.js +1 -1
  14. package/dist/hoi4_agent_tools/event/service.js +103 -7
  15. package/dist/hoi4_agent_tools/focus/continuous-render.js +17 -8
  16. package/dist/hoi4_agent_tools/focus/presentation.js +96 -41
  17. package/dist/hoi4_agent_tools/focus/render.js +27 -16
  18. package/dist/hoi4_agent_tools/gui/assets.js +9 -2
  19. package/dist/hoi4_agent_tools/gui/inspection-artifact.js +99 -0
  20. package/dist/hoi4_agent_tools/gui/layout.js +126 -27
  21. package/dist/hoi4_agent_tools/gui/renderer.js +16 -0
  22. package/dist/hoi4_agent_tools/gui/source-graph.js +61 -28
  23. package/dist/hoi4_agent_tools/gui/studio.js +23 -8
  24. package/dist/hoi4_agent_tools/map/diagnostic-limit.js +90 -9
  25. package/dist/hoi4_agent_tools/map/model.js +22 -4
  26. package/dist/hoi4_agent_tools/map/service.js +4 -3
  27. package/dist/hoi4_agent_tools/map/validation.js +3 -2
  28. package/dist/hoi4_agent_tools/mcp/server/create.js +1 -3
  29. package/dist/hoi4_agent_tools/mcp/server/output-schemas.js +1 -1
  30. package/dist/hoi4_agent_tools/mcp/tools/event.js +6 -6
  31. package/dist/hoi4_agent_tools/mcp/tools/focus.js +149 -224
  32. package/dist/hoi4_agent_tools/mcp/tools/gui.js +35 -25
  33. package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -3
  34. package/dist/hoi4_agent_tools/runtime.js +112 -2
  35. package/dist/hoi4_agent_tools/schemas/common.js +5 -1
  36. package/dist/hoi4_agent_tools/version.js +1 -1
  37. package/docs/events.md +4 -5
  38. package/docs/focus.md +5 -4
  39. package/docs/gui.md +5 -1
  40. package/docs/map.md +1 -1
  41. package/docs/setup.md +9 -6
  42. package/package.json +1 -1
  43. package/server.json +2 -2
  44. package/dist/hoi4_agent_tools/mcp/tools/mods.js +0 -54
@@ -1,18 +1,99 @@
1
1
  export const MAP_DIAGNOSTIC_LIMIT = 2_000;
2
- export function addMapDiagnostic(diagnostics, diagnostic) {
3
- if (diagnostics.at(-1)?.code === 'MAP_DIAGNOSTICS_TRUNCATED')
2
+ function isHardDiagnostic(diagnostic) {
3
+ return diagnostic.severity === 'error' || diagnostic.severity === 'blocker';
4
+ }
5
+ function isWorkspacePath(path) {
6
+ return path.startsWith('mod:') || path.startsWith('fixture:');
7
+ }
8
+ function isBaselineOnlyDiagnostic(diagnostic) {
9
+ if (diagnostic.location === undefined || isWorkspacePath(diagnostic.location.path))
10
+ return false;
11
+ return !(diagnostic.related ?? []).some(({ path }) => isWorkspacePath(path));
12
+ }
13
+ function addOmittedDiagnostic(details, diagnostic) {
14
+ details.omitted += 1;
15
+ details.omittedBySeverity[diagnostic.severity] =
16
+ (details.omittedBySeverity[diagnostic.severity] ?? 0) + 1;
17
+ details.omittedByCode[diagnostic.code] = (details.omittedByCode[diagnostic.code] ?? 0) + 1;
18
+ }
19
+ function truncationDiagnostic(omitted) {
20
+ const details = {
21
+ limit: MAP_DIAGNOSTIC_LIMIT,
22
+ retained: MAP_DIAGNOSTIC_LIMIT - 1,
23
+ omitted: 0,
24
+ omittedBySeverity: {},
25
+ omittedByCode: {},
26
+ };
27
+ for (const diagnostic of omitted)
28
+ addOmittedDiagnostic(details, diagnostic);
29
+ const hardOmitted = omitted.some(isHardDiagnostic);
30
+ return {
31
+ code: 'MAP_DIAGNOSTICS_TRUNCATED',
32
+ severity: hardOmitted ? 'blocker' : 'info',
33
+ category: 'map',
34
+ message: hardOmitted
35
+ ? 'Map diagnostic detail exceeded the result ceiling and omitted an error or blocker'
36
+ : 'Map diagnostic detail exceeded the result ceiling; deterministic samples and omission counts were retained',
37
+ details,
38
+ };
39
+ }
40
+ function existingTruncationDiagnostic(diagnostics) {
41
+ const marker = diagnostics.at(-1);
42
+ return marker?.code === 'MAP_DIAGNOSTICS_TRUNCATED'
43
+ ? marker
44
+ : undefined;
45
+ }
46
+ function addBoundedDiagnostic(diagnostics, diagnostic, includeBaseline) {
47
+ if (!includeBaseline && isBaselineOnlyDiagnostic(diagnostic))
48
+ return;
49
+ const marker = existingTruncationDiagnostic(diagnostics);
50
+ if (marker !== undefined) {
51
+ if (isHardDiagnostic(diagnostic)) {
52
+ const replaceIndex = diagnostics
53
+ .slice(0, -1)
54
+ .findLastIndex((retained) => !isHardDiagnostic(retained));
55
+ if (replaceIndex >= 0) {
56
+ const replaced = diagnostics[replaceIndex];
57
+ diagnostics[replaceIndex] = diagnostic;
58
+ addOmittedDiagnostic(marker.details, replaced);
59
+ return;
60
+ }
61
+ marker.severity = 'blocker';
62
+ marker.message =
63
+ 'Map diagnostic detail exceeded the result ceiling and omitted an error or blocker';
64
+ }
65
+ addOmittedDiagnostic(marker.details, diagnostic);
4
66
  return;
67
+ }
5
68
  if (diagnostics.length < MAP_DIAGNOSTIC_LIMIT) {
6
69
  diagnostics.push(diagnostic);
7
70
  return;
8
71
  }
9
- diagnostics[MAP_DIAGNOSTIC_LIMIT - 1] = {
10
- code: 'MAP_DIAGNOSTICS_TRUNCATED',
11
- severity: 'blocker',
12
- category: 'map',
13
- message: 'Map diagnostics exceeded the configured output limit',
14
- details: { limit: MAP_DIAGNOSTIC_LIMIT, retained: MAP_DIAGNOSTIC_LIMIT - 1 },
15
- };
72
+ const candidates = [...diagnostics, diagnostic];
73
+ const hard = candidates.filter(isHardDiagnostic);
74
+ const soft = candidates.filter((candidate) => !isHardDiagnostic(candidate));
75
+ const retained = hard.length >= MAP_DIAGNOSTIC_LIMIT - 1
76
+ ? hard.slice(0, MAP_DIAGNOSTIC_LIMIT - 1)
77
+ : [...hard, ...soft.slice(0, MAP_DIAGNOSTIC_LIMIT - 1 - hard.length)];
78
+ const retainedCounts = new Map();
79
+ for (const item of retained)
80
+ retainedCounts.set(item, (retainedCounts.get(item) ?? 0) + 1);
81
+ const omitted = candidates.filter((candidate) => {
82
+ const count = retainedCounts.get(candidate) ?? 0;
83
+ if (count === 0)
84
+ return true;
85
+ retainedCounts.set(candidate, count - 1);
86
+ return false;
87
+ });
88
+ diagnostics.splice(0, diagnostics.length, ...retained, truncationDiagnostic(omitted));
89
+ }
90
+ export function addMapDiagnostic(diagnostics, diagnostic) {
91
+ addBoundedDiagnostic(diagnostics, diagnostic, false);
92
+ }
93
+ /** Active topology validation must report failures in whichever source wins
94
+ * precedence, including inherited game and dependency files. */
95
+ export function addActiveMapDiagnostic(diagnostics, diagnostic) {
96
+ addBoundedDiagnostic(diagnostics, diagnostic, true);
16
97
  }
17
98
  export function addMapDiagnostics(diagnostics, additions) {
18
99
  for (const diagnostic of additions)
@@ -24,11 +24,13 @@ const MAP_TEXT_FIELD_LIMIT = 10_000;
24
24
  const MAP_MODEL_MAX_SCRIPT_FILES = 5_000;
25
25
  const MAP_MODEL_MAX_SOURCE_TOKENS = 1_000_000;
26
26
  const MAP_MODEL_MAX_RECORDS = 500_000;
27
+ const MAP_MODEL_MAX_LOCALISATION_RECORDS = 2_000_000;
27
28
  const MAP_MODEL_MAX_MEMBERSHIP_EDGES = 1_000_000;
28
29
  class MapModelBudget {
29
30
  #documents = new Set();
30
31
  #tokens = 0;
31
32
  #records = 0;
33
+ #localisationRecords = 0;
32
34
  #membershipEdges = 0;
33
35
  assertScriptFiles(count) {
34
36
  if (count > MAP_MODEL_MAX_SCRIPT_FILES) {
@@ -50,6 +52,13 @@ class MapModelBudget {
50
52
  this.block('domain records', this.#records, MAP_MODEL_MAX_RECORDS);
51
53
  }
52
54
  }
55
+ addLocalisationRecords(count) {
56
+ this.#localisationRecords += count;
57
+ if (!Number.isSafeInteger(this.#localisationRecords) ||
58
+ this.#localisationRecords > MAP_MODEL_MAX_LOCALISATION_RECORDS) {
59
+ this.block('localisation records', this.#localisationRecords, MAP_MODEL_MAX_LOCALISATION_RECORDS);
60
+ }
61
+ }
53
62
  addMembershipEdges(count) {
54
63
  this.#membershipEdges += count;
55
64
  if (!Number.isSafeInteger(this.#membershipEdges) ||
@@ -869,7 +878,9 @@ export class MapWorkspaceIndex {
869
878
  const modelBudget = new MapModelBudget();
870
879
  modelBudget.assertScriptFiles(this.sourceFiles.filter((file) => inConfiguredRoot(file.relativePath, this.sourceRoots.states) ||
871
880
  this.sourceRoots.map.some((root) => normalizedPath(file.relativePath).startsWith(`${root}/strategicregions/`)) ||
872
- normalizedPath(file.relativePath).endsWith('.asset')).length);
881
+ ((file.rootKind === 'mod' || file.rootKind === 'fixture') &&
882
+ normalizedPath(file.relativePath).endsWith('.asset') &&
883
+ file.bytes.includes('locator'))).length);
873
884
  this.defaultMapFile = fileInRoots(this.activeFiles, this.sourceRoots.map, 'default.map');
874
885
  const definitionsName = defaultMapSelectorValue(this.defaultMapFile, 'definitions', 'definition.csv');
875
886
  const provincesName = defaultMapSelectorValue(this.defaultMapFile, 'provinces', 'provinces.bmp');
@@ -1031,7 +1042,9 @@ export class MapWorkspaceIndex {
1031
1042
  weatherFile === undefined ? [] : parseWeatherPositions(weatherFile, this.diagnostics);
1032
1043
  modelBudget.addRecords(this.weatherPositions.length);
1033
1044
  this.entityLocators = this.activeFiles.all
1034
- .filter((file) => normalizedPath(file.relativePath).endsWith('.asset'))
1045
+ .filter((file) => (file.rootKind === 'mod' || file.rootKind === 'fixture') &&
1046
+ normalizedPath(file.relativePath).endsWith('.asset') &&
1047
+ file.bytes.includes('locator'))
1035
1048
  .flatMap((file) => {
1036
1049
  const locators = parseEntityLocators(file, this.diagnostics);
1037
1050
  if (locators[0] !== undefined)
@@ -1042,8 +1055,13 @@ export class MapWorkspaceIndex {
1042
1055
  for (const file of this.activeFiles.all.filter((entry) => inConfiguredRoot(entry.relativePath, this.sourceRoots.localisation) &&
1043
1056
  normalizedPath(entry.relativePath).endsWith('.yml'))) {
1044
1057
  const document = parseLocalisation(file.bytes, file.displayPath);
1045
- addMapDiagnostics(this.diagnostics, document.diagnostics);
1046
- modelBudget.addRecords(document.entries.length);
1058
+ if (file.rootKind === 'mod' || file.rootKind === 'fixture') {
1059
+ addMapDiagnostics(this.diagnostics, document.diagnostics);
1060
+ }
1061
+ // Localisation is a supporting lookup table rather than connected map topology. Account for
1062
+ // it separately so a normal full-game language database cannot exhaust the domain-record
1063
+ // budget before provinces, states, regions, railways, and adjacencies are available.
1064
+ modelBudget.addLocalisationRecords(document.entries.length);
1047
1065
  for (const entry of document.entries) {
1048
1066
  this.localisationKeys.add(`${entry.language}:${entry.key}`);
1049
1067
  const indexed = { file, document, entry };
@@ -4,6 +4,7 @@ import { ArtifactStore, boundedSourceHashEvidence, publicArtifactLink, } from '.
4
4
  import { canonicalJson, compareCodeUnits, hashCanonical, sha256Bytes } from '../core/canonical.js';
5
5
  import { sortDiagnostics } from '../core/diagnostics.js';
6
6
  import { CoreEngine } from '../core/engine.js';
7
+ import { SymbolIndex } from '../core/index.js';
7
8
  import { RenderBudget } from '../core/render-budget.js';
8
9
  import { ServiceError } from '../core/result.js';
9
10
  import { WorkspaceScanner } from '../core/scanner.js';
@@ -386,7 +387,6 @@ function sourceTextPatterns(roots) {
386
387
  const normalized = normalizeSourceRoot(root);
387
388
  return [`${normalized}/english/**/*.{yml,yaml}`, `${normalized}/*.{yml,yaml}`];
388
389
  }),
389
- '**/*.asset',
390
390
  ];
391
391
  }
392
392
  function provinceBitmapPatterns(defaultMaps, mapRoots) {
@@ -491,14 +491,15 @@ export class AgentNudger {
491
491
  const selectorSnapshot = await this.engine.scan(workspaceId, { patterns: defaultMapPatterns(sourceRoots) }, principal, signal);
492
492
  const bitmapPatterns = provinceBitmapPatterns(selectorSnapshot.files, sourceRoots.map);
493
493
  const contentSnapshot = await this.engine.scan(workspaceId, { patterns: [...sourceTextPatterns(sourceRoots), ...bitmapPatterns] }, principal, signal);
494
+ const locatorSnapshot = await this.engine.scan(workspaceId, { patterns: ['**/*.asset'], rootKinds: ['mod', 'fixture'] }, principal, signal);
494
495
  signal?.throwIfAborted();
495
- const files = contentSnapshot.files;
496
+ const files = [...contentSnapshot.files, ...locatorSnapshot.files].sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
496
497
  const revision = hashCanonical(files.map(({ displayPath, loadOrder, sha256 }) => ({ displayPath, loadOrder, sha256 })));
497
498
  return {
498
499
  workspaceId,
499
500
  revision,
500
501
  files,
501
- index: MapWorkspaceIndex.build(files, sourceRoots, contentSnapshot.index),
502
+ index: MapWorkspaceIndex.build(files, sourceRoots, SymbolIndex.build(files)),
502
503
  };
503
504
  }
504
505
  async validate(workspaceId, principal, signal) {
@@ -1,7 +1,7 @@
1
1
  import { setImmediate as yieldToEventLoop } from 'node:timers/promises';
2
2
  import { sortDiagnostics } from '../core/diagnostics.js';
3
3
  import { assertRenderDimensions } from '../core/render-budget.js';
4
- import { addMapDiagnostic as addBoundedMapDiagnostic } from './diagnostic-limit.js';
4
+ import { addActiveMapDiagnostic, addMapDiagnostic } from './diagnostic-limit.js';
5
5
  import { rgbKey } from './bmp.js';
6
6
  import { MAP_PROVINCE_ENGINE_MAX_PIXELS } from './model.js';
7
7
  const MAP_DIAGNOSTIC_DETAIL_SAMPLE_LIMIT = 100;
@@ -53,7 +53,8 @@ function definitionLocation(definition) {
53
53
  return requiredLineLocation(definition.document, definition.line);
54
54
  }
55
55
  function addDiagnostic(diagnostics, options, diagnostic) {
56
- addBoundedMapDiagnostic(diagnostics, {
56
+ const add = options.includeBaselineDiagnostics === true ? addActiveMapDiagnostic : addMapDiagnostic;
57
+ add(diagnostics, {
57
58
  ...diagnostic,
58
59
  ...(diagnostic.operationId !== undefined || options.operationId === undefined
59
60
  ? {}
@@ -1,12 +1,11 @@
1
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
2
  import { PACKAGE_NAME, PACKAGE_VERSION } from '../../version.js';
3
- import { registerModsTool } from '../tools/mods.js';
4
3
  import { registerFocusTools } from '../tools/focus.js';
5
4
  import { registerGuiTools } from '../tools/gui.js';
6
5
  import { registerMapTools } from '../tools/map.js';
7
6
  import { registerEventTools } from '../tools/event.js';
8
7
  import { registerMcpResources } from '../resources/register.js';
9
- export const SERVER_INSTRUCTIONS = 'Use hoi4.mods to select a workspace. Use hoi4.event_inspect for scan, roots, trace, explain_path, state_flow, lint, and impact; hoi4.event_render and hoi4.event_compare return linked evidence. JSON resources are authoritative. Event tools are read-only. Only focus, GUI, and map rewrite tools edit configured mods.';
8
+ export const SERVER_INSTRUCTIONS = 'Use hoi4.focus_inspect and hoi4.focus_render for fast structural work; hoi4.focus_raster adds decoded icons and PNG output. Use hoi4.event_inspect to analyze event chains. Event tools are read-only. Focus, GUI, and map rewrite tools edit the current mod. Large evidence is returned through linked resources.';
10
9
  export function createMcpServer(engine, context = {}) {
11
10
  const server = new McpServer({
12
11
  name: PACKAGE_NAME,
@@ -16,7 +15,6 @@ export function createMcpServer(engine, context = {}) {
16
15
  capabilities: { logging: {} },
17
16
  instructions: SERVER_INSTRUCTIONS,
18
17
  });
19
- registerModsTool(server, engine, context);
20
18
  registerFocusTools(server, engine, context);
21
19
  registerGuiTools(server, engine, context);
22
20
  registerMapTools(server, engine, context);
@@ -16,7 +16,7 @@ export const renderHashesSchema = z
16
16
  .object({
17
17
  html: sha256Schema,
18
18
  svg: sha256Schema,
19
- png: sha256Schema,
19
+ png: sha256Schema.optional(),
20
20
  json: sha256Schema,
21
21
  })
22
22
  .strict();
@@ -306,11 +306,11 @@ export function registerEventTools(server, engine, context) {
306
306
  outputSchema: eventInspectOutputSchema,
307
307
  annotations: readOnlyEventTool,
308
308
  }, async (input, extra) => {
309
- const { workspaceId } = input;
309
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
310
310
  try {
311
311
  const progress = progressReporter(extra);
312
312
  await progress.report(0, 3, 'Analyzing event chain');
313
- const inspected = await viewer.inspect(inspectRequest(input, context, progress.signal));
313
+ const inspected = await viewer.inspect(inspectRequest({ ...input, workspaceId }, context, progress.signal));
314
314
  await progress.report(2, 3, 'Linking complete event analysis');
315
315
  const result = emptyServiceResult(workspaceId, {
316
316
  mode: inspected.mode,
@@ -344,11 +344,11 @@ export function registerEventTools(server, engine, context) {
344
344
  outputSchema: eventRenderOutputSchema,
345
345
  annotations: readOnlyEventTool,
346
346
  }, async (input, extra) => {
347
- const { workspaceId } = input;
347
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
348
348
  try {
349
349
  const progress = progressReporter(extra);
350
350
  await progress.report(0, 3, 'Rendering event-chain view');
351
- const rendered = await viewer.renderAndStore(renderRequest(input, context, progress.signal));
351
+ const rendered = await viewer.renderAndStore(renderRequest({ ...input, workspaceId }, context, progress.signal));
352
352
  await progress.report(2, 3, 'Linking complete event renders');
353
353
  const result = emptyServiceResult(workspaceId, {
354
354
  view: rendered.render.view,
@@ -389,11 +389,11 @@ export function registerEventTools(server, engine, context) {
389
389
  outputSchema: eventCompareOutputSchema,
390
390
  annotations: readOnlyEventTool,
391
391
  }, async (input, extra) => {
392
- const { workspaceId } = input;
392
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
393
393
  try {
394
394
  const progress = progressReporter(extra);
395
395
  await progress.report(0, 3, 'Comparing event-chain graphs');
396
- const compared = await viewer.compareAndStore(compareRequest(input, context, progress.signal));
396
+ const compared = await viewer.compareAndStore(compareRequest({ ...input, workspaceId }, context, progress.signal));
397
397
  await progress.report(2, 3, 'Linking complete event comparison');
398
398
  const { comparison } = compared;
399
399
  const result = emptyServiceResult(workspaceId, {