hoi4-agent-tools 3.6.0 → 3.7.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 (66) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +122 -28
  3. package/dist/hoi4_agent_tools/core/engine.js +7 -7
  4. package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
  5. package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
  6. package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
  7. package/dist/hoi4_agent_tools/core/job-worker-host.js +27 -11
  8. package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
  9. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
  10. package/dist/hoi4_agent_tools/core/scanner.js +133 -47
  11. package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
  12. package/dist/hoi4_agent_tools/map/service.js +2 -2
  13. package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
  14. package/dist/hoi4_agent_tools/reference/lines.js +12 -0
  15. package/dist/hoi4_agent_tools/reference/operations.js +45 -2
  16. package/dist/hoi4_agent_tools/reference/script-validation.js +277 -0
  17. package/dist/hoi4_agent_tools/reference/service.js +139 -67
  18. package/dist/hoi4_agent_tools/reference/source-lookup.js +91 -15
  19. package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
  20. package/dist/hoi4_agent_tools/schemas/reference.js +55 -0
  21. package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
  22. package/dist/hoi4_agent_tools/version.js +1 -1
  23. package/docs/README.md +1 -0
  24. package/docs/examples.md +1 -11
  25. package/docs/gui.md +1 -15
  26. package/docs/reference.md +43 -2
  27. package/docs/research/reference-benchmark.md +41 -0
  28. package/docs/tools.md +37 -36
  29. package/npm-shrinkwrap.json +7088 -0
  30. package/package.json +7 -6
  31. package/schemas/configuration.schema.json +1 -1
  32. package/schemas/continuous-focus-palette.schema.json +1 -1
  33. package/schemas/custom-weighted-pool.schema.json +1 -1
  34. package/schemas/decision-inspect-input.schema.json +1 -1
  35. package/schemas/event-feature-manifest.schema.json +1 -1
  36. package/schemas/focus-plan.schema.json +1 -1
  37. package/schemas/focus-planning-sidecar.schema.json +1 -1
  38. package/schemas/gui-animation-source.schema.json +1 -1
  39. package/schemas/gui-helper.schema.json +1 -1
  40. package/schemas/gui-scenario.schema.json +1 -1
  41. package/schemas/helper-expansion-request.schema.json +1 -1
  42. package/schemas/helper-expansion-summary.schema.json +1 -1
  43. package/schemas/impact-inspect-input.schema.json +1 -1
  44. package/schemas/map-operation.schema.json +1 -1
  45. package/schemas/mechanic-test-input.schema.json +1 -1
  46. package/schemas/operation-result.schema.json +1 -1
  47. package/schemas/package-check-input.schema.json +1 -1
  48. package/schemas/package-manifest.schema.json +1 -1
  49. package/schemas/probability-analysis-result.schema.json +1 -1
  50. package/schemas/probability-compare-input.schema.json +1 -1
  51. package/schemas/probability-evaluate-input.schema.json +1 -1
  52. package/schemas/probability-inspect-input.schema.json +1 -1
  53. package/schemas/probability-render-input.schema.json +1 -1
  54. package/schemas/probability-scenario-set.schema.json +1 -1
  55. package/schemas/probability-sequence-input.schema.json +1 -1
  56. package/schemas/probability-simulate-input.schema.json +1 -1
  57. package/schemas/probability-sweep-input.schema.json +1 -1
  58. package/schemas/reference-context-input.schema.json +1 -1
  59. package/schemas/reference-read-input.schema.json +9 -1
  60. package/schemas/reference-search-input.schema.json +1 -1
  61. package/schemas/scenario-suite.schema.json +1 -1
  62. package/schemas/scenario-test-input.schema.json +1 -1
  63. package/schemas/script-validate-input.schema.json +92 -0
  64. package/schemas/shared-scenario.schema.json +1 -1
  65. package/schemas/source-lookup-input.schema.json +68 -2
  66. package/server.json +2 -2
@@ -0,0 +1,277 @@
1
+ import path from 'node:path';
2
+ import { hashCanonical } from '../core/canonical.js';
3
+ import { ServiceError } from '../core/result.js';
4
+ import { locationFor, parseClausewitz, } from '../core/source/index.js';
5
+ const MAX_COMMANDS = 256;
6
+ const booleanCommands = new Set(['AND', 'OR', 'NOT', 'XOR', 'NAND', 'NOR']);
7
+ const containers = new Set([
8
+ 'and',
9
+ 'or',
10
+ 'not',
11
+ 'xor',
12
+ 'nand',
13
+ 'nor',
14
+ 'hidden_trigger',
15
+ 'hidden_effect',
16
+ ]);
17
+ const conditionals = new Set(['if', 'else_if', 'else']);
18
+ const iteratorParameters = new Set([
19
+ 'tooltip',
20
+ 'random_select_amount',
21
+ 'display_individual_scopes',
22
+ 'count',
23
+ ]);
24
+ function editDistance(a, b) {
25
+ let row = Array.from({ length: b.length + 1 }, (_, index) => index);
26
+ for (let i = 0; i < a.length; i++) {
27
+ const next = [i + 1];
28
+ for (let j = 0; j < b.length; j++)
29
+ next.push(Math.min(next[j] + 1, row[j + 1] + 1, row[j] + Number(a[i] !== b[j])));
30
+ if (Math.min(...next) > 3)
31
+ return 4;
32
+ row = next;
33
+ }
34
+ return row[b.length];
35
+ }
36
+ /** Check only rules explicitly available from one selected documentation authority. */
37
+ export async function validateScript(references, workspace, input, signal) {
38
+ if (Buffer.byteLength(input.source, 'utf8') > 64_000)
39
+ throw new ServiceError('SCRIPT_SOURCE_TOO_LARGE', 'Snippet exceeds 64000 UTF-8 bytes');
40
+ const inventory = await references.inventory(workspace, signal, [input.documentation]);
41
+ const catalogs = { effect: new Map(), trigger: new Map() };
42
+ const sources = new Map();
43
+ for (const section of inventory.sections) {
44
+ const filename = path.basename(section.path).toLowerCase();
45
+ const match = /^(effects?|triggers?)_(?:documentation|docs)\.(?:md|log)$/u.exec(filename);
46
+ if (match === null)
47
+ continue;
48
+ sources.set(section.path, section.revision);
49
+ if (!/^[a-z_][a-z0-9_]{0,255}$/u.test(section.heading))
50
+ continue;
51
+ const scopeLine = /^\s*(?:\*\s*)?Supported Scopes:\s*(.+?)\s*$/imu.exec(section.lines.join('\n'));
52
+ if (scopeLine === null || !/^[a-z_,\s]+$/iu.test(scopeLine[1]))
53
+ continue;
54
+ const scopes = [
55
+ ...new Set(scopeLine[1]
56
+ .toLowerCase()
57
+ .split(/[,\s]+/u)
58
+ .filter(Boolean)),
59
+ ].sort();
60
+ const kind = match[1].startsWith('effect') ? 'effect' : 'trigger';
61
+ const previous = catalogs[kind].get(section.heading);
62
+ const { lines: _lines, ...reference } = section;
63
+ if (previous !== undefined) {
64
+ previous.conflict ||= previous.scopes.join(',') !== scopes.join(',');
65
+ }
66
+ else
67
+ catalogs[kind].set(section.heading, { reference, scopes, conflict: false });
68
+ }
69
+ const document = parseClausewitz(Buffer.from(input.source, 'utf8'), 'snippet');
70
+ const findings = [];
71
+ let visited = 0;
72
+ let checkedCommands = 0;
73
+ let argumentBlocksUnchecked = 0;
74
+ let suggestionBudget = 4000;
75
+ const bindings = new Map();
76
+ for (const binding of input.bindings) {
77
+ if (bindings.has(binding.name))
78
+ throw new ServiceError('SCRIPT_BINDING_DUPLICATE', 'Scope bindings must have unique names');
79
+ if (binding.name === 'ROOT' || binding.name === 'THIS' || binding.name === 'PREV')
80
+ throw new ServiceError('SCRIPT_BINDING_RESERVED', 'ROOT, THIS and PREV follow the supplied scope and traversal stack');
81
+ bindings.set(binding.name, binding.scope);
82
+ }
83
+ const report = (entry, kind, scope, details) => {
84
+ const position = locationFor('snippet', document.lineIndex, entry.start, entry.end).start;
85
+ findings.push({
86
+ ...details,
87
+ command: entry.key.value.slice(0, 256),
88
+ kind,
89
+ scope,
90
+ line: position.line,
91
+ column: position.column,
92
+ });
93
+ };
94
+ for (const diagnostic of document.diagnostics)
95
+ findings.push({
96
+ code: diagnostic.code,
97
+ status: 'error',
98
+ command: '',
99
+ kind: input.kind,
100
+ scope: input.scope,
101
+ line: diagnostic.location?.start.line ?? 1,
102
+ column: diagnostic.location?.start.column ?? 1,
103
+ message: diagnostic.message.slice(0, 500),
104
+ suggestions: [],
105
+ });
106
+ const walk = (block, kind, scope, previous) => {
107
+ for (const entry of block.entries) {
108
+ signal?.throwIfAborted();
109
+ if (++visited > MAX_COMMANDS) {
110
+ return;
111
+ }
112
+ if (entry.type !== 'assignment') {
113
+ const position = locationFor('snippet', document.lineIndex, entry.start, entry.end).start;
114
+ findings.push({
115
+ code: 'SCRIPT_EXPECTED_COMMAND',
116
+ status: 'error',
117
+ command: '',
118
+ kind,
119
+ scope,
120
+ line: position.line,
121
+ column: position.column,
122
+ message: 'Expected a command assignment in this effect or trigger body',
123
+ suggestions: [],
124
+ });
125
+ continue;
126
+ }
127
+ const raw = entry.key.value;
128
+ if (raw.startsWith('@'))
129
+ continue;
130
+ const name = booleanCommands.has(raw) ? raw.toLowerCase() : raw;
131
+ const explicitScope = raw === 'ROOT'
132
+ ? input.scope
133
+ : raw === 'THIS'
134
+ ? scope
135
+ : raw === 'PREV'
136
+ ? previous
137
+ : bindings.get(raw);
138
+ const fixedScope = /^[A-Z][A-Z0-9]{2}$/u.test(raw) &&
139
+ !['ROOT', 'THIS', 'PREV', 'FROM', 'AND', 'NOT', 'XOR', 'NOR'].includes(raw)
140
+ ? 'country'
141
+ : /^\d+$/u.test(raw)
142
+ ? 'state'
143
+ : undefined;
144
+ if (entry.value.type === 'block' &&
145
+ (explicitScope !== undefined || fixedScope !== undefined)) {
146
+ walk(entry.value, kind, explicitScope ?? fixedScope, scope);
147
+ continue;
148
+ }
149
+ const signature = catalogs[kind].get(name);
150
+ if (signature === undefined) {
151
+ const other = catalogs[kind === 'effect' ? 'trigger' : 'effect'].get(name);
152
+ const suggestions = name.length > 128
153
+ ? []
154
+ : [...catalogs[kind].keys()]
155
+ .filter((candidate) => Math.abs(candidate.length - name.length) <= 3)
156
+ .filter(() => suggestionBudget-- > 0)
157
+ .map((candidate) => ({ candidate, distance: editDistance(name, candidate) }))
158
+ .filter(({ distance }) => distance <= 3)
159
+ .sort((a, b) => a.distance - b.distance || a.candidate.localeCompare(b.candidate, 'en'))
160
+ .slice(0, 3)
161
+ .map(({ candidate }) => candidate);
162
+ report(entry, kind, scope, {
163
+ code: other === undefined ? 'SCRIPT_COMMAND_UNRESOLVED' : 'SCRIPT_COMMAND_WRONG_KIND',
164
+ status: other === undefined ? 'unresolved' : 'error',
165
+ message: other === undefined
166
+ ? 'Not documented as a native command in the selected source; inspect scripted helpers or declare the scope binding'
167
+ : `Documented as a ${kind === 'effect' ? 'trigger' : 'effect'}, not a ${kind}`,
168
+ suggestions,
169
+ ...(other === undefined ? {} : { reference: other.reference }),
170
+ });
171
+ continue;
172
+ }
173
+ checkedCommands++;
174
+ const wrongScope = !signature.scopes.includes('any') && !signature.scopes.includes(scope);
175
+ report(entry, kind, scope, {
176
+ code: signature.conflict
177
+ ? 'SCRIPT_DOCUMENTATION_CONFLICT'
178
+ : wrongScope
179
+ ? 'SCRIPT_COMMAND_WRONG_SCOPE'
180
+ : 'SCRIPT_COMMAND_SUPPORTED',
181
+ status: signature.conflict ? 'unresolved' : wrongScope ? 'error' : 'supported',
182
+ suggestions: [],
183
+ reference: signature.reference,
184
+ message: signature.conflict
185
+ ? 'Conflicting supported scopes in the selected documentation'
186
+ : wrongScope
187
+ ? `Supported scopes: ${signature.scopes.join(', ')}`
188
+ : 'Command kind and declared scope match the selected documentation',
189
+ });
190
+ const iterator = /^(?:every|random|any|all)_(?:(?:owned|controlled|neighbor)_)?(state|country)$/u.exec(name);
191
+ if (entry.value.type !== 'block') {
192
+ if (containers.has(name) || conditionals.has(name) || iterator !== null)
193
+ report(entry, kind, scope, {
194
+ code: 'SCRIPT_EXPECTED_BLOCK',
195
+ status: 'error',
196
+ message: 'This control command requires a block',
197
+ suggestions: [],
198
+ reference: signature.reference,
199
+ });
200
+ continue;
201
+ }
202
+ if (containers.has(name)) {
203
+ walk(entry.value, kind, scope, previous);
204
+ continue;
205
+ }
206
+ if (conditionals.has(name) || iterator !== null) {
207
+ const childScope = iterator?.[1] ?? scope;
208
+ for (const child of entry.value.entries) {
209
+ if (child.type === 'assignment' && child.key.value === 'limit') {
210
+ if (child.value.type === 'block')
211
+ walk(child.value, 'trigger', childScope, iterator === null ? previous : scope);
212
+ else
213
+ report(child, 'trigger', childScope, {
214
+ code: 'SCRIPT_EXPECTED_BLOCK',
215
+ status: 'error',
216
+ message: 'limit requires a trigger block',
217
+ suggestions: [],
218
+ });
219
+ }
220
+ else if (iterator !== null &&
221
+ child.type === 'assignment' &&
222
+ iteratorParameters.has(child.key.value)) {
223
+ argumentBlocksUnchecked++;
224
+ }
225
+ else
226
+ walk({ type: 'block', entries: [child], start: child.start, end: child.end }, kind, childScope, iterator === null ? previous : scope);
227
+ }
228
+ }
229
+ else {
230
+ argumentBlocksUnchecked++;
231
+ report(entry, kind, scope, {
232
+ code: 'SCRIPT_ARGUMENT_BLOCK_UNCHECKED',
233
+ status: 'unresolved',
234
+ message: 'Native argument block is not validated from prose documentation; read the cited syntax before use',
235
+ suggestions: [],
236
+ reference: signature.reference,
237
+ });
238
+ }
239
+ }
240
+ };
241
+ if (document.diagnostics.every(({ severity }) => severity !== 'error' && severity !== 'blocker'))
242
+ walk(document.root, input.kind, input.scope);
243
+ const unresolvedCount = findings.filter(({ status }) => status === 'unresolved').length;
244
+ const invalid = findings.some(({ status }) => status === 'error');
245
+ const truncated = visited > MAX_COMMANDS;
246
+ const incomplete = unresolvedCount > 0 || truncated || inventory.skipped > 0 || catalogs[input.kind].size === 0;
247
+ const order = { error: 0, unresolved: 1, supported: 2 };
248
+ findings.sort((a, b) => order[a.status] - order[b.status] ||
249
+ a.line - b.line ||
250
+ a.column - b.column ||
251
+ a.code.localeCompare(b.code, 'en'));
252
+ const result = {
253
+ valid: invalid ? false : incomplete ? null : true,
254
+ checksPerformed: ['syntax', 'command_kind', 'declared_scope'],
255
+ parametersChecked: false,
256
+ documentation: {
257
+ source: input.documentation,
258
+ revision: hashCanonical([...sources].sort()),
259
+ effectCount: catalogs.effect.size,
260
+ triggerCount: catalogs.trigger.size,
261
+ skippedSources: inventory.skipped,
262
+ },
263
+ findings: findings.slice(0, input.limit),
264
+ totalFindings: findings.length,
265
+ omittedFindings: Math.max(0, findings.length - input.limit),
266
+ checkedCommands,
267
+ unresolvedCount,
268
+ truncated,
269
+ argumentBlocksUnchecked,
270
+ };
271
+ while (Buffer.byteLength(JSON.stringify(result), 'utf8') > 16_000 && result.findings.length > 1)
272
+ result.findings.pop();
273
+ result.omittedFindings = result.totalFindings - result.findings.length;
274
+ if (Buffer.byteLength(JSON.stringify(result), 'utf8') > 16_000)
275
+ throw new ServiceError('REFERENCE_RESPONSE_TOO_LARGE', 'Command documentation metadata exceeds the bounded reply');
276
+ return result;
277
+ }
@@ -1,15 +1,29 @@
1
- import { open, realpath, stat } from 'node:fs/promises';
1
+ import { realpath } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import fg from 'fast-glob';
3
+ import { globFiles } from '../core/file-glob.js';
4
4
  import { compareCodeUnits, sha256Bytes } from '../core/canonical.js';
5
5
  import { ServiceError } from '../core/result.js';
6
6
  import { isWithin } from '../core/workspace.js';
7
- import { readBoundedLines } from './lines.js';
8
- import { readBoundedFile } from '../core/scanner.js';
7
+ import { prepareSource, verifySource, readBoundedFile, } from '../core/scanner.js';
8
+ import { decodeSource } from '../core/source/encoding.js';
9
+ import { readBoundedJsonLines } from './lines.js';
9
10
  const MAX_FILES = 512;
10
11
  const MAX_FILE_BYTES = 2_000_000;
11
12
  const MAX_TOTAL_BYTES = 32_000_000;
12
13
  const MAX_SECTIONS = 20_000;
14
+ const MAX_REFERENCE_METADATA_BYTES = 24_000;
15
+ function referenceText(bytes) {
16
+ if (bytes.length >= 2 &&
17
+ ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff))) {
18
+ const payload = Buffer.from(bytes.subarray(2));
19
+ if (payload.length % 2 !== 0)
20
+ throw new ServiceError('REFERENCE_ENCODING_INVALID', 'UTF-16 reference text has an incomplete code unit');
21
+ if (bytes[0] === 0xfe)
22
+ payload.swap16();
23
+ return payload.toString('utf16le');
24
+ }
25
+ return decodeSource(bytes).text;
26
+ }
13
27
  const searchStopWords = new Set([
14
28
  'a',
15
29
  'an',
@@ -78,7 +92,7 @@ function sectionize(root, filePath, content, revision) {
78
92
  const relative = path.relative(root.root, filePath).replaceAll('\\', '/');
79
93
  const title = path
80
94
  .basename(filePath)
81
- .replace(/\.(md|log)$/u, '')
95
+ .replace(/\.(md|log)$/iu, '')
82
96
  .replaceAll('_', ' ')
83
97
  .replace(/ - Hearts of Iron 4 Wiki$/u, '');
84
98
  const headings = [];
@@ -95,7 +109,7 @@ function sectionize(root, filePath, content, revision) {
95
109
  if (match)
96
110
  headings.push({ heading: match[2].replace(/<a\b[^>]*><\/a>/gu, '').trim(), start: index });
97
111
  else if (root.kind === 'script_doc' &&
98
- filePath.endsWith('.log') &&
112
+ filePath.toLowerCase().endsWith('.log') &&
99
113
  /^[A-Za-z_][A-Za-z0-9_.:-]*$/u.test(line.trim()) &&
100
114
  !/^\s/u.test(line) &&
101
115
  /^\s+\S/u.test(lines[index + 1] ?? '')) {
@@ -222,16 +236,33 @@ export class ReferenceService {
222
236
  this.#cacheBytes -= oldest[1].bytes;
223
237
  }
224
238
  }
225
- async inventory(workspace, signal) {
239
+ async inventory(workspace, signal, sources) {
226
240
  const sections = [];
227
241
  const coverage = {
228
- game_doc: { files: 0, sections: 0, unavailable: true },
229
- wiki: { files: 0, sections: 0, unavailable: true },
230
- script_doc: { files: 0, sections: 0, unavailable: true },
242
+ game_doc: {
243
+ files: 0,
244
+ sections: 0,
245
+ unavailable: true,
246
+ included: sources === undefined || sources.includes('game_doc'),
247
+ },
248
+ wiki: {
249
+ files: 0,
250
+ sections: 0,
251
+ unavailable: true,
252
+ included: sources === undefined || sources.includes('wiki'),
253
+ },
254
+ script_doc: {
255
+ files: 0,
256
+ sections: 0,
257
+ unavailable: true,
258
+ included: sources === undefined || sources.includes('script_doc'),
259
+ },
231
260
  };
232
261
  let skipped = 0;
233
262
  let bytes = 0;
234
263
  for (const root of sourceRoots(workspace)) {
264
+ if (sources !== undefined && !sources.includes(root.kind))
265
+ continue;
235
266
  signal?.throwIfAborted();
236
267
  let canonicalRoot;
237
268
  try {
@@ -250,80 +281,104 @@ export class ReferenceService {
250
281
  throw new ServiceError('REFERENCE_ROOT_ESCAPE', 'Reference root resolves outside its configured workspace');
251
282
  }
252
283
  coverage[root.kind].unavailable = false;
253
- const names = await fg(root.patterns, {
284
+ const names = await globFiles(root.patterns, {
254
285
  cwd: canonicalRoot,
255
- onlyFiles: true,
256
- followSymbolicLinks: false,
257
- dot: false,
286
+ caseSensitive: false,
287
+ ...(signal === undefined ? {} : { signal }),
258
288
  });
259
289
  names.sort(compareCodeUnits);
260
- for (const name of names) {
290
+ for (let offset = 0; offset < names.length; offset += 8) {
261
291
  signal?.throwIfAborted();
262
292
  if (coverage[root.kind].files >= MAX_FILES || sections.length >= MAX_SECTIONS) {
263
- skipped++;
264
- continue;
265
- }
266
- const candidate = path.join(canonicalRoot, name);
267
- const canonical = await realpath(candidate);
268
- if (!isWithin(canonicalRoot, canonical)) {
269
- skipped++;
270
- continue;
271
- }
272
- const metadata = await stat(canonical);
273
- if (metadata.size > MAX_FILE_BYTES || bytes + metadata.size > MAX_TOTAL_BYTES) {
274
- skipped++;
275
- continue;
293
+ skipped += names.length - offset;
294
+ break;
276
295
  }
277
- bytes += metadata.size;
278
- coverage[root.kind].files++;
279
- const key = `${root.kind}:${canonicalRoot}:${canonical}`;
280
- const handle = await open(canonical, 'r');
281
- let content;
296
+ const readRoot = { path: canonicalRoot };
297
+ const prepared = await Promise.allSettled(names.slice(offset, offset + 8).map((name) => prepareSource(readRoot, name, signal)));
298
+ const opened = prepared.flatMap((entry) => entry.status === 'fulfilled' ? [entry.value] : []);
282
299
  try {
283
- const opened = await handle.stat();
284
- const resolvedAfterOpen = await realpath(canonical);
285
- const current = await stat(resolvedAfterOpen);
286
- if (!opened.isFile() ||
287
- !isWithin(canonicalRoot, resolvedAfterOpen) ||
288
- opened.dev !== current.dev ||
289
- opened.ino !== current.ino) {
300
+ signal?.throwIfAborted();
301
+ for (const entry of prepared) {
302
+ if (entry.status !== 'rejected')
303
+ continue;
304
+ if (entry.reason instanceof ServiceError && entry.reason.code === 'SCAN_ROOT_ESCAPE') {
305
+ skipped++;
306
+ continue;
307
+ }
290
308
  throw new ServiceError('REFERENCE_SOURCE_CHANGED', 'Reference path changed while opening it; retry the request');
291
309
  }
292
- content = await readBoundedFile(handle, Math.min(MAX_FILE_BYTES, MAX_TOTAL_BYTES - bytes + metadata.size), signal);
310
+ const admittedSources = [];
311
+ for (const source of opened) {
312
+ if (!source.metadata.isFile() ||
313
+ source.metadata.size > MAX_FILE_BYTES ||
314
+ bytes + source.metadata.size > MAX_TOTAL_BYTES ||
315
+ coverage[root.kind].files >= MAX_FILES) {
316
+ skipped++;
317
+ continue;
318
+ }
319
+ bytes += source.metadata.size;
320
+ coverage[root.kind].files++;
321
+ admittedSources.push(source);
322
+ }
323
+ const reads = await Promise.allSettled(admittedSources.map(async (source) => {
324
+ const content = await readBoundedFile(source.handle, source.metadata.size, signal);
325
+ await verifySource(readRoot, source);
326
+ if (content.length !== source.metadata.size)
327
+ throw new ServiceError('REFERENCE_SOURCE_CHANGED', 'Reference changed while it was being read; retry the request');
328
+ return content;
329
+ }));
330
+ signal?.throwIfAborted();
331
+ const failed = reads.find((entry) => entry.status === 'rejected');
332
+ if (failed?.status === 'rejected')
333
+ throw new ServiceError('REFERENCE_SOURCE_CHANGED', 'Reference changed while it was being read; retry the request');
334
+ for (const [index, read] of reads.entries()) {
335
+ if (read.status !== 'fulfilled')
336
+ continue;
337
+ const canonical = admittedSources[index].canonicalPath;
338
+ const content = read.value;
339
+ const key = `${root.kind}:${canonicalRoot}:${canonical}`;
340
+ const revision = sha256Bytes(content);
341
+ let cached = this.#cache.get(key);
342
+ if (cached?.revision !== revision)
343
+ cached = {
344
+ revision,
345
+ bytes: content.length,
346
+ sections: sectionize(root, canonical, referenceText(content), revision),
347
+ };
348
+ this.retain(key, cached);
349
+ const admitted = cached.sections.slice(0, MAX_SECTIONS - sections.length);
350
+ if (admitted.length < cached.sections.length)
351
+ skipped++;
352
+ sections.push(...admitted);
353
+ coverage[root.kind].sections += admitted.length;
354
+ }
293
355
  }
294
356
  finally {
295
- await handle.close();
357
+ await Promise.all(opened.map(({ handle }) => handle.close()));
296
358
  }
297
- bytes += content.length - metadata.size;
298
- const revision = sha256Bytes(content);
299
- let cached = this.#cache.get(key);
300
- if (cached?.revision !== revision) {
301
- cached = {
302
- revision,
303
- bytes: content.length,
304
- sections: sectionize(root, canonical, content.toString('utf8'), revision),
305
- };
306
- }
307
- this.retain(key, cached);
308
- const admitted = cached.sections.slice(0, MAX_SECTIONS - sections.length);
309
- if (admitted.length < cached.sections.length)
310
- skipped++;
311
- sections.push(...admitted);
312
- coverage[root.kind].sections += admitted.length;
313
359
  }
314
360
  }
315
361
  return { sections, coverage, skipped };
316
362
  }
317
363
  async search(workspace, input, signal) {
318
- const inventory = await this.inventory(workspace, signal);
364
+ const inventory = await this.inventory(workspace, signal, input.sources);
319
365
  const matches = this.rank(inventory.sections, input.query, input.sources);
366
+ const results = matches.slice(0, input.limit).map(({ section, score, excerpt }) => ({
367
+ ...this.publicSection(section),
368
+ score,
369
+ excerpt,
370
+ }));
371
+ const requested = results.length;
372
+ while (Buffer.byteLength(JSON.stringify(results), 'utf8') > MAX_REFERENCE_METADATA_BYTES &&
373
+ results.length > 1)
374
+ results.pop();
375
+ if (Buffer.byteLength(JSON.stringify(results), 'utf8') > MAX_REFERENCE_METADATA_BYTES)
376
+ throw new ServiceError('REFERENCE_RESPONSE_TOO_LARGE', 'Reference metadata exceeds the bounded reply');
320
377
  return {
321
- results: matches.slice(0, input.limit).map(({ section, score, excerpt }) => ({
322
- ...this.publicSection(section),
323
- score,
324
- excerpt,
325
- })),
378
+ results,
326
379
  total: matches.length,
380
+ omitted: matches.length - results.length,
381
+ limitedByBytes: results.length < requested,
327
382
  coverage: inventory.coverage,
328
383
  skipped: inventory.skipped,
329
384
  };
@@ -364,7 +419,17 @@ export class ReferenceService {
364
419
  left.section.startLine - right.section.startLine);
365
420
  }
366
421
  async read(workspace, input, signal) {
367
- const inventory = await this.inventory(workspace, signal);
422
+ let source = input.source;
423
+ if (source === undefined) {
424
+ for (const cached of this.#cache.values()) {
425
+ const section = cached.sections.find(({ id }) => id === input.id);
426
+ if (section !== undefined) {
427
+ source = section.source;
428
+ break;
429
+ }
430
+ }
431
+ }
432
+ const inventory = await this.inventory(workspace, signal, source === undefined ? undefined : [source]);
368
433
  const section = inventory.sections.find(({ id }) => id === input.id);
369
434
  if (section === undefined)
370
435
  throw new ServiceError('REFERENCE_SECTION_UNKNOWN', 'Reference section is not in this workspace');
@@ -373,7 +438,7 @@ export class ReferenceService {
373
438
  currentRevision: section.revision,
374
439
  });
375
440
  const startLine = input.startLine ?? section.startLine;
376
- const selected = readBoundedLines(section.lines, section.startLine, startLine, input.startColumn ?? 1, input.maxLines, 8_000);
441
+ const selected = readBoundedJsonLines(section.lines, section.startLine, startLine, input.startColumn ?? 1, input.maxLines, 8_000, 8_192);
377
442
  return {
378
443
  ...this.publicSection(section),
379
444
  sectionStartLine: section.startLine,
@@ -422,13 +487,20 @@ export class ReferenceService {
422
487
  return true;
423
488
  });
424
489
  const selected = prioritized.slice(0, input.limit);
490
+ const requestedCount = selected.length;
491
+ while (Buffer.byteLength(JSON.stringify(selected.map((section) => this.publicSection(section))), 'utf8') > MAX_REFERENCE_METADATA_BYTES &&
492
+ selected.length > 1)
493
+ selected.pop();
494
+ if (Buffer.byteLength(JSON.stringify(selected.map((section) => this.publicSection(section))), 'utf8') > MAX_REFERENCE_METADATA_BYTES)
495
+ throw new ServiceError('REFERENCE_RESPONSE_TOO_LARGE', 'Reference metadata exceeds the bounded reply');
425
496
  return {
426
497
  surface: input.surface,
427
498
  sections: selected.map((section) => ({
428
499
  ...this.publicSection(section),
429
500
  excerpt: ranked.find((entry) => entry.section.id === section.id)?.excerpt ?? section.excerpt,
430
501
  })),
431
- omitted: Math.max(0, prioritized.length - input.limit),
502
+ omitted: Math.max(0, prioritized.length - selected.length),
503
+ limitedByBytes: selected.length < requestedCount,
432
504
  omittedSources: required
433
505
  .filter(({ section }) => !selected.some((entry) => entry.path === section.path && entry.source === section.source))
434
506
  .map(({ name }) => name),