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.
Files changed (72) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +137 -116
  3. package/dist/hoi4_agent_tools/core/engine.js +7 -7
  4. package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
  5. package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
  6. package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
  7. package/dist/hoi4_agent_tools/core/job-worker-host.js +27 -11
  8. package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
  9. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
  10. package/dist/hoi4_agent_tools/core/scanner.js +133 -47
  11. package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
  12. package/dist/hoi4_agent_tools/gui/layout.js +237 -11
  13. package/dist/hoi4_agent_tools/gui/renderer.js +36 -3
  14. package/dist/hoi4_agent_tools/gui/scenario.js +18 -0
  15. package/dist/hoi4_agent_tools/gui/studio.js +73 -21
  16. package/dist/hoi4_agent_tools/map/service.js +2 -2
  17. package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
  18. package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
  19. package/dist/hoi4_agent_tools/reference/lines.js +12 -0
  20. package/dist/hoi4_agent_tools/reference/operations.js +45 -2
  21. package/dist/hoi4_agent_tools/reference/script-validation.js +277 -0
  22. package/dist/hoi4_agent_tools/reference/service.js +139 -67
  23. package/dist/hoi4_agent_tools/reference/source-lookup.js +91 -15
  24. package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
  25. package/dist/hoi4_agent_tools/schemas/reference.js +55 -0
  26. package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
  27. package/dist/hoi4_agent_tools/version.js +1 -1
  28. package/docs/README.md +34 -18
  29. package/docs/examples.md +75 -0
  30. package/docs/gui.md +23 -0
  31. package/docs/reference.md +43 -2
  32. package/docs/research/reference-benchmark.md +41 -0
  33. package/docs/setup.md +50 -26
  34. package/docs/tools.md +65 -0
  35. package/npm-shrinkwrap.json +7088 -0
  36. package/package.json +10 -7
  37. package/schemas/configuration.schema.json +1 -1
  38. package/schemas/continuous-focus-palette.schema.json +1 -1
  39. package/schemas/custom-weighted-pool.schema.json +1 -1
  40. package/schemas/decision-inspect-input.schema.json +1 -1
  41. package/schemas/event-feature-manifest.schema.json +1 -1
  42. package/schemas/focus-plan.schema.json +1 -1
  43. package/schemas/focus-planning-sidecar.schema.json +1 -1
  44. package/schemas/gui-animation-source.schema.json +1 -1
  45. package/schemas/gui-helper.schema.json +1 -1
  46. package/schemas/gui-scenario.schema.json +139 -89
  47. package/schemas/helper-expansion-request.schema.json +1 -1
  48. package/schemas/helper-expansion-summary.schema.json +1 -1
  49. package/schemas/impact-inspect-input.schema.json +1 -1
  50. package/schemas/map-operation.schema.json +1 -1
  51. package/schemas/mechanic-test-input.schema.json +1 -1
  52. package/schemas/operation-result.schema.json +1 -1
  53. package/schemas/package-check-input.schema.json +1 -1
  54. package/schemas/package-manifest.schema.json +1 -1
  55. package/schemas/probability-analysis-result.schema.json +1 -1
  56. package/schemas/probability-compare-input.schema.json +1 -1
  57. package/schemas/probability-evaluate-input.schema.json +1 -1
  58. package/schemas/probability-inspect-input.schema.json +1 -1
  59. package/schemas/probability-render-input.schema.json +1 -1
  60. package/schemas/probability-scenario-set.schema.json +1 -1
  61. package/schemas/probability-sequence-input.schema.json +1 -1
  62. package/schemas/probability-simulate-input.schema.json +1 -1
  63. package/schemas/probability-sweep-input.schema.json +1 -1
  64. package/schemas/reference-context-input.schema.json +1 -1
  65. package/schemas/reference-read-input.schema.json +9 -1
  66. package/schemas/reference-search-input.schema.json +1 -1
  67. package/schemas/scenario-suite.schema.json +1 -1
  68. package/schemas/scenario-test-input.schema.json +1 -1
  69. package/schemas/script-validate-input.schema.json +92 -0
  70. package/schemas/shared-scenario.schema.json +1 -1
  71. package/schemas/source-lookup-input.schema.json +68 -2
  72. package/server.json +3 -3
@@ -1,15 +1,29 @@
1
- import { open, realpath, stat } from 'node:fs/promises';
1
+ import { realpath } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import fg from 'fast-glob';
3
+ import { globFiles } from '../core/file-glob.js';
4
4
  import { compareCodeUnits, sha256Bytes } from '../core/canonical.js';
5
5
  import { ServiceError } from '../core/result.js';
6
6
  import { isWithin } from '../core/workspace.js';
7
- import { readBoundedLines } from './lines.js';
8
- import { readBoundedFile } from '../core/scanner.js';
7
+ import { prepareSource, verifySource, readBoundedFile, } from '../core/scanner.js';
8
+ import { decodeSource } from '../core/source/encoding.js';
9
+ import { readBoundedJsonLines } from './lines.js';
9
10
  const MAX_FILES = 512;
10
11
  const MAX_FILE_BYTES = 2_000_000;
11
12
  const MAX_TOTAL_BYTES = 32_000_000;
12
13
  const MAX_SECTIONS = 20_000;
14
+ const MAX_REFERENCE_METADATA_BYTES = 24_000;
15
+ function referenceText(bytes) {
16
+ if (bytes.length >= 2 &&
17
+ ((bytes[0] === 0xff && bytes[1] === 0xfe) || (bytes[0] === 0xfe && bytes[1] === 0xff))) {
18
+ const payload = Buffer.from(bytes.subarray(2));
19
+ if (payload.length % 2 !== 0)
20
+ throw new ServiceError('REFERENCE_ENCODING_INVALID', 'UTF-16 reference text has an incomplete code unit');
21
+ if (bytes[0] === 0xfe)
22
+ payload.swap16();
23
+ return payload.toString('utf16le');
24
+ }
25
+ return decodeSource(bytes).text;
26
+ }
13
27
  const searchStopWords = new Set([
14
28
  'a',
15
29
  'an',
@@ -78,7 +92,7 @@ function sectionize(root, filePath, content, revision) {
78
92
  const relative = path.relative(root.root, filePath).replaceAll('\\', '/');
79
93
  const title = path
80
94
  .basename(filePath)
81
- .replace(/\.(md|log)$/u, '')
95
+ .replace(/\.(md|log)$/iu, '')
82
96
  .replaceAll('_', ' ')
83
97
  .replace(/ - Hearts of Iron 4 Wiki$/u, '');
84
98
  const headings = [];
@@ -95,7 +109,7 @@ function sectionize(root, filePath, content, revision) {
95
109
  if (match)
96
110
  headings.push({ heading: match[2].replace(/<a\b[^>]*><\/a>/gu, '').trim(), start: index });
97
111
  else if (root.kind === 'script_doc' &&
98
- filePath.endsWith('.log') &&
112
+ filePath.toLowerCase().endsWith('.log') &&
99
113
  /^[A-Za-z_][A-Za-z0-9_.:-]*$/u.test(line.trim()) &&
100
114
  !/^\s/u.test(line) &&
101
115
  /^\s+\S/u.test(lines[index + 1] ?? '')) {
@@ -222,16 +236,33 @@ export class ReferenceService {
222
236
  this.#cacheBytes -= oldest[1].bytes;
223
237
  }
224
238
  }
225
- async inventory(workspace, signal) {
239
+ async inventory(workspace, signal, sources) {
226
240
  const sections = [];
227
241
  const coverage = {
228
- game_doc: { files: 0, sections: 0, unavailable: true },
229
- wiki: { files: 0, sections: 0, unavailable: true },
230
- script_doc: { files: 0, sections: 0, unavailable: true },
242
+ game_doc: {
243
+ files: 0,
244
+ sections: 0,
245
+ unavailable: true,
246
+ included: sources === undefined || sources.includes('game_doc'),
247
+ },
248
+ wiki: {
249
+ files: 0,
250
+ sections: 0,
251
+ unavailable: true,
252
+ included: sources === undefined || sources.includes('wiki'),
253
+ },
254
+ script_doc: {
255
+ files: 0,
256
+ sections: 0,
257
+ unavailable: true,
258
+ included: sources === undefined || sources.includes('script_doc'),
259
+ },
231
260
  };
232
261
  let skipped = 0;
233
262
  let bytes = 0;
234
263
  for (const root of sourceRoots(workspace)) {
264
+ if (sources !== undefined && !sources.includes(root.kind))
265
+ continue;
235
266
  signal?.throwIfAborted();
236
267
  let canonicalRoot;
237
268
  try {
@@ -250,80 +281,104 @@ export class ReferenceService {
250
281
  throw new ServiceError('REFERENCE_ROOT_ESCAPE', 'Reference root resolves outside its configured workspace');
251
282
  }
252
283
  coverage[root.kind].unavailable = false;
253
- const names = await fg(root.patterns, {
284
+ const names = await globFiles(root.patterns, {
254
285
  cwd: canonicalRoot,
255
- onlyFiles: true,
256
- followSymbolicLinks: false,
257
- dot: false,
286
+ caseSensitive: false,
287
+ ...(signal === undefined ? {} : { signal }),
258
288
  });
259
289
  names.sort(compareCodeUnits);
260
- for (const name of names) {
290
+ for (let offset = 0; offset < names.length; offset += 8) {
261
291
  signal?.throwIfAborted();
262
292
  if (coverage[root.kind].files >= MAX_FILES || sections.length >= MAX_SECTIONS) {
263
- skipped++;
264
- continue;
265
- }
266
- const candidate = path.join(canonicalRoot, name);
267
- const canonical = await realpath(candidate);
268
- if (!isWithin(canonicalRoot, canonical)) {
269
- skipped++;
270
- continue;
271
- }
272
- const metadata = await stat(canonical);
273
- if (metadata.size > MAX_FILE_BYTES || bytes + metadata.size > MAX_TOTAL_BYTES) {
274
- skipped++;
275
- continue;
293
+ skipped += names.length - offset;
294
+ break;
276
295
  }
277
- bytes += metadata.size;
278
- coverage[root.kind].files++;
279
- const key = `${root.kind}:${canonicalRoot}:${canonical}`;
280
- const handle = await open(canonical, 'r');
281
- let content;
296
+ const readRoot = { path: canonicalRoot };
297
+ const prepared = await Promise.allSettled(names.slice(offset, offset + 8).map((name) => prepareSource(readRoot, name, signal)));
298
+ const opened = prepared.flatMap((entry) => entry.status === 'fulfilled' ? [entry.value] : []);
282
299
  try {
283
- const opened = await handle.stat();
284
- const resolvedAfterOpen = await realpath(canonical);
285
- const current = await stat(resolvedAfterOpen);
286
- if (!opened.isFile() ||
287
- !isWithin(canonicalRoot, resolvedAfterOpen) ||
288
- opened.dev !== current.dev ||
289
- opened.ino !== current.ino) {
300
+ signal?.throwIfAborted();
301
+ for (const entry of prepared) {
302
+ if (entry.status !== 'rejected')
303
+ continue;
304
+ if (entry.reason instanceof ServiceError && entry.reason.code === 'SCAN_ROOT_ESCAPE') {
305
+ skipped++;
306
+ continue;
307
+ }
290
308
  throw new ServiceError('REFERENCE_SOURCE_CHANGED', 'Reference path changed while opening it; retry the request');
291
309
  }
292
- content = await readBoundedFile(handle, Math.min(MAX_FILE_BYTES, MAX_TOTAL_BYTES - bytes + metadata.size), signal);
310
+ const admittedSources = [];
311
+ for (const source of opened) {
312
+ if (!source.metadata.isFile() ||
313
+ source.metadata.size > MAX_FILE_BYTES ||
314
+ bytes + source.metadata.size > MAX_TOTAL_BYTES ||
315
+ coverage[root.kind].files >= MAX_FILES) {
316
+ skipped++;
317
+ continue;
318
+ }
319
+ bytes += source.metadata.size;
320
+ coverage[root.kind].files++;
321
+ admittedSources.push(source);
322
+ }
323
+ const reads = await Promise.allSettled(admittedSources.map(async (source) => {
324
+ const content = await readBoundedFile(source.handle, source.metadata.size, signal);
325
+ await verifySource(readRoot, source);
326
+ if (content.length !== source.metadata.size)
327
+ throw new ServiceError('REFERENCE_SOURCE_CHANGED', 'Reference changed while it was being read; retry the request');
328
+ return content;
329
+ }));
330
+ signal?.throwIfAborted();
331
+ const failed = reads.find((entry) => entry.status === 'rejected');
332
+ if (failed?.status === 'rejected')
333
+ throw new ServiceError('REFERENCE_SOURCE_CHANGED', 'Reference changed while it was being read; retry the request');
334
+ for (const [index, read] of reads.entries()) {
335
+ if (read.status !== 'fulfilled')
336
+ continue;
337
+ const canonical = admittedSources[index].canonicalPath;
338
+ const content = read.value;
339
+ const key = `${root.kind}:${canonicalRoot}:${canonical}`;
340
+ const revision = sha256Bytes(content);
341
+ let cached = this.#cache.get(key);
342
+ if (cached?.revision !== revision)
343
+ cached = {
344
+ revision,
345
+ bytes: content.length,
346
+ sections: sectionize(root, canonical, referenceText(content), revision),
347
+ };
348
+ this.retain(key, cached);
349
+ const admitted = cached.sections.slice(0, MAX_SECTIONS - sections.length);
350
+ if (admitted.length < cached.sections.length)
351
+ skipped++;
352
+ sections.push(...admitted);
353
+ coverage[root.kind].sections += admitted.length;
354
+ }
293
355
  }
294
356
  finally {
295
- await handle.close();
357
+ await Promise.all(opened.map(({ handle }) => handle.close()));
296
358
  }
297
- bytes += content.length - metadata.size;
298
- const revision = sha256Bytes(content);
299
- let cached = this.#cache.get(key);
300
- if (cached?.revision !== revision) {
301
- cached = {
302
- revision,
303
- bytes: content.length,
304
- sections: sectionize(root, canonical, content.toString('utf8'), revision),
305
- };
306
- }
307
- this.retain(key, cached);
308
- const admitted = cached.sections.slice(0, MAX_SECTIONS - sections.length);
309
- if (admitted.length < cached.sections.length)
310
- skipped++;
311
- sections.push(...admitted);
312
- coverage[root.kind].sections += admitted.length;
313
359
  }
314
360
  }
315
361
  return { sections, coverage, skipped };
316
362
  }
317
363
  async search(workspace, input, signal) {
318
- const inventory = await this.inventory(workspace, signal);
364
+ const inventory = await this.inventory(workspace, signal, input.sources);
319
365
  const matches = this.rank(inventory.sections, input.query, input.sources);
366
+ const results = matches.slice(0, input.limit).map(({ section, score, excerpt }) => ({
367
+ ...this.publicSection(section),
368
+ score,
369
+ excerpt,
370
+ }));
371
+ const requested = results.length;
372
+ while (Buffer.byteLength(JSON.stringify(results), 'utf8') > MAX_REFERENCE_METADATA_BYTES &&
373
+ results.length > 1)
374
+ results.pop();
375
+ if (Buffer.byteLength(JSON.stringify(results), 'utf8') > MAX_REFERENCE_METADATA_BYTES)
376
+ throw new ServiceError('REFERENCE_RESPONSE_TOO_LARGE', 'Reference metadata exceeds the bounded reply');
320
377
  return {
321
- results: matches.slice(0, input.limit).map(({ section, score, excerpt }) => ({
322
- ...this.publicSection(section),
323
- score,
324
- excerpt,
325
- })),
378
+ results,
326
379
  total: matches.length,
380
+ omitted: matches.length - results.length,
381
+ limitedByBytes: results.length < requested,
327
382
  coverage: inventory.coverage,
328
383
  skipped: inventory.skipped,
329
384
  };
@@ -364,7 +419,17 @@ export class ReferenceService {
364
419
  left.section.startLine - right.section.startLine);
365
420
  }
366
421
  async read(workspace, input, signal) {
367
- const inventory = await this.inventory(workspace, signal);
422
+ let source = input.source;
423
+ if (source === undefined) {
424
+ for (const cached of this.#cache.values()) {
425
+ const section = cached.sections.find(({ id }) => id === input.id);
426
+ if (section !== undefined) {
427
+ source = section.source;
428
+ break;
429
+ }
430
+ }
431
+ }
432
+ const inventory = await this.inventory(workspace, signal, source === undefined ? undefined : [source]);
368
433
  const section = inventory.sections.find(({ id }) => id === input.id);
369
434
  if (section === undefined)
370
435
  throw new ServiceError('REFERENCE_SECTION_UNKNOWN', 'Reference section is not in this workspace');
@@ -373,7 +438,7 @@ export class ReferenceService {
373
438
  currentRevision: section.revision,
374
439
  });
375
440
  const startLine = input.startLine ?? section.startLine;
376
- const selected = readBoundedLines(section.lines, section.startLine, startLine, input.startColumn ?? 1, input.maxLines, 8_000);
441
+ const selected = readBoundedJsonLines(section.lines, section.startLine, startLine, input.startColumn ?? 1, input.maxLines, 8_000, 8_192);
377
442
  return {
378
443
  ...this.publicSection(section),
379
444
  sectionStartLine: section.startLine,
@@ -422,13 +487,20 @@ export class ReferenceService {
422
487
  return true;
423
488
  });
424
489
  const selected = prioritized.slice(0, input.limit);
490
+ const requestedCount = selected.length;
491
+ while (Buffer.byteLength(JSON.stringify(selected.map((section) => this.publicSection(section))), 'utf8') > MAX_REFERENCE_METADATA_BYTES &&
492
+ selected.length > 1)
493
+ selected.pop();
494
+ if (Buffer.byteLength(JSON.stringify(selected.map((section) => this.publicSection(section))), 'utf8') > MAX_REFERENCE_METADATA_BYTES)
495
+ throw new ServiceError('REFERENCE_RESPONSE_TOO_LARGE', 'Reference metadata exceeds the bounded reply');
425
496
  return {
426
497
  surface: input.surface,
427
498
  sections: selected.map((section) => ({
428
499
  ...this.publicSection(section),
429
500
  excerpt: ranked.find((entry) => entry.section.id === section.id)?.excerpt ?? section.excerpt,
430
501
  })),
431
- omitted: Math.max(0, prioritized.length - input.limit),
502
+ omitted: Math.max(0, prioritized.length - selected.length),
503
+ limitedByBytes: selected.length < requestedCount,
432
504
  omittedSources: required
433
505
  .filter(({ section }) => !selected.some((entry) => entry.path === section.path && entry.source === section.source))
434
506
  .map(({ name }) => name),
@@ -1,21 +1,72 @@
1
1
  import { ServiceError } from '../core/result.js';
2
2
  import { decodeSource } from '../core/source/encoding.js';
3
- import { readBoundedLines } from './lines.js';
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 snapshot = await engine.scan(workspaceId, {}, principal, signal);
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 definitions = snapshot.index.symbols
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
- .slice(0, input.maxDefinitions)
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
- : readBoundedLines(lines, start ?? fromLine, fromLine, index === 0 ? (input.fromColumn ?? 1) : 1, input.maxLines, 2_000);
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
- const semantic = input.includeReferences
52
- ? scanImpactSemanticReferences(snapshot, 200_000, { includeOnActions: true })
53
- : undefined;
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
- return {
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
+ }