hoi4-agent-tools 3.5.1 → 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.
- package/CHANGELOG.md +18 -0
- package/README.md +137 -116
- 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-worker-host.js +27 -11
- package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
- package/dist/hoi4_agent_tools/core/scanner.js +133 -47
- package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
- package/dist/hoi4_agent_tools/gui/layout.js +237 -11
- package/dist/hoi4_agent_tools/gui/renderer.js +36 -3
- package/dist/hoi4_agent_tools/gui/scenario.js +18 -0
- package/dist/hoi4_agent_tools/gui/studio.js +73 -21
- package/dist/hoi4_agent_tools/map/service.js +2 -2
- package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -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/script-validation.js +277 -0
- package/dist/hoi4_agent_tools/reference/service.js +139 -67
- package/dist/hoi4_agent_tools/reference/source-lookup.js +91 -15
- package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
- package/dist/hoi4_agent_tools/schemas/reference.js +55 -0
- package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +34 -18
- package/docs/examples.md +75 -0
- package/docs/gui.md +23 -0
- package/docs/reference.md +43 -2
- package/docs/research/reference-benchmark.md +41 -0
- package/docs/setup.md +50 -26
- package/docs/tools.md +65 -0
- package/npm-shrinkwrap.json +7088 -0
- package/package.json +10 -7
- package/schemas/configuration.schema.json +1 -1
- 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 +139 -89
- 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 +68 -2
- package/server.json +3 -3
|
@@ -1,15 +1,29 @@
|
|
|
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';
|
|
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)$/
|
|
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: {
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
284
|
+
const names = await globFiles(root.patterns, {
|
|
254
285
|
cwd: canonicalRoot,
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
dot: false,
|
|
286
|
+
caseSensitive: false,
|
|
287
|
+
...(signal === undefined ? {} : { signal }),
|
|
258
288
|
});
|
|
259
289
|
names.sort(compareCodeUnits);
|
|
260
|
-
for (
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
const
|
|
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
|
-
|
|
284
|
-
const
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 -
|
|
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),
|
|
@@ -1,21 +1,72 @@
|
|
|
1
1
|
import { ServiceError } from '../core/result.js';
|
|
2
2
|
import { decodeSource } from '../core/source/encoding.js';
|
|
3
|
-
import {
|
|
3
|
+
import { readBoundedJsonLines } from './lines.js';
|
|
4
4
|
import { scanImpactSemanticReferences } from '../core/impact-semantic.js';
|
|
5
|
+
import { parseClausewitz } from '../core/source/parser.js';
|
|
6
|
+
import { navigateSource } from './source-navigation.js';
|
|
7
|
+
const semanticCache = new WeakMap();
|
|
5
8
|
/** Exact source definitions and usage over the existing shared Clausewitz index. */
|
|
6
9
|
export async function sourceLookup(engine, workspaceId, input, principal, signal) {
|
|
7
|
-
const
|
|
10
|
+
const narrowPatterns = {
|
|
11
|
+
event: ['events/**/*.txt'],
|
|
12
|
+
scripted_effect: ['common/scripted_effects/**/*.txt'],
|
|
13
|
+
scripted_trigger: ['common/scripted_triggers/**/*.txt'],
|
|
14
|
+
};
|
|
15
|
+
const patterns = input.kind === 'event' && input.includeReferences
|
|
16
|
+
? [
|
|
17
|
+
'events/**/*.txt',
|
|
18
|
+
'common/national_focus/**/*.txt',
|
|
19
|
+
'common/continuous_focus/**/*.txt',
|
|
20
|
+
'common/decisions/**/*.txt',
|
|
21
|
+
'common/ideas/**/*.txt',
|
|
22
|
+
'common/technologies/**/*.txt',
|
|
23
|
+
'common/scripted_effects/**/*.txt',
|
|
24
|
+
'common/scripted_triggers/**/*.txt',
|
|
25
|
+
'common/scripted_guis/**/*.txt',
|
|
26
|
+
'common/on_actions/**/*.txt',
|
|
27
|
+
]
|
|
28
|
+
: input.includeReferences || input.kind === undefined
|
|
29
|
+
? undefined
|
|
30
|
+
: narrowPatterns[input.kind];
|
|
31
|
+
const snapshot = await engine.scan(workspaceId, patterns === undefined ? {} : { patterns }, principal, signal);
|
|
8
32
|
if (input.expectedRevision !== undefined && input.expectedRevision !== snapshot.revision) {
|
|
9
33
|
throw new ServiceError('SOURCE_REVISION_STALE', 'Indexed source changed; look up the symbol again', { currentRevision: snapshot.revision });
|
|
10
34
|
}
|
|
11
|
-
const
|
|
35
|
+
const navigating = input.keyPath !== undefined || input.view === 'structure';
|
|
36
|
+
const matchedDefinitions = snapshot.index.symbols
|
|
12
37
|
.filter((entry) => entry.id === input.symbol && (input.kind === undefined || entry.kind === input.kind))
|
|
13
|
-
.sort((left, right) => Number(left.overridden) - Number(right.overridden) || right.loadOrder - left.loadOrder)
|
|
14
|
-
|
|
38
|
+
.sort((left, right) => Number(left.overridden) - Number(right.overridden) || right.loadOrder - left.loadOrder);
|
|
39
|
+
if (navigating && new Set(matchedDefinitions.map(({ kind }) => kind)).size > 1)
|
|
40
|
+
throw new ServiceError('SOURCE_KIND_AMBIGUOUS', 'Nested navigation requires kind when the identifier has multiple symbol kinds');
|
|
41
|
+
const definitions = matchedDefinitions
|
|
42
|
+
.slice(0, navigating ? 1 : input.maxDefinitions)
|
|
15
43
|
.map((entry, index) => {
|
|
16
44
|
const source = snapshot.index.files.get(entry.path);
|
|
17
45
|
const start = entry.location?.start.line;
|
|
18
46
|
const end = entry.location?.end.line;
|
|
47
|
+
const navigation = navigating
|
|
48
|
+
? (() => {
|
|
49
|
+
if (source === undefined ||
|
|
50
|
+
entry.location === undefined ||
|
|
51
|
+
/\.ya?ml$/iu.test(entry.path))
|
|
52
|
+
throw new ServiceError('SOURCE_STRUCTURE_UNAVAILABLE', 'Nested navigation requires an indexed Clausewitz definition');
|
|
53
|
+
return navigateSource(parseClausewitz(source.bytes, source.displayPath), {
|
|
54
|
+
start: entry.location.start.offset,
|
|
55
|
+
end: entry.location.end.offset,
|
|
56
|
+
}, input.keyPath ?? [], {
|
|
57
|
+
structure: input.view === 'structure',
|
|
58
|
+
maxChildren: input.maxChildren,
|
|
59
|
+
childOffset: input.childOffset,
|
|
60
|
+
maxLines: input.maxLines,
|
|
61
|
+
...(index === 0 && input.fromLine !== undefined
|
|
62
|
+
? { fromLine: input.fromLine }
|
|
63
|
+
: {}),
|
|
64
|
+
...(index === 0 && input.fromColumn !== undefined
|
|
65
|
+
? { fromColumn: input.fromColumn }
|
|
66
|
+
: {}),
|
|
67
|
+
});
|
|
68
|
+
})()
|
|
69
|
+
: undefined;
|
|
19
70
|
const fromLine = index === 0 ? (input.fromLine ?? start) : start;
|
|
20
71
|
if (fromLine !== undefined &&
|
|
21
72
|
start !== undefined &&
|
|
@@ -30,7 +81,7 @@ export async function sourceLookup(engine, workspaceId, input, principal, signal
|
|
|
30
81
|
.slice((start ?? fromLine) - 1, end);
|
|
31
82
|
const selected = fromLine === undefined || lines.length === 0
|
|
32
83
|
? null
|
|
33
|
-
:
|
|
84
|
+
: readBoundedJsonLines(lines, start ?? fromLine, fromLine, index === 0 ? (input.fromColumn ?? 1) : 1, input.maxLines, 2_000, 4_096);
|
|
34
85
|
return {
|
|
35
86
|
kind: entry.kind,
|
|
36
87
|
id: entry.id,
|
|
@@ -41,16 +92,19 @@ export async function sourceLookup(engine, workspaceId, input, principal, signal
|
|
|
41
92
|
sourceShadowed: entry.sourceShadowed,
|
|
42
93
|
startLine: start ?? null,
|
|
43
94
|
endLine: end ?? null,
|
|
44
|
-
fromLine: fromLine ?? null,
|
|
45
|
-
toLine: selected?.endLine ?? null,
|
|
46
|
-
nextLine: selected?.nextLine ?? null,
|
|
47
|
-
nextColumn: selected?.nextColumn ?? null,
|
|
48
|
-
text: selected?.text ?? '',
|
|
95
|
+
fromLine: navigation?.fromLine ?? fromLine ?? null,
|
|
96
|
+
toLine: navigation?.endLine ?? selected?.endLine ?? null,
|
|
97
|
+
nextLine: navigation === undefined ? (selected?.nextLine ?? null) : navigation.nextLine,
|
|
98
|
+
nextColumn: navigation === undefined ? (selected?.nextColumn ?? null) : navigation.nextColumn,
|
|
99
|
+
text: navigation?.text ?? selected?.text ?? '',
|
|
100
|
+
...(navigation === undefined ? {} : { navigation: navigation.navigation }),
|
|
49
101
|
};
|
|
50
102
|
});
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
:
|
|
103
|
+
let semantic = input.includeReferences ? semanticCache.get(snapshot) : undefined;
|
|
104
|
+
if (input.includeReferences && semantic === undefined) {
|
|
105
|
+
semantic = scanImpactSemanticReferences(snapshot, 200_000, { includeOnActions: true });
|
|
106
|
+
semanticCache.set(snapshot, semantic);
|
|
107
|
+
}
|
|
54
108
|
const referenceIdentities = new Set();
|
|
55
109
|
const matchedReferences = input.includeReferences
|
|
56
110
|
? [...snapshot.index.references, ...(semantic?.references ?? [])]
|
|
@@ -70,11 +124,19 @@ export async function sourceLookup(engine, workspaceId, input, principal, signal
|
|
|
70
124
|
path: entry.path,
|
|
71
125
|
line: entry.location?.start.line ?? null,
|
|
72
126
|
}));
|
|
73
|
-
|
|
127
|
+
const result = {
|
|
128
|
+
sourceScope: input.kind === 'event'
|
|
129
|
+
? input.includeReferences
|
|
130
|
+
? 'event_consumers'
|
|
131
|
+
: 'event_definitions'
|
|
132
|
+
: patterns === undefined
|
|
133
|
+
? 'workspace_index'
|
|
134
|
+
: 'helper_definitions',
|
|
74
135
|
revision: snapshot.revision,
|
|
75
136
|
complete: snapshot.complete,
|
|
76
137
|
skippedSourceCount: snapshot.skippedSourceCount,
|
|
77
138
|
definitionCount: snapshot.index.symbols.filter((entry) => entry.id === input.symbol && (input.kind === undefined || entry.kind === input.kind)).length,
|
|
139
|
+
definitionsTruncated: matchedDefinitions.length > definitions.length,
|
|
78
140
|
definitions,
|
|
79
141
|
references,
|
|
80
142
|
referencesIncluded: input.includeReferences,
|
|
@@ -87,4 +149,18 @@ export async function sourceLookup(engine, workspaceId, input, principal, signal
|
|
|
87
149
|
matchedReferences.length <= references.length,
|
|
88
150
|
unresolvedReferenceCount: semantic?.unresolved.length ?? 0,
|
|
89
151
|
};
|
|
152
|
+
while (Buffer.byteLength(JSON.stringify(result), 'utf8') > 24_000 &&
|
|
153
|
+
result.references.length > 0) {
|
|
154
|
+
result.references.pop();
|
|
155
|
+
result.referencesTruncated = true;
|
|
156
|
+
result.referencesComplete = false;
|
|
157
|
+
}
|
|
158
|
+
while (Buffer.byteLength(JSON.stringify(result), 'utf8') > 24_000 &&
|
|
159
|
+
result.definitions.length > 1) {
|
|
160
|
+
result.definitions.pop();
|
|
161
|
+
result.definitionsTruncated = true;
|
|
162
|
+
}
|
|
163
|
+
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > 24_000)
|
|
164
|
+
throw new ServiceError('REFERENCE_RESPONSE_TOO_LARGE', 'Selected source metadata exceeds the bounded reply; use entry-index selectors for long nested keys');
|
|
165
|
+
return result;
|
|
90
166
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { ServiceError } from '../core/result.js';
|
|
2
|
+
import { locationFor } from '../core/source/lexer.js';
|
|
3
|
+
import { readBoundedJsonLines } from './lines.js';
|
|
4
|
+
function preview(text, limit) {
|
|
5
|
+
let end = Math.min(text.length, limit);
|
|
6
|
+
if (end > 0 &&
|
|
7
|
+
/[\uD800-\uDBFF]/u.test(text[end - 1]) &&
|
|
8
|
+
/[\uDC00-\uDFFF]/u.test(text[end] ?? ''))
|
|
9
|
+
end--;
|
|
10
|
+
return { text: text.slice(0, end), truncated: end < text.length };
|
|
11
|
+
}
|
|
12
|
+
function findEntry(block, range) {
|
|
13
|
+
for (const entry of block.entries) {
|
|
14
|
+
if (entry.start === range.start && entry.end === range.end)
|
|
15
|
+
return entry;
|
|
16
|
+
const value = entry.type === 'assignment' ? entry.value : entry;
|
|
17
|
+
if (value.type !== 'block' || value.start > range.start || value.end < range.end)
|
|
18
|
+
continue;
|
|
19
|
+
if (value.start === range.start && value.end === range.end)
|
|
20
|
+
return value;
|
|
21
|
+
const found = findEntry(value, range);
|
|
22
|
+
if (found !== undefined)
|
|
23
|
+
return found;
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Navigate actual parser nodes, preserving duplicate keys and exact source boundaries. */
|
|
28
|
+
export function navigateSource(document, range, keyPath, options) {
|
|
29
|
+
if (document.diagnostics.some(({ severity }) => severity === 'error' || severity === 'blocker'))
|
|
30
|
+
throw new ServiceError('SOURCE_STRUCTURE_UNAVAILABLE', 'Cannot navigate a source document with parse errors');
|
|
31
|
+
const definition = findEntry(document.root, range);
|
|
32
|
+
if (definition === undefined)
|
|
33
|
+
throw new ServiceError('SOURCE_STRUCTURE_UNAVAILABLE', 'Indexed definition does not identify an exact parser node');
|
|
34
|
+
let selected = definition;
|
|
35
|
+
const resolvedPath = [];
|
|
36
|
+
for (const step of keyPath) {
|
|
37
|
+
const value = selected.type === 'assignment' ? selected.value : selected;
|
|
38
|
+
if (value.type !== 'block')
|
|
39
|
+
throw new ServiceError('SOURCE_PATH_NOT_BLOCK', 'Cannot navigate inside a scalar source value', { keyPath: resolvedPath });
|
|
40
|
+
if ('index' in step) {
|
|
41
|
+
const child = value.entries[step.index];
|
|
42
|
+
if (child === undefined)
|
|
43
|
+
throw new ServiceError('SOURCE_INDEX_UNKNOWN', 'No source child exists at the requested entry index', { index: step.index });
|
|
44
|
+
selected = child;
|
|
45
|
+
resolvedPath.push({ index: step.index });
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const matches = value.entries.filter((entry) => entry.type === 'assignment' && entry.key.value === step.key);
|
|
49
|
+
if (step.occurrence === undefined && matches.length > 1)
|
|
50
|
+
throw new ServiceError('SOURCE_PATH_AMBIGUOUS', 'Repeated key requires an explicit zero-based occurrence', { key: step.key, occurrences: matches.length });
|
|
51
|
+
const occurrence = step.occurrence ?? 0;
|
|
52
|
+
const child = matches[occurrence];
|
|
53
|
+
if (child === undefined)
|
|
54
|
+
throw new ServiceError('SOURCE_PATH_UNKNOWN', 'No source child matches the requested key and occurrence', { key: step.key, occurrence });
|
|
55
|
+
selected = child;
|
|
56
|
+
resolvedPath.push(Buffer.byteLength(step.key, 'utf8') > 128
|
|
57
|
+
? { index: value.entries.indexOf(child) }
|
|
58
|
+
: { key: step.key, occurrence });
|
|
59
|
+
}
|
|
60
|
+
const location = locationFor(document.path, document.lineIndex, selected.start, selected.end);
|
|
61
|
+
const fromLine = options.fromLine ?? location.start.line;
|
|
62
|
+
const fromColumn = options.fromColumn ?? (fromLine === location.start.line ? location.start.column : 1);
|
|
63
|
+
if (fromLine < location.start.line ||
|
|
64
|
+
fromLine > location.end.line ||
|
|
65
|
+
(fromLine === location.start.line && fromColumn < location.start.column) ||
|
|
66
|
+
(fromLine === location.end.line && fromColumn > location.end.column))
|
|
67
|
+
throw new ServiceError('SOURCE_POSITION_OUTSIDE_SELECTION', 'Requested position is outside the selected parser node');
|
|
68
|
+
const lines = document.text.split(/\r?\n/u).slice(location.start.line - 1, location.end.line);
|
|
69
|
+
lines[lines.length - 1] = lines.at(-1).slice(0, location.end.column - 1);
|
|
70
|
+
const read = options.structure
|
|
71
|
+
? { text: '', endLine: location.end.line, nextLine: null, nextColumn: null }
|
|
72
|
+
: readBoundedJsonLines(lines, location.start.line, fromLine, fromColumn, options.maxLines, 2000, 4096);
|
|
73
|
+
const value = selected.type === 'assignment' ? selected.value : selected;
|
|
74
|
+
const entries = options.structure && value.type === 'block' ? value.entries : [];
|
|
75
|
+
const occurrences = new Map();
|
|
76
|
+
if (options.structure && options.childOffset > entries.length)
|
|
77
|
+
throw new ServiceError('SOURCE_CHILD_OFFSET_OUTSIDE_SELECTION', 'Child offset exceeds the selected block');
|
|
78
|
+
for (const entry of entries.slice(0, options.childOffset)) {
|
|
79
|
+
const key = entry.type === 'assignment' ? entry.key.value : null;
|
|
80
|
+
occurrences.set(key, (occurrences.get(key) ?? 0) + 1);
|
|
81
|
+
}
|
|
82
|
+
const candidates = entries
|
|
83
|
+
.slice(options.childOffset, options.childOffset + options.maxChildren)
|
|
84
|
+
.map((entry, index) => {
|
|
85
|
+
const key = entry.type === 'assignment' ? entry.key.value : null;
|
|
86
|
+
const occurrence = occurrences.get(key) ?? 0;
|
|
87
|
+
occurrences.set(key, occurrence + 1);
|
|
88
|
+
const childValue = entry.type === 'assignment' ? entry.value : entry;
|
|
89
|
+
const childLocation = locationFor(document.path, document.lineIndex, entry.start, entry.end);
|
|
90
|
+
const keyPreview = key === null ? null : preview(key, 1024);
|
|
91
|
+
const valuePreview = childValue.type === 'scalar' ? preview(childValue.value, 120) : null;
|
|
92
|
+
return {
|
|
93
|
+
key: keyPreview?.text ?? null,
|
|
94
|
+
index: options.childOffset + index,
|
|
95
|
+
keyTruncated: keyPreview?.truncated ?? false,
|
|
96
|
+
occurrence,
|
|
97
|
+
kind: childValue.type,
|
|
98
|
+
value: valuePreview?.text ?? null,
|
|
99
|
+
valueTruncated: valuePreview?.truncated ?? false,
|
|
100
|
+
startLine: childLocation.start.line,
|
|
101
|
+
endLine: childLocation.end.line,
|
|
102
|
+
childCount: childValue.type === 'block' ? childValue.entries.length : 0,
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
const children = [];
|
|
106
|
+
let childBytes = 2;
|
|
107
|
+
for (const child of candidates) {
|
|
108
|
+
const bytes = Buffer.byteLength(JSON.stringify(child), 'utf8') + 1;
|
|
109
|
+
if (childBytes + bytes > 6000)
|
|
110
|
+
break;
|
|
111
|
+
children.push(child);
|
|
112
|
+
childBytes += bytes;
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
...read,
|
|
116
|
+
fromLine,
|
|
117
|
+
navigation: {
|
|
118
|
+
keyPath: resolvedPath,
|
|
119
|
+
startLine: location.start.line,
|
|
120
|
+
startColumn: location.start.column,
|
|
121
|
+
endLine: location.end.line,
|
|
122
|
+
endColumn: location.end.column,
|
|
123
|
+
children,
|
|
124
|
+
omittedChildren: Math.max(0, entries.length - options.childOffset - children.length),
|
|
125
|
+
nextChildOffset: options.childOffset + children.length < entries.length
|
|
126
|
+
? options.childOffset + children.length
|
|
127
|
+
: null,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|