hoi4-agent-tools 3.9.0 → 4.0.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 (157) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/README.md +115 -147
  3. package/dist/bin/http.js +12 -0
  4. package/dist/bin/setup.js +3 -2
  5. package/dist/bin/stdio.js +12 -0
  6. package/dist/hoi4_agent_tools/core/artifacts.js +40 -5
  7. package/dist/hoi4_agent_tools/core/canonical.js +18 -4
  8. package/dist/hoi4_agent_tools/core/configuration.js +7 -0
  9. package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +12 -0
  10. package/dist/hoi4_agent_tools/core/domain-tools.js +65 -0
  11. package/dist/hoi4_agent_tools/core/domain-validation.js +5 -2
  12. package/dist/hoi4_agent_tools/core/engine.js +56 -10
  13. package/dist/hoi4_agent_tools/core/index.js +45 -20
  14. package/dist/hoi4_agent_tools/core/job-executor.js +14 -1
  15. package/dist/hoi4_agent_tools/core/job-operation-registration.js +15 -7
  16. package/dist/hoi4_agent_tools/core/job-store.js +78 -5
  17. package/dist/hoi4_agent_tools/core/job-transactions.js +31 -2
  18. package/dist/hoi4_agent_tools/core/job-worker-host.js +215 -116
  19. package/dist/hoi4_agent_tools/core/job-worker.js +90 -36
  20. package/dist/hoi4_agent_tools/core/operation-result.js +123 -10
  21. package/dist/hoi4_agent_tools/core/operation-tasks.js +49 -89
  22. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +25 -21
  23. package/dist/hoi4_agent_tools/core/server-registry.js +155 -0
  24. package/dist/hoi4_agent_tools/core/server-status-resource.js +22 -0
  25. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +26 -1
  26. package/dist/hoi4_agent_tools/core/source/cache.js +108 -29
  27. package/dist/hoi4_agent_tools/core/source/lexer.js +11 -3
  28. package/dist/hoi4_agent_tools/core/transactions.js +114 -23
  29. package/dist/hoi4_agent_tools/core/workspace.js +84 -8
  30. package/dist/hoi4_agent_tools/decision/ai.js +1 -1
  31. package/dist/hoi4_agent_tools/decision/service.js +28 -0
  32. package/dist/hoi4_agent_tools/decision/source-inventory.js +7 -5
  33. package/dist/hoi4_agent_tools/event/job-operations.js +9 -5
  34. package/dist/hoi4_agent_tools/event/operations.js +14 -1
  35. package/dist/hoi4_agent_tools/event/service.js +7 -0
  36. package/dist/hoi4_agent_tools/focus/job-operations.js +1 -2
  37. package/dist/hoi4_agent_tools/focus/tool-operations.js +1 -1
  38. package/dist/hoi4_agent_tools/gui/scenario-generator.js +11 -3
  39. package/dist/hoi4_agent_tools/gui/studio.js +20 -7
  40. package/dist/hoi4_agent_tools/gui/validators.js +8 -6
  41. package/dist/hoi4_agent_tools/map/bmp.js +1 -1
  42. package/dist/hoi4_agent_tools/map/derived.js +748 -0
  43. package/dist/hoi4_agent_tools/map/job-operations.js +7 -3
  44. package/dist/hoi4_agent_tools/map/model.js +103 -0
  45. package/dist/hoi4_agent_tools/map/operations.js +427 -58
  46. package/dist/hoi4_agent_tools/map/references.js +483 -0
  47. package/dist/hoi4_agent_tools/map/service.js +136 -61
  48. package/dist/hoi4_agent_tools/map/tool-operations.js +2 -1
  49. package/dist/hoi4_agent_tools/map/validation.js +3 -0
  50. package/dist/hoi4_agent_tools/map/world.js +1280 -0
  51. package/dist/hoi4_agent_tools/mcp/resources/register-modern.js +13 -1
  52. package/dist/hoi4_agent_tools/mcp/resources/register.js +6 -0
  53. package/dist/hoi4_agent_tools/mcp/server/argument-errors.js +72 -0
  54. package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +23 -5
  55. package/dist/hoi4_agent_tools/mcp/server/create.js +7 -0
  56. package/dist/hoi4_agent_tools/mcp/server/request-lifecycle.js +24 -30
  57. package/dist/hoi4_agent_tools/mcp/server/tool-listing.js +45 -0
  58. package/dist/hoi4_agent_tools/mcp/tools/analysis.js +25 -5
  59. package/dist/hoi4_agent_tools/mcp/tools/event.js +18 -29
  60. package/dist/hoi4_agent_tools/mcp/tools/focus.js +4 -12
  61. package/dist/hoi4_agent_tools/mcp/tools/gui.js +3 -3
  62. package/dist/hoi4_agent_tools/mcp/tools/job.js +2 -2
  63. package/dist/hoi4_agent_tools/mcp/tools/map.js +13 -16
  64. package/dist/hoi4_agent_tools/mcp/tools/probability.js +42 -48
  65. package/dist/hoi4_agent_tools/mcp/tools/reference.js +22 -5
  66. package/dist/hoi4_agent_tools/mcp/tools/technology.js +16 -26
  67. package/dist/hoi4_agent_tools/mcp/transports/http.js +28 -4
  68. package/dist/hoi4_agent_tools/mcp/transports/stdio-lifetime.js +37 -18
  69. package/dist/hoi4_agent_tools/probability/job-operations.js +4 -9
  70. package/dist/hoi4_agent_tools/probability/operations.js +175 -13
  71. package/dist/hoi4_agent_tools/probability/source-analysis.js +24 -1
  72. package/dist/hoi4_agent_tools/reference/error-log.js +359 -0
  73. package/dist/hoi4_agent_tools/reference/known-mistakes.js +307 -0
  74. package/dist/hoi4_agent_tools/reference/known-mistakes.json +154 -0
  75. package/dist/hoi4_agent_tools/reference/mod-index.js +101 -0
  76. package/dist/hoi4_agent_tools/reference/operations.js +88 -19
  77. package/dist/hoi4_agent_tools/reference/player-text.js +4 -3
  78. package/dist/hoi4_agent_tools/reference/script-validation.js +19 -1
  79. package/dist/hoi4_agent_tools/reference/service.js +6 -0
  80. package/dist/hoi4_agent_tools/schemas/event.js +78 -19
  81. package/dist/hoi4_agent_tools/schemas/focus-requests.js +10 -0
  82. package/dist/hoi4_agent_tools/schemas/lenient-arguments.js +266 -0
  83. package/dist/hoi4_agent_tools/schemas/map-requests.js +63 -20
  84. package/dist/hoi4_agent_tools/schemas/map.js +122 -3
  85. package/dist/hoi4_agent_tools/schemas/mod-tools.js +181 -0
  86. package/dist/hoi4_agent_tools/schemas/probability-requests.js +119 -9
  87. package/dist/hoi4_agent_tools/schemas/reference.js +13 -0
  88. package/dist/hoi4_agent_tools/schemas/scenarios.js +1 -3
  89. package/dist/hoi4_agent_tools/schemas/script-validation.js +13 -6
  90. package/dist/hoi4_agent_tools/schemas/technology.js +83 -23
  91. package/dist/hoi4_agent_tools/technology/job-operations.js +3 -4
  92. package/dist/hoi4_agent_tools/technology/operations.js +14 -1
  93. package/dist/hoi4_agent_tools/technology/service.js +7 -0
  94. package/dist/hoi4_agent_tools/version.js +1 -1
  95. package/docs/README.md +7 -4
  96. package/docs/analysis.md +13 -0
  97. package/docs/configuration.md +281 -0
  98. package/docs/development.md +24 -2
  99. package/docs/events.md +27 -3
  100. package/docs/examples.md +6 -6
  101. package/docs/focus.md +125 -4
  102. package/docs/gui.md +98 -17
  103. package/docs/helper-expansion.md +8 -8
  104. package/docs/http.md +3 -1
  105. package/docs/jobs.md +19 -13
  106. package/docs/map.md +197 -6
  107. package/docs/mechanics.md +4 -0
  108. package/docs/probability.md +42 -15
  109. package/docs/reference.md +71 -4
  110. package/docs/setup.md +45 -11
  111. package/docs/technology.md +26 -1
  112. package/docs/third-party-notices.md +11 -0
  113. package/docs/tools.md +151 -42
  114. package/docs/troubleshooting.md +61 -0
  115. package/examples/clients/codex-config.toml +5 -2
  116. package/examples/clients/generic.json +2 -2
  117. package/examples/config/config.json +7 -0
  118. package/npm-shrinkwrap.json +161 -162
  119. package/package.json +13 -8
  120. package/schemas/configuration.schema.json +144 -128
  121. package/schemas/continuous-focus-palette.schema.json +1 -1
  122. package/schemas/custom-weighted-pool.schema.json +1 -1
  123. package/schemas/decision-inspect-input.schema.json +1 -1
  124. package/schemas/error-log-input.schema.json +55 -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 +1 -1
  132. package/schemas/helper-expansion-summary.schema.json +1 -1
  133. package/schemas/impact-inspect-input.schema.json +1 -1
  134. package/schemas/map-operation.schema.json +597 -126
  135. package/schemas/mechanic-test-input.schema.json +1 -1
  136. package/schemas/mod-index-input.schema.json +51 -0
  137. package/schemas/operation-result.schema.json +2 -2
  138. package/schemas/package-check-input.schema.json +1 -1
  139. package/schemas/package-manifest.schema.json +1 -1
  140. package/schemas/probability-analysis-result.schema.json +1 -1
  141. package/schemas/probability-compare-input.schema.json +1 -1
  142. package/schemas/probability-evaluate-input.schema.json +1 -1
  143. package/schemas/probability-inspect-input.schema.json +1 -1
  144. package/schemas/probability-render-input.schema.json +1 -1
  145. package/schemas/probability-scenario-set.schema.json +1 -1
  146. package/schemas/probability-sequence-input.schema.json +1 -1
  147. package/schemas/probability-simulate-input.schema.json +1 -1
  148. package/schemas/probability-sweep-input.schema.json +1 -1
  149. package/schemas/reference-context-input.schema.json +1 -1
  150. package/schemas/reference-read-input.schema.json +1 -1
  151. package/schemas/reference-search-input.schema.json +1 -1
  152. package/schemas/scenario-suite.schema.json +3 -5
  153. package/schemas/scenario-test-input.schema.json +3 -5
  154. package/schemas/script-validate-input.schema.json +1 -1
  155. package/schemas/shared-scenario.schema.json +1 -1
  156. package/schemas/source-lookup-input.schema.json +1 -1
  157. package/server.json +3 -3
@@ -0,0 +1,155 @@
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 ABANDONED_MS = 24 * STALE_MS;
15
+ const recordSchema = z
16
+ .object({
17
+ pid: z.number().int().positive(),
18
+ version: z.string().max(64),
19
+ transport: z.enum(['stdio', 'http']),
20
+ startedAt: z.string().max(64),
21
+ updatedAt: z.string().max(64),
22
+ lastActivityAt: z.string().max(64).optional(),
23
+ rssBytes: z.number().int().min(0),
24
+ heapUsedBytes: z.number().int().min(0),
25
+ })
26
+ .strict();
27
+ function directory(resolver) {
28
+ const state = resolver.serverState();
29
+ return state === undefined ? undefined : path.join(state.root, 'servers');
30
+ }
31
+ /** Whether a process exists; a permission refusal still proves it exists. */
32
+ export function processAlive(pid) {
33
+ try {
34
+ process.kill(pid, 0);
35
+ return true;
36
+ }
37
+ catch (error) {
38
+ return error.code === 'EPERM';
39
+ }
40
+ }
41
+ /** The registry of this server process, or a status-only one when no server started it. */
42
+ export function serverRegistry(resolver) {
43
+ return ServerRegistry.active ?? new ServerRegistry(resolver, 'stdio');
44
+ }
45
+ export class ServerRegistry {
46
+ resolver;
47
+ transport;
48
+ lastActivity;
49
+ /** The registry whose heartbeats this process writes. */
50
+ static active;
51
+ #startedAt = new Date().toISOString();
52
+ #timer;
53
+ constructor(resolver, transport, lastActivity = () => undefined) {
54
+ this.resolver = resolver;
55
+ this.transport = transport;
56
+ this.lastActivity = lastActivity;
57
+ }
58
+ current() {
59
+ const memory = process.memoryUsage();
60
+ const activity = this.lastActivity();
61
+ return {
62
+ pid: process.pid,
63
+ version: PACKAGE_VERSION,
64
+ transport: this.transport,
65
+ startedAt: this.#startedAt,
66
+ updatedAt: new Date().toISOString(),
67
+ ...(activity === undefined ? {} : { lastActivityAt: new Date(activity).toISOString() }),
68
+ rssBytes: memory.rss,
69
+ heapUsedBytes: memory.heapUsed,
70
+ };
71
+ }
72
+ /** Start heartbeats; the record is removed when the process exits normally. */
73
+ start() {
74
+ ServerRegistry.active = this;
75
+ const root = directory(this.resolver);
76
+ if (root === undefined)
77
+ return;
78
+ const file = path.join(root, `${process.pid}.json`);
79
+ const beat = () => void mkdir(root, { recursive: true })
80
+ .then(async () => {
81
+ const temporary = `${file}.${Date.now()}.tmp`;
82
+ try {
83
+ await writeFile(temporary, `${JSON.stringify(this.current())}\n`);
84
+ await rename(temporary, file);
85
+ }
86
+ catch {
87
+ await unlink(temporary).catch(() => undefined);
88
+ }
89
+ })
90
+ .catch(() => undefined);
91
+ beat();
92
+ this.#timer = setInterval(beat, HEARTBEAT_MS);
93
+ this.#timer.unref();
94
+ process.once('exit', () => {
95
+ try {
96
+ unlinkSync(file);
97
+ }
98
+ catch {
99
+ // A record left behind is removed by the next status read once the pid is gone.
100
+ }
101
+ });
102
+ }
103
+ async status() {
104
+ const current = this.current();
105
+ const config = this.resolver.config();
106
+ const limits = {
107
+ maxConcurrentTools: config.maxConcurrentTools,
108
+ maxSharedTools: config.maxSharedTools,
109
+ jobDeadlineSeconds: config.jobDeadlineSeconds,
110
+ jobCancelGraceSeconds: config.jobCancelGraceSeconds,
111
+ stdioIdleExitMinutes: config.stdioIdleExitMinutes,
112
+ jobWorkerMaxHeapMiB: config.jobWorkerMaxHeapMiB,
113
+ };
114
+ const root = directory(this.resolver);
115
+ const servers = [];
116
+ let removed = 0;
117
+ if (root !== undefined) {
118
+ const entries = await readdir(root).catch(() => []);
119
+ // Temporary heartbeat files left by a failed rename.
120
+ for (const name of entries) {
121
+ const temporary = /^\d+\.json\.(\d+)\.tmp$/u.exec(name);
122
+ if (temporary !== null && Date.now() - Number(temporary[1]) > STALE_MS)
123
+ await unlink(path.join(root, name)).catch(() => undefined);
124
+ }
125
+ const names = entries.filter((name) => /^\d+\.json$/u.test(name)).slice(0, MAX_RECORDS);
126
+ for (const name of names) {
127
+ const file = path.join(root, name);
128
+ const parsed = recordSchema.safeParse(await readFile(file, 'utf8')
129
+ .then((text) => JSON.parse(text))
130
+ .catch(() => undefined));
131
+ if (!parsed.success)
132
+ continue;
133
+ const record = parsed.data;
134
+ if (record.pid === process.pid)
135
+ continue;
136
+ if (!processAlive(record.pid)) {
137
+ removed += 1;
138
+ await unlink(file).catch(() => undefined);
139
+ continue;
140
+ }
141
+ const age = Date.now() - Date.parse(record.updatedAt);
142
+ // A record silent for this long belongs to an exited process whose pid was reused.
143
+ if (age > ABANDONED_MS) {
144
+ removed += 1;
145
+ await unlink(file).catch(() => undefined);
146
+ continue;
147
+ }
148
+ servers.push({ ...record, state: age > STALE_MS ? 'stale' : 'live' });
149
+ }
150
+ }
151
+ servers.push({ ...current, state: 'live' });
152
+ servers.sort((left, right) => right.rssBytes - left.rssBytes || left.pid - right.pid);
153
+ return { current, servers, removed, limits };
154
+ }
155
+ }
@@ -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
+ }
@@ -20,11 +20,13 @@ export class SharedRequestCapacity {
20
20
  stateRoot;
21
21
  capacity;
22
22
  deletePendingLimitMs;
23
+ leaseMaxAgeMs;
23
24
  now;
24
25
  constructor(stateRoot, capacity = 4, options = {}) {
25
26
  this.stateRoot = stateRoot;
26
27
  this.capacity = capacity;
27
28
  this.deletePendingLimitMs = options.deletePendingLimitMs ?? 60_000;
29
+ this.leaseMaxAgeMs = options.leaseMaxAgeMs;
28
30
  this.now = options.now ?? Date.now;
29
31
  }
30
32
  async run(signal, action) {
@@ -34,6 +36,7 @@ export class SharedRequestCapacity {
34
36
  handoffToProcess: () => {
35
37
  return Promise.reject(new ServiceError('REQUEST_LEASE_STORAGE_REQUIRED', 'Worker ownership transfer requires persistent capacity storage'));
36
38
  },
39
+ reclaimFromProcess: () => Promise.resolve(),
37
40
  });
38
41
  const root = await containedGeneratedPath(await canonicalPath(this.stateRoot, signal), 'request-capacity', sha256Bytes(hostname().toLowerCase()).slice(0, 16));
39
42
  await mkdir(root, { recursive: true });
@@ -112,6 +115,14 @@ export class SharedRequestCapacity {
112
115
  owner = next;
113
116
  transferredPid = pid;
114
117
  },
118
+ reclaimFromProcess: async () => {
119
+ if (transferredPid === undefined)
120
+ return;
121
+ const next = path.join(slot, `${process.pid}-${randomUUID()}.lease`);
122
+ await retryWindowsSharing(() => rename(owner, next));
123
+ owner = next;
124
+ transferredPid = undefined;
125
+ },
115
126
  });
116
127
  }
117
128
  finally {
@@ -137,6 +148,18 @@ export class SharedRequestCapacity {
137
148
  await delay(sharingRetryDelay(waits) * (0.75 + Math.random() / 2), undefined, { signal });
138
149
  }
139
150
  }
151
+ async leaseExpired(lease) {
152
+ if (this.leaseMaxAgeMs === undefined)
153
+ return false;
154
+ try {
155
+ return this.now() - (await lstat(lease)).mtimeMs > this.leaseMaxAgeMs;
156
+ }
157
+ catch (error) {
158
+ if (error.code === 'ENOENT')
159
+ return false;
160
+ throw error;
161
+ }
162
+ }
140
163
  async reap(slot) {
141
164
  try {
142
165
  const metadata = await lstat(slot);
@@ -150,7 +173,9 @@ export class SharedRequestCapacity {
150
173
  else {
151
174
  for (const name of owners) {
152
175
  const match = /^([1-9][0-9]*)-[0-9a-f-]{36}\.lease$/u.exec(name);
153
- if (match === null || processAlive(Number(match[1])))
176
+ if (match === null)
177
+ return;
178
+ if (processAlive(Number(match[1])) && !(await this.leaseExpired(path.join(slot, name))))
154
179
  return;
155
180
  // Unique owner names prevent a concurrent reaper from deleting a new lease.
156
181
  await unlink(path.join(slot, name)).catch((error) => {
@@ -3,7 +3,7 @@ import { sha256Bytes } from '../canonical.js';
3
3
  export function sourceDocumentCacheKey(bytes, sourcePath) {
4
4
  return `${sha256Bytes(bytes)}:${sourcePath}`;
5
5
  }
6
- function decodedDocument(encoded, expected) {
6
+ function decodedDocument(encoded) {
7
7
  const value = deserialize(encoded);
8
8
  if (value === null ||
9
9
  typeof value !== 'object' ||
@@ -20,36 +20,62 @@ function decodedDocument(encoded, expected) {
20
20
  !('bytes' in value) ||
21
21
  !(value.bytes instanceof Uint8Array))
22
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
23
  return value;
29
24
  }
30
- /** Process-local parsed facts; serialized entries cannot be mutated by their consumers. */
25
+ /** Puts between halvings of every entry's hit count, so entries no longer read become evictable. */
26
+ const HIT_AGING_INTERVAL = 4096;
27
+ /** Keys of documents turned away from a full cache, remembered so a repeat can be admitted. */
28
+ const DOORKEEPER_KEYS = 16_384;
29
+ /**
30
+ * Process-local parsed facts; serialized entries cannot be mutated by their consumers.
31
+ *
32
+ * A workspace scan often parses more source than the cache holds. Plain LRU then evicts
33
+ * every document before its next use and pays serialization for nothing. A full cache
34
+ * therefore turns a document away the first time, remembering only its key, and admits it
35
+ * on a later miss by displacing entries that were never read; a rejected document is not
36
+ * serialized. Repeated scans keep what fits and hit it. Hit counts age, so documents that
37
+ * stop being read become evictable again.
38
+ */
31
39
  export class SourceDocumentCache {
32
40
  maxBytes;
33
41
  #entries = new Map();
34
42
  #bytes = 0;
43
+ /** Bytes held by entries without hits, which a new document may displace. */
44
+ #coldBytes = 0;
45
+ #puts = 0;
35
46
  #hits = 0;
36
47
  #misses = 0;
37
48
  #evictions = 0;
49
+ #rejections = 0;
50
+ #doorkeeper = new Set();
38
51
  constructor(maxBytes = 64 * 1024 * 1024) {
39
52
  this.maxBytes = maxBytes;
40
53
  if (!Number.isSafeInteger(maxBytes) || maxBytes < 0)
41
54
  throw new RangeError('Source cache size must be a non-negative integer');
42
55
  }
43
56
  get(key) {
44
- const encoded = this.#entries.get(key);
45
- if (encoded === undefined) {
57
+ const entry = this.#entries.get(key);
58
+ if (entry === undefined) {
59
+ this.#misses += 1;
60
+ return undefined;
61
+ }
62
+ let document;
63
+ try {
64
+ document = decodedDocument(entry.encoded);
65
+ }
66
+ catch {
67
+ // An imported entry is decoded here, on first use; one that cannot be decoded is a miss.
68
+ this.#remove(key);
46
69
  this.#misses += 1;
47
70
  return undefined;
48
71
  }
49
72
  this.#hits += 1;
73
+ if (entry.hits === 0)
74
+ this.#coldBytes -= entrySize(key, entry.encoded);
75
+ entry.hits += 1;
50
76
  this.#entries.delete(key);
51
- this.#entries.set(key, encoded);
52
- return decodedDocument(encoded);
77
+ this.#entries.set(key, entry);
78
+ return document;
53
79
  }
54
80
  /** Availability check without deserialization or cache-hit accounting. */
55
81
  has(key) {
@@ -61,51 +87,104 @@ export class SourceDocumentCache {
61
87
  const estimatedBytes = document.bytes.length * 12 + document.tokens.length * 512 + key.length * 2 + 128;
62
88
  if (this.maxBytes === 0 || estimatedBytes > this.maxBytes)
63
89
  return;
64
- const encoded = serialize(document);
65
- this.retain(key, encoded);
90
+ this.#age();
91
+ const previous = this.#entries.get(key);
92
+ const reusable = previous === undefined ? 0 : entrySize(key, previous.encoded);
93
+ const free = this.maxBytes - this.#bytes + reusable;
94
+ if (estimatedBytes > free) {
95
+ // Only a document missed before may displace entries, and only entries never read.
96
+ if (!this.#doorkeeper.delete(key) || estimatedBytes > free + this.#coldBytes) {
97
+ this.#remember(key);
98
+ this.#rejections += 1;
99
+ return;
100
+ }
101
+ }
102
+ this.retain(key, serialize(document));
66
103
  }
67
- /** Import one authenticated same-runtime entry without counting it as a process-local hit. */
68
- importEncoded(key, encoded, expected) {
69
- decodedDocument(encoded, expected);
104
+ /**
105
+ * Import one authenticated same-runtime entry without counting it as a process-local hit.
106
+ * The caller has verified the entry against the key's source bytes (the key is their hash and
107
+ * the path), so the document is decoded when it is first read instead of for every file at
108
+ * load, which cost as much as parsing the files again; `get` treats an undecodable entry as
109
+ * a miss.
110
+ */
111
+ importEncoded(key, encoded) {
70
112
  this.retain(key, Buffer.from(encoded));
71
113
  }
72
114
  exportEncoded(key) {
73
- const encoded = this.#entries.get(key);
74
- return encoded === undefined ? undefined : Buffer.from(encoded);
115
+ const entry = this.#entries.get(key);
116
+ return entry === undefined ? undefined : Buffer.from(entry.encoded);
75
117
  }
76
118
  retain(key, encoded) {
77
- const size = encoded.length + key.length * 2 + 128;
119
+ const size = entrySize(key, encoded);
78
120
  if (size > this.maxBytes)
79
121
  return;
80
- const previous = this.#entries.get(key);
81
- if (previous !== undefined)
82
- this.#bytes -= previous.length + key.length * 2 + 128;
83
- this.#entries.delete(key);
84
- while (this.#bytes + size > this.maxBytes) {
85
- const oldest = this.#entries.entries().next().value;
86
- if (oldest === undefined)
122
+ this.#remove(key);
123
+ // Displace the oldest entries that were never read again; read entries stay.
124
+ for (const [oldKey, entry] of this.#entries) {
125
+ if (this.#bytes + size <= this.maxBytes)
87
126
  break;
88
- this.#entries.delete(oldest[0]);
89
- this.#bytes -= oldest[1].length + oldest[0].length * 2 + 128;
127
+ if (entry.hits > 0)
128
+ continue;
129
+ this.#remove(oldKey);
90
130
  this.#evictions += 1;
91
131
  }
92
- this.#entries.set(key, encoded);
132
+ if (this.#bytes + size > this.maxBytes) {
133
+ this.#rejections += 1;
134
+ return;
135
+ }
136
+ this.#entries.set(key, { encoded, hits: 0 });
93
137
  this.#bytes += size;
138
+ this.#coldBytes += size;
139
+ }
140
+ #remember(key) {
141
+ this.#doorkeeper.delete(key);
142
+ this.#doorkeeper.add(key);
143
+ if (this.#doorkeeper.size > DOORKEEPER_KEYS)
144
+ this.#doorkeeper.delete(this.#doorkeeper.values().next().value);
145
+ }
146
+ #remove(key) {
147
+ const entry = this.#entries.get(key);
148
+ if (entry === undefined)
149
+ return;
150
+ const size = entrySize(key, entry.encoded);
151
+ this.#entries.delete(key);
152
+ this.#bytes -= size;
153
+ if (entry.hits === 0)
154
+ this.#coldBytes -= size;
155
+ }
156
+ #age() {
157
+ this.#puts += 1;
158
+ if (this.#puts % HIT_AGING_INTERVAL !== 0)
159
+ return;
160
+ for (const [key, entry] of this.#entries) {
161
+ if (entry.hits === 0)
162
+ continue;
163
+ entry.hits = Math.floor(entry.hits / 2);
164
+ if (entry.hits === 0)
165
+ this.#coldBytes += entrySize(key, entry.encoded);
166
+ }
94
167
  }
95
168
  clear() {
96
169
  this.#entries.clear();
170
+ this.#doorkeeper.clear();
97
171
  this.#bytes = 0;
172
+ this.#coldBytes = 0;
98
173
  }
99
174
  statistics() {
100
175
  return {
101
176
  hits: this.#hits,
102
177
  misses: this.#misses,
103
178
  evictions: this.#evictions,
179
+ rejections: this.#rejections,
104
180
  retainedBytes: this.#bytes,
105
181
  retainedDocuments: this.#entries.size,
106
182
  };
107
183
  }
108
184
  }
185
+ function entrySize(key, encoded) {
186
+ return encoded.length + key.length * 2 + 128;
187
+ }
109
188
  // All domains call the same parser; cache keys include both bytes and source location.
110
189
  // Authorization remains in workspace reads, before any document reaches this cache.
111
190
  export const sourceDocuments = new SourceDocumentCache();
@@ -1,4 +1,12 @@
1
1
  import { SOURCE_LINE_LIMIT, SOURCE_TOKEN_LIMIT, SourceDiagnosticCollector } from './limits.js';
2
+ const unicodeSpace = /\s/u;
3
+ /** The same characters as the `\s` class; the regular expression only runs for non-ASCII text. */
4
+ function isSpace(character) {
5
+ const code = character.charCodeAt(0);
6
+ if (code < 128)
7
+ return code === 32 || (code >= 9 && code <= 13);
8
+ return code > 159 && unicodeSpace.test(character);
9
+ }
2
10
  const operatorStarts = new Set(['=', '<', '>', '!']);
3
11
  function startsOperator(text, index) {
4
12
  const character = text[index] ?? '';
@@ -141,8 +149,8 @@ export function lexClausewitz(text, sourcePath, options = {}) {
141
149
  const startLine = line;
142
150
  const startColumn = column;
143
151
  const current = text[index] ?? '';
144
- if (/\s/u.test(current)) {
145
- while (index < text.length && /\s/u.test(text[index] ?? ''))
152
+ if (isSpace(current)) {
153
+ while (index < text.length && isSpace(text[index] ?? ''))
146
154
  advance();
147
155
  add('whitespace', start, startLine, startColumn);
148
156
  continue;
@@ -236,7 +244,7 @@ export function lexClausewitz(text, sourcePath, options = {}) {
236
244
  }
237
245
  while (index < text.length) {
238
246
  const character = text[index] ?? '';
239
- if (/\s/u.test(character) ||
247
+ if (isSpace(character) ||
240
248
  character === '#' ||
241
249
  character === '{' ||
242
250
  character === '}' ||