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.
- package/CHANGELOG.md +23 -0
- package/README.md +124 -28
- package/dist/hoi4_agent_tools/core/configuration.js +2 -0
- package/dist/hoi4_agent_tools/core/engine.js +7 -7
- package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
- package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
- package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
- package/dist/hoi4_agent_tools/core/job-store.js +19 -40
- package/dist/hoi4_agent_tools/core/job-worker-host.js +84 -19
- package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
- package/dist/hoi4_agent_tools/core/operation-tasks.js +2 -7
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
- package/dist/hoi4_agent_tools/core/scanner.js +136 -47
- package/dist/hoi4_agent_tools/core/shared-request-capacity.js +27 -13
- package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
- package/dist/hoi4_agent_tools/core/windows-sharing.js +42 -0
- package/dist/hoi4_agent_tools/core/workspace.js +114 -8
- package/dist/hoi4_agent_tools/event/artifact-validation.js +1 -1
- package/dist/hoi4_agent_tools/event/service.js +1 -1
- package/dist/hoi4_agent_tools/focus/service.js +2 -2
- package/dist/hoi4_agent_tools/gui/studio.js +16 -3
- package/dist/hoi4_agent_tools/map/model.js +81 -2
- package/dist/hoi4_agent_tools/map/service.js +3 -2
- package/dist/hoi4_agent_tools/map/validation.js +60 -4
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
- package/dist/hoi4_agent_tools/package-check/service.js +1 -1
- package/dist/hoi4_agent_tools/reference/lines.js +12 -0
- package/dist/hoi4_agent_tools/reference/operations.js +45 -2
- package/dist/hoi4_agent_tools/reference/ranking.js +309 -0
- package/dist/hoi4_agent_tools/reference/script-validation.js +267 -0
- package/dist/hoi4_agent_tools/reference/service.js +285 -177
- package/dist/hoi4_agent_tools/reference/source-locate.js +156 -0
- package/dist/hoi4_agent_tools/reference/source-lookup.js +125 -18
- package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
- package/dist/hoi4_agent_tools/reference/suggestions.js +38 -0
- package/dist/hoi4_agent_tools/schemas/reference.js +135 -2
- package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
- package/dist/hoi4_agent_tools/schemas/transaction.js +1 -1
- package/dist/hoi4_agent_tools/technology/service.js +1 -1
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +1 -0
- package/docs/development.md +6 -0
- package/docs/examples.md +3 -13
- package/docs/gui.md +1 -15
- package/docs/map.md +10 -0
- package/docs/reference.md +47 -4
- package/docs/research/reference-benchmark.md +80 -0
- package/docs/setup.md +15 -0
- package/docs/tools.md +37 -36
- package/npm-shrinkwrap.json +7101 -0
- package/package.json +8 -7
- package/schemas/configuration.schema.json +95 -88
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +9 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +92 -0
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +144 -15
- package/server.json +2 -2
|
@@ -1,101 +1,91 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import
|
|
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 {
|
|
8
|
-
import {
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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)$/
|
|
61
|
+
.replace(/\.(md|log)$/iu, '')
|
|
82
62
|
.replaceAll('_', ' ')
|
|
83
63
|
.replace(/ - Hearts of Iron 4 Wiki$/u, '');
|
|
84
64
|
const headings = [];
|
|
85
|
-
|
|
65
|
+
const rows = [];
|
|
66
|
+
let fence;
|
|
86
67
|
for (let index = 0; index < lines.length; index += 1) {
|
|
87
68
|
const line = lines[index];
|
|
88
|
-
|
|
89
|
-
|
|
69
|
+
const marker = fenceMarker(line);
|
|
70
|
+
if (fence === undefined && marker !== undefined) {
|
|
71
|
+
fence = marker;
|
|
90
72
|
continue;
|
|
91
73
|
}
|
|
92
|
-
if (
|
|
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
|
-
|
|
108
|
-
const
|
|
109
|
-
const
|
|
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:
|
|
105
|
+
heading: heading.slice(0, 512),
|
|
117
106
|
path: filePath,
|
|
118
|
-
startLine:
|
|
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: {
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
267
|
+
const names = await globFiles(root.patterns, {
|
|
254
268
|
cwd: canonicalRoot,
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
dot: false,
|
|
269
|
+
caseSensitive: false,
|
|
270
|
+
...(signal === undefined ? {} : { signal }),
|
|
258
271
|
});
|
|
259
272
|
names.sort(compareCodeUnits);
|
|
260
|
-
for (
|
|
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
|
-
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
if (
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
442
|
-
return publicSection;
|
|
550
|
+
return publicReferenceSection(section);
|
|
443
551
|
}
|
|
444
552
|
}
|