hoi4-agent-tools 3.6.0 → 3.8.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 (87) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +124 -28
  3. package/dist/hoi4_agent_tools/core/configuration.js +2 -0
  4. package/dist/hoi4_agent_tools/core/engine.js +7 -7
  5. package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
  6. package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
  7. package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
  8. package/dist/hoi4_agent_tools/core/job-store.js +19 -40
  9. package/dist/hoi4_agent_tools/core/job-worker-host.js +84 -19
  10. package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
  11. package/dist/hoi4_agent_tools/core/operation-tasks.js +2 -7
  12. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
  13. package/dist/hoi4_agent_tools/core/scanner.js +136 -47
  14. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +27 -13
  15. package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
  16. package/dist/hoi4_agent_tools/core/windows-sharing.js +42 -0
  17. package/dist/hoi4_agent_tools/core/workspace.js +114 -8
  18. package/dist/hoi4_agent_tools/event/artifact-validation.js +1 -1
  19. package/dist/hoi4_agent_tools/event/service.js +1 -1
  20. package/dist/hoi4_agent_tools/focus/service.js +2 -2
  21. package/dist/hoi4_agent_tools/gui/studio.js +16 -3
  22. package/dist/hoi4_agent_tools/map/model.js +81 -2
  23. package/dist/hoi4_agent_tools/map/service.js +3 -2
  24. package/dist/hoi4_agent_tools/map/validation.js +60 -4
  25. package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
  26. package/dist/hoi4_agent_tools/package-check/service.js +1 -1
  27. package/dist/hoi4_agent_tools/reference/lines.js +12 -0
  28. package/dist/hoi4_agent_tools/reference/operations.js +45 -2
  29. package/dist/hoi4_agent_tools/reference/ranking.js +309 -0
  30. package/dist/hoi4_agent_tools/reference/script-validation.js +267 -0
  31. package/dist/hoi4_agent_tools/reference/service.js +285 -177
  32. package/dist/hoi4_agent_tools/reference/source-locate.js +156 -0
  33. package/dist/hoi4_agent_tools/reference/source-lookup.js +125 -18
  34. package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
  35. package/dist/hoi4_agent_tools/reference/suggestions.js +38 -0
  36. package/dist/hoi4_agent_tools/schemas/reference.js +135 -2
  37. package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
  38. package/dist/hoi4_agent_tools/schemas/transaction.js +1 -1
  39. package/dist/hoi4_agent_tools/technology/service.js +1 -1
  40. package/dist/hoi4_agent_tools/version.js +1 -1
  41. package/docs/README.md +1 -0
  42. package/docs/development.md +6 -0
  43. package/docs/examples.md +3 -13
  44. package/docs/gui.md +1 -15
  45. package/docs/map.md +10 -0
  46. package/docs/reference.md +47 -4
  47. package/docs/research/reference-benchmark.md +80 -0
  48. package/docs/setup.md +15 -0
  49. package/docs/tools.md +37 -36
  50. package/npm-shrinkwrap.json +7101 -0
  51. package/package.json +8 -7
  52. package/schemas/configuration.schema.json +95 -88
  53. package/schemas/continuous-focus-palette.schema.json +1 -1
  54. package/schemas/custom-weighted-pool.schema.json +1 -1
  55. package/schemas/decision-inspect-input.schema.json +1 -1
  56. package/schemas/event-feature-manifest.schema.json +1 -1
  57. package/schemas/focus-plan.schema.json +1 -1
  58. package/schemas/focus-planning-sidecar.schema.json +1 -1
  59. package/schemas/gui-animation-source.schema.json +1 -1
  60. package/schemas/gui-helper.schema.json +1 -1
  61. package/schemas/gui-scenario.schema.json +1 -1
  62. package/schemas/helper-expansion-request.schema.json +1 -1
  63. package/schemas/helper-expansion-summary.schema.json +1 -1
  64. package/schemas/impact-inspect-input.schema.json +1 -1
  65. package/schemas/map-operation.schema.json +1 -1
  66. package/schemas/mechanic-test-input.schema.json +1 -1
  67. package/schemas/operation-result.schema.json +1 -1
  68. package/schemas/package-check-input.schema.json +1 -1
  69. package/schemas/package-manifest.schema.json +1 -1
  70. package/schemas/probability-analysis-result.schema.json +1 -1
  71. package/schemas/probability-compare-input.schema.json +1 -1
  72. package/schemas/probability-evaluate-input.schema.json +1 -1
  73. package/schemas/probability-inspect-input.schema.json +1 -1
  74. package/schemas/probability-render-input.schema.json +1 -1
  75. package/schemas/probability-scenario-set.schema.json +1 -1
  76. package/schemas/probability-sequence-input.schema.json +1 -1
  77. package/schemas/probability-simulate-input.schema.json +1 -1
  78. package/schemas/probability-sweep-input.schema.json +1 -1
  79. package/schemas/reference-context-input.schema.json +1 -1
  80. package/schemas/reference-read-input.schema.json +9 -1
  81. package/schemas/reference-search-input.schema.json +1 -1
  82. package/schemas/scenario-suite.schema.json +1 -1
  83. package/schemas/scenario-test-input.schema.json +1 -1
  84. package/schemas/script-validate-input.schema.json +92 -0
  85. package/schemas/shared-scenario.schema.json +1 -1
  86. package/schemas/source-lookup-input.schema.json +144 -15
  87. package/server.json +2 -2
@@ -1,101 +1,91 @@
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';
10
+ import { RankingIndex, sectionTerms } from './ranking.js';
9
11
  const MAX_FILES = 512;
10
12
  const MAX_FILE_BYTES = 2_000_000;
11
13
  const MAX_TOTAL_BYTES = 32_000_000;
12
14
  const MAX_SECTIONS = 20_000;
13
- const searchStopWords = new Set([
14
- 'a',
15
- 'an',
16
- 'and',
17
- 'at',
18
- 'be',
19
- 'can',
20
- 'come',
21
- 'do',
22
- 'does',
23
- 'for',
24
- 'from',
25
- 'how',
26
- 'in',
27
- 'is',
28
- 'of',
29
- 'the',
30
- 'to',
31
- 'where',
32
- 'with',
33
- 'work',
34
- ]);
35
- function normalizeTerm(term) {
36
- if (term === 'dated')
37
- return 'date';
38
- if (term.endsWith('ies') && term.length > 5)
39
- return `${term.slice(0, -3)}y`;
40
- if (term.endsWith('ses') && term.length > 5)
41
- return term.slice(0, -2);
42
- if (term.endsWith('s') && term.length > 4)
43
- return term.slice(0, -1);
44
- return term;
45
- }
46
- function queryTerms(query) {
47
- return [
48
- ...new Set((query.toLowerCase().match(/[a-z0-9_.:-]+/gu) ?? [])
49
- .map(normalizeTerm)
50
- .filter((term) => /[a-z0-9]/u.test(term) && !searchStopWords.has(term))),
51
- ];
15
+ const MAX_REFERENCE_METADATA_BYTES = 24_000;
16
+ const PHRASE_CANDIDATES = 60;
17
+ const EXACT_HEADING_SCORE = 1000;
18
+ const sourceOrder = { game_doc: 0, wiki: 1, script_doc: 2 };
19
+ function referenceText(bytes) {
20
+ if (bytes.length >= 2 &&
21
+ ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff))) {
22
+ const payload = Buffer.from(bytes.subarray(2));
23
+ if (payload.length % 2 !== 0)
24
+ throw new ServiceError('REFERENCE_ENCODING_INVALID', 'UTF-16 reference text has an incomplete code unit');
25
+ if (bytes[0] === 0xfe)
26
+ payload.swap16();
27
+ return payload.toString('utf16le');
28
+ }
29
+ return decodeSource(bytes).text;
52
30
  }
53
- function containsTerm(text, term) {
54
- if (term.length > 2)
55
- return text.includes(term);
56
- const escaped = term.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
57
- return new RegExp(`(^|[^a-z0-9_])${escaped}($|[^a-z0-9_])`, 'u').test(text);
31
+ /** The citation fields of a section, without its indexed text or term statistics. */
32
+ export function publicReferenceSection(section) {
33
+ const { lines: _lines, terms: _terms, ...citation } = section;
34
+ return citation;
58
35
  }
59
- function snippet(lines, terms = []) {
60
- let matching = -1;
61
- let best = -1;
62
- for (const [index, line] of lines.entries()) {
63
- const count = terms.filter((term) => containsTerm(line.toLowerCase(), term)).length;
64
- if (count > best) {
65
- matching = index;
66
- best = count;
67
- }
68
- }
69
- const selected = lines
70
- .slice(Math.max(0, matching), Math.max(0, matching) + 3)
36
+ /** Up to three non-empty lines from `first`, trimmed to the 300-character excerpt bound. */
37
+ function snippet(lines, first = 0) {
38
+ return lines
39
+ .slice(first, first + 3)
71
40
  .map((line) => line.trim())
72
41
  .filter(Boolean)
73
- .join(' ');
74
- return selected.slice(0, 300);
42
+ .join(' ')
43
+ .slice(0, 300);
44
+ }
45
+ // A Markdown table row whose first cell names a command, trigger, modifier, or on action.
46
+ const identifierRow = /^\|\s*`?([A-Za-z_][A-Za-z0-9_@.:]*)`?\s*\|/u;
47
+ const tableSeparator = /^\|\s*:?-{3,}/u;
48
+ /** A closing fence repeats the opening character at least as often and has no info string. */
49
+ function fenceMarker(line) {
50
+ const match = /^\s*(`{3,}|~{3,})(.*)$/u.exec(line);
51
+ if (match === null)
52
+ return undefined;
53
+ const marker = match[1];
54
+ return { character: marker[0], length: marker.length, info: match[2].trim() };
75
55
  }
76
56
  function sectionize(root, filePath, content, revision) {
77
57
  const lines = content.replace(/^\uFEFF/u, '').split(/\r?\n/u);
78
58
  const relative = path.relative(root.root, filePath).replaceAll('\\', '/');
79
59
  const title = path
80
60
  .basename(filePath)
81
- .replace(/\.(md|log)$/u, '')
61
+ .replace(/\.(md|log)$/iu, '')
82
62
  .replaceAll('_', ' ')
83
63
  .replace(/ - Hearts of Iron 4 Wiki$/u, '');
84
64
  const headings = [];
85
- let inFence = false;
65
+ const rows = [];
66
+ let fence;
86
67
  for (let index = 0; index < lines.length; index += 1) {
87
68
  const line = lines[index];
88
- if (/^\s*(```|~~~)/u.test(line)) {
89
- inFence = !inFence;
69
+ const marker = fenceMarker(line);
70
+ if (fence === undefined && marker !== undefined) {
71
+ fence = marker;
90
72
  continue;
91
73
  }
92
- if (inFence)
74
+ if (fence !== undefined) {
75
+ if (marker?.character === fence.character &&
76
+ marker.length >= fence.length &&
77
+ marker.info === '')
78
+ fence = undefined;
93
79
  continue;
80
+ }
81
+ const row = identifierRow.exec(line);
82
+ if (row !== null && !tableSeparator.test(lines[index + 1] ?? ''))
83
+ rows.push({ heading: row[1], start: index });
94
84
  const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/u.exec(line);
95
85
  if (match)
96
86
  headings.push({ heading: match[2].replace(/<a\b[^>]*><\/a>/gu, '').trim(), start: index });
97
87
  else if (root.kind === 'script_doc' &&
98
- filePath.endsWith('.log') &&
88
+ filePath.toLowerCase().endsWith('.log') &&
99
89
  /^[A-Za-z_][A-Za-z0-9_.:-]*$/u.test(line.trim()) &&
100
90
  !/^\s/u.test(line) &&
101
91
  /^\s+\S/u.test(lines[index + 1] ?? '')) {
@@ -104,24 +94,30 @@ function sectionize(root, filePath, content, revision) {
104
94
  }
105
95
  if (headings.length === 0 || headings[0].start > 0)
106
96
  headings.unshift({ heading: title, start: 0 });
107
- return headings.map((current, index) => {
108
- const end = headings[index + 1]?.start ?? lines.length;
109
- const sectionLines = lines.slice(current.start, end);
110
- const id = sha256Bytes(Buffer.from(`${root.kind}\0${relative}\0${current.start + 1}\0${current.heading}`, 'utf8'));
97
+ const section = (heading, start, end) => {
98
+ const sectionLines = lines.slice(start, end);
99
+ const id = sha256Bytes(Buffer.from(`${root.kind}\0${relative}\0${start + 1}\0${heading}`, 'utf8'));
111
100
  return {
112
101
  id,
113
102
  revision,
114
103
  source: root.kind,
115
104
  title,
116
- heading: current.heading.slice(0, 512),
105
+ heading: heading.slice(0, 512),
117
106
  path: filePath,
118
- startLine: current.start + 1,
107
+ startLine: start + 1,
119
108
  endLine: end,
120
109
  excerpt: snippet(sectionLines),
121
110
  authority: root.authority,
122
111
  lines: sectionLines,
112
+ terms: sectionTerms(title, heading, sectionLines),
123
113
  };
124
- });
114
+ };
115
+ // Heading sections come first, so a page's opening section still names the page. Table
116
+ // rows follow as one-line sections that cite one exact command or modifier entry.
117
+ return [
118
+ ...headings.map((current, index) => section(current.heading, current.start, headings[index + 1]?.start ?? lines.length)),
119
+ ...rows.map((row) => section(row.heading, row.start, row.start + 1)),
120
+ ];
125
121
  }
126
122
  function sourceRoots(workspace) {
127
123
  const gameRoot = workspace.gameRoot ?? (workspace.registration.kind === 'game' ? workspace.modRoot : undefined);
@@ -208,6 +204,7 @@ const gameDocNames = {
208
204
  /** A workspace-private, read-only reference index over explicitly known local roots. */
209
205
  export class ReferenceService {
210
206
  #cache = new Map();
207
+ #indexes = new Map();
211
208
  #cacheBytes = 0;
212
209
  retain(key, value) {
213
210
  this.#cacheBytes -= this.#cache.get(key)?.bytes ?? 0;
@@ -222,16 +219,33 @@ export class ReferenceService {
222
219
  this.#cacheBytes -= oldest[1].bytes;
223
220
  }
224
221
  }
225
- async inventory(workspace, signal) {
222
+ async inventory(workspace, signal, sources) {
226
223
  const sections = [];
227
224
  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 },
225
+ game_doc: {
226
+ files: 0,
227
+ sections: 0,
228
+ unavailable: true,
229
+ included: sources === undefined || sources.includes('game_doc'),
230
+ },
231
+ wiki: {
232
+ files: 0,
233
+ sections: 0,
234
+ unavailable: true,
235
+ included: sources === undefined || sources.includes('wiki'),
236
+ },
237
+ script_doc: {
238
+ files: 0,
239
+ sections: 0,
240
+ unavailable: true,
241
+ included: sources === undefined || sources.includes('script_doc'),
242
+ },
231
243
  };
232
244
  let skipped = 0;
233
245
  let bytes = 0;
234
246
  for (const root of sourceRoots(workspace)) {
247
+ if (sources !== undefined && !sources.includes(root.kind))
248
+ continue;
235
249
  signal?.throwIfAborted();
236
250
  let canonicalRoot;
237
251
  try {
@@ -250,121 +264,205 @@ export class ReferenceService {
250
264
  throw new ServiceError('REFERENCE_ROOT_ESCAPE', 'Reference root resolves outside its configured workspace');
251
265
  }
252
266
  coverage[root.kind].unavailable = false;
253
- const names = await fg(root.patterns, {
267
+ const names = await globFiles(root.patterns, {
254
268
  cwd: canonicalRoot,
255
- onlyFiles: true,
256
- followSymbolicLinks: false,
257
- dot: false,
269
+ caseSensitive: false,
270
+ ...(signal === undefined ? {} : { signal }),
258
271
  });
259
272
  names.sort(compareCodeUnits);
260
- for (const name of names) {
273
+ for (let offset = 0; offset < names.length; offset += 8) {
261
274
  signal?.throwIfAborted();
262
275
  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;
276
+ skipped += names.length - offset;
277
+ break;
271
278
  }
272
- const metadata = await stat(canonical);
273
- if (metadata.size > MAX_FILE_BYTES || bytes + metadata.size > MAX_TOTAL_BYTES) {
274
- skipped++;
275
- continue;
276
- }
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;
279
+ const readRoot = { path: canonicalRoot };
280
+ const prepared = await Promise.allSettled(names.slice(offset, offset + 8).map((name) => prepareSource(readRoot, name, signal)));
281
+ const opened = prepared.flatMap((entry) => entry.status === 'fulfilled' ? [entry.value] : []);
282
282
  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) {
283
+ signal?.throwIfAborted();
284
+ for (const entry of prepared) {
285
+ if (entry.status !== 'rejected')
286
+ continue;
287
+ if (entry.reason instanceof ServiceError && entry.reason.code === 'SCAN_ROOT_ESCAPE') {
288
+ skipped++;
289
+ continue;
290
+ }
290
291
  throw new ServiceError('REFERENCE_SOURCE_CHANGED', 'Reference path changed while opening it; retry the request');
291
292
  }
292
- content = await readBoundedFile(handle, Math.min(MAX_FILE_BYTES, MAX_TOTAL_BYTES - bytes + metadata.size), signal);
293
+ const admittedSources = [];
294
+ for (const source of opened) {
295
+ if (!source.metadata.isFile() ||
296
+ source.metadata.size > MAX_FILE_BYTES ||
297
+ bytes + source.metadata.size > MAX_TOTAL_BYTES ||
298
+ coverage[root.kind].files >= MAX_FILES) {
299
+ skipped++;
300
+ continue;
301
+ }
302
+ bytes += source.metadata.size;
303
+ coverage[root.kind].files++;
304
+ admittedSources.push(source);
305
+ }
306
+ const reads = await Promise.allSettled(admittedSources.map(async (source) => {
307
+ const content = await readBoundedFile(source.handle, source.metadata.size, signal);
308
+ await verifySource(readRoot, source);
309
+ if (content.length !== source.metadata.size)
310
+ throw new ServiceError('REFERENCE_SOURCE_CHANGED', 'Reference changed while it was being read; retry the request');
311
+ return content;
312
+ }));
313
+ signal?.throwIfAborted();
314
+ const failed = reads.find((entry) => entry.status === 'rejected');
315
+ if (failed?.status === 'rejected')
316
+ throw new ServiceError('REFERENCE_SOURCE_CHANGED', 'Reference changed while it was being read; retry the request');
317
+ for (const [index, read] of reads.entries()) {
318
+ if (read.status !== 'fulfilled')
319
+ continue;
320
+ const canonical = admittedSources[index].canonicalPath;
321
+ const content = read.value;
322
+ const key = `${root.kind}:${canonicalRoot}:${canonical}`;
323
+ const revision = sha256Bytes(content);
324
+ let cached = this.#cache.get(key);
325
+ if (cached?.revision !== revision)
326
+ cached = {
327
+ revision,
328
+ bytes: content.length,
329
+ sections: sectionize(root, canonical, referenceText(content), revision),
330
+ };
331
+ this.retain(key, cached);
332
+ const admitted = cached.sections.slice(0, MAX_SECTIONS - sections.length);
333
+ if (admitted.length < cached.sections.length)
334
+ skipped++;
335
+ sections.push(...admitted);
336
+ coverage[root.kind].sections += admitted.length;
337
+ }
293
338
  }
294
339
  finally {
295
- await handle.close();
296
- }
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
- };
340
+ await Promise.all(opened.map(({ handle }) => handle.close()));
306
341
  }
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
342
  }
314
343
  }
315
344
  return { sections, coverage, skipped };
316
345
  }
317
346
  async search(workspace, input, signal) {
318
- const inventory = await this.inventory(workspace, signal);
319
- const matches = this.rank(inventory.sections, input.query, input.sources);
347
+ const inventory = await this.inventory(workspace, signal, input.sources);
348
+ const { ranked: matches, describe } = this.rank(inventory.sections, input.query);
349
+ const results = matches.slice(0, input.limit).map((match) => {
350
+ const { section, score, excerpt, matchLine } = describe(match);
351
+ return { ...this.publicSection(section), score, excerpt, matchLine };
352
+ });
353
+ const requested = results.length;
354
+ while (Buffer.byteLength(JSON.stringify(results), 'utf8') > MAX_REFERENCE_METADATA_BYTES &&
355
+ results.length > 1)
356
+ results.pop();
357
+ if (Buffer.byteLength(JSON.stringify(results), 'utf8') > MAX_REFERENCE_METADATA_BYTES)
358
+ throw new ServiceError('REFERENCE_RESPONSE_TOO_LARGE', 'Reference metadata exceeds the bounded reply');
320
359
  return {
321
- results: matches.slice(0, input.limit).map(({ section, score, excerpt }) => ({
322
- ...this.publicSection(section),
323
- score,
324
- excerpt,
325
- })),
360
+ results,
326
361
  total: matches.length,
362
+ omitted: matches.length - results.length,
363
+ limitedByBytes: results.length < requested,
327
364
  coverage: inventory.coverage,
328
365
  skipped: inventory.skipped,
329
366
  };
330
367
  }
331
- rank(sections, question, sources) {
332
- const query = question.toLowerCase();
333
- const terms = queryTerms(query);
334
- return sections
335
- .flatMap((section) => {
336
- if (sources !== undefined && !sources.includes(section.source))
337
- return [];
338
- const title = section.title.toLowerCase();
339
- const heading = section.heading.toLowerCase();
340
- const body = section.lines.join('\n').toLowerCase();
341
- let matched = 0;
342
- let score = 0;
343
- for (const term of terms) {
344
- const inTitle = containsTerm(title, term);
345
- const inHeading = containsTerm(heading, term);
346
- const inBody = containsTerm(body, term);
347
- if (!inTitle && !inHeading && !inBody)
348
- continue;
349
- matched++;
350
- score += (inTitle ? 24 : 0) + (inHeading ? 18 : 0) + (inBody ? 3 : 0);
351
- }
352
- if (matched < Math.max(1, Math.ceil(terms.length * 0.6)))
353
- return [];
354
- if (heading === query)
355
- score += 100;
356
- else if (heading.includes(query))
357
- score += 70;
358
- if (body.includes(query))
359
- score += 30;
360
- return [{ section, score, excerpt: snippet(section.lines, terms) }];
361
- })
362
- .sort((left, right) => right.score - left.score ||
368
+ /** One index per distinct set of section revisions; IDF depends on the whole inventory. */
369
+ rankingIndex(sections) {
370
+ const parts = [];
371
+ let previous;
372
+ for (const section of sections) {
373
+ if (previous?.path !== section.path || previous.revision !== section.revision)
374
+ parts.push(`${section.source}\0${section.path}\0${section.revision}`);
375
+ previous = section;
376
+ }
377
+ const key = sha256Bytes(Buffer.from(parts.join('\n'), 'utf8'));
378
+ const cached = this.#indexes.get(key);
379
+ if (cached !== undefined) {
380
+ this.#indexes.delete(key);
381
+ this.#indexes.set(key, cached);
382
+ return cached;
383
+ }
384
+ const index = new RankingIndex(sections.map(({ terms }) => terms));
385
+ this.#indexes.set(key, index);
386
+ while (this.#indexes.size > 4) {
387
+ const oldest = this.#indexes.keys().next().value;
388
+ if (oldest === undefined)
389
+ break;
390
+ this.#indexes.delete(oldest);
391
+ }
392
+ return index;
393
+ }
394
+ rank(sections, question) {
395
+ const index = this.rankingIndex(sections);
396
+ const plan = index.plan(question);
397
+ const scored = [];
398
+ for (const section of sections) {
399
+ const score = index.score(plan, section.terms, section.heading);
400
+ if (score !== undefined)
401
+ scored.push({ section, score: Math.round(score * 1000) / 1000 });
402
+ }
403
+ // Installed documentation precedes the wiki when both describe a command equally well.
404
+ const order = (left, right) => right.score - left.score ||
405
+ sourceOrder[left.section.source] - sourceOrder[right.section.source] ||
363
406
  compareCodeUnits(left.section.path, right.section.path) ||
364
- left.section.startLine - right.section.startLine);
407
+ left.section.startLine - right.section.startLine;
408
+ scored.sort(order);
409
+ // Phrase adjacency is costlier to measure, so it only reorders the leading candidates.
410
+ const leading = scored.slice(0, PHRASE_CANDIDATES);
411
+ for (const entry of leading)
412
+ entry.score =
413
+ Math.round((entry.score + index.phraseBonus(plan, entry.section.lines)) * 1000) / 1000;
414
+ leading.sort(order);
415
+ scored.splice(0, leading.length, ...leading);
416
+ // An exact table row ranked above the section containing it is the sharper citation.
417
+ const rows = new Map();
418
+ const ranked = scored.filter(({ section }) => {
419
+ const above = rows.get(section.path) ?? [];
420
+ if (section.lines.length > 1 &&
421
+ above.some((line) => line > section.startLine && line < section.endLine + 1))
422
+ return false;
423
+ if (section.lines.length === 1)
424
+ rows.set(section.path, [...above, section.startLine]);
425
+ return true;
426
+ });
427
+ // The same command documented in several sources says little new after its first
428
+ // citation. Later copies wait behind distinct answers unless they match exactly.
429
+ const named = new Set();
430
+ const distinct = [];
431
+ const repeated = [];
432
+ for (const entry of ranked) {
433
+ const key = entry.section.heading.toLowerCase();
434
+ if (named.has(key) && entry.score < EXACT_HEADING_SCORE)
435
+ repeated.push(entry);
436
+ else
437
+ distinct.push(entry);
438
+ named.add(key);
439
+ }
440
+ ranked.splice(0, ranked.length, ...distinct, ...repeated);
441
+ return {
442
+ ranked,
443
+ describe: ({ section, score }) => {
444
+ const offset = index.bestLine(plan, section.lines);
445
+ return {
446
+ section,
447
+ score,
448
+ excerpt: snippet(section.lines, offset),
449
+ matchLine: section.startLine + offset,
450
+ };
451
+ },
452
+ };
365
453
  }
366
454
  async read(workspace, input, signal) {
367
- const inventory = await this.inventory(workspace, signal);
455
+ let source = input.source;
456
+ if (source === undefined) {
457
+ for (const cached of this.#cache.values()) {
458
+ const section = cached.sections.find(({ id }) => id === input.id);
459
+ if (section !== undefined) {
460
+ source = section.source;
461
+ break;
462
+ }
463
+ }
464
+ }
465
+ const inventory = await this.inventory(workspace, signal, source === undefined ? undefined : [source]);
368
466
  const section = inventory.sections.find(({ id }) => id === input.id);
369
467
  if (section === undefined)
370
468
  throw new ServiceError('REFERENCE_SECTION_UNKNOWN', 'Reference section is not in this workspace');
@@ -373,7 +471,7 @@ export class ReferenceService {
373
471
  currentRevision: section.revision,
374
472
  });
375
473
  const startLine = input.startLine ?? section.startLine;
376
- const selected = readBoundedLines(section.lines, section.startLine, startLine, input.startColumn ?? 1, input.maxLines, 8_000);
474
+ const selected = readBoundedJsonLines(section.lines, section.startLine, startLine, input.startColumn ?? 1, input.maxLines, 8_000, 8_192);
377
475
  return {
378
476
  ...this.publicSection(section),
379
477
  sectionStartLine: section.startLine,
@@ -386,7 +484,8 @@ export class ReferenceService {
386
484
  async context(workspace, input, signal) {
387
485
  const inventory = await this.inventory(workspace, signal);
388
486
  const wanted = surfaceNames[input.surface];
389
- const ranked = input.question === undefined ? [] : this.rank(inventory.sections, input.question);
487
+ const ranking = input.question === undefined ? undefined : this.rank(inventory.sections, input.question);
488
+ const ranked = ranking?.ranked ?? [];
390
489
  const required = [];
391
490
  const missing = [];
392
491
  const includeRequired = (name, section) => {
@@ -422,13 +521,23 @@ export class ReferenceService {
422
521
  return true;
423
522
  });
424
523
  const selected = prioritized.slice(0, input.limit);
524
+ const requestedCount = selected.length;
525
+ while (Buffer.byteLength(JSON.stringify(selected.map((section) => this.publicSection(section))), 'utf8') > MAX_REFERENCE_METADATA_BYTES &&
526
+ selected.length > 1)
527
+ selected.pop();
528
+ if (Buffer.byteLength(JSON.stringify(selected.map((section) => this.publicSection(section))), 'utf8') > MAX_REFERENCE_METADATA_BYTES)
529
+ throw new ServiceError('REFERENCE_RESPONSE_TOO_LARGE', 'Reference metadata exceeds the bounded reply');
425
530
  return {
426
531
  surface: input.surface,
427
- sections: selected.map((section) => ({
428
- ...this.publicSection(section),
429
- excerpt: ranked.find((entry) => entry.section.id === section.id)?.excerpt ?? section.excerpt,
430
- })),
431
- omitted: Math.max(0, prioritized.length - input.limit),
532
+ sections: selected.map((section) => {
533
+ const match = ranked.find((entry) => entry.section.id === section.id);
534
+ if (match === undefined || ranking === undefined)
535
+ return this.publicSection(section);
536
+ const { excerpt, matchLine } = ranking.describe(match);
537
+ return { ...this.publicSection(section), excerpt, matchLine };
538
+ }),
539
+ omitted: Math.max(0, prioritized.length - selected.length),
540
+ limitedByBytes: selected.length < requestedCount,
432
541
  omittedSources: required
433
542
  .filter(({ section }) => !selected.some((entry) => entry.path === section.path && entry.source === section.source))
434
543
  .map(({ name }) => name),
@@ -438,7 +547,6 @@ export class ReferenceService {
438
547
  };
439
548
  }
440
549
  publicSection(section) {
441
- const { lines: _lines, ...publicSection } = section;
442
- return publicSection;
550
+ return publicReferenceSection(section);
443
551
  }
444
552
  }