hoi4-agent-tools 3.4.1 → 3.5.1
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 +24 -1
- package/README.md +26 -18
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +41 -2
- package/dist/hoi4_agent_tools/core/condition-schema.js +18 -0
- package/dist/hoi4_agent_tools/core/configuration.js +10 -0
- package/dist/hoi4_agent_tools/core/domain-scan-patterns.js +9 -1
- package/dist/hoi4_agent_tools/core/impact-semantic.js +11 -4
- package/dist/hoi4_agent_tools/core/scanner.js +1 -1
- package/dist/hoi4_agent_tools/core/scenario-model.js +6 -0
- package/dist/hoi4_agent_tools/core/scenario-state.js +3 -0
- package/dist/hoi4_agent_tools/core/workspace.js +13 -1
- package/dist/hoi4_agent_tools/event/graph.js +8 -1
- package/dist/hoi4_agent_tools/event/service.js +1 -0
- package/dist/hoi4_agent_tools/event/source-analysis.js +1 -1
- package/dist/hoi4_agent_tools/focus/render.js +10 -2
- package/dist/hoi4_agent_tools/gui/layout.js +58 -11
- package/dist/hoi4_agent_tools/gui/scenario.js +3 -1
- package/dist/hoi4_agent_tools/gui/source-graph.js +4 -1
- package/dist/hoi4_agent_tools/gui/studio.js +32 -10
- package/dist/hoi4_agent_tools/map/model.js +121 -12
- package/dist/hoi4_agent_tools/map/operations.js +80 -7
- package/dist/hoi4_agent_tools/map/service.js +1 -0
- package/dist/hoi4_agent_tools/map/validation.js +38 -1
- package/dist/hoi4_agent_tools/mcp/server/create-modern-operations.js +18 -1
- package/dist/hoi4_agent_tools/mcp/server/create.js +2 -0
- package/dist/hoi4_agent_tools/mcp/server/instructions.js +1 -1
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +60 -0
- package/dist/hoi4_agent_tools/probability/service.js +2 -2
- package/dist/hoi4_agent_tools/probability/source-analysis.js +16 -0
- package/dist/hoi4_agent_tools/probability/value-evaluator.js +2 -1
- package/dist/hoi4_agent_tools/reference/lines.js +52 -0
- package/dist/hoi4_agent_tools/reference/operations.js +41 -0
- package/dist/hoi4_agent_tools/reference/service.js +444 -0
- package/dist/hoi4_agent_tools/reference/source-lookup.js +90 -0
- package/dist/hoi4_agent_tools/schemas/analysis.js +2 -0
- package/dist/hoi4_agent_tools/schemas/gui-requests.js +1 -1
- package/dist/hoi4_agent_tools/schemas/probability-requests.js +1 -1
- package/dist/hoi4_agent_tools/schemas/probability.js +26 -2
- package/dist/hoi4_agent_tools/schemas/reference.js +161 -0
- package/dist/hoi4_agent_tools/technology/render.js +9 -4
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +3 -0
- package/docs/gui.md +6 -0
- package/docs/map.md +8 -0
- package/docs/probability.md +7 -0
- package/docs/reference.md +32 -0
- package/docs/research/reference-benchmark.md +42 -0
- package/docs/setup.md +6 -0
- package/examples/clients/codex-config.toml +2 -0
- package/package.json +4 -2
- package/schemas/configuration.schema.json +72 -58
- 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 +43 -26
- 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 +102 -75
- 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 +49 -32
- 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 +160 -131
- package/schemas/probability-evaluate-input.schema.json +161 -132
- package/schemas/probability-inspect-input.schema.json +20 -11
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +110 -90
- package/schemas/probability-sequence-input.schema.json +154 -134
- package/schemas/probability-simulate-input.schema.json +161 -132
- package/schemas/probability-sweep-input.schema.json +163 -134
- package/schemas/reference-context-input.schema.json +42 -0
- package/schemas/reference-read-input.schema.json +41 -0
- package/schemas/reference-search-input.schema.json +45 -0
- package/schemas/scenario-suite.schema.json +92 -75
- package/schemas/scenario-test-input.schema.json +103 -86
- package/schemas/shared-scenario.schema.json +39 -22
- package/schemas/source-lookup-input.schema.json +61 -0
- package/server.json +2 -2
- package/docs/images/readme/chaos-meter-ingame.png +0 -0
- package/docs/images/readme/chaos-meter-mcp.png +0 -0
- package/docs/images/readme/communist-insurgency-ingame.png +0 -0
- package/docs/images/readme/communist-insurgency-mcp.png +0 -0
- package/docs/images/readme/event-chain-mcp.png +0 -0
- package/docs/images/readme/focus-tree-mcp.png +0 -0
- package/docs/images/readme/map-mcp.png +0 -0
- package/docs/images/readme/probability-mcp.png +0 -0
- package/docs/images/readme/settings-advanced-mcp.png +0 -0
- package/docs/images/readme/settings-events-mcp.png +0 -0
- package/docs/images/readme/technology-tree-mcp.png +0 -0
|
@@ -962,7 +962,7 @@ export class ProbabilityAnalyzer {
|
|
|
962
962
|
adapter !== undefined &&
|
|
963
963
|
adapter !== 'custom_weighted_pool')
|
|
964
964
|
throw new ServiceError('PROBABILITY_ADAPTER_MISMATCH', 'customPoolManifest inspection requires adapter custom_weighted_pool');
|
|
965
|
-
const snapshot = await this.scan(context, [source?.path], source === undefined ? 'none' : source.inlineClausewitz === undefined ? 'domain' : 'shared');
|
|
965
|
+
const snapshot = await this.scan(context, [source?.path, source?.snapshotPath], source === undefined ? 'none' : source.inlineClausewitz === undefined ? 'domain' : 'shared');
|
|
966
966
|
if (customPoolManifest !== undefined) {
|
|
967
967
|
const surface = customSurface(customPoolManifest, snapshot.revision);
|
|
968
968
|
const gameVersionVerification = await this.verifyGameVersion(context, 'custom_weighted_pool');
|
|
@@ -1150,7 +1150,7 @@ export class ProbabilityAnalyzer {
|
|
|
1150
1150
|
request.adapter !== undefined &&
|
|
1151
1151
|
request.adapter !== 'custom_weighted_pool')
|
|
1152
1152
|
throw new ServiceError('PROBABILITY_ADAPTER_MISMATCH', 'customPoolManifest requires the custom_weighted_pool adapter');
|
|
1153
|
-
const snapshot = await this.scan(request, [request.source?.path], usingManifest ? 'none' : request.source?.inlineClausewitz === undefined ? 'domain' : 'shared');
|
|
1153
|
+
const snapshot = await this.scan(request, [request.source?.path, request.source?.snapshotPath], usingManifest ? 'none' : request.source?.inlineClausewitz === undefined ? 'domain' : 'shared');
|
|
1154
1154
|
const resolvedAdapter = usingManifest
|
|
1155
1155
|
? 'custom_weighted_pool'
|
|
1156
1156
|
: resolveSourceBackedAdapter(snapshot, request.source, request.candidatePool ?? [], request.adapter);
|
|
@@ -319,6 +319,22 @@ function candidatesFor(context, adapterId) {
|
|
|
319
319
|
}
|
|
320
320
|
}
|
|
321
321
|
function sourceContexts(snapshot, source) {
|
|
322
|
+
if (source.snapshotPath !== undefined) {
|
|
323
|
+
if (source.path === undefined ||
|
|
324
|
+
source.expectedSourceHash === undefined ||
|
|
325
|
+
source.inlineClausewitz !== undefined ||
|
|
326
|
+
source.virtualPatch !== undefined)
|
|
327
|
+
throw new ServiceError('PROBABILITY_SNAPSHOT_CONTRACT_REQUIRED', 'Frozen source requires a logical path and exact snapshot hash');
|
|
328
|
+
const snapshotPath = normalized(source.snapshotPath);
|
|
329
|
+
const selected = snapshot.files.filter((file) => file.shadowedBy === undefined && normalized(file.relativePath) === snapshotPath);
|
|
330
|
+
if (selected.length !== 1)
|
|
331
|
+
throw new ServiceError('PROBABILITY_SNAPSHOT_NOT_FOUND', 'Frozen source must resolve to one active authorized file', { snapshotPath: source.snapshotPath });
|
|
332
|
+
const original = selected[0];
|
|
333
|
+
if (original.sha256 !== source.expectedSourceHash)
|
|
334
|
+
throw new ServiceError('PROBABILITY_SOURCE_STALE', 'Frozen probability source hash is stale', { expectedSourceHash: source.expectedSourceHash, actualSourceHashes: [original.sha256] });
|
|
335
|
+
const file = { ...original, relativePath: normalized(source.path) };
|
|
336
|
+
return [{ file, document: parseClausewitz(file.bytes, file.displayPath) }];
|
|
337
|
+
}
|
|
322
338
|
if (source.inlineClausewitz !== undefined || source.virtualPatch !== undefined) {
|
|
323
339
|
const text = source.virtualPatch ?? source.inlineClausewitz ?? '';
|
|
324
340
|
const bytes = Buffer.from(text, 'utf8');
|
|
@@ -198,6 +198,7 @@ export function evaluateValueBlock(block, candidate, scenario, definitions, init
|
|
|
198
198
|
const trace = [];
|
|
199
199
|
const unresolved = [];
|
|
200
200
|
const referencedProvenance = [];
|
|
201
|
+
const { candidateOverrides: _eligibilityOverrides, ...modifierScenario } = scenario;
|
|
201
202
|
if (block === undefined)
|
|
202
203
|
return { value: initial, interval: current, trace, unresolved };
|
|
203
204
|
for (const assignment of assignments(block)) {
|
|
@@ -257,7 +258,7 @@ export function evaluateValueBlock(block, candidate, scenario, definitions, init
|
|
|
257
258
|
if (assignment.key.value !== 'modifier')
|
|
258
259
|
continue;
|
|
259
260
|
const modifier = assignment.value;
|
|
260
|
-
const condition = evaluateTriggerBlock(conditionBlock(modifier),
|
|
261
|
+
const condition = evaluateTriggerBlock(conditionBlock(modifier), modifierScenario, candidate, definitions);
|
|
261
262
|
const conditionExpression = conditionSignature(conditionBlock(modifier));
|
|
262
263
|
unresolved.push(...condition.unresolved);
|
|
263
264
|
referencedProvenance.push(...(condition.helperProvenance ?? []));
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ServiceError } from '../core/result.js';
|
|
2
|
+
/** Preserve source text while bounding one reply by lines and UTF-8 bytes. */
|
|
3
|
+
export function readBoundedLines(lines, firstLine, startLine, startColumn, maxLines, maxBytes) {
|
|
4
|
+
let lineIndex = startLine - firstLine;
|
|
5
|
+
let columnIndex = startColumn - 1;
|
|
6
|
+
if (lineIndex < 0 || lineIndex >= lines.length || columnIndex > (lines[lineIndex]?.length ?? 0)) {
|
|
7
|
+
throw new ServiceError('REFERENCE_POSITION_OUTSIDE_SECTION', 'Requested position is outside the selected section');
|
|
8
|
+
}
|
|
9
|
+
const first = lines[lineIndex];
|
|
10
|
+
if (columnIndex > 0 &&
|
|
11
|
+
/[\uD800-\uDBFF]/u.test(first[columnIndex - 1]) &&
|
|
12
|
+
/[\uDC00-\uDFFF]/u.test(first[columnIndex] ?? '')) {
|
|
13
|
+
throw new ServiceError('REFERENCE_POSITION_OUTSIDE_SECTION', 'Requested column splits a Unicode character');
|
|
14
|
+
}
|
|
15
|
+
const selected = [];
|
|
16
|
+
let bytes = 0;
|
|
17
|
+
let endLine = startLine;
|
|
18
|
+
while (lineIndex < lines.length && selected.length < maxLines) {
|
|
19
|
+
const line = lines[lineIndex];
|
|
20
|
+
const remainder = line.slice(columnIndex);
|
|
21
|
+
const separator = selected.length === 0 ? 0 : 1;
|
|
22
|
+
const allowance = maxBytes - bytes - separator;
|
|
23
|
+
if (allowance <= 0)
|
|
24
|
+
break;
|
|
25
|
+
let consumed = 0;
|
|
26
|
+
let used = 0;
|
|
27
|
+
for (const character of remainder) {
|
|
28
|
+
const characterBytes = Buffer.byteLength(character, 'utf8');
|
|
29
|
+
if (used + characterBytes > allowance)
|
|
30
|
+
break;
|
|
31
|
+
consumed += character.length;
|
|
32
|
+
used += characterBytes;
|
|
33
|
+
}
|
|
34
|
+
if (consumed === 0 && remainder.length > 0)
|
|
35
|
+
break;
|
|
36
|
+
selected.push(remainder.slice(0, consumed));
|
|
37
|
+
bytes += separator + used;
|
|
38
|
+
endLine = firstLine + lineIndex;
|
|
39
|
+
if (consumed < remainder.length) {
|
|
40
|
+
columnIndex += consumed;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
lineIndex++;
|
|
44
|
+
columnIndex = 0;
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
text: selected.join('\n'),
|
|
48
|
+
endLine,
|
|
49
|
+
nextLine: lineIndex < lines.length ? firstLine + lineIndex : null,
|
|
50
|
+
nextColumn: lineIndex < lines.length ? columnIndex + 1 : null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { requireOperationScope, resolveOperationWorkspaceId, } from '../core/operation-context.js';
|
|
2
|
+
import { emptyServiceResult } from '../core/result.js';
|
|
3
|
+
import { toolResult } from '../core/operation-result.js';
|
|
4
|
+
import { referenceContextRequestSchema, referenceReadRequestSchema, referenceSearchRequestSchema, sourceLookupRequestSchema, } from '../schemas/reference.js';
|
|
5
|
+
import { ReferenceService } from './service.js';
|
|
6
|
+
import { sourceLookup } from './source-lookup.js';
|
|
7
|
+
const schemas = {
|
|
8
|
+
'hoi4.reference_search': referenceSearchRequestSchema,
|
|
9
|
+
'hoi4.reference_read': referenceReadRequestSchema,
|
|
10
|
+
'hoi4.reference_context': referenceContextRequestSchema,
|
|
11
|
+
'hoi4.source_lookup': sourceLookupRequestSchema,
|
|
12
|
+
};
|
|
13
|
+
export function isReferenceTool(name) {
|
|
14
|
+
return Object.hasOwn(schemas, name);
|
|
15
|
+
}
|
|
16
|
+
const sharedReferences = new WeakMap();
|
|
17
|
+
/** One authorization and execution contract for both MCP protocol adapters. */
|
|
18
|
+
export class ReferenceToolService {
|
|
19
|
+
engine;
|
|
20
|
+
references;
|
|
21
|
+
constructor(engine) {
|
|
22
|
+
this.engine = engine;
|
|
23
|
+
const cached = sharedReferences.get(engine) ?? new ReferenceService();
|
|
24
|
+
sharedReferences.set(engine, cached);
|
|
25
|
+
this.references = cached;
|
|
26
|
+
}
|
|
27
|
+
async call(name, input, context, signal) {
|
|
28
|
+
requireOperationScope(context, 'hoi4:read');
|
|
29
|
+
const parsed = schemas[name].parse(input);
|
|
30
|
+
const workspaceId = await resolveOperationWorkspaceId(this.engine, context, parsed.workspaceId, signal);
|
|
31
|
+
const workspace = this.engine.resolver.get(workspaceId, context.principal);
|
|
32
|
+
const data = name === 'hoi4.reference_search'
|
|
33
|
+
? await this.references.search(workspace, referenceSearchRequestSchema.parse(parsed), signal)
|
|
34
|
+
: name === 'hoi4.reference_read'
|
|
35
|
+
? await this.references.read(workspace, referenceReadRequestSchema.parse(parsed), signal)
|
|
36
|
+
: name === 'hoi4.reference_context'
|
|
37
|
+
? 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));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { open, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fg from 'fast-glob';
|
|
4
|
+
import { compareCodeUnits, sha256Bytes } from '../core/canonical.js';
|
|
5
|
+
import { ServiceError } from '../core/result.js';
|
|
6
|
+
import { isWithin } from '../core/workspace.js';
|
|
7
|
+
import { readBoundedLines } from './lines.js';
|
|
8
|
+
import { readBoundedFile } from '../core/scanner.js';
|
|
9
|
+
const MAX_FILES = 512;
|
|
10
|
+
const MAX_FILE_BYTES = 2_000_000;
|
|
11
|
+
const MAX_TOTAL_BYTES = 32_000_000;
|
|
12
|
+
const MAX_SECTIONS = 20_000;
|
|
13
|
+
const searchStopWords = new Set([
|
|
14
|
+
'a',
|
|
15
|
+
'an',
|
|
16
|
+
'and',
|
|
17
|
+
'at',
|
|
18
|
+
'be',
|
|
19
|
+
'can',
|
|
20
|
+
'come',
|
|
21
|
+
'do',
|
|
22
|
+
'does',
|
|
23
|
+
'for',
|
|
24
|
+
'from',
|
|
25
|
+
'how',
|
|
26
|
+
'in',
|
|
27
|
+
'is',
|
|
28
|
+
'of',
|
|
29
|
+
'the',
|
|
30
|
+
'to',
|
|
31
|
+
'where',
|
|
32
|
+
'with',
|
|
33
|
+
'work',
|
|
34
|
+
]);
|
|
35
|
+
function normalizeTerm(term) {
|
|
36
|
+
if (term === 'dated')
|
|
37
|
+
return 'date';
|
|
38
|
+
if (term.endsWith('ies') && term.length > 5)
|
|
39
|
+
return `${term.slice(0, -3)}y`;
|
|
40
|
+
if (term.endsWith('ses') && term.length > 5)
|
|
41
|
+
return term.slice(0, -2);
|
|
42
|
+
if (term.endsWith('s') && term.length > 4)
|
|
43
|
+
return term.slice(0, -1);
|
|
44
|
+
return term;
|
|
45
|
+
}
|
|
46
|
+
function queryTerms(query) {
|
|
47
|
+
return [
|
|
48
|
+
...new Set((query.toLowerCase().match(/[a-z0-9_.:-]+/gu) ?? [])
|
|
49
|
+
.map(normalizeTerm)
|
|
50
|
+
.filter((term) => /[a-z0-9]/u.test(term) && !searchStopWords.has(term))),
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
function containsTerm(text, term) {
|
|
54
|
+
if (term.length > 2)
|
|
55
|
+
return text.includes(term);
|
|
56
|
+
const escaped = term.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
|
57
|
+
return new RegExp(`(^|[^a-z0-9_])${escaped}($|[^a-z0-9_])`, 'u').test(text);
|
|
58
|
+
}
|
|
59
|
+
function snippet(lines, terms = []) {
|
|
60
|
+
let matching = -1;
|
|
61
|
+
let best = -1;
|
|
62
|
+
for (const [index, line] of lines.entries()) {
|
|
63
|
+
const count = terms.filter((term) => containsTerm(line.toLowerCase(), term)).length;
|
|
64
|
+
if (count > best) {
|
|
65
|
+
matching = index;
|
|
66
|
+
best = count;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const selected = lines
|
|
70
|
+
.slice(Math.max(0, matching), Math.max(0, matching) + 3)
|
|
71
|
+
.map((line) => line.trim())
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.join(' ');
|
|
74
|
+
return selected.slice(0, 300);
|
|
75
|
+
}
|
|
76
|
+
function sectionize(root, filePath, content, revision) {
|
|
77
|
+
const lines = content.replace(/^\uFEFF/u, '').split(/\r?\n/u);
|
|
78
|
+
const relative = path.relative(root.root, filePath).replaceAll('\\', '/');
|
|
79
|
+
const title = path
|
|
80
|
+
.basename(filePath)
|
|
81
|
+
.replace(/\.(md|log)$/u, '')
|
|
82
|
+
.replaceAll('_', ' ')
|
|
83
|
+
.replace(/ - Hearts of Iron 4 Wiki$/u, '');
|
|
84
|
+
const headings = [];
|
|
85
|
+
let inFence = false;
|
|
86
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
87
|
+
const line = lines[index];
|
|
88
|
+
if (/^\s*(```|~~~)/u.test(line)) {
|
|
89
|
+
inFence = !inFence;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (inFence)
|
|
93
|
+
continue;
|
|
94
|
+
const match = /^(#{1,6})\s+(.+?)\s*#*\s*$/u.exec(line);
|
|
95
|
+
if (match)
|
|
96
|
+
headings.push({ heading: match[2].replace(/<a\b[^>]*><\/a>/gu, '').trim(), start: index });
|
|
97
|
+
else if (root.kind === 'script_doc' &&
|
|
98
|
+
filePath.endsWith('.log') &&
|
|
99
|
+
/^[A-Za-z_][A-Za-z0-9_.:-]*$/u.test(line.trim()) &&
|
|
100
|
+
!/^\s/u.test(line) &&
|
|
101
|
+
/^\s+\S/u.test(lines[index + 1] ?? '')) {
|
|
102
|
+
headings.push({ heading: line.trim(), start: index });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (headings.length === 0 || headings[0].start > 0)
|
|
106
|
+
headings.unshift({ heading: title, start: 0 });
|
|
107
|
+
return headings.map((current, index) => {
|
|
108
|
+
const end = headings[index + 1]?.start ?? lines.length;
|
|
109
|
+
const sectionLines = lines.slice(current.start, end);
|
|
110
|
+
const id = sha256Bytes(Buffer.from(`${root.kind}\0${relative}\0${current.start + 1}\0${current.heading}`, 'utf8'));
|
|
111
|
+
return {
|
|
112
|
+
id,
|
|
113
|
+
revision,
|
|
114
|
+
source: root.kind,
|
|
115
|
+
title,
|
|
116
|
+
heading: current.heading.slice(0, 512),
|
|
117
|
+
path: filePath,
|
|
118
|
+
startLine: current.start + 1,
|
|
119
|
+
endLine: end,
|
|
120
|
+
excerpt: snippet(sectionLines),
|
|
121
|
+
authority: root.authority,
|
|
122
|
+
lines: sectionLines,
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function sourceRoots(workspace) {
|
|
127
|
+
const gameRoot = workspace.gameRoot ?? (workspace.registration.kind === 'game' ? workspace.modRoot : undefined);
|
|
128
|
+
return [
|
|
129
|
+
...(gameRoot === undefined
|
|
130
|
+
? []
|
|
131
|
+
: [
|
|
132
|
+
{
|
|
133
|
+
kind: 'game_doc',
|
|
134
|
+
root: gameRoot,
|
|
135
|
+
patterns: ['documentation/**/*.md', 'common/**/*documentation.md'],
|
|
136
|
+
authority: 'Installed game documentation',
|
|
137
|
+
},
|
|
138
|
+
]),
|
|
139
|
+
{
|
|
140
|
+
kind: 'wiki',
|
|
141
|
+
root: workspace.wikiRoot ?? path.join(workspace.modRoot, 'paradox_wiki'),
|
|
142
|
+
patterns: ['**/*.md'],
|
|
143
|
+
authority: 'Offline Paradox Wiki snapshot',
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
kind: 'script_doc',
|
|
147
|
+
root: workspace.scriptDocsRoot ?? path.join(workspace.modRoot, 'script_docs'),
|
|
148
|
+
patterns: ['**/*.md', '**/*.log'],
|
|
149
|
+
authority: 'User-provided game script documentation',
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
}
|
|
153
|
+
const surfaceNames = {
|
|
154
|
+
general: ['Data structures', 'Triggers', 'Effects', 'Modifiers', 'Scopes'],
|
|
155
|
+
event: [
|
|
156
|
+
'Event modding',
|
|
157
|
+
'Data structures',
|
|
158
|
+
'Effects',
|
|
159
|
+
'Triggers',
|
|
160
|
+
'Scopes',
|
|
161
|
+
'On actions',
|
|
162
|
+
'Localisation',
|
|
163
|
+
],
|
|
164
|
+
decision: ['Decision modding', 'Effects', 'Triggers', 'Scopes', 'Localisation'],
|
|
165
|
+
idea: ['Idea modding', 'Modifiers', 'Localisation'],
|
|
166
|
+
focus: ['National focus modding', 'AI focuses', 'Effects', 'Triggers', 'Localisation'],
|
|
167
|
+
technology: ['Technology modding', 'Equipment modding', 'Modifiers', 'Localisation'],
|
|
168
|
+
gui: [
|
|
169
|
+
'Interface modding',
|
|
170
|
+
'Scripted GUI modding',
|
|
171
|
+
'Graphical asset modding',
|
|
172
|
+
'Localisation',
|
|
173
|
+
'Scopes',
|
|
174
|
+
],
|
|
175
|
+
map: [
|
|
176
|
+
'Map modding',
|
|
177
|
+
'State modding',
|
|
178
|
+
'Strategic region modding',
|
|
179
|
+
'Bookmark modding',
|
|
180
|
+
'Localisation',
|
|
181
|
+
],
|
|
182
|
+
localisation: ['Localisation', 'Data structures'],
|
|
183
|
+
ai: ['AI modding', 'AI focuses', 'Triggers', 'Scopes'],
|
|
184
|
+
};
|
|
185
|
+
const gameDocNames = {
|
|
186
|
+
general: ['documentation/script_concept_documentation'],
|
|
187
|
+
event: [
|
|
188
|
+
'documentation/effects_documentation',
|
|
189
|
+
'documentation/triggers_documentation',
|
|
190
|
+
'common/on_actions/_documentation',
|
|
191
|
+
],
|
|
192
|
+
decision: ['common/decisions/_documentation', 'documentation/effects_documentation'],
|
|
193
|
+
idea: ['documentation/modifiers_documentation'],
|
|
194
|
+
focus: [
|
|
195
|
+
'documentation/effects_documentation',
|
|
196
|
+
'documentation/triggers_documentation',
|
|
197
|
+
'common/focus_inlay_windows/documentation',
|
|
198
|
+
],
|
|
199
|
+
technology: ['common/units/equipment/_documentation', 'documentation/modifiers_documentation'],
|
|
200
|
+
gui: ['common/scripted_guis/_documentation'],
|
|
201
|
+
map: ['common/map_modes/documentation', 'common/strategic_locations/documentation'],
|
|
202
|
+
localisation: [
|
|
203
|
+
'documentation/loc_formatter_documentation',
|
|
204
|
+
'documentation/loc_objects_documentation',
|
|
205
|
+
],
|
|
206
|
+
ai: ['common/ai_strategy/_documentation', 'documentation/triggers_documentation'],
|
|
207
|
+
};
|
|
208
|
+
/** A workspace-private, read-only reference index over explicitly known local roots. */
|
|
209
|
+
export class ReferenceService {
|
|
210
|
+
#cache = new Map();
|
|
211
|
+
#cacheBytes = 0;
|
|
212
|
+
retain(key, value) {
|
|
213
|
+
this.#cacheBytes -= this.#cache.get(key)?.bytes ?? 0;
|
|
214
|
+
this.#cache.delete(key);
|
|
215
|
+
this.#cache.set(key, value);
|
|
216
|
+
this.#cacheBytes += value.bytes;
|
|
217
|
+
while (this.#cacheBytes > MAX_TOTAL_BYTES || this.#cache.size > 1_024) {
|
|
218
|
+
const oldest = this.#cache.entries().next().value;
|
|
219
|
+
if (oldest === undefined)
|
|
220
|
+
break;
|
|
221
|
+
this.#cache.delete(oldest[0]);
|
|
222
|
+
this.#cacheBytes -= oldest[1].bytes;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async inventory(workspace, signal) {
|
|
226
|
+
const sections = [];
|
|
227
|
+
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 },
|
|
231
|
+
};
|
|
232
|
+
let skipped = 0;
|
|
233
|
+
let bytes = 0;
|
|
234
|
+
for (const root of sourceRoots(workspace)) {
|
|
235
|
+
signal?.throwIfAborted();
|
|
236
|
+
let canonicalRoot;
|
|
237
|
+
try {
|
|
238
|
+
canonicalRoot = await realpath(root.root);
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
coverage[root.kind].unavailable = true;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const configuredAuthorityRoot = root.kind === 'game_doc'
|
|
245
|
+
? root.root
|
|
246
|
+
: root.kind === 'wiki'
|
|
247
|
+
? (workspace.wikiRoot ?? workspace.modRoot)
|
|
248
|
+
: (workspace.scriptDocsRoot ?? workspace.modRoot);
|
|
249
|
+
if (!isWithin(configuredAuthorityRoot, canonicalRoot)) {
|
|
250
|
+
throw new ServiceError('REFERENCE_ROOT_ESCAPE', 'Reference root resolves outside its configured workspace');
|
|
251
|
+
}
|
|
252
|
+
coverage[root.kind].unavailable = false;
|
|
253
|
+
const names = await fg(root.patterns, {
|
|
254
|
+
cwd: canonicalRoot,
|
|
255
|
+
onlyFiles: true,
|
|
256
|
+
followSymbolicLinks: false,
|
|
257
|
+
dot: false,
|
|
258
|
+
});
|
|
259
|
+
names.sort(compareCodeUnits);
|
|
260
|
+
for (const name of names) {
|
|
261
|
+
signal?.throwIfAborted();
|
|
262
|
+
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;
|
|
276
|
+
}
|
|
277
|
+
bytes += metadata.size;
|
|
278
|
+
coverage[root.kind].files++;
|
|
279
|
+
const key = `${root.kind}:${canonicalRoot}:${canonical}`;
|
|
280
|
+
const handle = await open(canonical, 'r');
|
|
281
|
+
let content;
|
|
282
|
+
try {
|
|
283
|
+
const opened = await handle.stat();
|
|
284
|
+
const resolvedAfterOpen = await realpath(canonical);
|
|
285
|
+
const current = await stat(resolvedAfterOpen);
|
|
286
|
+
if (!opened.isFile() ||
|
|
287
|
+
!isWithin(canonicalRoot, resolvedAfterOpen) ||
|
|
288
|
+
opened.dev !== current.dev ||
|
|
289
|
+
opened.ino !== current.ino) {
|
|
290
|
+
throw new ServiceError('REFERENCE_SOURCE_CHANGED', 'Reference path changed while opening it; retry the request');
|
|
291
|
+
}
|
|
292
|
+
content = await readBoundedFile(handle, Math.min(MAX_FILE_BYTES, MAX_TOTAL_BYTES - bytes + metadata.size), signal);
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
await handle.close();
|
|
296
|
+
}
|
|
297
|
+
bytes += content.length - metadata.size;
|
|
298
|
+
const revision = sha256Bytes(content);
|
|
299
|
+
let cached = this.#cache.get(key);
|
|
300
|
+
if (cached?.revision !== revision) {
|
|
301
|
+
cached = {
|
|
302
|
+
revision,
|
|
303
|
+
bytes: content.length,
|
|
304
|
+
sections: sectionize(root, canonical, content.toString('utf8'), revision),
|
|
305
|
+
};
|
|
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
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return { sections, coverage, skipped };
|
|
316
|
+
}
|
|
317
|
+
async search(workspace, input, signal) {
|
|
318
|
+
const inventory = await this.inventory(workspace, signal);
|
|
319
|
+
const matches = this.rank(inventory.sections, input.query, input.sources);
|
|
320
|
+
return {
|
|
321
|
+
results: matches.slice(0, input.limit).map(({ section, score, excerpt }) => ({
|
|
322
|
+
...this.publicSection(section),
|
|
323
|
+
score,
|
|
324
|
+
excerpt,
|
|
325
|
+
})),
|
|
326
|
+
total: matches.length,
|
|
327
|
+
coverage: inventory.coverage,
|
|
328
|
+
skipped: inventory.skipped,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
rank(sections, question, sources) {
|
|
332
|
+
const query = question.toLowerCase();
|
|
333
|
+
const terms = queryTerms(query);
|
|
334
|
+
return sections
|
|
335
|
+
.flatMap((section) => {
|
|
336
|
+
if (sources !== undefined && !sources.includes(section.source))
|
|
337
|
+
return [];
|
|
338
|
+
const title = section.title.toLowerCase();
|
|
339
|
+
const heading = section.heading.toLowerCase();
|
|
340
|
+
const body = section.lines.join('\n').toLowerCase();
|
|
341
|
+
let matched = 0;
|
|
342
|
+
let score = 0;
|
|
343
|
+
for (const term of terms) {
|
|
344
|
+
const inTitle = containsTerm(title, term);
|
|
345
|
+
const inHeading = containsTerm(heading, term);
|
|
346
|
+
const inBody = containsTerm(body, term);
|
|
347
|
+
if (!inTitle && !inHeading && !inBody)
|
|
348
|
+
continue;
|
|
349
|
+
matched++;
|
|
350
|
+
score += (inTitle ? 24 : 0) + (inHeading ? 18 : 0) + (inBody ? 3 : 0);
|
|
351
|
+
}
|
|
352
|
+
if (matched < Math.max(1, Math.ceil(terms.length * 0.6)))
|
|
353
|
+
return [];
|
|
354
|
+
if (heading === query)
|
|
355
|
+
score += 100;
|
|
356
|
+
else if (heading.includes(query))
|
|
357
|
+
score += 70;
|
|
358
|
+
if (body.includes(query))
|
|
359
|
+
score += 30;
|
|
360
|
+
return [{ section, score, excerpt: snippet(section.lines, terms) }];
|
|
361
|
+
})
|
|
362
|
+
.sort((left, right) => right.score - left.score ||
|
|
363
|
+
compareCodeUnits(left.section.path, right.section.path) ||
|
|
364
|
+
left.section.startLine - right.section.startLine);
|
|
365
|
+
}
|
|
366
|
+
async read(workspace, input, signal) {
|
|
367
|
+
const inventory = await this.inventory(workspace, signal);
|
|
368
|
+
const section = inventory.sections.find(({ id }) => id === input.id);
|
|
369
|
+
if (section === undefined)
|
|
370
|
+
throw new ServiceError('REFERENCE_SECTION_UNKNOWN', 'Reference section is not in this workspace');
|
|
371
|
+
if (section.revision !== input.revision)
|
|
372
|
+
throw new ServiceError('REFERENCE_REVISION_STALE', 'Reference source changed; search again', {
|
|
373
|
+
currentRevision: section.revision,
|
|
374
|
+
});
|
|
375
|
+
const startLine = input.startLine ?? section.startLine;
|
|
376
|
+
const selected = readBoundedLines(section.lines, section.startLine, startLine, input.startColumn ?? 1, input.maxLines, 8_000);
|
|
377
|
+
return {
|
|
378
|
+
...this.publicSection(section),
|
|
379
|
+
sectionStartLine: section.startLine,
|
|
380
|
+
sectionEndLine: section.endLine,
|
|
381
|
+
startLine,
|
|
382
|
+
startColumn: input.startColumn ?? 1,
|
|
383
|
+
...selected,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
async context(workspace, input, signal) {
|
|
387
|
+
const inventory = await this.inventory(workspace, signal);
|
|
388
|
+
const wanted = surfaceNames[input.surface];
|
|
389
|
+
const ranked = input.question === undefined ? [] : this.rank(inventory.sections, input.question);
|
|
390
|
+
const required = [];
|
|
391
|
+
const missing = [];
|
|
392
|
+
const includeRequired = (name, section) => {
|
|
393
|
+
if (section === undefined) {
|
|
394
|
+
missing.push(name);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const matching = ranked.find((entry) => entry.section.path === section.path && entry.section.source === section.source);
|
|
398
|
+
required.push({ name, section: matching?.section ?? section });
|
|
399
|
+
};
|
|
400
|
+
for (const name of [...new Set(wanted)]) {
|
|
401
|
+
const section = inventory.sections.find((entry) => entry.source === 'wiki' && entry.title.toLowerCase() === name.toLowerCase());
|
|
402
|
+
includeRequired(name, section);
|
|
403
|
+
}
|
|
404
|
+
for (const name of gameDocNames[input.surface]) {
|
|
405
|
+
const section = inventory.sections.find((entry) => entry.source === 'game_doc' &&
|
|
406
|
+
entry.path.replaceAll('\\', '/').toLowerCase().endsWith(`${name}.md`));
|
|
407
|
+
includeRequired(name, section);
|
|
408
|
+
}
|
|
409
|
+
const questionOrder = new Map(ranked.map(({ section }, index) => [section.id, index]));
|
|
410
|
+
const byQuestion = (left, right) => (questionOrder.get(left.section.id) ?? Number.MAX_SAFE_INTEGER) -
|
|
411
|
+
(questionOrder.get(right.section.id) ?? Number.MAX_SAFE_INTEGER);
|
|
412
|
+
const game = required.filter(({ section }) => section.source === 'game_doc').sort(byQuestion);
|
|
413
|
+
const wiki = required.filter(({ section }) => section.source === 'wiki').sort(byQuestion);
|
|
414
|
+
const seen = new Set();
|
|
415
|
+
const prioritized = [
|
|
416
|
+
...[...game.slice(0, 1), ...wiki.slice(0, 1), ...game.slice(1), ...wiki.slice(1)].map(({ section }) => section),
|
|
417
|
+
...ranked.slice(0, 5).map(({ section }) => section),
|
|
418
|
+
].filter((section) => {
|
|
419
|
+
if (seen.has(section.id))
|
|
420
|
+
return false;
|
|
421
|
+
seen.add(section.id);
|
|
422
|
+
return true;
|
|
423
|
+
});
|
|
424
|
+
const selected = prioritized.slice(0, input.limit);
|
|
425
|
+
return {
|
|
426
|
+
surface: input.surface,
|
|
427
|
+
sections: selected.map((section) => ({
|
|
428
|
+
...this.publicSection(section),
|
|
429
|
+
excerpt: ranked.find((entry) => entry.section.id === section.id)?.excerpt ?? section.excerpt,
|
|
430
|
+
})),
|
|
431
|
+
omitted: Math.max(0, prioritized.length - input.limit),
|
|
432
|
+
omittedSources: required
|
|
433
|
+
.filter(({ section }) => !selected.some((entry) => entry.path === section.path && entry.source === section.source))
|
|
434
|
+
.map(({ name }) => name),
|
|
435
|
+
missing,
|
|
436
|
+
coverage: inventory.coverage,
|
|
437
|
+
skipped: inventory.skipped,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
publicSection(section) {
|
|
441
|
+
const { lines: _lines, ...publicSection } = section;
|
|
442
|
+
return publicSection;
|
|
443
|
+
}
|
|
444
|
+
}
|