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,233 @@
1
+ import { canonicalJson, compareCodeUnits, hashCanonical } from './canonical.js';
2
+ import { ReverseSourceDependencies } from './index-segments.js';
3
+ import { PACKAGE_VERSION } from '../version.js';
4
+ const MAX_PROOF_BYTES = 32 * 1024 * 1024;
5
+ const MAX_FRAGMENT_PROOFS = 10_000;
6
+ const MAX_SCOPE_PROOFS = 32;
7
+ /** Source identity includes provenance and interpretation, not just identical source bytes. */
8
+ export function semanticSourceIdentity(file) {
9
+ return hashCanonical({
10
+ version: 'semantic-source.v1',
11
+ toolVersion: PACKAGE_VERSION,
12
+ absolutePath: file.absolutePath,
13
+ path: file.displayPath,
14
+ relativePath: file.relativePath,
15
+ rootKind: file.rootKind,
16
+ loadOrder: file.loadOrder,
17
+ shadowedBy: file.shadowedBy ?? null,
18
+ hash: file.sha256,
19
+ });
20
+ }
21
+ function valueIdentity(value) {
22
+ if (value instanceof Set)
23
+ return [...value].map(valueIdentity);
24
+ if (value instanceof Map)
25
+ return [...value].map(([key, child]) => [
26
+ valueIdentity(key),
27
+ valueIdentity(child),
28
+ ]);
29
+ return value ?? null;
30
+ }
31
+ /** Observe actual typed catalog lookups, including unsuccessful lookups for later additions. */
32
+ export class SemanticCatalog {
33
+ bindings = new Map();
34
+ reads;
35
+ bind(name, kind, values) {
36
+ if (this.bindings.has(name))
37
+ throw new Error('Semantic catalog binding names must be unique');
38
+ this.bindings.set(name, { kind, values });
39
+ const record = (id) => this.reads?.set(canonicalJson([name, id]), { name, kind, id });
40
+ return new Proxy(values, {
41
+ get(target, property) {
42
+ if (property === 'get' || property === 'has')
43
+ return (id) => {
44
+ record(id);
45
+ return property === 'get'
46
+ ? target.get(id)
47
+ : target.has(id);
48
+ };
49
+ if (property === 'size') {
50
+ record(null);
51
+ return target.size;
52
+ }
53
+ if (['set', 'add', 'delete', 'clear'].includes(String(property)))
54
+ return () => {
55
+ throw new Error('Semantic catalogs are read-only');
56
+ };
57
+ if (property === Symbol.iterator ||
58
+ ['entries', 'keys', 'values', 'forEach'].includes(String(property))) {
59
+ record(null);
60
+ const method = Reflect.get(target, property, target);
61
+ return method.bind(target);
62
+ }
63
+ return Reflect.get(target, property, target);
64
+ },
65
+ });
66
+ }
67
+ collect(analyze) {
68
+ const previous = this.reads;
69
+ this.reads = new Map();
70
+ try {
71
+ const value = analyze();
72
+ const reads = [...this.reads.entries()]
73
+ .sort(([a], [b]) => compareCodeUnits(a, b))
74
+ .map(([, read]) => read);
75
+ return { value, reads, signature: this.signature(reads) };
76
+ }
77
+ finally {
78
+ this.reads = previous;
79
+ }
80
+ }
81
+ signature(reads) {
82
+ return hashCanonical(reads.map(({ name, kind, id }) => {
83
+ const binding = this.bindings.get(name);
84
+ if (binding?.kind !== kind)
85
+ return [name, kind, id, 'missing-binding'];
86
+ const values = binding.values;
87
+ return [
88
+ name,
89
+ kind,
90
+ id,
91
+ id === null
92
+ ? valueIdentity(values)
93
+ : [values.has(id), values instanceof Map ? valueIdentity(values.get(id)) : null],
94
+ ];
95
+ }));
96
+ }
97
+ }
98
+ class DependencyProofs {
99
+ entries = new Map();
100
+ scopes = new Map();
101
+ entryBytes = 0;
102
+ scopeBytes = 0;
103
+ put(key, proof) {
104
+ const bytes = canonicalJson(proof).length * 2 + key.length * 2 + 128;
105
+ const previous = this.entries.get(key);
106
+ if (previous !== undefined) {
107
+ this.entries.delete(key);
108
+ this.entryBytes -= previous.bytes;
109
+ }
110
+ if (bytes > MAX_PROOF_BYTES)
111
+ return;
112
+ this.entries.set(key, { proof, bytes });
113
+ this.entryBytes += bytes;
114
+ while (this.entries.size > MAX_FRAGMENT_PROOFS || this.entryBytes > MAX_PROOF_BYTES) {
115
+ const [oldest, entry] = this.entries.entries().next().value;
116
+ this.entries.delete(oldest);
117
+ this.entryBytes -= entry.bytes;
118
+ }
119
+ }
120
+ remember(scope, proof) {
121
+ const previous = this.scopes.get(scope);
122
+ if (previous !== undefined) {
123
+ this.scopes.delete(scope);
124
+ this.scopeBytes -= previous.bytes;
125
+ }
126
+ if (proof.bytes > MAX_PROOF_BYTES)
127
+ return;
128
+ this.scopes.set(scope, proof);
129
+ this.scopeBytes += proof.bytes;
130
+ while (this.scopes.size > MAX_SCOPE_PROOFS || this.scopeBytes > MAX_PROOF_BYTES) {
131
+ const [oldest, entry] = this.scopes.entries().next().value;
132
+ this.scopes.delete(oldest);
133
+ this.scopeBytes -= entry.bytes;
134
+ }
135
+ }
136
+ }
137
+ const proofsByCache = new WeakMap();
138
+ export function clearSemanticDependencies(cache) {
139
+ proofsByCache.delete(cache);
140
+ }
141
+ /** Reconcile both old and new typed definitions so removal, overlay and rename edges survive. */
142
+ export function beginSemanticAnalysis(cache, snapshot, domain, workspaceIdentity) {
143
+ const scope = hashCanonical({
144
+ domain,
145
+ workspaceId: snapshot.workspaceId,
146
+ workspaceIdentity: workspaceIdentity ?? null,
147
+ roots: [
148
+ ...new Set(snapshot.files.map(({ absolutePath, relativePath }) => absolutePath.slice(0, -relativePath.length))),
149
+ ].sort(compareCodeUnits),
150
+ });
151
+ let proofs = cache === undefined ? undefined : proofsByCache.get(cache);
152
+ if (proofs === undefined) {
153
+ proofs = new DependencyProofs();
154
+ if (cache !== undefined)
155
+ proofsByCache.set(cache, proofs);
156
+ }
157
+ const files = new Map(snapshot.files.map((file) => [file.displayPath, semanticSourceIdentity(file)]));
158
+ const symbols = snapshot.index.symbols.map(({ kind, id, path }) => ({ kind, id, path }));
159
+ const previous = proofs.scopes.get(scope);
160
+ const baselineRevision = previous?.baselineRevision ?? snapshot.revision;
161
+ const invalidations = new Map([...(previous?.invalidations ?? [])].filter(([path]) => files.has(path)));
162
+ let affectedFiles = new Set();
163
+ if (previous !== undefined) {
164
+ const changed = [...new Set([...previous.files.keys(), ...files.keys()])].filter((path) => previous.files.get(path) !== files.get(path));
165
+ if (changed.length > 0) {
166
+ const references = [
167
+ ...snapshot.index.references,
168
+ ];
169
+ for (const { proof } of proofs.entries.values())
170
+ if (proof.scope === scope)
171
+ for (const { kind, id } of proof.reads)
172
+ if (id !== null)
173
+ references.push({ path: proof.path, toKind: kind, to: id });
174
+ affectedFiles = new Set(new ReverseSourceDependencies([...previous.symbols, ...symbols], references).affectedFiles(changed));
175
+ for (const path of affectedFiles)
176
+ if (files.has(path))
177
+ invalidations.set(path, snapshot.revision);
178
+ }
179
+ }
180
+ const bytes = [...files].reduce((total, [path, address]) => total + 128 + (path.length + address.length) * 2, 0) +
181
+ symbols.reduce((total, { kind, id, path }) => total + 128 + (kind.length + id.length + path.length) * 2, 0) +
182
+ [...invalidations].reduce((total, [path, revision]) => total + 128 + (path.length + revision.length) * 2, 0);
183
+ proofs.remember(scope, { files, symbols, invalidations, bytes, baselineRevision });
184
+ return {
185
+ scope,
186
+ proofs,
187
+ affectedFiles,
188
+ complete: snapshot.complete,
189
+ invalidations,
190
+ baselineRevision,
191
+ };
192
+ }
193
+ export function semanticFragment(session, cache, file, legacyKey, catalog, analyze) {
194
+ if (cache === undefined)
195
+ return analyze();
196
+ const address = `${session.scope}:${semanticSourceIdentity(file)}`;
197
+ const previous = session.proofs.entries.get(address)?.proof;
198
+ const dependencyRevision = session.invalidations.get(file.displayPath) ?? session.baselineRevision;
199
+ if (!session.affectedFiles.has(file.displayPath)) {
200
+ if (previous?.environment === session.complete &&
201
+ previous.dependencyRevision === dependencyRevision &&
202
+ previous.signature === catalog.signature(previous.reads)) {
203
+ const value = cache.get(previous.key);
204
+ if (value !== undefined)
205
+ return value;
206
+ }
207
+ else if (previous === undefined) {
208
+ // Preserve custom graph-builder cache adapters; internally produced entries use exact read proofs.
209
+ const value = cache.get(legacyKey);
210
+ if (value !== undefined)
211
+ return value;
212
+ }
213
+ }
214
+ const { value, reads, signature } = catalog.collect(analyze);
215
+ const key = hashCanonical({
216
+ version: 'semantic-fragment.v1',
217
+ address,
218
+ signature,
219
+ complete: session.complete,
220
+ dependencyRevision,
221
+ });
222
+ cache.set(key, value, file.bytes.length);
223
+ session.proofs.put(address, {
224
+ scope: session.scope,
225
+ path: file.displayPath,
226
+ reads,
227
+ signature,
228
+ key,
229
+ environment: session.complete,
230
+ dependencyRevision,
231
+ });
232
+ return value;
233
+ }
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { lstat, mkdir, readdir, rmdir, unlink, writeFile } from 'node:fs/promises';
2
+ import { lstat, mkdir, readdir, rename, rmdir, unlink, writeFile } from 'node:fs/promises';
3
3
  import { hostname } from 'node:os';
4
4
  import path from 'node:path';
5
5
  import { setTimeout as delay } from 'node:timers/promises';
@@ -15,7 +15,6 @@ function processAlive(pid) {
15
15
  return error.code !== 'ESRCH';
16
16
  }
17
17
  }
18
- /** Bounds heavy work across task processes sharing the same private server state. */
19
18
  export class SharedRequestCapacity {
20
19
  stateRoot;
21
20
  capacity;
@@ -26,7 +25,11 @@ export class SharedRequestCapacity {
26
25
  async run(signal, action) {
27
26
  signal.throwIfAborted();
28
27
  if (this.stateRoot === undefined)
29
- return action();
28
+ return action({
29
+ handoffToProcess: () => {
30
+ return Promise.reject(new ServiceError('REQUEST_LEASE_STORAGE_REQUIRED', 'Worker ownership transfer requires persistent capacity storage'));
31
+ },
32
+ });
30
33
  const root = await containedGeneratedPath(await canonicalPath(this.stateRoot, signal), 'request-capacity', sha256Bytes(hostname().toLowerCase()).slice(0, 16));
31
34
  await mkdir(root, { recursive: true });
32
35
  const lease = `${process.pid}-${randomUUID()}.lease`;
@@ -74,18 +77,47 @@ export class SharedRequestCapacity {
74
77
  await rmdir(slot).catch(() => undefined);
75
78
  throw error;
76
79
  }
80
+ let transferredPid;
77
81
  try {
78
82
  signal.throwIfAborted();
79
- return await action();
83
+ return await action({
84
+ handoffToProcess: async (pid) => {
85
+ if (transferredPid !== undefined)
86
+ throw new ServiceError('REQUEST_LEASE_ALREADY_TRANSFERRED', 'The capacity lease already belongs to a child process');
87
+ if (!Number.isSafeInteger(pid) ||
88
+ pid <= 0 ||
89
+ pid === process.pid ||
90
+ !processAlive(pid))
91
+ throw new ServiceError('REQUEST_LEASE_OWNER_INVALID', 'Capacity can only transfer to another live process');
92
+ const next = path.join(slot, `${pid}-${randomUUID()}.lease`);
93
+ try {
94
+ await rename(owner, next);
95
+ }
96
+ catch (error) {
97
+ if (error.code === 'ENOENT')
98
+ throw new ServiceError('REQUEST_LEASE_HANDOFF_LOST', 'The capacity lease disappeared before worker dispatch');
99
+ throw error;
100
+ }
101
+ owner = next;
102
+ transferredPid = pid;
103
+ },
104
+ });
80
105
  }
81
106
  finally {
82
- await unlink(owner);
83
- await rmdir(slot).catch((error) => {
84
- const code = error.code;
85
- if (!['ENOENT', 'ENOTEMPTY'].includes(code ?? '') &&
86
- !(process.platform === 'win32' && ['EPERM', 'EBUSY'].includes(code ?? '')))
87
- throw error;
88
- });
107
+ // A launcher can fail while its admitted child still runs. Keep that child's
108
+ // lease until process-exit evidence permits a later reaper to reclaim it.
109
+ if (transferredPid === undefined || !processAlive(transferredPid)) {
110
+ await unlink(owner).catch((error) => {
111
+ if (error.code !== 'ENOENT')
112
+ throw error;
113
+ });
114
+ await rmdir(slot).catch((error) => {
115
+ const code = error.code;
116
+ if (!['ENOENT', 'ENOTEMPTY'].includes(code ?? '') &&
117
+ !(process.platform === 'win32' && ['EPERM', 'EBUSY'].includes(code ?? '')))
118
+ throw error;
119
+ });
120
+ }
89
121
  }
90
122
  }
91
123
  await delay(100, undefined, { signal });
@@ -0,0 +1,107 @@
1
+ import { deserialize, serialize } from 'node:v8';
2
+ import { sha256Bytes } from '../canonical.js';
3
+ export function sourceDocumentCacheKey(bytes, sourcePath) {
4
+ return `${sha256Bytes(bytes)}:${sourcePath}`;
5
+ }
6
+ function decodedDocument(encoded, expected) {
7
+ const value = deserialize(encoded);
8
+ if (value === null ||
9
+ typeof value !== 'object' ||
10
+ !('path' in value) ||
11
+ typeof value.path !== 'string' ||
12
+ !('sourceRevision' in value) ||
13
+ typeof value.sourceRevision !== 'string' ||
14
+ !('tokens' in value) ||
15
+ !Array.isArray(value.tokens) ||
16
+ !('diagnostics' in value) ||
17
+ !Array.isArray(value.diagnostics) ||
18
+ !('root' in value) ||
19
+ value.root === undefined ||
20
+ !('bytes' in value) ||
21
+ !(value.bytes instanceof Uint8Array))
22
+ throw new Error('Persistent source document is structurally invalid');
23
+ if (expected !== undefined &&
24
+ (value.path !== expected.sourcePath ||
25
+ value.sourceRevision !== sha256Bytes(expected.bytes) ||
26
+ !Buffer.from(value.bytes).equals(Buffer.from(expected.bytes))))
27
+ throw new Error('Persistent source document does not match its source identity');
28
+ return value;
29
+ }
30
+ /** Process-local parsed facts; serialized entries cannot be mutated by their consumers. */
31
+ export class SourceDocumentCache {
32
+ maxBytes;
33
+ #entries = new Map();
34
+ #bytes = 0;
35
+ #hits = 0;
36
+ #misses = 0;
37
+ #evictions = 0;
38
+ constructor(maxBytes = 64 * 1024 * 1024) {
39
+ this.maxBytes = maxBytes;
40
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
41
+ throw new RangeError('Source cache size must be a non-negative integer');
42
+ }
43
+ get(key) {
44
+ const encoded = this.#entries.get(key);
45
+ if (encoded === undefined) {
46
+ this.#misses += 1;
47
+ return undefined;
48
+ }
49
+ this.#hits += 1;
50
+ this.#entries.delete(key);
51
+ this.#entries.set(key, encoded);
52
+ return decodedDocument(encoded);
53
+ }
54
+ put(key, document) {
55
+ // Avoid allocating a large serialized copy just to discover that it cannot
56
+ // be retained. This conservative estimate affects caching, never parsing.
57
+ const estimatedBytes = document.bytes.length * 12 + document.tokens.length * 512 + key.length * 2 + 128;
58
+ if (this.maxBytes === 0 || estimatedBytes > this.maxBytes)
59
+ return;
60
+ const encoded = serialize(document);
61
+ this.retain(key, encoded);
62
+ }
63
+ /** Import one authenticated same-runtime entry without counting it as a process-local hit. */
64
+ importEncoded(key, encoded, expected) {
65
+ decodedDocument(encoded, expected);
66
+ this.retain(key, Buffer.from(encoded));
67
+ }
68
+ exportEncoded(key) {
69
+ const encoded = this.#entries.get(key);
70
+ return encoded === undefined ? undefined : Buffer.from(encoded);
71
+ }
72
+ retain(key, encoded) {
73
+ const size = encoded.length + key.length * 2 + 128;
74
+ if (size > this.maxBytes)
75
+ return;
76
+ const previous = this.#entries.get(key);
77
+ if (previous !== undefined)
78
+ this.#bytes -= previous.length + key.length * 2 + 128;
79
+ this.#entries.delete(key);
80
+ while (this.#bytes + size > this.maxBytes) {
81
+ const oldest = this.#entries.entries().next().value;
82
+ if (oldest === undefined)
83
+ break;
84
+ this.#entries.delete(oldest[0]);
85
+ this.#bytes -= oldest[1].length + oldest[0].length * 2 + 128;
86
+ this.#evictions += 1;
87
+ }
88
+ this.#entries.set(key, encoded);
89
+ this.#bytes += size;
90
+ }
91
+ clear() {
92
+ this.#entries.clear();
93
+ this.#bytes = 0;
94
+ }
95
+ statistics() {
96
+ return {
97
+ hits: this.#hits,
98
+ misses: this.#misses,
99
+ evictions: this.#evictions,
100
+ retainedBytes: this.#bytes,
101
+ retainedDocuments: this.#entries.size,
102
+ };
103
+ }
104
+ }
105
+ // All domains call the same parser; cache keys include both bytes and source location.
106
+ // Authorization remains in workspace reads, before any document reaches this cache.
107
+ export const sourceDocuments = new SourceDocumentCache();
@@ -1,6 +1,7 @@
1
1
  import { hasBlockingDiagnostics } from '../diagnostics.js';
2
2
  import { ServiceError } from '../result.js';
3
3
  import { sha256Bytes } from '../canonical.js';
4
+ import { sourceDocumentCacheKey, sourceDocuments } from './cache.js';
4
5
  import { decodeSource, encodeSource } from './encoding.js';
5
6
  import { createSourceLineIndex, isTrivia, lexClausewitz, locationFor, } from './lexer.js';
6
7
  import { SOURCE_LINE_LIMIT, SOURCE_MAX_BYTES, SOURCE_MAX_NESTING, SourceDiagnosticCollector, } from './limits.js';
@@ -224,7 +225,16 @@ class Parser {
224
225
  return locationFor(this.sourcePath, this.lineIndex, token.start, token.end);
225
226
  }
226
227
  }
227
- export function parseClausewitz(bytes, sourcePath) {
228
+ export function parseClausewitz(bytes, sourcePath, cache = sourceDocuments) {
229
+ const key = sourceDocumentCacheKey(bytes, sourcePath);
230
+ const cached = cache.get(key);
231
+ if (cached !== undefined)
232
+ return cached;
233
+ const document = parseClausewitzUncached(bytes, sourcePath);
234
+ cache.put(key, document);
235
+ return document;
236
+ }
237
+ function parseClausewitzUncached(bytes, sourcePath) {
228
238
  const decoded = decodeSource(bytes);
229
239
  const diagnostics = new SourceDiagnosticCollector();
230
240
  if (bytes.byteLength > SOURCE_MAX_BYTES) {
@@ -0,0 +1,125 @@
1
+ import path from 'node:path';
2
+ import { compareCodeUnits, hashCanonical, sha256Bytes } from './canonical.js';
3
+ import { SymbolIndex } from './index.js';
4
+ import { isWithin } from './workspace.js';
5
+ const MAX_EDITS = 64;
6
+ const MAX_EDIT_BYTES = 8 * 1024 * 1024;
7
+ const MAX_TOTAL_BYTES = 32 * 1024 * 1024;
8
+ const SOURCE_EXTENSION = /\.(?:txt|gui|gfx|yml|yaml)$/iu;
9
+ function checkedRelativePath(relativePath) {
10
+ if (relativePath.length === 0 ||
11
+ relativePath.length > 4096 ||
12
+ relativePath.includes('\\') ||
13
+ relativePath.includes('\0') ||
14
+ path.posix.isAbsolute(relativePath) ||
15
+ /^[A-Za-z]:/u.test(relativePath))
16
+ throw new RangeError('Proposed source path is not workspace-relative');
17
+ const parts = relativePath.split('/');
18
+ if (parts.some((part) => part.length === 0 || part === '.' || part === '..'))
19
+ throw new RangeError('Proposed source path is not normalized');
20
+ if (!SOURCE_EXTENSION.test(relativePath))
21
+ throw new RangeError('Proposed source must be a supported text source');
22
+ return relativePath;
23
+ }
24
+ function revision(files) {
25
+ return hashCanonical(files.map(({ absolutePath, displayPath, relativePath, rootKind, loadOrder, shadowedBy, sha256 }) => ({
26
+ absolutePath,
27
+ displayPath,
28
+ relativePath,
29
+ rootKind,
30
+ loadOrder,
31
+ shadowedBy: shadowedBy ?? null,
32
+ sha256,
33
+ })));
34
+ }
35
+ /** Builds a source-only proposal in memory under the authorized mod root. */
36
+ export function buildProposedSourceOverlay(baseline, workspace, edits) {
37
+ if (edits.length < 1 || edits.length > MAX_EDITS)
38
+ throw new RangeError(`Proposed overlay requires 1-${MAX_EDITS} edits`);
39
+ if (baseline.workspaceId !== workspace.id)
40
+ throw new Error('Proposed overlay workspace does not match its baseline snapshot');
41
+ const modRoot = workspace.roots.find(({ kind }) => kind === 'mod');
42
+ if (modRoot?.path !== workspace.modRoot)
43
+ throw new Error('Authorized mod root is unavailable');
44
+ const files = baseline.files.map((file) => {
45
+ const copy = { ...file };
46
+ delete copy.shadowedBy;
47
+ return copy;
48
+ });
49
+ const changes = [];
50
+ const seen = new Set();
51
+ let totalBytes = 0;
52
+ for (const edit of edits) {
53
+ const relativePath = checkedRelativePath(edit.relativePath);
54
+ const key = relativePath.toLowerCase();
55
+ if (seen.has(key))
56
+ throw new Error(`Duplicate proposed source path: ${relativePath}`);
57
+ seen.add(key);
58
+ const existingIndex = files.findIndex((file) => file.rootKind === 'mod' && file.relativePath.toLowerCase() === relativePath.toLowerCase());
59
+ const existing = existingIndex < 0 ? undefined : files[existingIndex];
60
+ const sourcePath = existing?.relativePath ?? relativePath;
61
+ const absolutePath = path.resolve(modRoot.path, ...sourcePath.split('/'));
62
+ if (!isWithin(modRoot.path, absolutePath) || absolutePath === modRoot.path)
63
+ throw new RangeError('Proposed source path escapes the authorized mod root');
64
+ const displayPath = existing?.displayPath ?? `mod:${sourcePath}`;
65
+ if (existing !== undefined)
66
+ files.splice(existingIndex, 1);
67
+ if (edit.content === null) {
68
+ if (existing === undefined)
69
+ throw new Error(`Proposed removal has no mod source: ${relativePath}`);
70
+ changes.push({
71
+ path: existing.displayPath,
72
+ change: 'removed',
73
+ beforeHash: existing.sha256,
74
+ afterHash: null,
75
+ });
76
+ continue;
77
+ }
78
+ const bytes = Buffer.from(edit.content, 'utf8');
79
+ totalBytes += bytes.length;
80
+ if (bytes.length > MAX_EDIT_BYTES || totalBytes > MAX_TOTAL_BYTES)
81
+ throw new RangeError('Proposed source content exceeds the overlay limit');
82
+ const sha256 = sha256Bytes(bytes);
83
+ files.push({
84
+ absolutePath,
85
+ displayPath,
86
+ relativePath: sourcePath,
87
+ rootKind: 'mod',
88
+ loadOrder: modRoot.loadOrder,
89
+ size: bytes.length,
90
+ modifiedMs: 0,
91
+ sha256,
92
+ bytes,
93
+ });
94
+ changes.push({
95
+ path: displayPath,
96
+ change: existing === undefined ? 'added' : 'replaced',
97
+ beforeHash: existing?.sha256 ?? null,
98
+ afterHash: sha256,
99
+ });
100
+ }
101
+ files.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.relativePath, right.relativePath));
102
+ const groups = new Map();
103
+ for (const file of files) {
104
+ const group = groups.get(file.relativePath.toLowerCase()) ?? [];
105
+ group.push(file);
106
+ groups.set(file.relativePath.toLowerCase(), group);
107
+ }
108
+ for (const group of groups.values()) {
109
+ group.sort((left, right) => right.loadOrder - left.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
110
+ for (const file of group.slice(1))
111
+ file.shadowedBy = group[0].displayPath;
112
+ }
113
+ const index = SymbolIndex.build(files);
114
+ const snapshot = {
115
+ workspaceId: baseline.workspaceId,
116
+ revision: revision(files),
117
+ files,
118
+ index,
119
+ complete: index.complete,
120
+ skippedSourceCount: index.skippedSourceCount,
121
+ skippedSources: index.skippedSources,
122
+ diagnostics: index.diagnostics,
123
+ };
124
+ return { baselineRevision: baseline.revision, snapshot, changes };
125
+ }