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.
- package/CHANGELOG.md +9 -0
- package/dist/bin/http.js +2 -0
- package/dist/bin/stdio.js +2 -0
- package/dist/hoi4_agent_tools/core/engine.js +27 -8
- package/dist/hoi4_agent_tools/core/index.js +45 -20
- package/dist/hoi4_agent_tools/core/server-registry.js +138 -0
- package/dist/hoi4_agent_tools/core/server-status-resource.js +22 -0
- package/dist/hoi4_agent_tools/core/source/cache.js +89 -20
- package/dist/hoi4_agent_tools/event/service.js +7 -0
- package/dist/hoi4_agent_tools/gui/scenario-generator.js +11 -3
- package/dist/hoi4_agent_tools/gui/studio.js +20 -7
- package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +13 -1
- package/dist/hoi4_agent_tools/mcp/resources/register.js +6 -0
- package/dist/hoi4_agent_tools/mcp/tools/technology.js +5 -2
- package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +6 -10
- package/dist/hoi4_agent_tools/schemas/technology.js +4 -2
- package/dist/hoi4_agent_tools/technology/service.js +7 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/setup.md +1 -0
- package/npm-shrinkwrap.json +12 -8
- package/package.json +1 -1
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +1 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +1 -1
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +1 -1
- 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
|
-
|
|
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(`${
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
441
|
-
|
|
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
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
477
|
+
for (const symbol of reused.symbols)
|
|
453
478
|
this.addSymbol(symbol);
|
|
454
|
-
for (const reference of
|
|
479
|
+
for (const reference of reused.references)
|
|
455
480
|
this.addReference(reference);
|
|
456
|
-
this.addDiagnostics(
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
45
|
-
if (
|
|
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,
|
|
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
|
-
|
|
65
|
-
this.
|
|
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
|
|
74
|
-
return
|
|
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 =
|
|
109
|
+
const size = entrySize(key, encoded);
|
|
78
110
|
if (size > this.maxBytes)
|
|
79
111
|
return;
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
89
|
-
|
|
117
|
+
if (entry.hits > 0)
|
|
118
|
+
continue;
|
|
119
|
+
this.#remove(oldKey);
|
|
90
120
|
this.#evictions += 1;
|
|
91
121
|
}
|
|
92
|
-
this.#
|
|
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,
|
|
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,
|
|
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
|
|
505
|
+
for (const key of imageTokens) {
|
|
498
506
|
const sprite = evaluate(key, 'image');
|
|
499
507
|
if (sprite !== undefined)
|
|
500
508
|
values[key] = sprite;
|