hoi4-agent-tools 3.6.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +124 -28
  3. package/dist/hoi4_agent_tools/core/configuration.js +2 -0
  4. package/dist/hoi4_agent_tools/core/engine.js +7 -7
  5. package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
  6. package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
  7. package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
  8. package/dist/hoi4_agent_tools/core/job-store.js +19 -40
  9. package/dist/hoi4_agent_tools/core/job-worker-host.js +84 -19
  10. package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
  11. package/dist/hoi4_agent_tools/core/operation-tasks.js +2 -7
  12. package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
  13. package/dist/hoi4_agent_tools/core/scanner.js +136 -47
  14. package/dist/hoi4_agent_tools/core/shared-request-capacity.js +27 -13
  15. package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
  16. package/dist/hoi4_agent_tools/core/windows-sharing.js +42 -0
  17. package/dist/hoi4_agent_tools/core/workspace.js +114 -8
  18. package/dist/hoi4_agent_tools/event/artifact-validation.js +1 -1
  19. package/dist/hoi4_agent_tools/event/service.js +1 -1
  20. package/dist/hoi4_agent_tools/focus/service.js +2 -2
  21. package/dist/hoi4_agent_tools/gui/studio.js +16 -3
  22. package/dist/hoi4_agent_tools/map/model.js +81 -2
  23. package/dist/hoi4_agent_tools/map/service.js +3 -2
  24. package/dist/hoi4_agent_tools/map/validation.js +60 -4
  25. package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
  26. package/dist/hoi4_agent_tools/package-check/service.js +1 -1
  27. package/dist/hoi4_agent_tools/reference/lines.js +12 -0
  28. package/dist/hoi4_agent_tools/reference/operations.js +45 -2
  29. package/dist/hoi4_agent_tools/reference/ranking.js +309 -0
  30. package/dist/hoi4_agent_tools/reference/script-validation.js +267 -0
  31. package/dist/hoi4_agent_tools/reference/service.js +285 -177
  32. package/dist/hoi4_agent_tools/reference/source-locate.js +156 -0
  33. package/dist/hoi4_agent_tools/reference/source-lookup.js +125 -18
  34. package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
  35. package/dist/hoi4_agent_tools/reference/suggestions.js +38 -0
  36. package/dist/hoi4_agent_tools/schemas/reference.js +135 -2
  37. package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
  38. package/dist/hoi4_agent_tools/schemas/transaction.js +1 -1
  39. package/dist/hoi4_agent_tools/technology/service.js +1 -1
  40. package/dist/hoi4_agent_tools/version.js +1 -1
  41. package/docs/README.md +1 -0
  42. package/docs/development.md +6 -0
  43. package/docs/examples.md +3 -13
  44. package/docs/gui.md +1 -15
  45. package/docs/map.md +10 -0
  46. package/docs/reference.md +47 -4
  47. package/docs/research/reference-benchmark.md +80 -0
  48. package/docs/setup.md +15 -0
  49. package/docs/tools.md +37 -36
  50. package/npm-shrinkwrap.json +7101 -0
  51. package/package.json +8 -7
  52. package/schemas/configuration.schema.json +95 -88
  53. package/schemas/continuous-focus-palette.schema.json +1 -1
  54. package/schemas/custom-weighted-pool.schema.json +1 -1
  55. package/schemas/decision-inspect-input.schema.json +1 -1
  56. package/schemas/event-feature-manifest.schema.json +1 -1
  57. package/schemas/focus-plan.schema.json +1 -1
  58. package/schemas/focus-planning-sidecar.schema.json +1 -1
  59. package/schemas/gui-animation-source.schema.json +1 -1
  60. package/schemas/gui-helper.schema.json +1 -1
  61. package/schemas/gui-scenario.schema.json +1 -1
  62. package/schemas/helper-expansion-request.schema.json +1 -1
  63. package/schemas/helper-expansion-summary.schema.json +1 -1
  64. package/schemas/impact-inspect-input.schema.json +1 -1
  65. package/schemas/map-operation.schema.json +1 -1
  66. package/schemas/mechanic-test-input.schema.json +1 -1
  67. package/schemas/operation-result.schema.json +1 -1
  68. package/schemas/package-check-input.schema.json +1 -1
  69. package/schemas/package-manifest.schema.json +1 -1
  70. package/schemas/probability-analysis-result.schema.json +1 -1
  71. package/schemas/probability-compare-input.schema.json +1 -1
  72. package/schemas/probability-evaluate-input.schema.json +1 -1
  73. package/schemas/probability-inspect-input.schema.json +1 -1
  74. package/schemas/probability-render-input.schema.json +1 -1
  75. package/schemas/probability-scenario-set.schema.json +1 -1
  76. package/schemas/probability-sequence-input.schema.json +1 -1
  77. package/schemas/probability-simulate-input.schema.json +1 -1
  78. package/schemas/probability-sweep-input.schema.json +1 -1
  79. package/schemas/reference-context-input.schema.json +1 -1
  80. package/schemas/reference-read-input.schema.json +9 -1
  81. package/schemas/reference-search-input.schema.json +1 -1
  82. package/schemas/scenario-suite.schema.json +1 -1
  83. package/schemas/scenario-test-input.schema.json +1 -1
  84. package/schemas/script-validate-input.schema.json +92 -0
  85. package/schemas/shared-scenario.schema.json +1 -1
  86. package/schemas/source-lookup-input.schema.json +144 -15
  87. package/server.json +2 -2
@@ -546,6 +546,36 @@ function* validateDefinitions(index, diagnostics, options) {
546
546
  }
547
547
  for (const [definitionIndex, definition] of index.definitions.entries()) {
548
548
  yield* cancellationCheckpoint(options.signal, definitionIndex, 256);
549
+ const terrain = index.terrainCategories?.get(definition.terrain);
550
+ if (index.terrainCategories !== undefined && terrain === undefined) {
551
+ addDiagnostic(diagnostics, options, {
552
+ code: 'MAP_TERRAIN_UNKNOWN',
553
+ severity: 'error',
554
+ category: 'map',
555
+ message: `Province ${definition.id} uses terrain ${definition.terrain}, which no common/terrain category defines`,
556
+ location: definitionLocation(definition),
557
+ });
558
+ }
559
+ if (definition.type === 'land' &&
560
+ index.continents !== undefined &&
561
+ definition.continent > index.continents.length) {
562
+ addDiagnostic(diagnostics, options, {
563
+ code: 'MAP_CONTINENT_UNKNOWN',
564
+ severity: 'error',
565
+ category: 'map',
566
+ message: `Land province ${definition.id} uses continent ${definition.continent}, but continent.txt defines ${index.continents.length}`,
567
+ location: definitionLocation(definition),
568
+ });
569
+ }
570
+ if (definition.type === 'land' && terrain?.water === true) {
571
+ addDiagnostic(diagnostics, options, {
572
+ code: 'MAP_LAND_TERRAIN_WATER',
573
+ severity: 'error',
574
+ category: 'map',
575
+ message: `Land province ${definition.id} uses water terrain ${definition.terrain}`,
576
+ location: definitionLocation(definition),
577
+ });
578
+ }
549
579
  if (definition.type === 'land' && definition.continent === 0) {
550
580
  addDiagnostic(diagnostics, options, {
551
581
  code: 'MAP_LAND_CONTINENT_MISSING',
@@ -555,21 +585,30 @@ function* validateDefinitions(index, diagnostics, options) {
555
585
  location: definitionLocation(definition),
556
586
  });
557
587
  }
558
- if (definition.type === 'sea' && definition.terrain !== 'ocean') {
588
+ // With terrain categories available, any is_water category suits seas and lakes; without
589
+ // them, the base game's ocean and lakes categories are the only known water terrains.
590
+ const water = (fallback) => index.terrainCategories === undefined
591
+ ? definition.terrain === fallback
592
+ : terrain === undefined || terrain.water;
593
+ if (definition.type === 'sea' && !water('ocean')) {
559
594
  addDiagnostic(diagnostics, options, {
560
595
  code: 'MAP_SEA_TERRAIN_INVALID',
561
596
  severity: 'error',
562
597
  category: 'map',
563
- message: `Sea province ${definition.id} must use ocean terrain`,
598
+ message: index.terrainCategories === undefined
599
+ ? `Sea province ${definition.id} must use ocean terrain`
600
+ : `Sea province ${definition.id} must use a water terrain (is_water = yes)`,
564
601
  location: definitionLocation(definition),
565
602
  });
566
603
  }
567
- if (definition.type === 'lake' && definition.terrain !== 'lakes') {
604
+ if (definition.type === 'lake' && !water('lakes')) {
568
605
  addDiagnostic(diagnostics, options, {
569
606
  code: 'MAP_LAKE_TERRAIN_INVALID',
570
607
  severity: 'error',
571
608
  category: 'map',
572
- message: `Lake province ${definition.id} must use lakes terrain`,
609
+ message: index.terrainCategories === undefined
610
+ ? `Lake province ${definition.id} must use lakes terrain`
611
+ : `Lake province ${definition.id} must use a water terrain (is_water = yes)`,
573
612
  location: definitionLocation(definition),
574
613
  });
575
614
  }
@@ -711,6 +750,23 @@ function* validateStatesAndRegions(index, diagnostics, options) {
711
750
  });
712
751
  }
713
752
  }
753
+ if (index.terrainCategories !== undefined)
754
+ for (const region of index.regions) {
755
+ if (region.navalTerrain === undefined)
756
+ continue;
757
+ const category = index.terrainCategories.get(region.navalTerrain);
758
+ if (category?.naval === true)
759
+ continue;
760
+ addDiagnostic(diagnostics, options, {
761
+ code: 'MAP_REGION_NAVAL_TERRAIN_INVALID',
762
+ severity: 'error',
763
+ category: 'map',
764
+ message: category === undefined
765
+ ? `Strategic region ${region.id} names naval terrain ${region.navalTerrain}, which no common/terrain category defines`
766
+ : `Strategic region ${region.id} names ${region.navalTerrain}, which is not a naval terrain (naval_terrain = yes)`,
767
+ location: regionLocation(region),
768
+ });
769
+ }
714
770
  for (const [id, regions] of duplicateValues(index.regions, (region) => String(region.id))) {
715
771
  const firstRegion = regions[0];
716
772
  if (firstRegion === undefined)
@@ -1,6 +1,7 @@
1
1
  import { errorResult, strictOperationResultSchema } from '../../core/operation-result.js';
2
2
  import { ReferenceToolService } from '../../reference/operations.js';
3
3
  import { referenceContextRequestSchema, referenceReadRequestSchema, referenceSearchRequestSchema, sourceLookupRequestSchema, referenceContextDataSchema, referenceReadDataSchema, referenceSearchDataSchema, sourceLookupDataSchema, } from '../../schemas/reference.js';
4
+ import { scriptValidateDataSchema, scriptValidateRequestSchema, } from '../../schemas/script-validation.js';
4
5
  const readOnly = {
5
6
  readOnlyHint: true,
6
7
  destructiveHint: false,
@@ -35,11 +36,19 @@ export const referenceTools = [
35
36
  {
36
37
  name: 'hoi4.source_lookup',
37
38
  title: 'Look up HOI4 source symbol',
38
- description: 'Find exact Clausewitz definitions, override status, a bounded source block, and indexed usages in the configured game and mod.',
39
+ description: 'Find exact Clausewitz definitions and usages; navigate nested keyPath blocks or list bounded child structure with view=structure. Or pass path and line to get the definitions and keyPath containing that line.',
39
40
  inputSchema: sourceLookupRequestSchema,
40
41
  outputSchema: strictOperationResultSchema(sourceLookupDataSchema),
41
42
  annotations: readOnly,
42
43
  },
44
+ {
45
+ name: 'hoi4.script_validate',
46
+ title: 'Check HOI4 script commands and scopes',
47
+ description: 'Check effect or trigger snippet syntax, native command kinds and declared scopes against cited installed documentation. Unknown helpers and native argument blocks remain explicit.',
48
+ inputSchema: scriptValidateRequestSchema,
49
+ outputSchema: strictOperationResultSchema(scriptValidateDataSchema),
50
+ annotations: readOnly,
51
+ },
43
52
  ];
44
53
  export function registerReferenceTools(server, engine, context) {
45
54
  const service = new ReferenceToolService(engine);
@@ -100,7 +100,7 @@ export class PackageAnalyzer {
100
100
  });
101
101
  const results = indexedChecks(snapshot, input.manifest);
102
102
  const read = async (relativePath) => {
103
- const resolved = await this.engine.resolver.resolvePath(input.workspaceId, relativePath, 'read', ['mod', 'game', 'dependency', 'fixture'], input.principal);
103
+ const resolved = await this.engine.resolver.resolvePath(input.workspaceId, relativePath, 'read', ['mod', 'game', 'dlc', 'dependency', 'fixture'], input.principal);
104
104
  const metadata = await stat(resolved.path);
105
105
  if (!metadata.isFile() || metadata.size > MAX_FILE_BYTES)
106
106
  throw new ServiceError('PACKAGE_FILE_LIMIT', 'Package file exceeds the bounded reader');
@@ -50,3 +50,15 @@ export function readBoundedLines(lines, firstLine, startLine, startColumn, maxLi
50
50
  nextColumn: lineIndex < lines.length ? columnIndex + 1 : null,
51
51
  };
52
52
  }
53
+ /** Preserve continuation while bounding JSON escaping as well as raw UTF-8 text. */
54
+ export function readBoundedJsonLines(lines, firstLine, startLine, startColumn, maxLines, maxBytes, maxJsonBytes) {
55
+ let budget = maxBytes;
56
+ for (;;) {
57
+ const result = readBoundedLines(lines, firstLine, startLine, startColumn, maxLines, budget);
58
+ if (Buffer.byteLength(JSON.stringify(result.text), 'utf8') <= maxJsonBytes)
59
+ return result;
60
+ budget = Math.max(4, Math.floor(budget / 2));
61
+ if (budget === 4 && maxJsonBytes < 26)
62
+ throw new RangeError('JSON text budget cannot retain a source character');
63
+ }
64
+ }
@@ -4,11 +4,14 @@ import { toolResult } from '../core/operation-result.js';
4
4
  import { referenceContextRequestSchema, referenceReadRequestSchema, referenceSearchRequestSchema, sourceLookupRequestSchema, } from '../schemas/reference.js';
5
5
  import { ReferenceService } from './service.js';
6
6
  import { sourceLookup } from './source-lookup.js';
7
+ import { scriptValidateDataSchema, scriptValidateRequestSchema, } from '../schemas/script-validation.js';
8
+ import { validateScript } from './script-validation.js';
7
9
  const schemas = {
8
10
  'hoi4.reference_search': referenceSearchRequestSchema,
9
11
  'hoi4.reference_read': referenceReadRequestSchema,
10
12
  'hoi4.reference_context': referenceContextRequestSchema,
11
13
  'hoi4.source_lookup': sourceLookupRequestSchema,
14
+ 'hoi4.script_validate': scriptValidateRequestSchema,
12
15
  };
13
16
  export function isReferenceTool(name) {
14
17
  return Object.hasOwn(schemas, name);
@@ -35,7 +38,47 @@ export class ReferenceToolService {
35
38
  ? await this.references.read(workspace, referenceReadRequestSchema.parse(parsed), signal)
36
39
  : name === 'hoi4.reference_context'
37
40
  ? await this.references.context(workspace, referenceContextRequestSchema.parse(parsed), signal)
38
- : await sourceLookup(this.engine, workspaceId, sourceLookupRequestSchema.parse(parsed), context.principal, signal);
39
- return toolResult(emptyServiceResult(workspaceId, data));
41
+ : name === 'hoi4.script_validate'
42
+ ? await validateScript(this.references, workspace, scriptValidateRequestSchema.parse(parsed), signal)
43
+ : await sourceLookup(this.engine, workspaceId, sourceLookupRequestSchema.parse(parsed), context.principal, signal);
44
+ const result = emptyServiceResult(workspaceId, data);
45
+ if (name === 'hoi4.script_validate') {
46
+ const checked = scriptValidateDataSchema.parse(data);
47
+ result.code = checked.valid === null ? 'SCRIPT_CHECK_PARTIAL' : 'SCRIPT_CHECKED';
48
+ result.validation = {
49
+ passed: checked.valid === true,
50
+ checks: [
51
+ {
52
+ id: 'script-command-checks',
53
+ passed: checked.valid === true,
54
+ message: checked.valid === true
55
+ ? 'Syntax, native command kinds and declared scopes match the selected documentation'
56
+ : checked.valid === false
57
+ ? 'Script checks found an error'
58
+ : 'Script checks have unresolved coverage',
59
+ },
60
+ ],
61
+ };
62
+ result.diagnostics = checked.findings
63
+ .filter(({ status }) => status !== 'supported')
64
+ .slice(0, 20)
65
+ .map((finding) => ({
66
+ code: finding.code,
67
+ severity: finding.status === 'error' ? 'error' : 'warning',
68
+ category: 'validation',
69
+ message: finding.message,
70
+ details: {
71
+ command: finding.command,
72
+ kind: finding.kind,
73
+ scope: finding.scope,
74
+ line: finding.line,
75
+ column: finding.column,
76
+ },
77
+ }));
78
+ while (Buffer.byteLength(JSON.stringify(result.diagnostics), 'utf8') > 4096 &&
79
+ result.diagnostics.length > 1)
80
+ result.diagnostics.pop();
81
+ }
82
+ return toolResult(result);
40
83
  }
41
84
  }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Term statistics and ranking for local documentation sections.
3
+ *
4
+ * Sections are scored with a field-weighted BM25 model: headings weigh more than body text,
5
+ * rare terms more than common ones, and long sections are normalized so that a passing
6
+ * mention in a thousand-line table does not outrank a focused entry. Clausewitz identifiers
7
+ * are indexed whole and by their underscore-separated words, so `is_controlled_by` answers
8
+ * both the exact identifier and a question about what is "controlled by" a country.
9
+ */
10
+ const stopWords = new Set([
11
+ 'a',
12
+ 'about',
13
+ 'after',
14
+ 'an',
15
+ 'and',
16
+ 'are',
17
+ 'as',
18
+ 'at',
19
+ 'be',
20
+ 'by',
21
+ 'can',
22
+ 'come',
23
+ 'do',
24
+ 'does',
25
+ 'for',
26
+ 'from',
27
+ 'get',
28
+ 'has',
29
+ 'have',
30
+ 'how',
31
+ 'i',
32
+ 'if',
33
+ 'in',
34
+ 'into',
35
+ 'is',
36
+ 'it',
37
+ 'its',
38
+ 'me',
39
+ 'my',
40
+ 'of',
41
+ 'on',
42
+ 'one',
43
+ 'or',
44
+ 'should',
45
+ 'so',
46
+ 'that',
47
+ 'the',
48
+ 'their',
49
+ 'them',
50
+ 'then',
51
+ 'there',
52
+ 'this',
53
+ 'to',
54
+ 'use',
55
+ 'used',
56
+ 'using',
57
+ 'want',
58
+ 'way',
59
+ 'what',
60
+ 'when',
61
+ 'where',
62
+ 'which',
63
+ 'while',
64
+ 'who',
65
+ 'why',
66
+ 'will',
67
+ 'with',
68
+ 'work',
69
+ 'you',
70
+ ]);
71
+ const vowels = /[aeiouy]/u;
72
+ const MAX_CACHED_STEMS = 100_000;
73
+ const stems = new Map();
74
+ const irregular = new Map([
75
+ ['hidden', 'hide'],
76
+ ['chosen', 'choose'],
77
+ ['given', 'give'],
78
+ ['written', 'write'],
79
+ ['children', 'child'],
80
+ ]);
81
+ /** Conservative suffix folding: `controls`, `controlled`, and `controlling` share `control`. */
82
+ export function stemTerm(input) {
83
+ const cached = stems.get(input);
84
+ if (cached !== undefined)
85
+ return cached;
86
+ const stem = foldTerm(input);
87
+ // Documentation vocabulary repeats heavily; a bounded memo avoids re-folding it.
88
+ if (stems.size >= MAX_CACHED_STEMS)
89
+ stems.clear();
90
+ stems.set(input, stem);
91
+ return stem;
92
+ }
93
+ function foldTerm(input) {
94
+ const word = irregular.get(input) ?? input;
95
+ if (word.length <= 3 || /\d/u.test(word))
96
+ return word;
97
+ let stem = word;
98
+ if (/i(?:es|ed)$/u.test(stem) && stem.length > 4)
99
+ stem = `${stem.slice(0, -3)}y`;
100
+ else if (stem.endsWith('sses'))
101
+ stem = stem.slice(0, -2);
102
+ else if (/(?:x|z|ch|sh)es$/u.test(stem))
103
+ stem = stem.slice(0, -2);
104
+ else if (stem.endsWith('s') && !/(?:ss|us|is)$/u.test(stem))
105
+ stem = stem.slice(0, -1);
106
+ else if (stem.endsWith('ing') && stem.length > 5 && vowels.test(stem.slice(0, -3)))
107
+ stem = stem.slice(0, -3);
108
+ else if (stem.endsWith('ed') && stem.length > 4 && vowels.test(stem.slice(0, -2)))
109
+ stem = stem.slice(0, -2);
110
+ // Fold a doubled final consonant (`stopp`, `controll`) but keep `ss` and `zz` words.
111
+ if (/([b-df-hj-np-rtv-y])\1$/u.test(stem) && stem.length > 4)
112
+ stem = stem.slice(0, -1);
113
+ // Derivational endings common in documentation prose: `capitulation`, `capitulates`,
114
+ // `capitulated`, and `capitulating` share `capitul`; `government` shares `govern`.
115
+ if (/(?:ated|ating)$/u.test(word) && stem.length > 6)
116
+ stem = stem.replace(/at$/u, '');
117
+ else if (stem.endsWith('ation') && stem.length > 8)
118
+ stem = stem.slice(0, -5);
119
+ else if (stem.endsWith('ate') && stem.length > 6)
120
+ stem = stem.slice(0, -3);
121
+ else if (stem.endsWith('ment') && stem.length > 7)
122
+ stem = stem.slice(0, -4);
123
+ if (stem.endsWith('e') && stem.length > 4)
124
+ stem = stem.slice(0, -1);
125
+ return stem;
126
+ }
127
+ const tokenPattern = /[a-z0-9]+(?:_[a-z0-9]+)*/gu;
128
+ /** Content terms of free text. Identifiers yield themselves and their stemmed words. */
129
+ export function textTerms(text) {
130
+ const output = [];
131
+ for (const token of text.toLowerCase().replace(/'s\b/gu, '').match(tokenPattern) ?? []) {
132
+ if (token.includes('_')) {
133
+ output.push(token);
134
+ for (const part of token.split('_'))
135
+ if (part.length > 0 && !stopWords.has(part))
136
+ output.push(stemTerm(part));
137
+ }
138
+ else if (!stopWords.has(token))
139
+ output.push(stemTerm(token));
140
+ }
141
+ return output;
142
+ }
143
+ function counts(terms) {
144
+ const result = new Map();
145
+ for (const term of terms)
146
+ result.set(term, (result.get(term) ?? 0) + 1);
147
+ return result;
148
+ }
149
+ const navigationLine = /^\s*(?:[*-]|\d+\.)\s+\[[^\]]*\]\((?:#|<)[^)]*\)\s*$/u;
150
+ export function sectionTerms(title, heading, lines) {
151
+ const bodyTerms = lines.flatMap((line) => textTerms(line));
152
+ const content = lines.filter((line) => line.trim().length > 0);
153
+ const links = content.filter((line) => navigationLine.test(line)).length;
154
+ return {
155
+ heading: counts(textTerms(heading)),
156
+ title: counts(textTerms(title)),
157
+ body: counts(bodyTerms),
158
+ bodyLength: bodyTerms.length,
159
+ headingWords: [
160
+ ...new Set(textTerms(heading.replaceAll('_', ' ')).filter((term) => !term.includes('_'))),
161
+ ],
162
+ navigation: heading.trim().toLowerCase() === 'table of contents' ||
163
+ (content.length >= 4 && links / content.length >= 0.6),
164
+ specialty: /console commands/iu.test(title)
165
+ ? 'console'
166
+ : /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/u.test(heading.trim())
167
+ ? 'define'
168
+ : undefined,
169
+ };
170
+ }
171
+ /** Script names abbreviate counts as `num_`, as in `num_divisions` or `num_of_factories`. */
172
+ const countWords = new Set(['count', 'number', 'amount', 'many', 'much']);
173
+ const headingWeight = 4;
174
+ const titleWeight = 0.5;
175
+ const bodyWeight = 1;
176
+ const saturation = 1.2;
177
+ const lengthNormalization = 0.75;
178
+ /** Corpus statistics for one immutable inventory of sections. */
179
+ export class RankingIndex {
180
+ entries;
181
+ #documentFrequency = new Map();
182
+ #averageBodyLength;
183
+ constructor(entries) {
184
+ this.entries = entries;
185
+ let total = 0;
186
+ for (const entry of entries) {
187
+ total += entry.bodyLength;
188
+ const seen = new Set([...entry.heading.keys(), ...entry.title.keys(), ...entry.body.keys()]);
189
+ for (const term of seen)
190
+ this.#documentFrequency.set(term, (this.#documentFrequency.get(term) ?? 0) + 1);
191
+ }
192
+ this.#averageBodyLength = entries.length === 0 ? 1 : Math.max(1, total / entries.length);
193
+ }
194
+ idf(term) {
195
+ const frequency = this.#documentFrequency.get(term) ?? 0;
196
+ const count = this.entries.length;
197
+ return Math.log(1 + (count - frequency + 0.5) / (frequency + 0.5));
198
+ }
199
+ plan(query) {
200
+ const normalized = query.trim().toLowerCase();
201
+ const sequence = textTerms(normalized);
202
+ const weights = new Map(sequence.map((term) => [term, 1]));
203
+ const words = (normalized.replace(/'s\b/gu, '').match(tokenPattern) ?? []).filter((word) => !stopWords.has(word) && !word.includes('_'));
204
+ // Clausewitz names often join two English words: `wargoal`, `manpower`, `warscore`.
205
+ for (let index = 1; index < words.length; index += 1) {
206
+ const joined = stemTerm(`${words[index - 1]}${words[index]}`);
207
+ if (this.#documentFrequency.has(joined) && !weights.has(joined))
208
+ weights.set(joined, 1);
209
+ }
210
+ if (words.some((word) => countWords.has(word)) && !weights.has('num'))
211
+ weights.set('num', 0.6);
212
+ const terms = [...weights].map(([term, weight]) => ({ term, idf: this.idf(term), weight }));
213
+ const plain = sequence.filter((term) => !term.includes('_'));
214
+ const pairs = [...new Set(plain.slice(1).map((term, index) => `${plain[index]} ${term}`))].map((pair) => {
215
+ const [left, right] = pair.split(' ');
216
+ return { pair, idf: (this.idf(left) + this.idf(right)) / 2 };
217
+ });
218
+ return {
219
+ terms,
220
+ pairs,
221
+ normalized,
222
+ identifiers: new Set(terms.map(({ term }) => term).filter((term) => term.includes('_'))),
223
+ wants: {
224
+ console: words.some((word) => word === 'console' || word.startsWith('command')),
225
+ define: words.some((word) => word.startsWith('define')) || /\b[A-Z]{2,}_[A-Z]/u.test(query),
226
+ },
227
+ };
228
+ }
229
+ /** Score one section; undefined when it matches too little of the query to cite. */
230
+ score(plan, entry, heading) {
231
+ if (plan.terms.length === 0)
232
+ return undefined;
233
+ const lengthFactor = 1 - lengthNormalization + (lengthNormalization * entry.bodyLength) / this.#averageBodyLength;
234
+ let score = 0;
235
+ let matched = 0;
236
+ for (const { term, idf, weight } of plan.terms) {
237
+ const weighted = headingWeight * (entry.heading.get(term) ?? 0) +
238
+ titleWeight * (entry.title.get(term) ?? 0) +
239
+ (bodyWeight * (entry.body.get(term) ?? 0)) / lengthFactor;
240
+ if (weighted === 0)
241
+ continue;
242
+ matched++;
243
+ score += (weight * idf * weighted * (saturation + 1)) / (weighted + saturation);
244
+ }
245
+ // Meaningful terms must agree; a single shared word rarely answers a longer question.
246
+ const words = plan.terms.filter(({ term, weight }) => !term.includes('_') && weight === 1).length;
247
+ const required = words >= 3 ? Math.ceil(words * 0.4) : 1;
248
+ if (matched === 0 || (matched < required && plan.identifiers.size === 0))
249
+ return undefined;
250
+ // A named identifier must itself appear; its words alone describe something else.
251
+ if (plan.identifiers.size > 0 &&
252
+ ![...plan.identifiers].some((term) => entry.heading.has(term) || entry.title.has(term) || entry.body.has(term)))
253
+ return undefined;
254
+ const headingText = heading.trim().toLowerCase();
255
+ // An exact heading is the answer; authority, not incidental wording, orders duplicates.
256
+ if (headingText === plan.normalized)
257
+ return 1000;
258
+ else if (plan.identifiers.has(headingText))
259
+ score += 40;
260
+ // A short heading whose every word the question names is usually the topic itself.
261
+ if (entry.headingWords.length > 0) {
262
+ const queryTerms = new Set(plan.terms.map(({ term }) => term));
263
+ const covered = entry.headingWords.filter((word) => queryTerms.has(word));
264
+ const fraction = covered.length / entry.headingWords.length;
265
+ score += fraction * fraction * covered.reduce((sum, word) => sum + this.idf(word), 0) * 0.6;
266
+ }
267
+ if (entry.navigation)
268
+ score *= 0.25;
269
+ if (entry.specialty === 'console' && !plan.wants.console)
270
+ score *= 0.7;
271
+ if (entry.specialty === 'define' && !plan.wants.define)
272
+ score *= 0.6;
273
+ return score;
274
+ }
275
+ /** Extra credit for query words that appear next to each other in the section. */
276
+ phraseBonus(plan, lines) {
277
+ if (plan.pairs.length === 0)
278
+ return 0;
279
+ const wanted = new Map(plan.pairs.map(({ pair, idf }) => [pair, idf]));
280
+ const found = new Set();
281
+ for (const line of lines) {
282
+ const terms = textTerms(line).filter((term) => !term.includes('_'));
283
+ for (let index = 1; index < terms.length; index += 1) {
284
+ const pair = `${terms[index - 1]} ${terms[index]}`;
285
+ if (wanted.has(pair))
286
+ found.add(pair);
287
+ }
288
+ if (found.size === wanted.size)
289
+ break;
290
+ }
291
+ return [...found].reduce((sum, pair) => sum + wanted.get(pair), 0) * 0.5;
292
+ }
293
+ /** The line offset carrying the most query information, for landing a bounded read. */
294
+ bestLine(plan, lines) {
295
+ let best = 0;
296
+ let bestScore = 0;
297
+ const weights = new Map(plan.terms.map(({ term, idf, weight }) => [term, idf * weight]));
298
+ for (const [index, line] of lines.entries()) {
299
+ let lineScore = 0;
300
+ for (const term of new Set(textTerms(line)))
301
+ lineScore += weights.get(term) ?? 0;
302
+ if (lineScore > bestScore) {
303
+ best = index;
304
+ bestScore = lineScore;
305
+ }
306
+ }
307
+ return best;
308
+ }
309
+ }