hoi4-agent-tools 3.9.0 → 4.0.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 (157) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +115 -147
  3. package/dist/bin/http.js +12 -0
  4. package/dist/bin/setup.js +3 -2
  5. package/dist/bin/stdio.js +12 -0
  6. package/dist/hoi4_agent_tools/core/artifacts.js +40 -5
  7. package/dist/hoi4_agent_tools/core/canonical.js +18 -4
  8. package/dist/hoi4_agent_tools/core/configuration.js +7 -0
  9. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +12 -0
  10. package/dist/hoi4_agent_tools/core/domain-tools.js +65 -0
  11. package/dist/hoi4_agent_tools/core/domain-validation.js +5 -2
  12. package/dist/hoi4_agent_tools/core/engine.js +56 -10
  13. package/dist/hoi4_agent_tools/core/index.js +45 -20
  14. package/dist/hoi4_agent_tools/core/job-executor.js +14 -1
  15. package/dist/hoi4_agent_tools/core/job-operation-registration.js +15 -7
  16. package/dist/hoi4_agent_tools/core/job-store.js +78 -5
  17. package/dist/hoi4_agent_tools/core/job-transactions.js +31 -2
  18. package/dist/hoi4_agent_tools/core/job-worker-host.js +215 -116
  19. package/dist/hoi4_agent_tools/core/job-worker.js +90 -36
  20. package/dist/hoi4_agent_tools/core/operation-result.js +123 -10
  21. package/dist/hoi4_agent_tools/core/operation-tasks.js +49 -89
  22. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +25 -21
  23. package/dist/hoi4_agent_tools/core/server-registry.js +155 -0
  24. package/dist/hoi4_agent_tools/core/server-status-resource.js +22 -0
  25. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
  26. package/dist/hoi4_agent_tools/core/source/cache.js +108 -29
  27. package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
  28. package/dist/hoi4_agent_tools/core/transactions.js +114 -23
  29. package/dist/hoi4_agent_tools/core/workspace.js +84 -8
  30. package/dist/hoi4_agent_tools/decision/ai.js +1 -1
  31. package/dist/hoi4_agent_tools/decision/service.js +28 -0
  32. package/dist/hoi4_agent_tools/decision/source-inventory.js +7 -5
  33. package/dist/hoi4_agent_tools/event/job-operations.js +9 -5
  34. package/dist/hoi4_agent_tools/event/operations.js +14 -1
  35. package/dist/hoi4_agent_tools/event/service.js +7 -0
  36. package/dist/hoi4_agent_tools/focus/job-operations.js +1 -2
  37. package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
  38. package/dist/hoi4_agent_tools/gui/scenario-generator.js +11 -3
  39. package/dist/hoi4_agent_tools/gui/studio.js +20 -7
  40. package/dist/hoi4_agent_tools/gui/validators.js +8 -6
  41. package/dist/hoi4_agent_tools/map/bmp.js +1 -1
  42. package/dist/hoi4_agent_tools/map/derived.js +748 -0
  43. package/dist/hoi4_agent_tools/map/job-operations.js +7 -3
  44. package/dist/hoi4_agent_tools/map/model.js +103 -0
  45. package/dist/hoi4_agent_tools/map/operations.js +427 -58
  46. package/dist/hoi4_agent_tools/map/references.js +483 -0
  47. package/dist/hoi4_agent_tools/map/service.js +136 -61
  48. package/dist/hoi4_agent_tools/map/tool-operations.js +2 -1
  49. package/dist/hoi4_agent_tools/map/validation.js +3 -0
  50. package/dist/hoi4_agent_tools/map/world.js +1280 -0
  51. package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +13 -1
  52. package/dist/hoi4_agent_tools/mcp/resources/register.js +6 -0
  53. package/dist/hoi4_agent_tools/mcp/server/argument-errors.js +72 -0
  54. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +23 -5
  55. package/dist/hoi4_agent_tools/mcp/server/create.js +7 -0
  56. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +24 -30
  57. package/dist/hoi4_agent_tools/mcp/server/tool-listing.js +45 -0
  58. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +25 -5
  59. package/dist/hoi4_agent_tools/mcp/tools/event.js +18 -29
  60. package/dist/hoi4_agent_tools/mcp/tools/focus.js +4 -12
  61. package/dist/hoi4_agent_tools/mcp/tools/gui.js +3 -3
  62. package/dist/hoi4_agent_tools/mcp/tools/job.js +2 -2
  63. package/dist/hoi4_agent_tools/mcp/tools/map.js +13 -16
  64. package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
  65. package/dist/hoi4_agent_tools/mcp/tools/reference.js +22 -5
  66. package/dist/hoi4_agent_tools/mcp/tools/technology.js +16 -26
  67. package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
  68. package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +37 -18
  69. package/dist/hoi4_agent_tools/probability/job-operations.js +4 -9
  70. package/dist/hoi4_agent_tools/probability/operations.js +175 -13
  71. package/dist/hoi4_agent_tools/probability/source-analysis.js +24 -1
  72. package/dist/hoi4_agent_tools/reference/error-log.js +359 -0
  73. package/dist/hoi4_agent_tools/reference/known-mistakes.js +307 -0
  74. package/dist/hoi4_agent_tools/reference/known-mistakes.json +154 -0
  75. package/dist/hoi4_agent_tools/reference/mod-index.js +101 -0
  76. package/dist/hoi4_agent_tools/reference/operations.js +88 -19
  77. package/dist/hoi4_agent_tools/reference/player-text.js +4 -3
  78. package/dist/hoi4_agent_tools/reference/script-validation.js +19 -1
  79. package/dist/hoi4_agent_tools/reference/service.js +6 -0
  80. package/dist/hoi4_agent_tools/schemas/event.js +78 -19
  81. package/dist/hoi4_agent_tools/schemas/focus-requests.js +10 -0
  82. package/dist/hoi4_agent_tools/schemas/lenient-arguments.js +266 -0
  83. package/dist/hoi4_agent_tools/schemas/map-requests.js +63 -20
  84. package/dist/hoi4_agent_tools/schemas/map.js +122 -3
  85. package/dist/hoi4_agent_tools/schemas/mod-tools.js +181 -0
  86. package/dist/hoi4_agent_tools/schemas/probability-requests.js +119 -9
  87. package/dist/hoi4_agent_tools/schemas/reference.js +13 -0
  88. package/dist/hoi4_agent_tools/schemas/scenarios.js +1 -3
  89. package/dist/hoi4_agent_tools/schemas/script-validation.js +13 -6
  90. package/dist/hoi4_agent_tools/schemas/technology.js +83 -23
  91. package/dist/hoi4_agent_tools/technology/job-operations.js +3 -4
  92. package/dist/hoi4_agent_tools/technology/operations.js +14 -1
  93. package/dist/hoi4_agent_tools/technology/service.js +7 -0
  94. package/dist/hoi4_agent_tools/version.js +1 -1
  95. package/docs/README.md +7 -4
  96. package/docs/analysis.md +13 -0
  97. package/docs/configuration.md +281 -0
  98. package/docs/development.md +24 -2
  99. package/docs/events.md +27 -3
  100. package/docs/examples.md +6 -6
  101. package/docs/focus.md +125 -4
  102. package/docs/gui.md +98 -17
  103. package/docs/helper-expansion.md +8 -8
  104. package/docs/http.md +3 -1
  105. package/docs/jobs.md +19 -13
  106. package/docs/map.md +197 -6
  107. package/docs/mechanics.md +4 -0
  108. package/docs/probability.md +42 -15
  109. package/docs/reference.md +71 -4
  110. package/docs/setup.md +45 -11
  111. package/docs/technology.md +26 -1
  112. package/docs/third-party-notices.md +11 -0
  113. package/docs/tools.md +151 -42
  114. package/docs/troubleshooting.md +61 -0
  115. package/examples/clients/codex-config.toml +5 -2
  116. package/examples/clients/generic.json +2 -2
  117. package/examples/config/config.json +7 -0
  118. package/npm-shrinkwrap.json +161 -162
  119. package/package.json +13 -8
  120. package/schemas/configuration.schema.json +144 -128
  121. package/schemas/continuous-focus-palette.schema.json +1 -1
  122. package/schemas/custom-weighted-pool.schema.json +1 -1
  123. package/schemas/decision-inspect-input.schema.json +1 -1
  124. package/schemas/error-log-input.schema.json +55 -0
  125. package/schemas/event-feature-manifest.schema.json +1 -1
  126. package/schemas/focus-plan.schema.json +1 -1
  127. package/schemas/focus-planning-sidecar.schema.json +1 -1
  128. package/schemas/gui-animation-source.schema.json +1 -1
  129. package/schemas/gui-helper.schema.json +1 -1
  130. package/schemas/gui-scenario.schema.json +1 -1
  131. package/schemas/helper-expansion-request.schema.json +1 -1
  132. package/schemas/helper-expansion-summary.schema.json +1 -1
  133. package/schemas/impact-inspect-input.schema.json +1 -1
  134. package/schemas/map-operation.schema.json +597 -126
  135. package/schemas/mechanic-test-input.schema.json +1 -1
  136. package/schemas/mod-index-input.schema.json +51 -0
  137. package/schemas/operation-result.schema.json +2 -2
  138. package/schemas/package-check-input.schema.json +1 -1
  139. package/schemas/package-manifest.schema.json +1 -1
  140. package/schemas/probability-analysis-result.schema.json +1 -1
  141. package/schemas/probability-compare-input.schema.json +1 -1
  142. package/schemas/probability-evaluate-input.schema.json +1 -1
  143. package/schemas/probability-inspect-input.schema.json +1 -1
  144. package/schemas/probability-render-input.schema.json +1 -1
  145. package/schemas/probability-scenario-set.schema.json +1 -1
  146. package/schemas/probability-sequence-input.schema.json +1 -1
  147. package/schemas/probability-simulate-input.schema.json +1 -1
  148. package/schemas/probability-sweep-input.schema.json +1 -1
  149. package/schemas/reference-context-input.schema.json +1 -1
  150. package/schemas/reference-read-input.schema.json +1 -1
  151. package/schemas/reference-search-input.schema.json +1 -1
  152. package/schemas/scenario-suite.schema.json +3 -5
  153. package/schemas/scenario-test-input.schema.json +3 -5
  154. package/schemas/script-validate-input.schema.json +1 -1
  155. package/schemas/shared-scenario.schema.json +1 -1
  156. package/schemas/source-lookup-input.schema.json +1 -1
  157. package/server.json +3 -3
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { access, mkdir, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
2
3
  import path from 'node:path';
3
4
  import { constants } from 'node:fs';
@@ -11,6 +12,17 @@ import { containedGeneratedPath, isWithin, resolvedWorkspaceIdentity } from './w
11
12
  import { TRANSACTION_MAX_ARTIFACTS, TRANSACTION_MAX_DIAGNOSTICS, TRANSACTION_MAX_FILES, TRANSACTION_MAX_MANIFEST_BYTES, TRANSACTION_MAX_OPERATIONS, TRANSACTION_MAX_READ_DEPENDENCIES, TRANSACTION_MAX_VALIDATION_CHECKS, } from './transaction-limits.js';
12
13
  import { PACKAGE_VERSION, TRANSACTION_VERSION } from '../version.js';
13
14
  import { transactionManifestSchema } from '../schemas/transaction.js';
15
+ /**
16
+ * A file name usable as an artifact name: mod files may contain spaces and other characters
17
+ * (`history/countries/AUR - Aurelia.txt`) that artifact names do not allow.
18
+ */
19
+ function diffArtifactStem(relativePath) {
20
+ const stem = path
21
+ .basename(relativePath)
22
+ .replace(/[^A-Za-z0-9._-]+/gu, '_')
23
+ .replace(/^[^A-Za-z0-9]+/u, '');
24
+ return (stem.length === 0 ? 'file' : stem).slice(0, 100);
25
+ }
14
26
  export function readDependenciesFromScannedFiles(files) {
15
27
  const unique = new Map();
16
28
  for (const file of files) {
@@ -154,8 +166,32 @@ export class TransactionManager {
154
166
  this.maxJournals = maxJournals;
155
167
  this.serverState = serverState;
156
168
  }
169
+ /** Workspaces whose startup recovery failed, with the reason; their rewrites are refused. */
170
+ #recoveryFailures = new Map();
171
+ /**
172
+ * Record that startup recovery failed for a workspace. The server still starts and reads the
173
+ * workspace; rewrites retry recovery and are refused while it keeps failing.
174
+ */
175
+ recordRecoveryFailure(workspaceId, error) {
176
+ this.#recoveryFailures.set(workspaceId, error instanceof Error ? error.message : 'Rewrite journal recovery failed');
177
+ }
178
+ async assertRecovered(workspaceId, signal) {
179
+ if (!this.#recoveryFailures.has(workspaceId))
180
+ return;
181
+ try {
182
+ await this.recover(workspaceId, undefined, signal);
183
+ this.#recoveryFailures.delete(workspaceId);
184
+ }
185
+ catch (error) {
186
+ if (error.name === 'AbortError')
187
+ throw error;
188
+ this.recordRecoveryFailure(workspaceId, error);
189
+ throw new ServiceError('TRANSACTION_RECOVERY_REQUIRED', `Rewrites of this workspace wait until its interrupted rewrite journal can be recovered: ${this.#recoveryFailures.get(workspaceId)}`);
190
+ }
191
+ }
157
192
  async plan(input) {
158
193
  input.signal?.throwIfAborted();
194
+ await this.assertRecovered(input.workspaceId, input.signal);
159
195
  if (input.changes.length > TRANSACTION_MAX_FILES ||
160
196
  input.operations.length > TRANSACTION_MAX_OPERATIONS ||
161
197
  (input.readDependencies?.length ?? 0) > TRANSACTION_MAX_READ_DEPENDENCIES ||
@@ -259,7 +295,7 @@ export class TransactionManager {
259
295
  };
260
296
  if (isText(file.mediaType)) {
261
297
  return {
262
- name: `${path.basename(pending.relativePath)}.diff`,
298
+ name: `${diffArtifactStem(pending.relativePath)}.diff`,
263
299
  mimeType: 'text/x-diff',
264
300
  content: unifiedTextDiff(sourceDiffText(pending.before), sourceDiffText(pending.after), `a/${pending.relativePath}`, `b/${pending.relativePath}`, { ...(input.signal === undefined ? {} : { signal: input.signal }) }),
265
301
  provenance: {
@@ -272,7 +308,7 @@ export class TransactionManager {
272
308
  };
273
309
  }
274
310
  return {
275
- name: `${path.basename(pending.relativePath)}.binary-diff.json`,
311
+ name: `${diffArtifactStem(pending.relativePath)}.binary-diff.json`,
276
312
  mimeType: 'application/json',
277
313
  content: `${canonicalJson(binaryDiff(pending.before ?? Buffer.alloc(0), pending.after ?? Buffer.alloc(0)))}\n`,
278
314
  provenance: {
@@ -336,20 +372,28 @@ export class TransactionManager {
336
372
  const workspace = this.resolver.get(workspaceId, options.principal);
337
373
  if (!workspace.writeEnabled)
338
374
  throw new ServiceError('WRITE_POLICY_DISABLED', 'Workspace writes are not enabled');
339
- const manifest = await this.load(workspace, transactionId, {
340
- headMode: 'reconcile',
341
- ...(options.signal === undefined ? {} : { signal: options.signal }),
342
- });
343
- this.assertBinding(manifest, workspace, expectedPlanHash, options.principal);
344
- if (!manifest.planPayload.validation.passed)
345
- throw new ServiceError('TRANSACTION_VALIDATION_BLOCKED', 'Dry-run validation did not pass');
346
- if (manifest.files.length === 0)
347
- throw new ServiceError('TRANSACTION_NO_CHANGES', 'Transaction contains no file changes');
348
- if (manifest.state !== 'planned')
349
- throw new ServiceError('TRANSACTION_STATE_INVALID', `Transaction is ${manifest.state}`);
350
- if (Date.parse(manifest.expiresAt) <= Date.now())
351
- throw new ServiceError('TRANSACTION_EXPIRED', 'Transaction has expired');
375
+ await this.assertRecovered(workspaceId, options.signal);
376
+ const loadApplicable = async () => {
377
+ const loaded = await this.load(workspace, transactionId, {
378
+ headMode: 'reconcile',
379
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
380
+ });
381
+ this.assertBinding(loaded, workspace, expectedPlanHash, options.principal);
382
+ if (!loaded.planPayload.validation.passed)
383
+ throw new ServiceError('TRANSACTION_VALIDATION_BLOCKED', 'Dry-run validation did not pass');
384
+ if (loaded.files.length === 0)
385
+ throw new ServiceError('TRANSACTION_NO_CHANGES', 'Transaction contains no file changes');
386
+ if (loaded.state !== 'planned')
387
+ throw new ServiceError('TRANSACTION_STATE_INVALID', `Transaction is ${loaded.state}`);
388
+ if (Date.parse(loaded.expiresAt) <= Date.now())
389
+ throw new ServiceError('TRANSACTION_EXPIRED', 'Transaction has expired');
390
+ return loaded;
391
+ };
392
+ // Refuse early without the lock, then decide on the journal as it is once the lock is
393
+ // held: a concurrent apply of the same plan may have finished in between.
394
+ await loadApplicable();
352
395
  return this.withWorkspaceLock(workspace, transactionId, async () => {
396
+ const manifest = await loadApplicable();
353
397
  await this.verifyReadDependencies(workspace, manifest, options.principal, options.signal);
354
398
  await this.verifyCurrentFiles(workspace, manifest, 'before', options.principal, options.signal);
355
399
  const targets = [];
@@ -379,8 +423,15 @@ export class TransactionManager {
379
423
  if (actualBeforeReplace !== file.beforeSha256) {
380
424
  throw new ServiceError('TRANSACTION_STALE', 'Source changed while transaction output was being staged');
381
425
  }
382
- if (existsBeforeReplace)
426
+ if (existsBeforeReplace) {
383
427
  await rename(target.target, target.backup);
428
+ // An edit between the hash check and the rename would otherwise be discarded with
429
+ // the backup after a successful apply.
430
+ if (sha256Bytes(await readFile(target.backup)) !== file.beforeSha256) {
431
+ await rename(target.backup, target.target);
432
+ throw new ServiceError('TRANSACTION_STALE', 'Source changed while transaction output was being staged');
433
+ }
434
+ }
384
435
  await options.hooks?.afterBackup?.(file.relativePath, index);
385
436
  if (target.staged !== null) {
386
437
  if (await fileExists(target.target)) {
@@ -597,6 +648,10 @@ export class TransactionManager {
597
648
  signal?.throwIfAborted();
598
649
  if (!transactionIdPattern.test(entry.name))
599
650
  continue;
651
+ // A journal folder without a manifest is a plan that never committed: nothing was
652
+ // applied from it, and pruning removes it once it expires.
653
+ if (!(await fileExists(path.join(directory, entry.name, 'manifest.json'))))
654
+ continue;
600
655
  const manifest = await this.load(workspace, entry.name, {
601
656
  headMode: 'none',
602
657
  rootBinding: 'ignored',
@@ -656,7 +711,10 @@ export class TransactionManager {
656
711
  }
657
712
  backupValid = true;
658
713
  }
659
- if (currentHash === file.beforeSha256) {
714
+ // A file this transaction never replaced keeps whatever it holds now, including an
715
+ // edit made while the transaction ran; it does not block restoring the others.
716
+ const untouched = !manifest.appliedFiles.includes(file.relativePath) && !backupExists;
717
+ if (currentHash === file.beforeSha256 || untouched) {
660
718
  restorations.push({ file, target, backup, staged, mode: 'keep' });
661
719
  continue;
662
720
  }
@@ -720,7 +778,7 @@ export class TransactionManager {
720
778
  if (await fileExists(restoration.staged))
721
779
  await unlink(restoration.staged);
722
780
  }
723
- await this.verifyCurrentFiles(workspace, manifest, 'before', principal);
781
+ await this.verifyCurrentFiles(workspace, manifest, 'before', principal, undefined, new Set(restorations.filter(({ mode }) => mode !== 'keep').map(({ file }) => file.relativePath)));
724
782
  manifest.rollbackStatus = 'applied';
725
783
  }
726
784
  safeFailure(error, fallbackCode, message) {
@@ -739,9 +797,11 @@ export class TransactionManager {
739
797
  if ((manifest.principal ?? null) !== (principal ?? null))
740
798
  throw new ServiceError('TRANSACTION_PRINCIPAL_MISMATCH', 'Transaction belongs to another principal');
741
799
  }
742
- async verifyCurrentFiles(workspace, manifest, phase, principal, signal) {
800
+ async verifyCurrentFiles(workspace, manifest, phase, principal, signal, only) {
743
801
  for (const file of manifest.files) {
744
802
  signal?.throwIfAborted();
803
+ if (only !== undefined && !only.has(file.relativePath))
804
+ continue;
745
805
  const { path: target } = await this.resolver.resolvePath(workspace.id, file.relativePath, 'write', ['mod'], principal);
746
806
  const exists = await fileExists(target);
747
807
  const actual = exists ? sha256Bytes(await readFile(target)) : null;
@@ -1219,7 +1279,13 @@ export class TransactionManager {
1219
1279
  return await action();
1220
1280
  }
1221
1281
  finally {
1222
- await rm(lock, { recursive: true, force: true });
1282
+ // Release only this holder's lock; never one another process holds after a takeover.
1283
+ const owner = await readFile(path.join(lock, 'owner.json'), 'utf8')
1284
+ .then((text) => JSON.parse(text))
1285
+ .catch(() => undefined);
1286
+ if (owner === undefined ||
1287
+ (owner.instanceId === lockInstanceId && owner.transactionId === transactionId))
1288
+ await rm(lock, { recursive: true, force: true });
1223
1289
  }
1224
1290
  }
1225
1291
  async clearCrashedProcessLock(lock) {
@@ -1243,8 +1309,7 @@ export class TransactionManager {
1243
1309
  catch {
1244
1310
  return false;
1245
1311
  }
1246
- await rm(lock, { recursive: true, force: true });
1247
- return true;
1312
+ return this.retireStaleLock(lock, undefined);
1248
1313
  }
1249
1314
  if (typeof owner !== 'object' ||
1250
1315
  owner === null ||
@@ -1272,7 +1337,33 @@ export class TransactionManager {
1272
1337
  if (error.code !== 'ESRCH')
1273
1338
  return false;
1274
1339
  }
1275
- await rm(lock, { recursive: true, force: true });
1340
+ return this.retireStaleLock(lock, canonicalJson(owner));
1341
+ }
1342
+ /**
1343
+ * Move a lock judged stale aside under a unique name; only one process can win the rename.
1344
+ * The moved lock must still carry the owner that was judged stale (`undefined`: no owner
1345
+ * file). Otherwise a new holder took the lock in between, and it is put back untouched.
1346
+ */
1347
+ async retireStaleLock(lock, judgedOwner) {
1348
+ const tombstone = `${lock}.stale-${randomUUID()}`;
1349
+ try {
1350
+ await rename(lock, tombstone);
1351
+ }
1352
+ catch {
1353
+ return false;
1354
+ }
1355
+ let movedOwner;
1356
+ try {
1357
+ movedOwner = canonicalJson(JSON.parse(await readFile(path.join(tombstone, 'owner.json'), 'utf8')));
1358
+ }
1359
+ catch {
1360
+ movedOwner = undefined;
1361
+ }
1362
+ if (movedOwner !== judgedOwner) {
1363
+ await rename(tombstone, lock).catch(() => undefined);
1364
+ return false;
1365
+ }
1366
+ await rm(tombstone, { recursive: true, force: true });
1276
1367
  return true;
1277
1368
  }
1278
1369
  }
@@ -53,6 +53,49 @@ async function discoverDlcLayers(gameRoot) {
53
53
  throw new ServiceError('WORKSPACE_DLC_LIMIT', `The game root lists more than ${MAX_DLC_LAYERS} DLC folders`);
54
54
  return layers.sort((left, right) => left.id - right.id || compareCodeUnits(left.folder, right.folder));
55
55
  }
56
+ /** Read a mod's `descriptor.mod` metadata, or `undefined` when it is absent or unreadable. */
57
+ export async function readModDescriptor(root) {
58
+ const descriptor = path.join(root, 'descriptor.mod');
59
+ let bytes;
60
+ try {
61
+ const metadata = await lstat(descriptor);
62
+ if (!metadata.isFile() || metadata.size > MAX_DESCRIPTOR_BYTES)
63
+ return undefined;
64
+ bytes = await readFile(descriptor);
65
+ }
66
+ catch {
67
+ return undefined;
68
+ }
69
+ const document = parseClausewitz(bytes, descriptor);
70
+ const result = { tags: [], dependencies: [], replacePaths: [] };
71
+ const text = (value) => value.slice(0, 256);
72
+ for (const entry of document.root.entries) {
73
+ if (entry.type !== 'assignment')
74
+ continue;
75
+ const key = entry.key.value.toLowerCase();
76
+ if (entry.value.type === 'scalar') {
77
+ const value = text(entry.value.value);
78
+ if (key === 'name')
79
+ result.name = value;
80
+ else if (key === 'version')
81
+ result.version = value;
82
+ else if (key === 'supported_version')
83
+ result.supportedVersion = value;
84
+ else if (key === 'remote_file_id')
85
+ result.remoteFileId = value;
86
+ else if (key === 'replace_path' && result.replacePaths.length < 64)
87
+ result.replacePaths.push(value);
88
+ continue;
89
+ }
90
+ const list = key === 'tags' ? result.tags : key === 'dependencies' ? result.dependencies : undefined;
91
+ if (list === undefined)
92
+ continue;
93
+ for (const item of entry.value.entries)
94
+ if (item.type === 'scalar' && list.length < 64)
95
+ list.push(text(item.value));
96
+ }
97
+ return result;
98
+ }
56
99
  /**
57
100
  * `replace_path` entries from a mod descriptor. The engine unloads earlier-loaded files
58
101
  * under each path. Entries that are not safe relative folders are ignored.
@@ -175,12 +218,12 @@ function requestedWorkspaceAlias(value) {
175
218
  export function resolvedWorkspaceIdentity(workspace) {
176
219
  return hashCanonical({
177
220
  id: workspace.id,
178
- roots: workspace.roots.map(({ kind, path: rootPath, loadOrder, writable, replacePaths }) => ({
221
+ roots: workspace.roots.map(({ kind, path: rootPath, loadOrder, writable, replacePaths, identityReplacePaths }) => ({
179
222
  kind,
180
223
  rootHash: sha256Bytes(process.platform === 'win32' ? rootPath.toLowerCase() : rootPath),
181
224
  loadOrder,
182
225
  writable,
183
- replacePaths,
226
+ replacePaths: identityReplacePaths ?? replacePaths,
184
227
  })),
185
228
  behavior: {
186
229
  kind: workspace.registration.kind,
@@ -246,6 +289,8 @@ function assertRelative(relativePath) {
246
289
  export class WorkspaceResolver {
247
290
  configuration;
248
291
  #byId = new Map();
292
+ /** Size and modification time of each descriptor when its `replace_path` entries were read. */
293
+ #descriptorStamps = new Map();
249
294
  #discoveredWorkspaceIds = new Set();
250
295
  #storageRoots = [];
251
296
  #modRoots = [];
@@ -454,6 +499,34 @@ export class WorkspaceResolver {
454
499
  .filter((workspace) => allowed === undefined || allowed.has(workspace.id))
455
500
  .sort((a, b) => compareCodeUnits(a.id, b.id));
456
501
  }
502
+ /**
503
+ * Re-read the `replace_path` entries of the workspace's mod and dependency descriptors when a
504
+ * descriptor changed since they were last read. A server keeps running while a rewrite such as
505
+ * a new world adds folders to the descriptor, and later scans must hide the replaced folders.
506
+ */
507
+ async refreshDescriptors(workspaceId, principal) {
508
+ const workspace = this.get(workspaceId, principal);
509
+ if (workspace.registration.kind === 'game')
510
+ return;
511
+ const dependencies = workspace.registration.dependencies;
512
+ for (const root of workspace.roots) {
513
+ if (root.kind !== 'mod' && root.kind !== 'dependency')
514
+ continue;
515
+ const descriptor = path.join(root.path, 'descriptor.mod');
516
+ const stamp = await lstat(descriptor).then((metadata) => `${metadata.size}:${metadata.mtimeMs}`, () => 'missing');
517
+ const key = `${workspace.id}\0${root.path}`;
518
+ if (this.#descriptorStamps.get(key) === stamp)
519
+ continue;
520
+ this.#descriptorStamps.set(key, stamp);
521
+ const index = workspace.dependencyRoots.indexOf(root.path);
522
+ const configured = root.kind === 'mod'
523
+ ? workspace.registration.replacePaths
524
+ : (dependencies[index]?.replacePaths ?? []);
525
+ root.replacePaths = [
526
+ ...new Set([...configured, ...(await descriptorReplacePaths(root.path))]),
527
+ ];
528
+ }
529
+ }
457
530
  get(workspaceId, principal) {
458
531
  const resolvedWorkspaceId = this.resolveWorkspaceId(workspaceId, principal);
459
532
  if (principal !== undefined && !this.allowedWorkspaceIds(principal).has(resolvedWorkspaceId)) {
@@ -476,7 +549,9 @@ export class WorkspaceResolver {
476
549
  .split('/')
477
550
  .find((segment) => segment !== '.' && segment.length > 0)
478
551
  ?.toLowerCase();
479
- if (access === 'write' && firstSegment === '.hoi4-agent') {
552
+ // Server storage holds other principals' proposals and journals; it is never mod source,
553
+ // for reads as well as writes.
554
+ if (firstSegment === '.hoi4-agent') {
480
555
  throw new ServiceError('PATH_GENERATED_STORAGE_RESERVED', 'Generated .hoi4-agent storage cannot be targeted as mod source');
481
556
  }
482
557
  const roots = workspace.roots.filter((root) => (rootKinds === undefined || rootKinds.includes(root.kind)) &&
@@ -487,12 +562,12 @@ export class WorkspaceResolver {
487
562
  continue;
488
563
  if (access === 'read' && !(await exists(candidate)))
489
564
  continue;
565
+ if (root.kind === 'mod' &&
566
+ (pathsOverlap(workspace.artifactRoot, candidate) ||
567
+ pathsOverlap(workspace.cacheRoot, candidate))) {
568
+ throw new ServiceError('PATH_GENERATED_STORAGE_RESERVED', 'Artifact and cache storage cannot be targeted as mod source');
569
+ }
490
570
  if (access === 'write') {
491
- if (root.kind === 'mod' &&
492
- (pathsOverlap(workspace.artifactRoot, candidate) ||
493
- pathsOverlap(workspace.cacheRoot, candidate))) {
494
- throw new ServiceError('PATH_GENERATED_STORAGE_RESERVED', 'Artifact and cache storage cannot be targeted as mod source');
495
- }
496
571
  const existingParent = await canonicalPath(path.dirname(candidate));
497
572
  if (!isWithin(root.path, existingParent))
498
573
  continue;
@@ -698,6 +773,7 @@ export class WorkspaceResolver {
698
773
  ];
699
774
  for (const root of roots) {
700
775
  root.path = await canonicalPath(root.path);
776
+ root.identityReplacePaths = root.replacePaths;
701
777
  }
702
778
  const workspaceBase = {
703
779
  id: registration.id,
@@ -85,7 +85,7 @@ export async function inspectDecisionAi(engine, before, id, scenarios, overlay,
85
85
  ...(overlay === undefined
86
86
  ? {}
87
87
  : {
88
- proposedUnresolved: 'The proposal changes multiple sources or adds/removes the weighted decision; use probability_compare with explicit source selectors',
88
+ proposedUnresolved: 'The proposal changes multiple sources or adds/removes the weighted decision; use hoi4.probability_analyze with analysis compare and explicit source selectors',
89
89
  }),
90
90
  };
91
91
  }
@@ -150,6 +150,34 @@ export class DecisionAnalyzer {
150
150
  overrides: inventory.overrides.length,
151
151
  scenarios: input.mode === 'inventory' ? 0 : input.scenarios.length,
152
152
  unresolvedDefinitions: inventory.unresolvedDefinitions.length,
153
+ ...(inventory.unresolvedDefinitions.length === 0
154
+ ? {}
155
+ : {
156
+ unresolvedSample: inventory.unresolvedDefinitions
157
+ .slice(0, 8)
158
+ .map(({ id, path, reason }) => ({ id, path, reason })),
159
+ }),
160
+ // The gate outcome of each scenario answers most inspections without the report.
161
+ ...(input.mode === 'inspect' && Array.isArray(analysis)
162
+ ? {
163
+ outcomes: analysis.slice(0, 16).map(({ scenarioId, inspection }) => inspection === null
164
+ ? { scenarioId, found: false }
165
+ : {
166
+ scenarioId,
167
+ found: true,
168
+ eligible: inspection.gates.eligible,
169
+ visible: inspection.gates.visible,
170
+ available: inspection.gates.available,
171
+ ...(inspection.gates.unresolved.length === 0
172
+ ? {}
173
+ : {
174
+ unresolved: inspection.gates.unresolved
175
+ .slice(0, 5)
176
+ .map(({ code, message }) => ({ code, message: message.slice(0, 300) })),
177
+ }),
178
+ }),
179
+ }
180
+ : {}),
153
181
  });
154
182
  result.code = complete ? 'DECISION_ANALYZED' : 'DECISION_ANALYZED_PARTIAL';
155
183
  result.artifacts = [publicArtifactLink(artifact)];
@@ -137,12 +137,14 @@ export function decisionSourceInventory(snapshot) {
137
137
  const category = typeof symbol.metadata.category === 'string' ? symbol.metadata.category : undefined;
138
138
  const file = files.get(symbol.path);
139
139
  const document = sourceDocument(file, documents);
140
- const categoryNode = document === undefined || category === undefined
140
+ // A file may open the same category block more than once; the decision is in one of them.
141
+ const decisionNode = document === undefined || category === undefined
141
142
  ? undefined
142
- : categoryBlock(document, category);
143
- const decisionNode = categoryNode === undefined
144
- ? undefined
145
- : assignments(categoryNode, symbol.id).find((assignment) => assignment.value.type === 'block')?.value;
143
+ : assignments(document.root, category)
144
+ .flatMap((categoryAssignment) => categoryAssignment.value.type === 'block'
145
+ ? assignments(categoryAssignment.value, symbol.id)
146
+ : [])
147
+ .find((assignment) => assignment.value.type === 'block')?.value;
146
148
  if (file === undefined ||
147
149
  document === undefined ||
148
150
  category === undefined ||
@@ -2,8 +2,8 @@ import { z } from 'zod/v4';
2
2
  import { IdleCacheLifetime } from '../core/idle-cache-lifetime.js';
3
3
  import { errorResult, toolResult } from '../core/operation-result.js';
4
4
  import { ServiceError } from '../core/result.js';
5
- import { eventInspectRequestSchema, eventRenderRequestSchema, eventCompareRequestSchema, } from '../schemas/event.js';
6
- import { inspectEvents, renderEvents, compareEvents, normalizeEventInspectionRequest, normalizeEventRenderRequest, normalizeEventCompareRequest, } from './operations.js';
5
+ import { eventInspectRequestSchema, eventRenderRequestSchema } from '../schemas/event.js';
6
+ import { inspectOrCompareEvents, renderEvents, normalizeEventInspectionRequest, normalizeEventRenderRequest, } from './operations.js';
7
7
  import { EventChainViewer } from './service.js';
8
8
  const wireResult = z.record(z.string(), z.json());
9
9
  /** Registers typed event operations without invoking transport handlers. */
@@ -30,7 +30,12 @@ export function registerEventJobs(operations, engine) {
30
30
  'revision' in result.data &&
31
31
  typeof result.data.revision === 'string'
32
32
  ? result.data.revision
33
- : undefined;
33
+ : result.data !== null &&
34
+ typeof result.data === 'object' &&
35
+ 'afterRevision' in result.data &&
36
+ typeof result.data.afterRevision === 'string'
37
+ ? result.data.afterRevision
38
+ : undefined;
34
39
  if (revision !== undefined)
35
40
  await context.stageResult(wire, { sourceRevision: revision });
36
41
  return wire;
@@ -45,7 +50,6 @@ export function registerEventJobs(operations, engine) {
45
50
  }
46
51
  });
47
52
  }
48
- register('hoi4.event_inspect', eventInspectRequestSchema, normalizeEventInspectionRequest, inspectEvents, 'Analyzing event chain');
53
+ register('hoi4.event_inspect', eventInspectRequestSchema, normalizeEventInspectionRequest, inspectOrCompareEvents, 'Analyzing event chain');
49
54
  register('hoi4.event_render', eventRenderRequestSchema, normalizeEventRenderRequest, renderEvents, 'Rendering event-chain view');
50
- register('hoi4.event_compare', eventCompareRequestSchema, normalizeEventCompareRequest, compareEvents, 'Comparing event-chain graphs');
51
55
  }
@@ -3,13 +3,26 @@ import { helperExpansionValidation } from '../core/helper-expansion.js';
3
3
  import { publicArtifactLink } from '../core/artifacts.js';
4
4
  import { setInlineFilesScanned } from '../core/operation-result.js';
5
5
  import { emptyServiceResult } from '../core/result.js';
6
- import { eventInspectRequestSchema, eventRenderRequestSchema, eventCompareRequestSchema, } from '../schemas/event.js';
6
+ import { eventInspectRequestSchema, splitEventInspectRequest, eventRenderRequestSchema, eventCompareRequestSchema, } from '../schemas/event.js';
7
7
  import { eventDiagnostics, } from './service.js';
8
8
  export function normalizeEventInspectionRequest(input) {
9
9
  // Validated request fields are JSON-only. Match wire semantics by removing optional
10
10
  // undefined fields, including nested source-selector columns, before service dispatch.
11
11
  return JSON.parse(JSON.stringify(eventInspectRequestSchema.parse(input)));
12
12
  }
13
+ /** Route mode compare to the graph comparison and every other mode to inspection. */
14
+ export function inspectOrCompareEvents(viewer, input) {
15
+ const split = splitEventInspectRequest(input);
16
+ if (!split.compare)
17
+ return inspectEvents(viewer, input);
18
+ const { signal, principal } = input;
19
+ return compareEvents(viewer, {
20
+ ...JSON.parse(JSON.stringify(split.request)),
21
+ workspaceId: input.workspaceId,
22
+ ...(principal === undefined ? {} : { principal }),
23
+ ...(signal === undefined ? {} : { signal }),
24
+ });
25
+ }
13
26
  export function normalizeEventRenderRequest(input) {
14
27
  return JSON.parse(JSON.stringify(eventRenderRequestSchema.parse(input)));
15
28
  }
@@ -1308,6 +1308,13 @@ export class EventChainViewer {
1308
1308
  input.signal?.throwIfAborted();
1309
1309
  if (input.proposedSources !== undefined)
1310
1310
  assertProposedSourceBounds(input.proposedSources);
1311
+ // Without references the comparison uses the previous cached revision; with none cached,
1312
+ // say so before scanning the workspace.
1313
+ if (input.before === undefined &&
1314
+ input.after === undefined &&
1315
+ input.proposedSources === undefined &&
1316
+ (this.#state.history.get(input.workspaceId)?.size ?? 0) === 0)
1317
+ throw new ServiceError('EVENT_COMPARISON_BASELINE_REQUIRED', 'Provide a cached revision, graph artifact, or proposed source overlay');
1311
1318
  const refresh = input.refresh ??
1312
1319
  (input.proposedSources !== undefined ||
1313
1320
  input.before === undefined ||
@@ -37,8 +37,7 @@ export function registerFocusJobs(operations, engine) {
37
37
  });
38
38
  }
39
39
  register('hoi4.focus_inspect', focusInspectRequestSchema, normalizeFocusInspectRequest, (input, context) => inspectFocus(engine, workbench, input, context));
40
- register('hoi4.focus_render', focusRenderRequestSchema, normalizeFocusRenderRequest, (input, context) => renderFocus(engine, workbench, input, context, false));
41
- register('hoi4.focus_raster', focusRenderRequestSchema, normalizeFocusRenderRequest, (input, context) => renderFocus(engine, workbench, input, context, true));
40
+ register('hoi4.focus_render', focusRenderRequestSchema, normalizeFocusRenderRequest, (input, context) => renderFocus(engine, workbench, input, context, input.png !== false || (input.cropFocusIds?.length ?? 0) > 0));
42
41
  operations.registerWrite('hoi4.focus_rewrite', focusRewriteRequestSchema, async (input, context) => {
43
42
  if (input.workspaceId !== 'current' && input.workspaceId !== context.workspaceId)
44
43
  throw new ServiceError('JOB_ARGUMENT_SCOPE_MISMATCH', 'The request workspace does not match the authorized job workspace');
@@ -583,7 +583,7 @@ export async function inspectFocus(engine, workbench, input, context) {
583
583
  export async function renderFocus(engine, workbench, input, context, rasterize) {
584
584
  const outputName = rasterize ? 'raster' : 'render';
585
585
  if (!rasterize && (input.cropFocusIds?.length ?? 0) > 0)
586
- throw new ServiceError('FOCUS_CROP_REQUIRES_RASTER', 'Focus problem crops require focus_raster');
586
+ throw new ServiceError('FOCUS_CROP_REQUIRES_RASTER', 'Focus problem crops are PNG images; omit png: false');
587
587
  await context.progress(0, 4, 'Importing and indexing focus source');
588
588
  const workspace = engine.resolver.get(context.workspaceId, context.principal);
589
589
  const snapshot = await engine.scan(context.workspaceId, { patterns: focusDomainScanPatterns(workspace) }, context.principal, context.signal);
@@ -477,24 +477,32 @@ function scriptedImageTokens(scripted) {
477
477
  * takes that branch. A token the scenario cannot decide is left out, so it still renders as
478
478
  * `[dynamic_loc]`, and caller-supplied values always win.
479
479
  */
480
- export function resolveScenarioScriptedLocalisation(graph, windowName, base, definitions = new ClausewitzEvaluationDefinitions()) {
480
+ export function resolveScenarioScriptedLocalisation(graph, windowName, base,
481
+ /** Built only when the window shows scripted localisation; building scans every helper. */
482
+ definitions = () => new ClausewitzEvaluationDefinitions()) {
481
483
  if (graph.scriptedLocalisation.length === 0)
482
484
  return base;
483
485
  const scripted = relatedScriptedGuis(graph, windowName);
484
486
  const elements = relevantElements(graph, windowName, scripted);
485
487
  const textual = tokensIn(sourceTexts(graph, elements, base.language, scripted)).textual;
488
+ const names = new Set(graph.scriptedLocalisation.map(({ name }) => name));
489
+ const shown = (token) => names.has(token.slice(token.lastIndexOf('.') + 1));
490
+ const imageTokens = scriptedImageTokens(scripted);
491
+ if (![...textual, ...imageTokens].some(shown))
492
+ return base;
493
+ const evaluation = definitions();
486
494
  const explicitValues = guiExplicitConditionValues(base);
487
495
  const values = { ...base.values };
488
496
  const templates = new Map();
489
497
  const evaluate = (key, kind) => explicitValues[key] !== undefined
490
498
  ? undefined
491
- : scriptedLocalisationValue(graph, key, base.language, kind, explicitValues, base, definitions, []);
499
+ : scriptedLocalisationValue(graph, key, base.language, kind, explicitValues, base, evaluation, []);
492
500
  for (const key of textual) {
493
501
  const template = evaluate(key, 'text');
494
502
  if (template !== undefined)
495
503
  templates.set(key, template);
496
504
  }
497
- for (const key of scriptedImageTokens(scripted)) {
505
+ for (const key of imageTokens) {
498
506
  const sprite = evaluate(key, 'image');
499
507
  if (sprite !== undefined)
500
508
  values[key] = sprite;