hoi4-agent-tools 3.0.9 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +10 -2
  3. package/dist/bin/stdio.js +23 -12
  4. package/dist/hoi4_agent_tools/core/analysis-checkpoints.js +82 -0
  5. package/dist/hoi4_agent_tools/core/analysis-evidence.js +9 -0
  6. package/dist/hoi4_agent_tools/core/analysis-job-operations.js +38 -0
  7. package/dist/hoi4_agent_tools/core/artifact-resource.js +173 -0
  8. package/dist/hoi4_agent_tools/core/artifacts.js +84 -2
  9. package/dist/hoi4_agent_tools/core/condition-evaluator.js +16 -0
  10. package/dist/hoi4_agent_tools/core/dependency-pages.js +216 -0
  11. package/dist/hoi4_agent_tools/core/dependency-walk.js +266 -0
  12. package/dist/hoi4_agent_tools/core/domain-validation.js +188 -0
  13. package/dist/hoi4_agent_tools/core/engine.js +48 -5
  14. package/dist/hoi4_agent_tools/core/helper-expansion.js +186 -0
  15. package/dist/hoi4_agent_tools/core/impact-comparison.js +49 -0
  16. package/dist/hoi4_agent_tools/core/impact-graph.js +301 -0
  17. package/dist/hoi4_agent_tools/core/impact-semantic.js +256 -0
  18. package/dist/hoi4_agent_tools/core/index-segments.js +137 -0
  19. package/dist/hoi4_agent_tools/core/index.js +47 -4
  20. package/dist/hoi4_agent_tools/core/job-controls.js +109 -0
  21. package/dist/hoi4_agent_tools/core/job-executor.js +190 -0
  22. package/dist/hoi4_agent_tools/core/job-service.js +112 -0
  23. package/dist/hoi4_agent_tools/core/job-store.js +641 -0
  24. package/dist/hoi4_agent_tools/core/job-transactions.js +126 -0
  25. package/dist/hoi4_agent_tools/core/job-worker-host.js +202 -0
  26. package/dist/hoi4_agent_tools/core/job-worker.js +64 -0
  27. package/dist/hoi4_agent_tools/core/operation-context.js +31 -0
  28. package/dist/hoi4_agent_tools/core/operation-result.js +412 -0
  29. package/dist/hoi4_agent_tools/core/operation-tasks.js +387 -0
  30. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +383 -0
  31. package/dist/hoi4_agent_tools/core/result.js +1 -0
  32. package/dist/hoi4_agent_tools/core/scanner.js +15 -75
  33. package/dist/hoi4_agent_tools/core/semantic-dependencies.js +233 -0
  34. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +43 -11
  35. package/dist/hoi4_agent_tools/core/source/cache.js +107 -0
  36. package/dist/hoi4_agent_tools/core/source/parser.js +11 -1
  37. package/dist/hoi4_agent_tools/core/source-overlay.js +125 -0
  38. package/dist/hoi4_agent_tools/core/transaction-execution.js +134 -0
  39. package/dist/hoi4_agent_tools/core/transactions.js +80 -27
  40. package/dist/hoi4_agent_tools/decision/ai.js +104 -0
  41. package/dist/hoi4_agent_tools/decision/comparison.js +106 -0
  42. package/dist/hoi4_agent_tools/decision/cost.js +177 -0
  43. package/dist/hoi4_agent_tools/decision/evaluation.js +256 -0
  44. package/dist/hoi4_agent_tools/decision/lifecycle.js +114 -0
  45. package/dist/hoi4_agent_tools/decision/service.js +172 -0
  46. package/dist/hoi4_agent_tools/decision/source-inventory.js +182 -0
  47. package/dist/hoi4_agent_tools/event/graph.js +214 -290
  48. package/dist/hoi4_agent_tools/event/helper-expansion.js +33 -0
  49. package/dist/hoi4_agent_tools/event/job-operations.js +51 -0
  50. package/dist/hoi4_agent_tools/event/operations.js +179 -0
  51. package/dist/hoi4_agent_tools/event/service.js +67 -15
  52. package/dist/hoi4_agent_tools/event/source-analysis.js +5 -4
  53. package/dist/hoi4_agent_tools/focus/job-operations.js +77 -0
  54. package/dist/hoi4_agent_tools/focus/tool-operations.js +669 -0
  55. package/dist/hoi4_agent_tools/focus/tool-support.js +123 -0
  56. package/dist/hoi4_agent_tools/gui/job-operations.js +81 -0
  57. package/dist/hoi4_agent_tools/gui/tool-operations.js +270 -0
  58. package/dist/hoi4_agent_tools/impact/scenario-suites.js +74 -0
  59. package/dist/hoi4_agent_tools/impact/service.js +127 -0
  60. package/dist/hoi4_agent_tools/map/job-operations.js +76 -0
  61. package/dist/hoi4_agent_tools/map/tool-operations.js +493 -0
  62. package/dist/hoi4_agent_tools/mcp/prompts/probability.js +25 -0
  63. package/dist/hoi4_agent_tools/mcp/prompts/register-modern.js +29 -0
  64. package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +34 -0
  65. package/dist/hoi4_agent_tools/mcp/resources/register.js +4 -150
  66. package/dist/hoi4_agent_tools/mcp/server/base-tools.js +3 -219
  67. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +188 -0
  68. package/dist/hoi4_agent_tools/mcp/server/create.js +18 -6
  69. package/dist/hoi4_agent_tools/mcp/server/instructions.js +2 -0
  70. package/dist/hoi4_agent_tools/mcp/server/job-task-store.js +67 -0
  71. package/dist/hoi4_agent_tools/mcp/server/legacy-task-tools.js +20 -0
  72. package/dist/hoi4_agent_tools/mcp/server/modern-tasks.js +168 -0
  73. package/dist/hoi4_agent_tools/mcp/server/progress.js +17 -15
  74. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +45 -0
  75. package/dist/hoi4_agent_tools/mcp/server/result.js +2 -412
  76. package/dist/hoi4_agent_tools/mcp/server/task-request-context.js +9 -0
  77. package/dist/hoi4_agent_tools/mcp/server/task-tool-catalog.js +16 -0
  78. package/dist/hoi4_agent_tools/mcp/server/task-tool-definition.js +1 -0
  79. package/dist/hoi4_agent_tools/mcp/server/transaction-execution.js +1 -123
  80. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +65 -0
  81. package/dist/hoi4_agent_tools/mcp/tools/chaosx.js +38 -21
  82. package/dist/hoi4_agent_tools/mcp/tools/event.js +24 -315
  83. package/dist/hoi4_agent_tools/mcp/tools/focus.js +34 -1039
  84. package/dist/hoi4_agent_tools/mcp/tools/gui.js +19 -504
  85. package/dist/hoi4_agent_tools/mcp/tools/job.js +46 -0
  86. package/dist/hoi4_agent_tools/mcp/tools/map.js +21 -600
  87. package/dist/hoi4_agent_tools/mcp/tools/probability.js +39 -463
  88. package/dist/hoi4_agent_tools/mcp/tools/technology.js +40 -304
  89. package/dist/hoi4_agent_tools/mcp/transports/http.js +58 -1
  90. package/dist/hoi4_agent_tools/mcp/transports/modern-task-routing.js +109 -0
  91. package/dist/hoi4_agent_tools/mcp/transports/negotiated-stdio.js +121 -0
  92. package/dist/hoi4_agent_tools/probability/job-operations.js +58 -0
  93. package/dist/hoi4_agent_tools/probability/operations.js +183 -0
  94. package/dist/hoi4_agent_tools/probability/service.js +24 -3
  95. package/dist/hoi4_agent_tools/schemas/analysis.js +228 -0
  96. package/dist/hoi4_agent_tools/schemas/common.js +2 -0
  97. package/dist/hoi4_agent_tools/schemas/event.js +72 -1
  98. package/dist/hoi4_agent_tools/schemas/focus-requests.js +189 -0
  99. package/dist/hoi4_agent_tools/schemas/gui-requests.js +193 -0
  100. package/dist/hoi4_agent_tools/schemas/helper-expansion.js +58 -0
  101. package/dist/hoi4_agent_tools/schemas/map-requests.js +85 -0
  102. package/dist/hoi4_agent_tools/schemas/probability-requests.js +168 -0
  103. package/dist/hoi4_agent_tools/schemas/technology.js +90 -1
  104. package/dist/hoi4_agent_tools/schemas/transaction.js +1 -0
  105. package/dist/hoi4_agent_tools/technology/graph.js +83 -78
  106. package/dist/hoi4_agent_tools/technology/helper-expansion.js +33 -0
  107. package/dist/hoi4_agent_tools/technology/job-operations.js +60 -0
  108. package/dist/hoi4_agent_tools/technology/operations.js +125 -0
  109. package/dist/hoi4_agent_tools/technology/service.js +89 -15
  110. package/dist/hoi4_agent_tools/technology/source-analysis.js +2 -1
  111. package/dist/hoi4_agent_tools/version.js +1 -1
  112. package/docs/README.md +3 -0
  113. package/docs/analysis.md +100 -0
  114. package/docs/development.md +2 -0
  115. package/docs/events.md +7 -2
  116. package/docs/helper-expansion.md +78 -0
  117. package/docs/http.md +2 -0
  118. package/docs/jobs.md +86 -0
  119. package/docs/technology.md +6 -2
  120. package/package.json +15 -6
  121. package/schemas/configuration.schema.json +1 -1
  122. package/schemas/continuous-focus-palette.schema.json +1 -1
  123. package/schemas/custom-weighted-pool.schema.json +1 -1
  124. package/schemas/decision-inspect-input.schema.json +290 -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 +44 -0
  132. package/schemas/helper-expansion-summary.schema.json +70 -0
  133. package/schemas/impact-inspect-input.schema.json +203 -0
  134. package/schemas/map-operation.schema.json +1 -1
  135. package/schemas/operation-result.schema.json +1 -1
  136. package/schemas/probability-analysis-result.schema.json +1 -1
  137. package/schemas/probability-compare-input.schema.json +1 -1
  138. package/schemas/probability-evaluate-input.schema.json +1 -1
  139. package/schemas/probability-inspect-input.schema.json +1 -1
  140. package/schemas/probability-render-input.schema.json +1 -1
  141. package/schemas/probability-scenario-set.schema.json +1 -1
  142. package/schemas/probability-sequence-input.schema.json +1 -1
  143. package/schemas/probability-simulate-input.schema.json +1 -1
  144. package/schemas/probability-sweep-input.schema.json +1 -1
  145. package/server.json +3 -3
@@ -0,0 +1,383 @@
1
+ import { link, lstat, mkdir, open, opendir, readdir, unlink } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { z } from 'zod/v4';
4
+ import { canonicalJson, hashCanonical, secureId, sha256Bytes } from './canonical.js';
5
+ import { indexSegmentAddress } from './index-segments.js';
6
+ import { SharedRequestCapacity } from './shared-request-capacity.js';
7
+ import { sourceDocumentCacheKey } from './source/cache.js';
8
+ import { containedGeneratedPath } from './workspace.js';
9
+ import { PACKAGE_VERSION } from '../version.js';
10
+ const digestSchema = z.string().regex(/^[a-f0-9]{64}$/u);
11
+ const entryKindSchema = z.enum(['source-document', 'index-segment']);
12
+ const unsignedEntrySchema = z
13
+ .object({
14
+ version: z.literal(1),
15
+ kind: entryKindSchema,
16
+ scopeHash: digestSchema,
17
+ address: digestSchema,
18
+ toolVersion: z.literal(PACKAGE_VERSION),
19
+ runtime: z.string().min(1),
20
+ payloadHash: digestSchema,
21
+ payload: z.string(),
22
+ })
23
+ .strict();
24
+ const entrySchema = unsignedEntrySchema.extend({ authenticationTag: digestSchema }).strict();
25
+ const entryFilePattern = /^(?<address>[a-f0-9]{64})\.json$/u;
26
+ function sourceAddress(key) {
27
+ return hashCanonical({
28
+ version: 'persistent-source-document.v1',
29
+ toolVersion: PACKAGE_VERSION,
30
+ runtime: `${process.versions.node}\0${process.versions.v8}`,
31
+ key,
32
+ });
33
+ }
34
+ function cacheScopeHash(scope) {
35
+ return hashCanonical({ version: 'analysis-cache-scope.v1', ...scope });
36
+ }
37
+ async function assertUnlinked(candidate) {
38
+ try {
39
+ if ((await lstat(candidate)).isSymbolicLink())
40
+ throw new Error('Persistent analysis cache cannot use symbolic links or junctions');
41
+ }
42
+ catch (error) {
43
+ if (error.code !== 'ENOENT')
44
+ throw error;
45
+ }
46
+ }
47
+ /**
48
+ * Internal same-version cache shared by authorized local processes.
49
+ * Entries are immutable, authenticated, scope-separated, and never public artifacts.
50
+ */
51
+ export class PersistentAnalysisCache {
52
+ state;
53
+ root;
54
+ maxBytes;
55
+ maxEntries;
56
+ maxSingleBytes;
57
+ #lock;
58
+ #hits = 0;
59
+ #misses = 0;
60
+ #writes = 0;
61
+ #retainedEntries = 0;
62
+ #retainedBytes = 0;
63
+ #evictions = 0;
64
+ #invalidEntries = 0;
65
+ #oversized = 0;
66
+ #invalidAddresses = new Set();
67
+ constructor(state, root, maxBytes, maxEntries, maxSingleBytes) {
68
+ this.state = state;
69
+ this.root = root;
70
+ this.maxBytes = maxBytes;
71
+ this.maxEntries = maxEntries;
72
+ this.maxSingleBytes = maxSingleBytes;
73
+ this.#lock = new SharedRequestCapacity(root, 1);
74
+ }
75
+ static async create(state, options = {}) {
76
+ const maxBytes = options.maxBytes ?? 268_435_456;
77
+ const maxEntries = options.maxEntries ?? 50_000;
78
+ const maxSingleBytes = options.maxSingleBytes ?? 16_777_216;
79
+ for (const [name, value] of [
80
+ ['byte budget', maxBytes],
81
+ ['entry budget', maxEntries],
82
+ ['single-entry budget', maxSingleBytes],
83
+ ]) {
84
+ if (!Number.isSafeInteger(value) || value < 0)
85
+ throw new RangeError(`Persistent analysis cache ${name} must be a non-negative integer`);
86
+ }
87
+ await assertUnlinked(path.join(state.root, 'analysis-cache'));
88
+ const root = await containedGeneratedPath(state.root, 'analysis-cache');
89
+ await mkdir(root, { recursive: true, mode: 0o700 });
90
+ await assertUnlinked(root);
91
+ return new PersistentAnalysisCache(state, root, maxBytes, maxEntries, maxSingleBytes);
92
+ }
93
+ async hydrate(scope, files, documents, segments, signal) {
94
+ signal?.throwIfAborted();
95
+ const scopeHash = cacheScopeHash(scope);
96
+ const [sourceEntries, indexEntries] = await Promise.all([
97
+ this.inventory(scopeHash, 'source-document', signal),
98
+ this.inventory(scopeHash, 'index-segment', signal),
99
+ ]);
100
+ for (const file of files) {
101
+ signal?.throwIfAborted();
102
+ const key = sourceDocumentCacheKey(file.bytes, file.displayPath);
103
+ const documentAddress = sourceAddress(key);
104
+ if (sourceEntries.has(documentAddress)) {
105
+ const payload = await this.read(scopeHash, 'source-document', documentAddress, signal);
106
+ if (payload !== undefined) {
107
+ try {
108
+ documents.importEncoded(key, payload, {
109
+ bytes: file.bytes,
110
+ sourcePath: file.displayPath,
111
+ });
112
+ }
113
+ catch {
114
+ this.#invalidEntries += 1;
115
+ this.#invalidAddresses.add(`${scopeHash}\0source-document\0${documentAddress}`);
116
+ }
117
+ }
118
+ }
119
+ for (const provinceDefinition of [false, true]) {
120
+ const address = indexSegmentAddress(file, provinceDefinition);
121
+ if (!indexEntries.has(address))
122
+ continue;
123
+ const payload = await this.read(scopeHash, 'index-segment', address, signal);
124
+ if (payload === undefined)
125
+ continue;
126
+ try {
127
+ segments.importEncoded(address, payload.toString('utf8'));
128
+ }
129
+ catch {
130
+ this.#invalidEntries += 1;
131
+ this.#invalidAddresses.add(`${scopeHash}\0index-segment\0${address}`);
132
+ }
133
+ }
134
+ }
135
+ }
136
+ async persist(scope, files, documents, segments, signal = new AbortController().signal) {
137
+ const scopeHash = cacheScopeHash(scope);
138
+ await this.#lock.run(signal, async () => {
139
+ const [sourceEntries, indexEntries] = await Promise.all([
140
+ this.inventory(scopeHash, 'source-document', signal),
141
+ this.inventory(scopeHash, 'index-segment', signal),
142
+ ]);
143
+ for (const file of files) {
144
+ signal.throwIfAborted();
145
+ const key = sourceDocumentCacheKey(file.bytes, file.displayPath);
146
+ const documentAddress = sourceAddress(key);
147
+ const document = documents.exportEncoded(key);
148
+ if (document !== undefined &&
149
+ (!sourceEntries.has(documentAddress) ||
150
+ this.#invalidAddresses.has(`${scopeHash}\0source-document\0${documentAddress}`))) {
151
+ if (await this.write(scopeHash, 'source-document', documentAddress, document, signal)) {
152
+ sourceEntries.add(documentAddress);
153
+ }
154
+ }
155
+ for (const provinceDefinition of [false, true]) {
156
+ const address = indexSegmentAddress(file, provinceDefinition);
157
+ const segment = segments.exportEncoded(address);
158
+ if (segment === undefined ||
159
+ (indexEntries.has(address) &&
160
+ !this.#invalidAddresses.has(`${scopeHash}\0index-segment\0${address}`)))
161
+ continue;
162
+ if (await this.write(scopeHash, 'index-segment', address, Buffer.from(segment), signal)) {
163
+ indexEntries.add(address);
164
+ }
165
+ }
166
+ }
167
+ await this.prune(signal);
168
+ });
169
+ }
170
+ statistics() {
171
+ return {
172
+ hits: this.#hits,
173
+ misses: this.#misses,
174
+ writes: this.#writes,
175
+ retainedEntries: this.#retainedEntries,
176
+ retainedBytes: this.#retainedBytes,
177
+ evictions: this.#evictions,
178
+ invalidEntries: this.#invalidEntries,
179
+ oversized: this.#oversized,
180
+ };
181
+ }
182
+ async directory(scopeHash, kind) {
183
+ await assertUnlinked(this.root);
184
+ await assertUnlinked(path.join(this.root, scopeHash));
185
+ await assertUnlinked(path.join(this.root, scopeHash, kind));
186
+ return containedGeneratedPath(this.root, scopeHash, kind);
187
+ }
188
+ async inventory(scopeHash, kind, signal) {
189
+ const directory = await this.directory(scopeHash, kind);
190
+ const entries = new Set();
191
+ let opened;
192
+ try {
193
+ opened = await opendir(directory);
194
+ }
195
+ catch (error) {
196
+ if (error.code === 'ENOENT')
197
+ return entries;
198
+ throw error;
199
+ }
200
+ for await (const entry of opened) {
201
+ signal?.throwIfAborted();
202
+ const match = entryFilePattern.exec(entry.name);
203
+ if (entry.isFile() && !entry.isSymbolicLink() && match?.groups?.address !== undefined) {
204
+ entries.add(match.groups.address);
205
+ }
206
+ }
207
+ return entries;
208
+ }
209
+ async read(scopeHash, kind, address, signal) {
210
+ signal?.throwIfAborted();
211
+ const directory = await this.directory(scopeHash, kind);
212
+ const file = await containedGeneratedPath(directory, `${digestSchema.parse(address)}.json`);
213
+ const invalidAddress = `${scopeHash}\0${kind}\0${address}`;
214
+ let handle;
215
+ try {
216
+ await assertUnlinked(file);
217
+ handle = await open(file, 'r');
218
+ const metadata = await handle.stat();
219
+ if (!metadata.isFile() || metadata.size > Math.ceil((this.maxSingleBytes * 4) / 3) + 4096)
220
+ throw new Error('Persistent analysis cache entry exceeds its storage budget');
221
+ const parsed = entrySchema.parse(JSON.parse(await handle.readFile('utf8')));
222
+ const { authenticationTag, ...entry } = parsed;
223
+ if (entry.scopeHash !== scopeHash ||
224
+ entry.kind !== kind ||
225
+ entry.address !== address ||
226
+ !this.state.verifyJournal({ kind: 'analysis-cache-entry.v1', entry }, authenticationTag))
227
+ throw new Error('Persistent analysis cache authentication or identity is invalid');
228
+ const payload = Buffer.from(entry.payload, 'base64');
229
+ if (payload.length > this.maxSingleBytes || sha256Bytes(payload) !== entry.payloadHash)
230
+ throw new Error('Persistent analysis cache payload is invalid');
231
+ this.#hits += 1;
232
+ this.#invalidAddresses.delete(invalidAddress);
233
+ return payload;
234
+ }
235
+ catch (error) {
236
+ if (error.code === 'ENOENT') {
237
+ this.#misses += 1;
238
+ return undefined;
239
+ }
240
+ this.#invalidEntries += 1;
241
+ this.#invalidAddresses.add(invalidAddress);
242
+ return undefined;
243
+ }
244
+ finally {
245
+ await handle?.close();
246
+ }
247
+ }
248
+ async write(scopeHash, kind, address, payload, signal) {
249
+ if (this.maxBytes === 0 ||
250
+ this.maxEntries === 0 ||
251
+ this.maxSingleBytes === 0 ||
252
+ payload.length > this.maxSingleBytes) {
253
+ this.#oversized += 1;
254
+ return false;
255
+ }
256
+ const directory = await this.directory(scopeHash, kind);
257
+ await mkdir(directory, { recursive: true, mode: 0o700 });
258
+ const target = await containedGeneratedPath(directory, `${digestSchema.parse(address)}.json`);
259
+ const invalidAddress = `${scopeHash}\0${kind}\0${address}`;
260
+ const unsigned = {
261
+ version: 1,
262
+ kind,
263
+ scopeHash,
264
+ address,
265
+ toolVersion: PACKAGE_VERSION,
266
+ runtime: `${process.versions.node}\0${process.versions.v8}`,
267
+ payloadHash: sha256Bytes(payload),
268
+ payload: payload.toString('base64'),
269
+ };
270
+ const bytes = Buffer.from(canonicalJson({
271
+ ...unsigned,
272
+ authenticationTag: this.state.authenticateJournal({
273
+ kind: 'analysis-cache-entry.v1',
274
+ entry: unsigned,
275
+ }),
276
+ }));
277
+ if (bytes.length > Math.ceil((this.maxSingleBytes * 4) / 3) + 4096) {
278
+ this.#oversized += 1;
279
+ return false;
280
+ }
281
+ const repair = this.#invalidAddresses.has(invalidAddress);
282
+ const existing = repair ? undefined : await this.read(scopeHash, kind, address, signal);
283
+ if (existing !== undefined) {
284
+ if (!existing.equals(payload))
285
+ this.#invalidEntries += 1;
286
+ return false;
287
+ }
288
+ if (repair || this.#invalidAddresses.has(invalidAddress)) {
289
+ await unlink(target).catch((error) => {
290
+ if (error.code !== 'ENOENT')
291
+ throw error;
292
+ });
293
+ }
294
+ await assertUnlinked(target);
295
+ const temporary = await containedGeneratedPath(directory, `.${secureId('analysis')}.tmp`);
296
+ let handle;
297
+ try {
298
+ handle = await open(temporary, 'wx', 0o600);
299
+ await handle.writeFile(bytes);
300
+ await handle.sync();
301
+ await handle.close();
302
+ handle = undefined;
303
+ signal.throwIfAborted();
304
+ try {
305
+ await link(temporary, target);
306
+ this.#writes += 1;
307
+ this.#invalidAddresses.delete(invalidAddress);
308
+ return true;
309
+ }
310
+ catch (error) {
311
+ if (error.code !== 'EEXIST')
312
+ throw error;
313
+ const winner = await this.read(scopeHash, kind, address, signal);
314
+ if (winner?.equals(payload) === true)
315
+ return false;
316
+ this.#invalidEntries += 1;
317
+ return false;
318
+ }
319
+ }
320
+ finally {
321
+ await handle?.close();
322
+ await unlink(temporary).catch((error) => {
323
+ if (error.code !== 'ENOENT')
324
+ throw error;
325
+ });
326
+ }
327
+ }
328
+ async prune(signal) {
329
+ const retained = [];
330
+ let scopes;
331
+ try {
332
+ scopes = await readdir(this.root);
333
+ }
334
+ catch (error) {
335
+ if (error.code === 'ENOENT')
336
+ return;
337
+ throw error;
338
+ }
339
+ for (const scopeHash of scopes) {
340
+ if (!digestSchema.safeParse(scopeHash).success)
341
+ continue;
342
+ for (const kind of entryKindSchema.options) {
343
+ const directory = await this.directory(scopeHash, kind);
344
+ let opened;
345
+ try {
346
+ opened = await opendir(directory);
347
+ }
348
+ catch (error) {
349
+ if (error.code === 'ENOENT')
350
+ continue;
351
+ throw error;
352
+ }
353
+ for await (const entry of opened) {
354
+ signal.throwIfAborted();
355
+ if (!entry.isFile() || entry.isSymbolicLink() || !entryFilePattern.test(entry.name))
356
+ continue;
357
+ const file = await containedGeneratedPath(directory, entry.name);
358
+ const metadata = await lstat(file);
359
+ retained.push({ file, bytes: metadata.size, modifiedMs: metadata.mtimeMs });
360
+ }
361
+ }
362
+ }
363
+ retained.sort((left, right) => left.modifiedMs - right.modifiedMs || left.file.localeCompare(right.file, 'en-US'));
364
+ let retainedBytes = retained.reduce((total, entry) => total + entry.bytes, 0);
365
+ let retainedEntries = retained.length;
366
+ for (const entry of retained) {
367
+ if (retainedBytes <= this.maxBytes && retainedEntries <= this.maxEntries)
368
+ break;
369
+ try {
370
+ await unlink(entry.file);
371
+ retainedBytes -= entry.bytes;
372
+ retainedEntries -= 1;
373
+ this.#evictions += 1;
374
+ }
375
+ catch (error) {
376
+ if (!['ENOENT', 'EPERM', 'EBUSY', 'EACCES'].includes(error.code ?? ''))
377
+ throw error;
378
+ }
379
+ }
380
+ this.#retainedBytes = retainedBytes;
381
+ this.#retainedEntries = retainedEntries;
382
+ }
383
+ }
@@ -1,3 +1,4 @@
1
+ export const MAX_INLINE_ARTIFACT_LINKS = 32;
1
2
  export function emptyServiceResult(workspaceId, data) {
2
3
  return {
3
4
  status: 'ok',
@@ -1,7 +1,7 @@
1
1
  import { open } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import fg from 'fast-glob';
4
- import { compareCodeUnits, hashCanonical, sha256Bytes } from './canonical.js';
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
7
  function normalizeRelative(value) {
@@ -27,17 +27,13 @@ export class WorkspaceScanner {
27
27
  serverMaxBytes;
28
28
  serverMaxFileBytes;
29
29
  #sourceCache = new Map();
30
- #gameScanCache = new Map();
31
30
  #sourceCacheBytes = 0;
32
- #gameScanCacheBytes = 0;
33
31
  #sourceCacheMaxBytes;
34
- #gameScanCacheMaxBytes;
35
32
  constructor(serverMaxFiles = DEFAULT_SCAN_MAX_FILES, serverMaxBytes = DEFAULT_SCAN_MAX_BYTES, serverMaxFileBytes = DEFAULT_SCAN_MAX_FILE_BYTES) {
36
33
  this.serverMaxFiles = serverMaxFiles;
37
34
  this.serverMaxBytes = serverMaxBytes;
38
35
  this.serverMaxFileBytes = serverMaxFileBytes;
39
36
  this.#sourceCacheMaxBytes = Math.max(1, Math.min(134_217_728, serverMaxBytes));
40
- this.#gameScanCacheMaxBytes = Math.min(134_217_728, serverMaxBytes);
41
37
  }
42
38
  cacheKey(absolutePath) {
43
39
  const resolved = path.resolve(absolutePath);
@@ -48,9 +44,11 @@ export class WorkspaceScanner {
48
44
  if (previous !== undefined)
49
45
  this.#sourceCacheBytes -= previous.bytes.length;
50
46
  this.#sourceCache.delete(key);
47
+ if (source.bytes.length > this.#sourceCacheMaxBytes)
48
+ return;
51
49
  this.#sourceCache.set(key, source);
52
50
  this.#sourceCacheBytes += source.bytes.length;
53
- while (this.#sourceCacheBytes > this.#sourceCacheMaxBytes && this.#sourceCache.size > 1) {
51
+ while (this.#sourceCacheBytes > this.#sourceCacheMaxBytes || this.#sourceCache.size > 16_384) {
54
52
  const oldest = this.#sourceCache.entries().next().value;
55
53
  if (oldest === undefined)
56
54
  break;
@@ -58,46 +56,20 @@ export class WorkspaceScanner {
58
56
  this.#sourceCacheBytes -= oldest[1].bytes.length;
59
57
  }
60
58
  }
61
- gameScanKey(workspace, root, options) {
62
- return hashCanonical({
63
- workspaceId: workspace.id,
64
- root: root.path,
65
- patterns: [...options.patterns].sort(compareCodeUnits),
66
- ignore: [...(options.ignore ?? ['**/.hoi4-agent/**'])].sort(compareCodeUnits),
67
- });
68
- }
69
- retainGameScan(key, files) {
70
- const bytes = files.reduce((total, file) => total + file.size, 0);
71
- const previous = this.#gameScanCache.get(key);
72
- if (previous !== undefined)
73
- this.#gameScanCacheBytes -= previous.bytes;
74
- this.#gameScanCache.delete(key);
75
- if (bytes > this.#gameScanCacheMaxBytes)
76
- return;
77
- this.#gameScanCache.set(key, { files: files.map((file) => ({ ...file })), bytes });
78
- this.#gameScanCacheBytes += bytes;
79
- while (this.#gameScanCache.size > 8 || this.#gameScanCacheBytes > this.#gameScanCacheMaxBytes) {
80
- const oldest = this.#gameScanCache.entries().next().value;
81
- if (oldest === undefined)
82
- break;
83
- this.#gameScanCache.delete(oldest[0]);
84
- this.#gameScanCacheBytes -= oldest[1].bytes;
85
- }
86
- }
87
59
  /** Release retained source buffers after an idle MCP analysis batch. */
88
60
  clearCaches() {
89
61
  this.#sourceCache.clear();
90
62
  this.#sourceCacheBytes = 0;
91
- this.#gameScanCache.clear();
92
- this.#gameScanCacheBytes = 0;
93
63
  }
94
64
  /** Cache accounting used by lifecycle and regression tests. */
95
65
  cacheUsage() {
96
66
  return {
97
67
  sourceBytes: this.#sourceCacheBytes,
98
68
  sourceEntries: this.#sourceCache.size,
99
- gameScanBytes: this.#gameScanCacheBytes,
100
- gameScanEntries: this.#gameScanCache.size,
69
+ // Retained for callers of the existing accounting interface. Inventories
70
+ // are no longer cached independently of verified source bytes.
71
+ gameScanBytes: 0,
72
+ gameScanEntries: 0,
101
73
  };
102
74
  }
103
75
  async scan(workspace, options) {
@@ -124,29 +96,8 @@ export class WorkspaceScanner {
124
96
  let enumeratedFiles = 0;
125
97
  for (const root of roots) {
126
98
  options.signal?.throwIfAborted();
127
- const gameScanKey = root.kind === 'game' ? this.gameScanKey(workspace, root, options) : undefined;
128
- const cachedGameFiles = gameScanKey === undefined ? undefined : this.#gameScanCache.get(gameScanKey);
129
- if (cachedGameFiles !== undefined && gameScanKey !== undefined) {
130
- this.#gameScanCache.delete(gameScanKey);
131
- this.#gameScanCache.set(gameScanKey, cachedGameFiles);
132
- enumeratedFiles += cachedGameFiles.files.length;
133
- if (enumeratedFiles > maxFiles)
134
- throw new ServiceError('SCAN_FILE_LIMIT', 'Scan exceeds the configured file limit', {
135
- files: enumeratedFiles,
136
- limit: maxFiles,
137
- });
138
- for (const file of cachedGameFiles.files) {
139
- totalBytes += file.size;
140
- if (totalBytes > maxBytes)
141
- throw new ServiceError('SCAN_BYTE_LIMIT', 'Scan exceeds the configured byte limit', {
142
- bytes: totalBytes,
143
- limit: maxBytes,
144
- });
145
- result.push({ ...file });
146
- }
147
- continue;
148
- }
149
- const rootFiles = [];
99
+ // Re-enumerate every root: installing content or changing load order can
100
+ // invalidate a vanilla inventory just as it can a mod inventory.
150
101
  const matches = fg.stream(options.patterns, {
151
102
  cwd: root.path,
152
103
  onlyFiles: true,
@@ -185,20 +136,12 @@ export class WorkspaceScanner {
185
136
  });
186
137
  }
187
138
  const cached = this.#sourceCache.get(cacheKey);
188
- const retained = cached?.size === metadata.size &&
189
- cached.modifiedMs === metadata.mtimeMs &&
190
- cached.changedMs === metadata.ctimeMs &&
191
- cached.inode === metadata.ino
192
- ? cached
193
- : undefined;
194
- const bytes = retained?.bytes ??
195
- (await readBoundedFile(handle, Math.min(remaining, this.serverMaxFileBytes), options.signal));
196
- const sha256 = retained?.sha256 ?? sha256Bytes(bytes);
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;
197
144
  this.retainSource(cacheKey, {
198
- size: metadata.size,
199
- modifiedMs: metadata.mtimeMs,
200
- changedMs: metadata.ctimeMs,
201
- inode: metadata.ino,
202
145
  sha256,
203
146
  bytes,
204
147
  });
@@ -215,14 +158,11 @@ export class WorkspaceScanner {
215
158
  bytes,
216
159
  };
217
160
  result.push(scanned);
218
- rootFiles.push(scanned);
219
161
  }
220
162
  finally {
221
163
  await handle.close();
222
164
  }
223
165
  }
224
- if (gameScanKey !== undefined)
225
- this.retainGameScan(gameScanKey, rootFiles);
226
166
  }
227
167
  result.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.relativePath, right.relativePath));
228
168
  const groups = new Map();