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.
Files changed (72) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +137 -116
  3. package/dist/hoi4_agent_tools/core/engine.js +7 -7
  4. package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
  5. package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
  6. package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
  7. package/dist/hoi4_agent_tools/core/job-worker-host.js +27 -11
  8. package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
  9. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
  10. package/dist/hoi4_agent_tools/core/scanner.js +133 -47
  11. package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
  12. package/dist/hoi4_agent_tools/gui/layout.js +237 -11
  13. package/dist/hoi4_agent_tools/gui/renderer.js +36 -3
  14. package/dist/hoi4_agent_tools/gui/scenario.js +18 -0
  15. package/dist/hoi4_agent_tools/gui/studio.js +73 -21
  16. package/dist/hoi4_agent_tools/map/service.js +2 -2
  17. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  18. package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
  19. package/dist/hoi4_agent_tools/reference/lines.js +12 -0
  20. package/dist/hoi4_agent_tools/reference/operations.js +45 -2
  21. package/dist/hoi4_agent_tools/reference/script-validation.js +277 -0
  22. package/dist/hoi4_agent_tools/reference/service.js +139 -67
  23. package/dist/hoi4_agent_tools/reference/source-lookup.js +91 -15
  24. package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
  25. package/dist/hoi4_agent_tools/schemas/reference.js +55 -0
  26. package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
  27. package/dist/hoi4_agent_tools/version.js +1 -1
  28. package/docs/README.md +34 -18
  29. package/docs/examples.md +75 -0
  30. package/docs/gui.md +23 -0
  31. package/docs/reference.md +43 -2
  32. package/docs/research/reference-benchmark.md +41 -0
  33. package/docs/setup.md +50 -26
  34. package/docs/tools.md +65 -0
  35. package/npm-shrinkwrap.json +7088 -0
  36. package/package.json +10 -7
  37. package/schemas/configuration.schema.json +1 -1
  38. package/schemas/continuous-focus-palette.schema.json +1 -1
  39. package/schemas/custom-weighted-pool.schema.json +1 -1
  40. package/schemas/decision-inspect-input.schema.json +1 -1
  41. package/schemas/event-feature-manifest.schema.json +1 -1
  42. package/schemas/focus-plan.schema.json +1 -1
  43. package/schemas/focus-planning-sidecar.schema.json +1 -1
  44. package/schemas/gui-animation-source.schema.json +1 -1
  45. package/schemas/gui-helper.schema.json +1 -1
  46. package/schemas/gui-scenario.schema.json +139 -89
  47. package/schemas/helper-expansion-request.schema.json +1 -1
  48. package/schemas/helper-expansion-summary.schema.json +1 -1
  49. package/schemas/impact-inspect-input.schema.json +1 -1
  50. package/schemas/map-operation.schema.json +1 -1
  51. package/schemas/mechanic-test-input.schema.json +1 -1
  52. package/schemas/operation-result.schema.json +1 -1
  53. package/schemas/package-check-input.schema.json +1 -1
  54. package/schemas/package-manifest.schema.json +1 -1
  55. package/schemas/probability-analysis-result.schema.json +1 -1
  56. package/schemas/probability-compare-input.schema.json +1 -1
  57. package/schemas/probability-evaluate-input.schema.json +1 -1
  58. package/schemas/probability-inspect-input.schema.json +1 -1
  59. package/schemas/probability-render-input.schema.json +1 -1
  60. package/schemas/probability-scenario-set.schema.json +1 -1
  61. package/schemas/probability-sequence-input.schema.json +1 -1
  62. package/schemas/probability-simulate-input.schema.json +1 -1
  63. package/schemas/probability-sweep-input.schema.json +1 -1
  64. package/schemas/reference-context-input.schema.json +1 -1
  65. package/schemas/reference-read-input.schema.json +9 -1
  66. package/schemas/reference-search-input.schema.json +1 -1
  67. package/schemas/scenario-suite.schema.json +1 -1
  68. package/schemas/scenario-test-input.schema.json +1 -1
  69. package/schemas/script-validate-input.schema.json +92 -0
  70. package/schemas/shared-scenario.schema.json +1 -1
  71. package/schemas/source-lookup-input.schema.json +68 -2
  72. package/server.json +3 -3
@@ -1,16 +1,10 @@
1
1
  import { z } from 'zod/v4';
2
2
  import { serverConfigurationSchema } from './configuration.js';
3
3
  import { CoreEngine } from './engine.js';
4
- import { JobExecutor, JobOperations } from './job-executor.js';
4
+ import { JobExecutor } from './job-executor.js';
5
5
  import { JobService } from './job-service.js';
6
6
  import { WorkspaceResolver } from './workspace.js';
7
- import { registerEventJobs } from '../event/job-operations.js';
8
- import { registerFocusJobs } from '../focus/job-operations.js';
9
- import { registerGuiJobs } from '../gui/job-operations.js';
10
- import { registerMapJobs } from '../map/job-operations.js';
11
- import { registerProbabilityJobs } from '../probability/job-operations.js';
12
- import { registerTechnologyJobs } from '../technology/job-operations.js';
13
- import { registerAnalysisJobs } from './analysis-job-operations.js';
7
+ import { registerWorkerOperations } from './job-operation-registration.js';
14
8
  // Fixed internal process entry point. It accepts only a trusted host's structured IPC
15
9
  // message, never a script path, module name, callback, command, or transport request.
16
10
  const inputSchema = z
@@ -38,19 +32,17 @@ process.once('message', (value) => {
38
32
  clearTimeout(startup);
39
33
  void (async () => {
40
34
  const input = inputSchema.parse(value);
35
+ // The send callback confirms enqueueing, not receipt. Acknowledge before the host
36
+ // disconnects; losing that channel after receipt never cancels durable execution.
37
+ if (process.connected)
38
+ process.send?.({ type: 'accepted' }, () => undefined);
41
39
  const resolver = await WorkspaceResolver.create(input.configuration);
42
40
  const engine = new CoreEngine(resolver);
43
41
  // Recovery is targeted by the job executor. Do not sweep or interfere with
44
42
  // unrelated live rewrite journals when a worker starts.
45
43
  const jobs = await JobService.create(resolver);
46
- const operations = new JobOperations();
47
- registerEventJobs(operations, engine);
48
- registerAnalysisJobs(operations, engine);
49
- registerTechnologyJobs(operations, engine);
50
- registerProbabilityJobs(operations, engine);
51
- registerMapJobs(operations, engine);
52
- registerGuiJobs(operations, engine);
53
- registerFocusJobs(operations, engine);
44
+ const record = await jobs.get(input.workspaceId, input.jobId, input.principal);
45
+ const operations = await registerWorkerOperations(engine, record.request.toolName);
54
46
  await new JobExecutor(engine, jobs, operations).run(input.workspaceId, input.jobId, input.principal);
55
47
  })()
56
48
  .catch(() => {
@@ -54,6 +54,8 @@ export class PersistentAnalysisCache {
54
54
  maxBytes;
55
55
  maxEntries;
56
56
  maxSingleBytes;
57
+ batchMaxWrites;
58
+ batchMaxPayloadBytes;
57
59
  #lock;
58
60
  #hits = 0;
59
61
  #misses = 0;
@@ -63,23 +65,30 @@ export class PersistentAnalysisCache {
63
65
  #evictions = 0;
64
66
  #invalidEntries = 0;
65
67
  #oversized = 0;
68
+ #limitedBatches = 0;
66
69
  #invalidAddresses = new Set();
67
- constructor(state, root, maxBytes, maxEntries, maxSingleBytes) {
70
+ constructor(state, root, maxBytes, maxEntries, maxSingleBytes, batchMaxWrites, batchMaxPayloadBytes) {
68
71
  this.state = state;
69
72
  this.root = root;
70
73
  this.maxBytes = maxBytes;
71
74
  this.maxEntries = maxEntries;
72
75
  this.maxSingleBytes = maxSingleBytes;
76
+ this.batchMaxWrites = batchMaxWrites;
77
+ this.batchMaxPayloadBytes = batchMaxPayloadBytes;
73
78
  this.#lock = new SharedRequestCapacity(root, 1);
74
79
  }
75
80
  static async create(state, options = {}) {
76
81
  const maxBytes = options.maxBytes ?? 268_435_456;
77
82
  const maxEntries = options.maxEntries ?? 50_000;
78
83
  const maxSingleBytes = options.maxSingleBytes ?? 16_777_216;
84
+ const batchMaxWrites = options.batchMaxWrites ?? 16;
85
+ const batchMaxPayloadBytes = options.batchMaxPayloadBytes ?? 1_048_576;
79
86
  for (const [name, value] of [
80
87
  ['byte budget', maxBytes],
81
88
  ['entry budget', maxEntries],
82
89
  ['single-entry budget', maxSingleBytes],
90
+ ['batch write budget', batchMaxWrites],
91
+ ['batch payload budget', batchMaxPayloadBytes],
83
92
  ]) {
84
93
  if (!Number.isSafeInteger(value) || value < 0)
85
94
  throw new RangeError(`Persistent analysis cache ${name} must be a non-negative integer`);
@@ -88,7 +97,7 @@ export class PersistentAnalysisCache {
88
97
  const root = await containedGeneratedPath(state.root, 'analysis-cache');
89
98
  await mkdir(root, { recursive: true, mode: 0o700 });
90
99
  await assertUnlinked(root);
91
- return new PersistentAnalysisCache(state, root, maxBytes, maxEntries, maxSingleBytes);
100
+ return new PersistentAnalysisCache(state, root, maxBytes, maxEntries, maxSingleBytes, batchMaxWrites, batchMaxPayloadBytes);
92
101
  }
93
102
  async hydrate(scope, files, documents, segments, signal) {
94
103
  signal?.throwIfAborted();
@@ -101,7 +110,7 @@ export class PersistentAnalysisCache {
101
110
  signal?.throwIfAborted();
102
111
  const key = sourceDocumentCacheKey(file.bytes, file.displayPath);
103
112
  const documentAddress = sourceAddress(key);
104
- if (sourceEntries.has(documentAddress)) {
113
+ if (!documents.has(key) && sourceEntries.has(documentAddress)) {
105
114
  const payload = await this.read(scopeHash, 'source-document', documentAddress, signal);
106
115
  if (payload !== undefined) {
107
116
  try {
@@ -118,7 +127,7 @@ export class PersistentAnalysisCache {
118
127
  }
119
128
  for (const provinceDefinition of [false, true]) {
120
129
  const address = indexSegmentAddress(file, provinceDefinition);
121
- if (!indexEntries.has(address))
130
+ if (segments.has(address) || !indexEntries.has(address))
122
131
  continue;
123
132
  const payload = await this.read(scopeHash, 'index-segment', address, signal);
124
133
  if (payload === undefined)
@@ -140,15 +149,34 @@ export class PersistentAnalysisCache {
140
149
  this.inventory(scopeHash, 'source-document', signal),
141
150
  this.inventory(scopeHash, 'index-segment', signal),
142
151
  ]);
152
+ const batch = { writes: 0, payloadBytes: 0, limited: false };
153
+ const retain = async (kind, address, payload) => {
154
+ if (batch.writes >= this.batchMaxWrites ||
155
+ payload.length > this.batchMaxPayloadBytes - batch.payloadBytes) {
156
+ batch.limited = true;
157
+ return false;
158
+ }
159
+ const written = await this.write(scopeHash, kind, address, payload, signal);
160
+ if (written) {
161
+ batch.writes++;
162
+ batch.payloadBytes += payload.length;
163
+ }
164
+ return written;
165
+ };
143
166
  for (const file of files) {
144
167
  signal.throwIfAborted();
168
+ if (batch.writes >= this.batchMaxWrites ||
169
+ batch.payloadBytes >= this.batchMaxPayloadBytes) {
170
+ batch.limited = true;
171
+ break;
172
+ }
145
173
  const key = sourceDocumentCacheKey(file.bytes, file.displayPath);
146
174
  const documentAddress = sourceAddress(key);
147
175
  const document = documents.exportEncoded(key);
148
176
  if (document !== undefined &&
149
177
  (!sourceEntries.has(documentAddress) ||
150
178
  this.#invalidAddresses.has(`${scopeHash}\0source-document\0${documentAddress}`))) {
151
- if (await this.write(scopeHash, 'source-document', documentAddress, document, signal)) {
179
+ if (await retain('source-document', documentAddress, document)) {
152
180
  sourceEntries.add(documentAddress);
153
181
  }
154
182
  }
@@ -159,11 +187,13 @@ export class PersistentAnalysisCache {
159
187
  (indexEntries.has(address) &&
160
188
  !this.#invalidAddresses.has(`${scopeHash}\0index-segment\0${address}`)))
161
189
  continue;
162
- if (await this.write(scopeHash, 'index-segment', address, Buffer.from(segment), signal)) {
190
+ if (await retain('index-segment', address, Buffer.from(segment))) {
163
191
  indexEntries.add(address);
164
192
  }
165
193
  }
166
194
  }
195
+ if (batch.limited)
196
+ this.#limitedBatches++;
167
197
  await this.prune(signal);
168
198
  });
169
199
  }
@@ -177,6 +207,7 @@ export class PersistentAnalysisCache {
177
207
  evictions: this.#evictions,
178
208
  invalidEntries: this.#invalidEntries,
179
209
  oversized: this.#oversized,
210
+ limitedBatches: this.#limitedBatches,
180
211
  };
181
212
  }
182
213
  async directory(scopeHash, kind) {
@@ -1,9 +1,53 @@
1
- import { open } from 'node:fs/promises';
1
+ import { open, realpath, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import fg from 'fast-glob';
3
+ import { iterateFiles } from './file-glob.js';
4
4
  import { compareCodeUnits, sha256Bytes } from './canonical.js';
5
5
  import { DEFAULT_SCAN_MAX_BYTES, DEFAULT_SCAN_MAX_FILE_BYTES, DEFAULT_SCAN_MAX_FILES, } from './configuration.js';
6
6
  import { ServiceError } from './result.js';
7
+ import { isWithin } from './workspace.js';
8
+ const SCAN_IO_CONCURRENCY = 8;
9
+ const SCAN_READ_BATCH_BYTES = 16_777_216;
10
+ export async function verifySource(root, source) {
11
+ const resolved = await realpath(source.absolutePath);
12
+ if (!isWithin(root.path, resolved))
13
+ throw new ServiceError('SCAN_ROOT_ESCAPE', 'Source path resolves outside its authorized root', {
14
+ file: source.relativePath,
15
+ });
16
+ const current = await stat(resolved);
17
+ const expected = source.metadata;
18
+ if (current.dev !== expected.dev ||
19
+ current.ino !== expected.ino ||
20
+ current.size !== expected.size ||
21
+ current.mtimeMs !== expected.mtimeMs ||
22
+ current.ctimeMs !== expected.ctimeMs)
23
+ throw new ServiceError('SCAN_SOURCE_CHANGED', 'Source changed while it was being scanned; retry a focused query', { file: source.relativePath });
24
+ }
25
+ export async function prepareSource(root, relativePath, signal) {
26
+ signal?.throwIfAborted();
27
+ const absolutePath = path.join(root.path, relativePath);
28
+ const canonicalPath = await realpath(absolutePath);
29
+ if (!isWithin(root.path, canonicalPath))
30
+ throw new ServiceError('SCAN_ROOT_ESCAPE', 'Source path resolves outside its authorized root', {
31
+ file: relativePath,
32
+ });
33
+ const handle = await open(canonicalPath, 'r');
34
+ try {
35
+ const source = {
36
+ relativePath,
37
+ absolutePath,
38
+ canonicalPath,
39
+ handle,
40
+ metadata: await handle.stat(),
41
+ };
42
+ await verifySource(root, source);
43
+ signal?.throwIfAborted();
44
+ return source;
45
+ }
46
+ catch (error) {
47
+ await handle.close();
48
+ throw error;
49
+ }
50
+ }
7
51
  function normalizeRelative(value) {
8
52
  return value.replaceAll('\\', '/').replace(/^\.\//u, '');
9
53
  }
@@ -98,14 +142,12 @@ export class WorkspaceScanner {
98
142
  options.signal?.throwIfAborted();
99
143
  // Re-enumerate every root: installing content or changing load order can
100
144
  // invalidate a vanilla inventory just as it can a mod inventory.
101
- const matches = fg.stream(options.patterns, {
145
+ const matches = iterateFiles(options.patterns, {
102
146
  cwd: root.path,
103
- onlyFiles: true,
104
- unique: true,
105
- dot: false,
106
- followSymbolicLinks: false,
107
147
  ignore: options.ignore ?? ['**/.hoi4-agent/**'],
148
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
108
149
  });
150
+ const relativePaths = [];
109
151
  for await (const match of matches) {
110
152
  options.signal?.throwIfAborted();
111
153
  enumeratedFiles += 1;
@@ -115,55 +157,99 @@ export class WorkspaceScanner {
115
157
  limit: maxFiles,
116
158
  });
117
159
  }
118
- const relativePath = normalizeRelative(String(match));
160
+ const relativePath = normalizeRelative(match);
119
161
  if (hiddenByReplacePath(workspace, root, relativePath))
120
162
  continue;
121
- const absolutePath = path.join(root.path, relativePath);
122
- const cacheKey = this.cacheKey(absolutePath);
123
- const handle = await open(absolutePath, 'r');
163
+ relativePaths.push(relativePath);
164
+ }
165
+ for (let offset = 0; offset < relativePaths.length; offset += SCAN_IO_CONCURRENCY) {
166
+ options.signal?.throwIfAborted();
167
+ const prepared = await Promise.allSettled(relativePaths
168
+ .slice(offset, offset + SCAN_IO_CONCURRENCY)
169
+ .map((relativePath) => prepareSource(root, relativePath, options.signal)));
170
+ const opened = prepared.flatMap((entry) => entry.status === 'fulfilled' ? [entry.value] : []);
124
171
  try {
125
- const metadata = await handle.stat();
126
- if (!metadata.isFile())
127
- continue;
128
- const remaining = maxBytes - totalBytes;
129
- if (metadata.size > remaining || metadata.size > this.serverMaxFileBytes) {
130
- throw new ServiceError('SCAN_BYTE_LIMIT', 'Scan exceeds the configured byte limit', {
131
- file: relativePath,
132
- fileBytes: metadata.size,
133
- bytes: totalBytes,
134
- limit: maxBytes,
135
- perFileLimit: this.serverMaxFileBytes,
136
- });
172
+ const failed = prepared.find((entry) => entry.status === 'rejected');
173
+ if (failed?.status === 'rejected')
174
+ throw failed.reason;
175
+ const readable = opened.filter(({ metadata }) => metadata.isFile());
176
+ let reserved = 0;
177
+ for (const source of readable) {
178
+ if (source.metadata.size > maxBytes - totalBytes - reserved ||
179
+ source.metadata.size > this.serverMaxFileBytes)
180
+ throw new ServiceError('SCAN_BYTE_LIMIT', 'Scan exceeds the configured byte limit', {
181
+ file: source.relativePath,
182
+ fileBytes: source.metadata.size,
183
+ bytes: totalBytes + reserved,
184
+ limit: maxBytes,
185
+ perFileLimit: this.serverMaxFileBytes,
186
+ });
187
+ reserved += source.metadata.size;
188
+ }
189
+ let readOffset = 0;
190
+ while (readOffset < readable.length) {
191
+ const group = [];
192
+ let groupBytes = 0;
193
+ while (readOffset < readable.length) {
194
+ const source = readable[readOffset];
195
+ if (group.length > 0 && groupBytes + source.metadata.size > SCAN_READ_BATCH_BYTES)
196
+ break;
197
+ group.push(source);
198
+ groupBytes += source.metadata.size;
199
+ readOffset++;
200
+ }
201
+ const reads = await Promise.allSettled(group.map(async (source) => {
202
+ let bytes;
203
+ try {
204
+ bytes = await readBoundedFile(source.handle, source.metadata.size, options.signal);
205
+ }
206
+ catch (error) {
207
+ if (error instanceof ServiceError && error.code === 'SCAN_BYTE_LIMIT')
208
+ throw new ServiceError('SCAN_SOURCE_CHANGED', 'Source grew while it was being scanned; retry a focused query', { file: source.relativePath });
209
+ throw error;
210
+ }
211
+ if (bytes.length !== source.metadata.size)
212
+ throw new ServiceError('SCAN_SOURCE_CHANGED', 'Source changed while it was being scanned; retry a focused query', { file: source.relativePath });
213
+ await verifySource(root, source);
214
+ options.signal?.throwIfAborted();
215
+ return bytes;
216
+ }));
217
+ const readFailure = reads.find((entry) => entry.status === 'rejected');
218
+ if (readFailure?.status === 'rejected')
219
+ throw readFailure.reason;
220
+ for (const [index, read] of reads.entries()) {
221
+ if (read.status !== 'fulfilled')
222
+ continue;
223
+ const source = group[index];
224
+ const cacheKey = this.cacheKey(source.absolutePath);
225
+ const observed = read.value;
226
+ const sha256 = sha256Bytes(observed);
227
+ const cached = this.#sourceCache.get(cacheKey);
228
+ const bytes = cached?.sha256 === sha256 && cached.bytes.equals(observed)
229
+ ? cached.bytes
230
+ : observed;
231
+ this.retainSource(cacheKey, { sha256, bytes });
232
+ totalBytes += bytes.length;
233
+ result.push({
234
+ absolutePath: source.absolutePath,
235
+ displayPath: `${rootLabel(root)}:${source.relativePath}`,
236
+ relativePath: source.relativePath,
237
+ rootKind: root.kind,
238
+ loadOrder: root.loadOrder,
239
+ size: bytes.length,
240
+ modifiedMs: source.metadata.mtimeMs,
241
+ sha256,
242
+ bytes,
243
+ });
244
+ }
137
245
  }
138
- const cached = this.#sourceCache.get(cacheKey);
139
- // Metadata is not content identity (including on aliased/networked
140
- // filesystems). Verify bytes before reusing parsed/indexed facts.
141
- const observed = await readBoundedFile(handle, Math.min(remaining, this.serverMaxFileBytes), options.signal);
142
- const sha256 = sha256Bytes(observed);
143
- const bytes = cached?.sha256 === sha256 && cached.bytes.equals(observed) ? cached.bytes : observed;
144
- this.retainSource(cacheKey, {
145
- sha256,
146
- bytes,
147
- });
148
- totalBytes += bytes.length;
149
- const scanned = {
150
- absolutePath,
151
- displayPath: `${rootLabel(root)}:${relativePath}`,
152
- relativePath,
153
- rootKind: root.kind,
154
- loadOrder: root.loadOrder,
155
- size: bytes.length,
156
- modifiedMs: metadata.mtimeMs,
157
- sha256,
158
- bytes,
159
- };
160
- result.push(scanned);
161
246
  }
162
247
  finally {
163
- await handle.close();
248
+ await Promise.all(opened.map(({ handle }) => handle.close()));
164
249
  }
165
250
  }
166
251
  }
252
+ options.signal?.throwIfAborted();
167
253
  result.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.relativePath, right.relativePath));
168
254
  const groups = new Map();
169
255
  for (const file of result) {
@@ -51,6 +51,10 @@ export class SourceDocumentCache {
51
51
  this.#entries.set(key, encoded);
52
52
  return decodedDocument(encoded);
53
53
  }
54
+ /** Availability check without deserialization or cache-hit accounting. */
55
+ has(key) {
56
+ return this.#entries.has(key);
57
+ }
54
58
  put(key, document) {
55
59
  // Avoid allocating a large serialized copy just to discover that it cannot
56
60
  // be retained. This conservative estimate affects caching, never parsing.