hoi4-agent-tools 4.0.5 → 4.2.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 (104) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +2 -0
  3. package/dist/hoi4_agent_tools/core/bounded-source.js +210 -0
  4. package/dist/hoi4_agent_tools/core/canonical.js +42 -0
  5. package/dist/hoi4_agent_tools/core/condition-evaluator.js +12 -8
  6. package/dist/hoi4_agent_tools/core/dependency-walk.js +21 -2
  7. package/dist/hoi4_agent_tools/core/diagnostic-rollup.js +91 -0
  8. package/dist/hoi4_agent_tools/core/domain-validation.js +12 -0
  9. package/dist/hoi4_agent_tools/core/engine.js +2 -1
  10. package/dist/hoi4_agent_tools/core/impact-graph.js +6 -2
  11. package/dist/hoi4_agent_tools/core/impact-semantic.js +12 -2
  12. package/dist/hoi4_agent_tools/core/index.js +1 -0
  13. package/dist/hoi4_agent_tools/core/job-executor.js +9 -0
  14. package/dist/hoi4_agent_tools/core/job-worker-host.js +12 -2
  15. package/dist/hoi4_agent_tools/core/managed-outputs.js +72 -0
  16. package/dist/hoi4_agent_tools/core/operation-result.js +28 -2
  17. package/dist/hoi4_agent_tools/core/scanner.js +17 -0
  18. package/dist/hoi4_agent_tools/core/server-state.js +9 -1
  19. package/dist/hoi4_agent_tools/core/temporary-directories.js +5 -1
  20. package/dist/hoi4_agent_tools/core/transaction-canonical.js +43 -0
  21. package/dist/hoi4_agent_tools/core/transaction-limits.js +7 -2
  22. package/dist/hoi4_agent_tools/core/transactions.js +355 -111
  23. package/dist/hoi4_agent_tools/decision/service.js +22 -1
  24. package/dist/hoi4_agent_tools/event/algorithms.js +3 -2
  25. package/dist/hoi4_agent_tools/event/graph.js +52 -35
  26. package/dist/hoi4_agent_tools/event/limits.js +4 -0
  27. package/dist/hoi4_agent_tools/event/operations.js +11 -0
  28. package/dist/hoi4_agent_tools/event/queries.js +13 -34
  29. package/dist/hoi4_agent_tools/event/render.js +4 -2
  30. package/dist/hoi4_agent_tools/event/service.js +93 -33
  31. package/dist/hoi4_agent_tools/event/source-analysis.js +94 -4
  32. package/dist/hoi4_agent_tools/event/state-keys.js +63 -0
  33. package/dist/hoi4_agent_tools/event/summary.js +130 -0
  34. package/dist/hoi4_agent_tools/impact/service.js +2 -1
  35. package/dist/hoi4_agent_tools/map/derived.js +21 -6
  36. package/dist/hoi4_agent_tools/map/job-operations.js +9 -3
  37. package/dist/hoi4_agent_tools/map/operations.js +72 -21
  38. package/dist/hoi4_agent_tools/map/reference-labels.js +238 -0
  39. package/dist/hoi4_agent_tools/map/reference-preparation.js +247 -0
  40. package/dist/hoi4_agent_tools/map/reference-world.js +1097 -0
  41. package/dist/hoi4_agent_tools/map/render.js +9 -1
  42. package/dist/hoi4_agent_tools/map/service.js +222 -18
  43. package/dist/hoi4_agent_tools/map/tool-operations.js +71 -2
  44. package/dist/hoi4_agent_tools/map/world-context.js +148 -0
  45. package/dist/hoi4_agent_tools/map/world-drainage.js +273 -0
  46. package/dist/hoi4_agent_tools/map/world-outputs.js +55 -0
  47. package/dist/hoi4_agent_tools/map/world-preview-style.js +157 -0
  48. package/dist/hoi4_agent_tools/map/world-preview.js +230 -0
  49. package/dist/hoi4_agent_tools/map/world-provinces.js +524 -0
  50. package/dist/hoi4_agent_tools/map/world.js +374 -253
  51. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +1 -0
  52. package/dist/hoi4_agent_tools/mcp/tools/event.js +66 -0
  53. package/dist/hoi4_agent_tools/mcp/tools/map.js +15 -2
  54. package/dist/hoi4_agent_tools/reference/source-lookup.js +4 -2
  55. package/dist/hoi4_agent_tools/schemas/map-requests.js +4 -0
  56. package/dist/hoi4_agent_tools/schemas/map.js +103 -10
  57. package/dist/hoi4_agent_tools/schemas/transaction.js +20 -3
  58. package/dist/hoi4_agent_tools/version.js +2 -2
  59. package/docs/analysis.md +4 -0
  60. package/docs/events.md +12 -0
  61. package/docs/images/reference-maps/political.png +0 -0
  62. package/docs/images/reference-maps/sketch.png +0 -0
  63. package/docs/map.md +45 -0
  64. package/docs/third-party-notices.md +6 -0
  65. package/npm-shrinkwrap.json +124 -2
  66. package/package.json +4 -1
  67. package/schemas/configuration.schema.json +1 -1
  68. package/schemas/continuous-focus-palette.schema.json +1 -1
  69. package/schemas/custom-weighted-pool.schema.json +1 -1
  70. package/schemas/decision-inspect-input.schema.json +1 -1
  71. package/schemas/error-log-input.schema.json +1 -1
  72. package/schemas/event-feature-manifest.schema.json +1 -1
  73. package/schemas/focus-plan.schema.json +1 -1
  74. package/schemas/focus-planning-sidecar.schema.json +1 -1
  75. package/schemas/gui-animation-source.schema.json +1 -1
  76. package/schemas/gui-helper.schema.json +1 -1
  77. package/schemas/gui-scenario.schema.json +1 -1
  78. package/schemas/helper-expansion-request.schema.json +1 -1
  79. package/schemas/helper-expansion-summary.schema.json +1 -1
  80. package/schemas/impact-inspect-input.schema.json +1 -1
  81. package/schemas/map-operation.schema.json +373 -70
  82. package/schemas/mechanic-test-input.schema.json +1 -1
  83. package/schemas/mod-index-input.schema.json +1 -1
  84. package/schemas/operation-result.schema.json +1 -1
  85. package/schemas/package-check-input.schema.json +1 -1
  86. package/schemas/package-manifest.schema.json +1 -1
  87. package/schemas/probability-analysis-result.schema.json +1 -1
  88. package/schemas/probability-compare-input.schema.json +1 -1
  89. package/schemas/probability-evaluate-input.schema.json +1 -1
  90. package/schemas/probability-inspect-input.schema.json +1 -1
  91. package/schemas/probability-render-input.schema.json +1 -1
  92. package/schemas/probability-scenario-set.schema.json +1 -1
  93. package/schemas/probability-sequence-input.schema.json +1 -1
  94. package/schemas/probability-simulate-input.schema.json +1 -1
  95. package/schemas/probability-sweep-input.schema.json +1 -1
  96. package/schemas/reference-context-input.schema.json +1 -1
  97. package/schemas/reference-read-input.schema.json +1 -1
  98. package/schemas/reference-search-input.schema.json +1 -1
  99. package/schemas/scenario-suite.schema.json +1 -1
  100. package/schemas/scenario-test-input.schema.json +1 -1
  101. package/schemas/script-validate-input.schema.json +1 -1
  102. package/schemas/shared-scenario.schema.json +1 -1
  103. package/schemas/source-lookup-input.schema.json +1 -1
  104. package/server.json +2 -2
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { closeSync, fstatSync, openSync, readSync, readdirSync, rmSync, statSync } from 'node:fs';
3
- import { mkdir } from 'node:fs/promises';
3
+ import { mkdir, stat } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { setTimeout as delay } from 'node:timers/promises';
@@ -134,6 +134,16 @@ function retainOrRemoveLog(file, evidence, logRoot) {
134
134
  * Bounded, fixed-entry child execution. Client disconnection never kills a worker; an
135
135
  * overdue or unresponsive cancelled read-only worker is stopped by its supervising host.
136
136
  */
137
+ /**
138
+ * Select the workspaces a job worker resolves again. A mod folder removed after the host
139
+ * discovered it must not stop jobs for every other workspace, so absent roots are left out.
140
+ * The job's own workspace is always kept, and a missing root still fails that job explicitly.
141
+ */
142
+ export async function workerWorkspaces(workspaces, workspaceId) {
143
+ const retained = await Promise.all(workspaces.map(async (workspace) => workspace.id === workspaceId ||
144
+ (await stat(workspace.modRoot).then((metadata) => metadata.isDirectory(), () => false))));
145
+ return workspaces.filter((_workspace, index) => retained[index]);
146
+ }
137
147
  export class JobWorkerHost {
138
148
  engine;
139
149
  jobs;
@@ -234,7 +244,7 @@ export class JobWorkerHost {
234
244
  return;
235
245
  const configuration = this.engine.resolver.config();
236
246
  const registeredWorkspaceIds = new Set(configuration.workspaces.map((workspace) => workspace.id));
237
- const resolvedWorkspaces = this.engine.resolver.list();
247
+ const resolvedWorkspaces = await workerWorkspaces(this.engine.resolver.list(), workspaceId);
238
248
  const discoveredWorkspaceIds = resolvedWorkspaces
239
249
  .map((workspace) => workspace.id)
240
250
  .filter((workspaceId) => !registeredWorkspaceIds.has(workspaceId));
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ import { canonicalJson, compareCodeUnits, sha256Bytes } from './canonical.js';
3
+ import { ServiceError } from './result.js';
4
+ const manifestSchema = z
5
+ .object({
6
+ version: z.literal(1),
7
+ files: z
8
+ .array(z
9
+ .object({
10
+ relativePath: z.string().min(1).max(512),
11
+ sha256: z.string().regex(/^[a-f0-9]{64}$/u),
12
+ })
13
+ .strict())
14
+ .max(5000),
15
+ })
16
+ .strict();
17
+ /** Pure planning only: the shared transaction engine performs every replacement and deletion. */
18
+ export function reconcileManagedOutputs(input) {
19
+ const sources = new Map(input.sources
20
+ .filter((f) => f.rootKind === 'mod')
21
+ .map((f) => [f.relativePath.replaceAll('\\', '/').toLowerCase(), f]));
22
+ const manifest = sources.get(input.manifestPath.toLowerCase());
23
+ const previous = manifest === undefined
24
+ ? []
25
+ : (() => {
26
+ if (manifest.bytes.length > 2_000_000)
27
+ throw new ServiceError('MANAGED_OUTPUT_MANIFEST_INVALID', 'Generated-output inventory exceeds its byte limit');
28
+ try {
29
+ return manifestSchema.parse(JSON.parse(manifest.bytes.toString('utf8'))).files;
30
+ }
31
+ catch {
32
+ throw new ServiceError('MANAGED_OUTPUT_MANIFEST_INVALID', 'Generated-output inventory is invalid');
33
+ }
34
+ })();
35
+ const seen = new Set();
36
+ for (const file of previous) {
37
+ const key = file.relativePath.toLowerCase();
38
+ if (file.relativePath.includes('\\') ||
39
+ file.relativePath.split('/').some((s) => !s || s === '.' || s === '..') ||
40
+ /^[a-z]:/iu.test(file.relativePath) ||
41
+ !input.permitsPath(file.relativePath) ||
42
+ seen.has(key))
43
+ throw new ServiceError('MANAGED_OUTPUT_MANIFEST_INVALID', 'Generated-output inventory contains an unsupported or duplicate path');
44
+ seen.add(key);
45
+ const current = sources.get(key);
46
+ if (current !== undefined && sha256Bytes(current.bytes) !== file.sha256)
47
+ throw new ServiceError('MANAGED_OUTPUT_EDITED', 'A generated file has been edited; regeneration would discard those changes', { relativePath: file.relativePath });
48
+ }
49
+ const outputs = input.proposed.filter((f) => f.content !== null && input.permitsPath(f.relativePath));
50
+ const next = new Set(outputs.map((f) => f.relativePath.toLowerCase()));
51
+ const deletions = previous
52
+ .filter((f) => !next.has(f.relativePath.toLowerCase()) && sources.has(f.relativePath.toLowerCase()))
53
+ .map((f) => ({
54
+ relativePath: f.relativePath,
55
+ content: null,
56
+ operationIds: [input.operationId],
57
+ }));
58
+ return [
59
+ ...deletions,
60
+ {
61
+ relativePath: input.manifestPath,
62
+ content: Buffer.from(canonicalJson({
63
+ version: 1,
64
+ files: outputs
65
+ .map((f) => ({ relativePath: f.relativePath, sha256: sha256Bytes(f.content) }))
66
+ .sort((a, b) => compareCodeUnits(a.relativePath, b.relativePath)),
67
+ }) + '\n'),
68
+ operationIds: [input.operationId],
69
+ mediaType: 'application/json',
70
+ },
71
+ ];
72
+ }
@@ -500,10 +500,36 @@ export function errorResult(error, workspaceId = '', context = {}) {
500
500
  ? `REWRITE_${serviceError.code.slice('TRANSACTION_'.length)}`
501
501
  : serviceError.code;
502
502
  const publicMessage = internalWriteError
503
- ? 'Rewrite could not be completed'
503
+ ? serviceError.code === 'TRANSACTION_STRUCTURE_LIMIT'
504
+ ? 'Rewrite exceeds the supported file, operation, validation, or evidence limits'
505
+ : serviceError.code === 'TRANSACTION_JOURNAL_LIMIT'
506
+ ? 'The requested rewrite exceeds the bounded rollback storage allowance'
507
+ : 'Rewrite could not be completed'
504
508
  : serviceError.message;
505
509
  const publicDetails = internalWriteError
506
- ? {}
510
+ ? ['TRANSACTION_STRUCTURE_LIMIT', 'TRANSACTION_JOURNAL_LIMIT'].includes(serviceError.code)
511
+ ? Object.fromEntries(Object.entries(serviceError.details).filter(([key, value]) => [
512
+ 'files',
513
+ 'filesLimit',
514
+ 'operations',
515
+ 'operationsLimit',
516
+ 'readDependencies',
517
+ 'readDependenciesLimit',
518
+ 'artifacts',
519
+ 'artifactsLimit',
520
+ 'diagnostics',
521
+ 'diagnosticsLimit',
522
+ 'checks',
523
+ 'checksLimit',
524
+ 'requestedWorkBytes',
525
+ 'plannedJournalBytes',
526
+ 'retainedBytes',
527
+ 'maximumBytes',
528
+ ].includes(key) &&
529
+ typeof value === 'number' &&
530
+ Number.isSafeInteger(value) &&
531
+ value >= 0))
532
+ : {}
507
533
  : Object.fromEntries(Object.entries(serviceError.details).filter(([key]) => ![
508
534
  'cause',
509
535
  'stack',
@@ -1,4 +1,5 @@
1
1
  import { open, realpath, stat } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
2
3
  import path from 'node:path';
3
4
  import { iterateFiles } from './file-glob.js';
4
5
  import { compareCodeUnits, sha256Bytes } from './canonical.js';
@@ -7,6 +8,22 @@ import { ServiceError } from './result.js';
7
8
  import { isWithin } from './workspace.js';
8
9
  const SCAN_IO_CONCURRENCY = 8;
9
10
  const SCAN_READ_BATCH_BYTES = 16_777_216;
11
+ /** Hash an open source using fixed memory; callers verify its canonical identity afterwards. */
12
+ export async function hashSourceBytes(source, signal) {
13
+ const hash = createHash('sha256');
14
+ const chunk = Buffer.alloc(Math.max(1, Math.min(1_048_576, source.metadata.size)));
15
+ let offset = 0;
16
+ while (offset < source.metadata.size) {
17
+ signal?.throwIfAborted();
18
+ const read = await source.handle.read(chunk, 0, Math.min(chunk.length, source.metadata.size - offset), offset);
19
+ if (!read.bytesRead)
20
+ throw new ServiceError('SCAN_SOURCE_CHANGED', 'Source changed while being hashed');
21
+ hash.update(chunk.subarray(0, read.bytesRead));
22
+ offset += read.bytesRead;
23
+ }
24
+ signal?.throwIfAborted();
25
+ return hash.digest('hex');
26
+ }
10
27
  export async function verifySource(root, source) {
11
28
  const resolved = await realpath(source.absolutePath);
12
29
  if (!isWithin(root.path, resolved))
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { z } from 'zod/v4';
5
5
  import { compareCodeUnits, canonicalJson, secureId } from './canonical.js';
6
6
  import { ServiceError } from './result.js';
7
+ import { TRANSACTION_MAX_MANIFEST_BYTES } from './transaction-limits.js';
7
8
  const journalKeyFile = 'journal-hmac.key';
8
9
  const journalKeyBytes = 32;
9
10
  const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/u);
@@ -157,7 +158,14 @@ export class ServerState {
157
158
  return new ServerState(canonicalRoot, Buffer.from(key));
158
159
  }
159
160
  authenticateJournal(payload) {
160
- return createHmac('sha256', this.journalKey).update(canonicalJson(payload)).digest('hex');
161
+ return this.authenticateCanonicalJournal(canonicalJson(payload));
162
+ }
163
+ /** Internal canonical-byte path; the signature is identical to authenticateJournal. */
164
+ authenticateCanonicalJournal(payload) {
165
+ if (Buffer.byteLength(payload, 'utf8') > TRANSACTION_MAX_MANIFEST_BYTES) {
166
+ throw stateError('TRANSACTION_MANIFEST_LIMIT', 'Transaction manifest exceeds its fixed byte limit');
167
+ }
168
+ return createHmac('sha256', this.journalKey).update(payload).digest('hex');
161
169
  }
162
170
  verifyJournal(payload, authenticationTag) {
163
171
  if (!/^[a-f0-9]{64}$/u.test(authenticationTag))
@@ -10,6 +10,7 @@ export const TEMPORARY_MAX_BYTES = 256 * 1024 * 1024;
10
10
  export const TEMPORARY_STALE_MS = 3 * 60 * 60 * 1000;
11
11
  // Dated event evaluation directories contain retained benchmark evidence, not scratch files.
12
12
  const scratchName = /^hoi4-(?:reference-source|event-eval)-[A-Za-z0-9]{6}$/u;
13
+ const referenceSnapshotName = /^\.reference-snapshot-[A-Za-z0-9]{6}$/u;
13
14
  const ownerSchema = z
14
15
  .object({
15
16
  version: z.literal(1),
@@ -100,7 +101,8 @@ export async function sweepTemporaryDirectories(parent, options = {}) {
100
101
  }
101
102
  }))).flat();
102
103
  const candidates = [];
103
- for (const name of (await readdir(root)).filter((name) => scratchName.test(name)).sort()) {
104
+ const names = options.referenceSnapshotsOnly ? referenceSnapshotName : scratchName;
105
+ for (const name of (await readdir(root)).filter((name) => names.test(name)).sort()) {
104
106
  const directory = path.join(root, name);
105
107
  if (protectedRoots.some((protectedRoot) => overlaps(directory, protectedRoot)))
106
108
  continue;
@@ -112,6 +114,8 @@ export async function sweepTemporaryDirectories(parent, options = {}) {
112
114
  (await realpath(directory)) !== directory)
113
115
  continue;
114
116
  const record = await owner(directory);
117
+ if (options.referenceSnapshotsOnly && record == null)
118
+ continue;
115
119
  // An interrupted sweep must not leave a permanent lock. Malformed or live locks
116
120
  // stay protected; only a confirmed dead local owner can relinquish its lock.
117
121
  const previousSweep = await owner(directory, SWEEP_LOCK);
@@ -0,0 +1,43 @@
1
+ import { canonicalJson, compareCodeUnits } from './canonical.js';
2
+ import { ServiceError } from './result.js';
3
+ import { TRANSACTION_MAX_MANIFEST_BYTES } from './transaction-limits.js';
4
+ /** A request-local snapshot of canonical immutable fields, without shared writable references. */
5
+ export class TransactionCanonicalSnapshot {
6
+ immutable = new Map();
7
+ mutable;
8
+ constructor(source, mutableKeys) {
9
+ this.mutable = new Set(mutableKeys);
10
+ let bytes = 2;
11
+ for (const [key, value] of Object.entries(source)) {
12
+ if (this.mutable.has(key) || value === undefined)
13
+ continue;
14
+ const encoded = canonicalJson(value);
15
+ bytes += Buffer.byteLength(JSON.stringify(key)) + Buffer.byteLength(encoded) + 2;
16
+ this.assertBound(bytes);
17
+ this.immutable.set(key, encoded);
18
+ }
19
+ }
20
+ serialize(source, excludedKeys = []) {
21
+ const mutable = source;
22
+ const excluded = new Set(excludedKeys);
23
+ const keys = [...new Set([...this.immutable.keys(), ...this.mutable])]
24
+ .filter((key) => !excluded.has(key) && (this.immutable.has(key) || mutable[key] !== undefined))
25
+ .sort(compareCodeUnits);
26
+ // JSON objects emit integer keys before other keys, including after canonical insertion.
27
+ const orderedKeys = Object.keys(Object.fromEntries(keys.map((key) => [key, null])));
28
+ let bytes = 2;
29
+ const entries = orderedKeys.map((key) => {
30
+ const value = this.immutable.get(key) ?? canonicalJson(mutable[key]);
31
+ const entry = `${JSON.stringify(key)}:${value}`;
32
+ bytes += Buffer.byteLength(entry) + 1;
33
+ this.assertBound(bytes);
34
+ return entry;
35
+ });
36
+ return `{${entries.join(',')}}`;
37
+ }
38
+ assertBound(bytes) {
39
+ if (bytes > TRANSACTION_MAX_MANIFEST_BYTES) {
40
+ throw new ServiceError('TRANSACTION_MANIFEST_LIMIT', 'Transaction manifest exceeds its fixed byte limit');
41
+ }
42
+ }
43
+ }
@@ -1,8 +1,13 @@
1
- export const TRANSACTION_MAX_FILES = 1_000;
1
+ // A generated world can contain thousands of state/region files. Review collections
2
+ // remain bounded independently of the unchanged 16 MiB authenticated manifest cap.
3
+ export const TRANSACTION_MAX_FILES = 5_000;
2
4
  export const TRANSACTION_MAX_OPERATIONS = 1_000;
3
5
  export const TRANSACTION_MAX_VALIDATION_CHECKS = 100;
4
6
  export const TRANSACTION_MAX_DIAGNOSTICS = 100;
5
- export const TRANSACTION_MAX_ARTIFACTS = 512;
7
+ export const TRANSACTION_MAX_ARTIFACTS = 6_000;
6
8
  export const TRANSACTION_MAX_APPLIED_FILES = TRANSACTION_MAX_FILES;
7
9
  export const TRANSACTION_MAX_READ_DEPENDENCIES = 10_000;
8
10
  export const TRANSACTION_MAX_MANIFEST_BYTES = 16_777_216;
11
+ export const TRANSACTION_MAX_PENDING_FILES = 16;
12
+ // A maximum-size world needs before/after blobs plus staging and rollback copies.
13
+ export const TRANSACTION_MAX_JOURNAL_BYTES = 1_073_741_824;