hoi4-agent-tools 3.9.0 → 3.10.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 (57) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/bin/http.js +2 -0
  3. package/dist/bin/stdio.js +2 -0
  4. package/dist/hoi4_agent_tools/core/engine.js +27 -8
  5. package/dist/hoi4_agent_tools/core/index.js +45 -20
  6. package/dist/hoi4_agent_tools/core/server-registry.js +138 -0
  7. package/dist/hoi4_agent_tools/core/server-status-resource.js +22 -0
  8. package/dist/hoi4_agent_tools/core/source/cache.js +89 -20
  9. package/dist/hoi4_agent_tools/event/service.js +7 -0
  10. package/dist/hoi4_agent_tools/gui/scenario-generator.js +11 -3
  11. package/dist/hoi4_agent_tools/gui/studio.js +20 -7
  12. package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +13 -1
  13. package/dist/hoi4_agent_tools/mcp/resources/register.js +6 -0
  14. package/dist/hoi4_agent_tools/mcp/tools/technology.js +5 -2
  15. package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +6 -10
  16. package/dist/hoi4_agent_tools/schemas/technology.js +4 -2
  17. package/dist/hoi4_agent_tools/technology/service.js +7 -0
  18. package/dist/hoi4_agent_tools/version.js +1 -1
  19. package/docs/setup.md +1 -0
  20. package/npm-shrinkwrap.json +12 -8
  21. package/package.json +1 -1
  22. package/schemas/configuration.schema.json +1 -1
  23. package/schemas/continuous-focus-palette.schema.json +1 -1
  24. package/schemas/custom-weighted-pool.schema.json +1 -1
  25. package/schemas/decision-inspect-input.schema.json +1 -1
  26. package/schemas/event-feature-manifest.schema.json +1 -1
  27. package/schemas/focus-plan.schema.json +1 -1
  28. package/schemas/focus-planning-sidecar.schema.json +1 -1
  29. package/schemas/gui-animation-source.schema.json +1 -1
  30. package/schemas/gui-helper.schema.json +1 -1
  31. package/schemas/gui-scenario.schema.json +1 -1
  32. package/schemas/helper-expansion-request.schema.json +1 -1
  33. package/schemas/helper-expansion-summary.schema.json +1 -1
  34. package/schemas/impact-inspect-input.schema.json +1 -1
  35. package/schemas/map-operation.schema.json +1 -1
  36. package/schemas/mechanic-test-input.schema.json +1 -1
  37. package/schemas/operation-result.schema.json +1 -1
  38. package/schemas/package-check-input.schema.json +1 -1
  39. package/schemas/package-manifest.schema.json +1 -1
  40. package/schemas/probability-analysis-result.schema.json +1 -1
  41. package/schemas/probability-compare-input.schema.json +1 -1
  42. package/schemas/probability-evaluate-input.schema.json +1 -1
  43. package/schemas/probability-inspect-input.schema.json +1 -1
  44. package/schemas/probability-render-input.schema.json +1 -1
  45. package/schemas/probability-scenario-set.schema.json +1 -1
  46. package/schemas/probability-sequence-input.schema.json +1 -1
  47. package/schemas/probability-simulate-input.schema.json +1 -1
  48. package/schemas/probability-sweep-input.schema.json +1 -1
  49. package/schemas/reference-context-input.schema.json +1 -1
  50. package/schemas/reference-read-input.schema.json +1 -1
  51. package/schemas/reference-search-input.schema.json +1 -1
  52. package/schemas/scenario-suite.schema.json +1 -1
  53. package/schemas/scenario-test-input.schema.json +1 -1
  54. package/schemas/script-validate-input.schema.json +1 -1
  55. package/schemas/shared-scenario.schema.json +1 -1
  56. package/schemas/source-lookup-input.schema.json +1 -1
  57. package/server.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.10.0 - 2026-10-06
4
+
5
+ - Rebuild a workspace index after an edit from the previous index, parsing and indexing only changed files, and keep the latest scan of each pattern set and each GUI graph scope instead of one per workspace. A repeated GUI preview of a large, actively edited mod took 64 s instead of 244 s.
6
+ - Admit parsed documents to a full cache only on their second miss and only in place of entries never read, so scans larger than the cache no longer serialize and evict every document; build scripted-localisation definitions only for windows that show scripted localisation.
7
+ - List every server process sharing a server state root, with its memory, last client activity and execution limits, in the `hoi4-agent://server/status` resource of local stdio connections.
8
+ - Refuse an `hoi4.event_compare` or `hoi4.tech_compare` call without references before scanning when no earlier revision is cached, instead of after a full scan.
9
+ - Report a `hoi4.tech_render` folder view without `folderId` as a readable argument error instead of a raw validation dump.
10
+ - Ship `proxy-addr` 2.0.8, which fixes IP spoofing through IPv4-mapped IPv6 trust subnets (GHSA-jqcg-44mw-7w3h); the HTTP server sets no `trust proxy`, so earlier releases did not reach the affected code.
11
+
3
12
  ## 3.9.0 - 2026-10-06
4
13
 
5
14
  - Exit a stdio server whose launching process has exited, and add `stdioIdleExitMinutes` so clients that keep idle per-subagent connections no longer accumulate servers.
package/dist/bin/http.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import { canonicalJson } from '../hoi4_agent_tools/core/canonical.js';
3
+ import { ServerRegistry } from '../hoi4_agent_tools/core/server-registry.js';
3
4
  import { createMcpServer } from '../hoi4_agent_tools/mcp/server/create.js';
4
5
  import { startHttpServer } from '../hoi4_agent_tools/mcp/transports/http.js';
5
6
  import { createEngine } from '../hoi4_agent_tools/runtime.js';
6
7
  async function main() {
7
8
  const engine = await createEngine();
8
9
  const handle = await startHttpServer(engine, engine.resolver.config(), createMcpServer);
10
+ new ServerRegistry(engine.resolver, 'http').start();
9
11
  const shutdown = async () => {
10
12
  await handle.close();
11
13
  process.exit(0);
package/dist/bin/stdio.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { canonicalJson } from '../hoi4_agent_tools/core/canonical.js';
3
3
  import { StdioFrameLimitError, StdioInvalidMessageError, StdioInvalidUtf8Error, } from '../hoi4_agent_tools/mcp/transports/bounded-stdio.js';
4
4
  import { serveNegotiatedStdio } from '../hoi4_agent_tools/mcp/transports/negotiated-stdio.js';
5
+ import { ServerRegistry } from '../hoi4_agent_tools/core/server-registry.js';
5
6
  import { StdioLifetime } from '../hoi4_agent_tools/mcp/transports/stdio-lifetime.js';
6
7
  import { createEngine } from '../hoi4_agent_tools/runtime.js';
7
8
  /**
@@ -63,6 +64,7 @@ async function main() {
63
64
  });
64
65
  closeTransport = () => transport.close();
65
66
  lifetime.start();
67
+ new ServerRegistry(engine.resolver, 'stdio', () => lifetime.lastActivity()).start();
66
68
  }
67
69
  main().catch((error) => {
68
70
  process.stderr.write(`${canonicalJson({
@@ -9,6 +9,12 @@ import { TransactionManager, transactionRootFingerprint } from './transactions.j
9
9
  import { RequestScheduler } from './request-scheduler.js';
10
10
  import { SharedRequestCapacity } from './shared-request-capacity.js';
11
11
  import { JobStore } from './job-store.js';
12
+ /**
13
+ * Completed scans kept per workspace. One tool call often scans several pattern sets (a GUI
14
+ * preview scans layouts, linked definitions and localisation), so a single slot made every
15
+ * repeat call rescan and reindex each set.
16
+ */
17
+ const SCAN_CACHE_ENTRIES_PER_WORKSPACE = 4;
12
18
  const RECOVERY_TTL_SECONDS = 3_600;
13
19
  const RECOVERY_MAX_BYTES = 536_870_912;
14
20
  const RECOVERY_MAX_RECORDS = 128;
@@ -167,9 +173,6 @@ export class CoreEngine {
167
173
  })}`;
168
174
  let flight = this.#scanFlights.get(requestKey);
169
175
  if (flight === undefined) {
170
- for (const key of this.#scanCache.keys())
171
- if (key.startsWith(`${workspaceId}:`) && !key.startsWith(`${requestKey}:`))
172
- this.#scanCache.delete(key);
173
176
  const controller = new AbortController();
174
177
  const promise = (async () => {
175
178
  return this.withScanAdmission(controller.signal, async () => {
@@ -193,8 +196,11 @@ export class CoreEngine {
193
196
  })));
194
197
  const cacheKey = `${requestKey}:${revision}`;
195
198
  const cached = this.#scanCache.get(cacheKey);
196
- if (cached !== undefined)
199
+ if (cached !== undefined) {
200
+ this.#scanCache.delete(cacheKey);
201
+ this.#scanCache.set(cacheKey, cached);
197
202
  return cached;
203
+ }
198
204
  const persistentAnalysisCache = await this.persistentAnalysisCache;
199
205
  const analysisScope = {
200
206
  workspaceIdentity: workspace.workspaceIdentity,
@@ -202,7 +208,12 @@ export class CoreEngine {
202
208
  principal: principal ?? null,
203
209
  };
204
210
  await persistentAnalysisCache?.hydrate(analysisScope, files, sourceDocuments, this.indexSegments, controller.signal);
205
- const index = await SymbolIndex.buildAsync(files, controller.signal, this.indexSegments);
211
+ // The previous revision of the same request still holds every unchanged file's
212
+ // records, so only edited files are parsed and indexed again.
213
+ const previous = [...this.#scanCache]
214
+ .filter(([key]) => key.startsWith(`${requestKey}:`))
215
+ .at(-1)?.[1].index;
216
+ const index = await SymbolIndex.buildAsync(files, controller.signal, this.indexSegments, previous);
206
217
  await persistentAnalysisCache?.persist(analysisScope, files, sourceDocuments, this.indexSegments, controller.signal);
207
218
  const snapshot = {
208
219
  workspaceId,
@@ -215,11 +226,18 @@ export class CoreEngine {
215
226
  diagnostics: index.diagnostics,
216
227
  };
217
228
  if ((this.#scanGenerations.get(workspaceId) ?? 0) === generation) {
229
+ // Replace this request's earlier revision and drop earlier generations; keep the
230
+ // most recent other requests of this workspace.
218
231
  for (const key of this.#scanCache.keys()) {
219
- if (key.startsWith(`${workspaceId}:`))
232
+ if (key.startsWith(`${requestKey}:`) ||
233
+ (key.startsWith(`${workspaceId}:`) &&
234
+ !key.startsWith(`${workspaceId}:${generation}:`)))
220
235
  this.#scanCache.delete(key);
221
236
  }
222
237
  this.#scanCache.set(cacheKey, snapshot);
238
+ const retained = [...this.#scanCache.keys()].filter((key) => key.startsWith(`${workspaceId}:`));
239
+ for (const key of retained.slice(0, -SCAN_CACHE_ENTRIES_PER_WORKSPACE))
240
+ this.#scanCache.delete(key);
223
241
  }
224
242
  return snapshot;
225
243
  });
@@ -384,7 +402,8 @@ export class CoreEngine {
384
402
  request.run();
385
403
  }
386
404
  }
387
- indexFiles(files) {
388
- return SymbolIndex.build(files, this.indexSegments);
405
+ /** `previous` supplies unchanged files' records from an earlier index of the same sources. */
406
+ indexFiles(files, previous) {
407
+ return SymbolIndex.build(files, this.indexSegments, previous);
389
408
  }
390
409
  }
@@ -1,7 +1,7 @@
1
1
  import { compareCodeUnits } from './canonical.js';
2
2
  import path from 'node:path';
3
3
  import { sortDiagnostics } from './diagnostics.js';
4
- import { indexSegmentAddress } from './index-segments.js';
4
+ import { indexSegmentAddress, } from './index-segments.js';
5
5
  import { parseClausewitz, parseLocalisation, assignments, childBlocks, firstScalar, locationFor, sourcePartialLimitDiagnostics, SOURCE_MAX_BYTES, SOURCE_MAX_NESTING, nodeLocation, } from './source/index.js';
6
6
  const spriteDefinitionKeys = new Set([
7
7
  'spritetype',
@@ -261,6 +261,11 @@ export class SymbolIndex {
261
261
  files = new Map();
262
262
  #active = new Map();
263
263
  #definitionFiles = new Set();
264
+ /**
265
+ * Where each completely indexed file's records sit in this index, by segment address, so a
266
+ * rebuild after an edit reuses unchanged files instead of parsing and indexing them again.
267
+ */
268
+ #fileRanges = new Map();
264
269
  #indexNestingBlocked = new Set();
265
270
  #skippedSourcePaths = new Set();
266
271
  #skippedPossibleSymbolKinds = new Set();
@@ -271,7 +276,7 @@ export class SymbolIndex {
271
276
  #referenceLimitReported = false;
272
277
  #diagnosticsTruncated = false;
273
278
  #indexTableBlocked = new Set();
274
- static build(files, segments) {
279
+ static build(files, segments, previous) {
275
280
  const index = new SymbolIndex();
276
281
  const definitionSelection = selectedDefinitionFiles(files);
277
282
  for (const displayPath of definitionSelection.selected)
@@ -281,13 +286,13 @@ export class SymbolIndex {
281
286
  }
282
287
  for (const file of [...files].sort((a, b) => a.loadOrder - b.loadOrder || compareCodeUnits(a.displayPath, b.displayPath))) {
283
288
  index.files.set(file.displayPath, file);
284
- index.indexFileWithSegments(file, segments);
289
+ index.indexFileWithSegments(file, segments, previous);
285
290
  }
286
291
  index.finalize();
287
292
  return index;
288
293
  }
289
294
  /** Yield between source files so protocol cancellation and progress remain responsive. */
290
- static async buildAsync(files, signal, segments) {
295
+ static async buildAsync(files, signal, segments, previous) {
291
296
  signal?.throwIfAborted();
292
297
  const index = new SymbolIndex();
293
298
  const definitionSelection = selectedDefinitionFiles(files);
@@ -300,7 +305,7 @@ export class SymbolIndex {
300
305
  for (const file of [...files].sort((a, b) => a.loadOrder - b.loadOrder || compareCodeUnits(a.displayPath, b.displayPath))) {
301
306
  signal?.throwIfAborted();
302
307
  index.files.set(file.displayPath, file);
303
- index.indexFileWithSegments(file, segments);
308
+ index.indexFileWithSegments(file, segments, previous);
304
309
  if (performance.now() - yieldedAt >= 20) {
305
310
  await new Promise((resolve) => setImmediate(resolve));
306
311
  signal?.throwIfAborted();
@@ -437,31 +442,50 @@ export class SymbolIndex {
437
442
  for (const diagnostic of diagnostics)
438
443
  this.addDiagnostic(diagnostic);
439
444
  }
440
- indexFileWithSegments(file, cache) {
441
- if (cache === undefined) {
445
+ /** The records one completely indexed file contributed to this index, if it was recorded. */
446
+ #segmentAt(address) {
447
+ const range = this.#fileRanges.get(address);
448
+ if (range === undefined)
449
+ return undefined;
450
+ return {
451
+ symbols: this.symbols.slice(...range.symbols),
452
+ references: this.references.slice(...range.references),
453
+ diagnostics: this.diagnostics.slice(...range.diagnostics),
454
+ complete: true,
455
+ };
456
+ }
457
+ indexFileWithSegments(file, cache, previous) {
458
+ if (cache === undefined && previous === undefined) {
442
459
  this.indexFile(file);
443
460
  return;
444
461
  }
445
462
  const address = indexSegmentAddress(file, this.#definitionFiles.has(file.displayPath));
446
- const cached = cache.get(address);
447
- if (cached?.complete === true &&
448
- this.symbols.length + cached.symbols.length <= INDEX_RECORD_LIMIT &&
449
- this.references.length + cached.references.length <= INDEX_RECORD_LIMIT &&
450
- this.diagnostics.length + cached.diagnostics.length <= INDEX_DIAGNOSTIC_LIMIT) {
463
+ const symbols = this.symbols.length;
464
+ const references = this.references.length;
465
+ const diagnostics = this.diagnostics.length;
466
+ const record = () => this.#fileRanges.set(address, {
467
+ symbols: [symbols, this.symbols.length],
468
+ references: [references, this.references.length],
469
+ diagnostics: [diagnostics, this.diagnostics.length],
470
+ });
471
+ const reused = (previous === undefined ? undefined : previous.#segmentAt(address)) ?? cache?.get(address);
472
+ if (reused?.complete === true &&
473
+ this.symbols.length + reused.symbols.length <= INDEX_RECORD_LIMIT &&
474
+ this.references.length + reused.references.length <= INDEX_RECORD_LIMIT &&
475
+ this.diagnostics.length + reused.diagnostics.length <= INDEX_DIAGNOSTIC_LIMIT) {
451
476
  this.#currentFileShadowed = file.shadowedBy !== undefined;
452
- for (const symbol of cached.symbols)
477
+ for (const symbol of reused.symbols)
453
478
  this.addSymbol(symbol);
454
- for (const reference of cached.references)
479
+ for (const reference of reused.references)
455
480
  this.addReference(reference);
456
- this.addDiagnostics(cached.diagnostics);
481
+ this.addDiagnostics(reused.diagnostics);
482
+ if (!this.#diagnosticsTruncated)
483
+ record();
457
484
  return;
458
485
  }
459
- const symbols = this.symbols.length;
460
- const references = this.references.length;
461
- const diagnostics = this.diagnostics.length;
462
486
  this.indexFile(file);
463
487
  // Incomplete files and aggregate ceilings retain the full builder's exact diagnostic
464
- // ordering and coverage bookkeeping. They are never cached as complete segments.
488
+ // ordering and coverage bookkeeping. They are never reused as complete segments.
465
489
  if (this.#indexTableBlocked.has(file.displayPath) ||
466
490
  this.#skippedSourcePaths.has(file.displayPath) ||
467
491
  this.#indexNestingBlocked.has(file.displayPath) ||
@@ -469,7 +493,8 @@ export class SymbolIndex {
469
493
  this.#referenceLimitReported ||
470
494
  this.#diagnosticsTruncated)
471
495
  return;
472
- cache.put(address, {
496
+ record();
497
+ cache?.put(address, {
473
498
  symbols: this.symbols.slice(symbols),
474
499
  references: this.references.slice(references),
475
500
  diagnostics: this.diagnostics.slice(diagnostics),
@@ -0,0 +1,138 @@
1
+ import { unlinkSync } from 'node:fs';
2
+ import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { z } from 'zod/v4';
5
+ import { PACKAGE_VERSION } from '../version.js';
6
+ /**
7
+ * Every server process sharing a server state root records a small heartbeat, so one status
8
+ * read shows how many servers and workers a machine runs and how much memory each holds.
9
+ */
10
+ const HEARTBEAT_MS = 60_000;
11
+ /** A record this old without an update belongs to a process that is blocked or gone. */
12
+ const STALE_MS = 5 * HEARTBEAT_MS;
13
+ const MAX_RECORDS = 512;
14
+ const recordSchema = z
15
+ .object({
16
+ pid: z.number().int().positive(),
17
+ version: z.string().max(64),
18
+ transport: z.enum(['stdio', 'http']),
19
+ startedAt: z.string().max(64),
20
+ updatedAt: z.string().max(64),
21
+ lastActivityAt: z.string().max(64).optional(),
22
+ rssBytes: z.number().int().min(0),
23
+ heapUsedBytes: z.number().int().min(0),
24
+ })
25
+ .strict();
26
+ function directory(resolver) {
27
+ const state = resolver.serverState();
28
+ return state === undefined ? undefined : path.join(state.root, 'servers');
29
+ }
30
+ /** Whether a process exists; a permission refusal still proves it exists. */
31
+ export function processAlive(pid) {
32
+ try {
33
+ process.kill(pid, 0);
34
+ return true;
35
+ }
36
+ catch (error) {
37
+ return error.code === 'EPERM';
38
+ }
39
+ }
40
+ /** The registry of this server process, or a status-only one when no server started it. */
41
+ export function serverRegistry(resolver) {
42
+ return ServerRegistry.active ?? new ServerRegistry(resolver, 'stdio');
43
+ }
44
+ export class ServerRegistry {
45
+ resolver;
46
+ transport;
47
+ lastActivity;
48
+ /** The registry whose heartbeats this process writes. */
49
+ static active;
50
+ #startedAt = new Date().toISOString();
51
+ #timer;
52
+ constructor(resolver, transport, lastActivity = () => undefined) {
53
+ this.resolver = resolver;
54
+ this.transport = transport;
55
+ this.lastActivity = lastActivity;
56
+ }
57
+ current() {
58
+ const memory = process.memoryUsage();
59
+ const activity = this.lastActivity();
60
+ return {
61
+ pid: process.pid,
62
+ version: PACKAGE_VERSION,
63
+ transport: this.transport,
64
+ startedAt: this.#startedAt,
65
+ updatedAt: new Date().toISOString(),
66
+ ...(activity === undefined ? {} : { lastActivityAt: new Date(activity).toISOString() }),
67
+ rssBytes: memory.rss,
68
+ heapUsedBytes: memory.heapUsed,
69
+ };
70
+ }
71
+ /** Start heartbeats; the record is removed when the process exits normally. */
72
+ start() {
73
+ ServerRegistry.active = this;
74
+ const root = directory(this.resolver);
75
+ if (root === undefined)
76
+ return;
77
+ const file = path.join(root, `${process.pid}.json`);
78
+ const beat = () => void mkdir(root, { recursive: true })
79
+ .then(async () => {
80
+ const temporary = `${file}.${Date.now()}.tmp`;
81
+ await writeFile(temporary, `${JSON.stringify(this.current())}\n`);
82
+ await rename(temporary, file);
83
+ })
84
+ .catch(() => undefined);
85
+ beat();
86
+ this.#timer = setInterval(beat, HEARTBEAT_MS);
87
+ this.#timer.unref();
88
+ process.once('exit', () => {
89
+ try {
90
+ unlinkSync(file);
91
+ }
92
+ catch {
93
+ // A record left behind is removed by the next status read once the pid is gone.
94
+ }
95
+ });
96
+ }
97
+ async status() {
98
+ const current = this.current();
99
+ const config = this.resolver.config();
100
+ const limits = {
101
+ maxConcurrentTools: config.maxConcurrentTools,
102
+ maxSharedTools: config.maxSharedTools,
103
+ jobDeadlineSeconds: config.jobDeadlineSeconds,
104
+ jobCancelGraceSeconds: config.jobCancelGraceSeconds,
105
+ stdioIdleExitMinutes: config.stdioIdleExitMinutes,
106
+ jobWorkerMaxHeapMiB: config.jobWorkerMaxHeapMiB,
107
+ };
108
+ const root = directory(this.resolver);
109
+ const servers = [];
110
+ let removed = 0;
111
+ if (root !== undefined) {
112
+ const names = (await readdir(root).catch(() => []))
113
+ .filter((name) => /^\d+\.json$/u.test(name))
114
+ .slice(0, MAX_RECORDS);
115
+ for (const name of names) {
116
+ const file = path.join(root, name);
117
+ const parsed = recordSchema.safeParse(await readFile(file, 'utf8')
118
+ .then((text) => JSON.parse(text))
119
+ .catch(() => undefined));
120
+ if (!parsed.success)
121
+ continue;
122
+ const record = parsed.data;
123
+ if (record.pid === process.pid)
124
+ continue;
125
+ if (!processAlive(record.pid)) {
126
+ removed += 1;
127
+ await unlink(file).catch(() => undefined);
128
+ continue;
129
+ }
130
+ const age = Date.now() - Date.parse(record.updatedAt);
131
+ servers.push({ ...record, state: age > STALE_MS ? 'stale' : 'live' });
132
+ }
133
+ }
134
+ servers.push({ ...current, state: 'live' });
135
+ servers.sort((left, right) => right.rssBytes - left.rssBytes || left.pid - right.pid);
136
+ return { current, servers, removed, limits };
137
+ }
138
+ }
@@ -0,0 +1,22 @@
1
+ import { canonicalJson } from './canonical.js';
2
+ import { serverRegistry } from './server-registry.js';
3
+ /** A fixed, listed resource: every server process sharing this server state and its memory. */
4
+ export const serverStatusResource = {
5
+ uri: 'hoi4-agent://server/status',
6
+ name: 'server-status',
7
+ title: 'HOI4 Agent Tools server status',
8
+ description: 'Live server processes sharing this server state, with memory, last client activity and execution limits.',
9
+ mimeType: 'application/json',
10
+ };
11
+ export async function readServerStatus(engine) {
12
+ const status = await serverRegistry(engine.resolver).status();
13
+ return {
14
+ contents: [
15
+ {
16
+ uri: serverStatusResource.uri,
17
+ mimeType: serverStatusResource.mimeType,
18
+ text: `${canonicalJson(status)}\n`,
19
+ },
20
+ ],
21
+ };
22
+ }
@@ -27,29 +27,50 @@ function decodedDocument(encoded, expected) {
27
27
  throw new Error('Persistent source document does not match its source identity');
28
28
  return value;
29
29
  }
30
- /** Process-local parsed facts; serialized entries cannot be mutated by their consumers. */
30
+ /** Puts between halvings of every entry's hit count, so entries no longer read become evictable. */
31
+ const HIT_AGING_INTERVAL = 4096;
32
+ /** Keys of documents turned away from a full cache, remembered so a repeat can be admitted. */
33
+ const DOORKEEPER_KEYS = 16_384;
34
+ /**
35
+ * Process-local parsed facts; serialized entries cannot be mutated by their consumers.
36
+ *
37
+ * A workspace scan often parses more source than the cache holds. Plain LRU then evicts
38
+ * every document before its next use and pays serialization for nothing. A full cache
39
+ * therefore turns a document away the first time, remembering only its key, and admits it
40
+ * on a later miss by displacing entries that were never read; a rejected document is not
41
+ * serialized. Repeated scans keep what fits and hit it. Hit counts age, so documents that
42
+ * stop being read become evictable again.
43
+ */
31
44
  export class SourceDocumentCache {
32
45
  maxBytes;
33
46
  #entries = new Map();
34
47
  #bytes = 0;
48
+ /** Bytes held by entries without hits, which a new document may displace. */
49
+ #coldBytes = 0;
50
+ #puts = 0;
35
51
  #hits = 0;
36
52
  #misses = 0;
37
53
  #evictions = 0;
54
+ #rejections = 0;
55
+ #doorkeeper = new Set();
38
56
  constructor(maxBytes = 64 * 1024 * 1024) {
39
57
  this.maxBytes = maxBytes;
40
58
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
41
59
  throw new RangeError('Source cache size must be a non-negative integer');
42
60
  }
43
61
  get(key) {
44
- const encoded = this.#entries.get(key);
45
- if (encoded === undefined) {
62
+ const entry = this.#entries.get(key);
63
+ if (entry === undefined) {
46
64
  this.#misses += 1;
47
65
  return undefined;
48
66
  }
49
67
  this.#hits += 1;
68
+ if (entry.hits === 0)
69
+ this.#coldBytes -= entrySize(key, entry.encoded);
70
+ entry.hits += 1;
50
71
  this.#entries.delete(key);
51
- this.#entries.set(key, encoded);
52
- return decodedDocument(encoded);
72
+ this.#entries.set(key, entry);
73
+ return decodedDocument(entry.encoded);
53
74
  }
54
75
  /** Availability check without deserialization or cache-hit accounting. */
55
76
  has(key) {
@@ -61,8 +82,19 @@ export class SourceDocumentCache {
61
82
  const estimatedBytes = document.bytes.length * 12 + document.tokens.length * 512 + key.length * 2 + 128;
62
83
  if (this.maxBytes === 0 || estimatedBytes > this.maxBytes)
63
84
  return;
64
- const encoded = serialize(document);
65
- this.retain(key, encoded);
85
+ this.#age();
86
+ const previous = this.#entries.get(key);
87
+ const reusable = previous === undefined ? 0 : entrySize(key, previous.encoded);
88
+ const free = this.maxBytes - this.#bytes + reusable;
89
+ if (estimatedBytes > free) {
90
+ // Only a document missed before may displace entries, and only entries never read.
91
+ if (!this.#doorkeeper.delete(key) || estimatedBytes > free + this.#coldBytes) {
92
+ this.#remember(key);
93
+ this.#rejections += 1;
94
+ return;
95
+ }
96
+ }
97
+ this.retain(key, serialize(document));
66
98
  }
67
99
  /** Import one authenticated same-runtime entry without counting it as a process-local hit. */
68
100
  importEncoded(key, encoded, expected) {
@@ -70,42 +102,79 @@ export class SourceDocumentCache {
70
102
  this.retain(key, Buffer.from(encoded));
71
103
  }
72
104
  exportEncoded(key) {
73
- const encoded = this.#entries.get(key);
74
- return encoded === undefined ? undefined : Buffer.from(encoded);
105
+ const entry = this.#entries.get(key);
106
+ return entry === undefined ? undefined : Buffer.from(entry.encoded);
75
107
  }
76
108
  retain(key, encoded) {
77
- const size = encoded.length + key.length * 2 + 128;
109
+ const size = entrySize(key, encoded);
78
110
  if (size > this.maxBytes)
79
111
  return;
80
- const previous = this.#entries.get(key);
81
- if (previous !== undefined)
82
- this.#bytes -= previous.length + key.length * 2 + 128;
83
- this.#entries.delete(key);
84
- while (this.#bytes + size > this.maxBytes) {
85
- const oldest = this.#entries.entries().next().value;
86
- if (oldest === undefined)
112
+ this.#remove(key);
113
+ // Displace the oldest entries that were never read again; read entries stay.
114
+ for (const [oldKey, entry] of this.#entries) {
115
+ if (this.#bytes + size <= this.maxBytes)
87
116
  break;
88
- this.#entries.delete(oldest[0]);
89
- this.#bytes -= oldest[1].length + oldest[0].length * 2 + 128;
117
+ if (entry.hits > 0)
118
+ continue;
119
+ this.#remove(oldKey);
90
120
  this.#evictions += 1;
91
121
  }
92
- this.#entries.set(key, encoded);
122
+ if (this.#bytes + size > this.maxBytes) {
123
+ this.#rejections += 1;
124
+ return;
125
+ }
126
+ this.#entries.set(key, { encoded, hits: 0 });
93
127
  this.#bytes += size;
128
+ this.#coldBytes += size;
129
+ }
130
+ #remember(key) {
131
+ this.#doorkeeper.delete(key);
132
+ this.#doorkeeper.add(key);
133
+ if (this.#doorkeeper.size > DOORKEEPER_KEYS)
134
+ this.#doorkeeper.delete(this.#doorkeeper.values().next().value);
135
+ }
136
+ #remove(key) {
137
+ const entry = this.#entries.get(key);
138
+ if (entry === undefined)
139
+ return;
140
+ const size = entrySize(key, entry.encoded);
141
+ this.#entries.delete(key);
142
+ this.#bytes -= size;
143
+ if (entry.hits === 0)
144
+ this.#coldBytes -= size;
145
+ }
146
+ #age() {
147
+ this.#puts += 1;
148
+ if (this.#puts % HIT_AGING_INTERVAL !== 0)
149
+ return;
150
+ for (const [key, entry] of this.#entries) {
151
+ if (entry.hits === 0)
152
+ continue;
153
+ entry.hits = Math.floor(entry.hits / 2);
154
+ if (entry.hits === 0)
155
+ this.#coldBytes += entrySize(key, entry.encoded);
156
+ }
94
157
  }
95
158
  clear() {
96
159
  this.#entries.clear();
160
+ this.#doorkeeper.clear();
97
161
  this.#bytes = 0;
162
+ this.#coldBytes = 0;
98
163
  }
99
164
  statistics() {
100
165
  return {
101
166
  hits: this.#hits,
102
167
  misses: this.#misses,
103
168
  evictions: this.#evictions,
169
+ rejections: this.#rejections,
104
170
  retainedBytes: this.#bytes,
105
171
  retainedDocuments: this.#entries.size,
106
172
  };
107
173
  }
108
174
  }
175
+ function entrySize(key, encoded) {
176
+ return encoded.length + key.length * 2 + 128;
177
+ }
109
178
  // All domains call the same parser; cache keys include both bytes and source location.
110
179
  // Authorization remains in workspace reads, before any document reaches this cache.
111
180
  export const sourceDocuments = new SourceDocumentCache();
@@ -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 ||
@@ -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;