hoi4-agent-tools 3.6.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +122 -28
- package/dist/hoi4_agent_tools/core/engine.js +7 -7
- package/dist/hoi4_agent_tools/core/file-glob.js +164 -0
- package/dist/hoi4_agent_tools/core/index-segments.js +4 -0
- package/dist/hoi4_agent_tools/core/job-operation-registration.js +31 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +27 -11
- package/dist/hoi4_agent_tools/core/job-worker.js +8 -16
- package/dist/hoi4_agent_tools/core/persistent-analysis-cache.js +37 -6
- package/dist/hoi4_agent_tools/core/scanner.js +133 -47
- package/dist/hoi4_agent_tools/core/source/cache.js +4 -0
- package/dist/hoi4_agent_tools/map/service.js +2 -2
- package/dist/hoi4_agent_tools/mcp/tools/reference.js +10 -1
- package/dist/hoi4_agent_tools/reference/lines.js +12 -0
- package/dist/hoi4_agent_tools/reference/operations.js +45 -2
- package/dist/hoi4_agent_tools/reference/script-validation.js +277 -0
- package/dist/hoi4_agent_tools/reference/service.js +139 -67
- package/dist/hoi4_agent_tools/reference/source-lookup.js +91 -15
- package/dist/hoi4_agent_tools/reference/source-navigation.js +130 -0
- package/dist/hoi4_agent_tools/schemas/reference.js +55 -0
- package/dist/hoi4_agent_tools/schemas/script-validation.js +69 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +1 -0
- package/docs/examples.md +1 -11
- package/docs/gui.md +1 -15
- package/docs/reference.md +43 -2
- package/docs/research/reference-benchmark.md +41 -0
- package/docs/tools.md +37 -36
- package/npm-shrinkwrap.json +7088 -0
- package/package.json +7 -6
- package/schemas/configuration.schema.json +1 -1
- package/schemas/continuous-focus-palette.schema.json +1 -1
- package/schemas/custom-weighted-pool.schema.json +1 -1
- package/schemas/decision-inspect-input.schema.json +1 -1
- package/schemas/event-feature-manifest.schema.json +1 -1
- package/schemas/focus-plan.schema.json +1 -1
- package/schemas/focus-planning-sidecar.schema.json +1 -1
- package/schemas/gui-animation-source.schema.json +1 -1
- package/schemas/gui-helper.schema.json +1 -1
- package/schemas/gui-scenario.schema.json +1 -1
- package/schemas/helper-expansion-request.schema.json +1 -1
- package/schemas/helper-expansion-summary.schema.json +1 -1
- package/schemas/impact-inspect-input.schema.json +1 -1
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/mechanic-test-input.schema.json +1 -1
- package/schemas/operation-result.schema.json +1 -1
- package/schemas/package-check-input.schema.json +1 -1
- package/schemas/package-manifest.schema.json +1 -1
- package/schemas/probability-analysis-result.schema.json +1 -1
- package/schemas/probability-compare-input.schema.json +1 -1
- package/schemas/probability-evaluate-input.schema.json +1 -1
- package/schemas/probability-inspect-input.schema.json +1 -1
- package/schemas/probability-render-input.schema.json +1 -1
- package/schemas/probability-scenario-set.schema.json +1 -1
- package/schemas/probability-sequence-input.schema.json +1 -1
- package/schemas/probability-simulate-input.schema.json +1 -1
- package/schemas/probability-sweep-input.schema.json +1 -1
- package/schemas/reference-context-input.schema.json +1 -1
- package/schemas/reference-read-input.schema.json +9 -1
- package/schemas/reference-search-input.schema.json +1 -1
- package/schemas/scenario-suite.schema.json +1 -1
- package/schemas/scenario-test-input.schema.json +1 -1
- package/schemas/script-validate-input.schema.json +92 -0
- package/schemas/shared-scenario.schema.json +1 -1
- package/schemas/source-lookup-input.schema.json +68 -2
- package/server.json +2 -2
|
@@ -54,6 +54,8 @@ export class PersistentAnalysisCache {
|
|
|
54
54
|
maxBytes;
|
|
55
55
|
maxEntries;
|
|
56
56
|
maxSingleBytes;
|
|
57
|
+
batchMaxWrites;
|
|
58
|
+
batchMaxPayloadBytes;
|
|
57
59
|
#lock;
|
|
58
60
|
#hits = 0;
|
|
59
61
|
#misses = 0;
|
|
@@ -63,23 +65,30 @@ export class PersistentAnalysisCache {
|
|
|
63
65
|
#evictions = 0;
|
|
64
66
|
#invalidEntries = 0;
|
|
65
67
|
#oversized = 0;
|
|
68
|
+
#limitedBatches = 0;
|
|
66
69
|
#invalidAddresses = new Set();
|
|
67
|
-
constructor(state, root, maxBytes, maxEntries, maxSingleBytes) {
|
|
70
|
+
constructor(state, root, maxBytes, maxEntries, maxSingleBytes, batchMaxWrites, batchMaxPayloadBytes) {
|
|
68
71
|
this.state = state;
|
|
69
72
|
this.root = root;
|
|
70
73
|
this.maxBytes = maxBytes;
|
|
71
74
|
this.maxEntries = maxEntries;
|
|
72
75
|
this.maxSingleBytes = maxSingleBytes;
|
|
76
|
+
this.batchMaxWrites = batchMaxWrites;
|
|
77
|
+
this.batchMaxPayloadBytes = batchMaxPayloadBytes;
|
|
73
78
|
this.#lock = new SharedRequestCapacity(root, 1);
|
|
74
79
|
}
|
|
75
80
|
static async create(state, options = {}) {
|
|
76
81
|
const maxBytes = options.maxBytes ?? 268_435_456;
|
|
77
82
|
const maxEntries = options.maxEntries ?? 50_000;
|
|
78
83
|
const maxSingleBytes = options.maxSingleBytes ?? 16_777_216;
|
|
84
|
+
const batchMaxWrites = options.batchMaxWrites ?? 16;
|
|
85
|
+
const batchMaxPayloadBytes = options.batchMaxPayloadBytes ?? 1_048_576;
|
|
79
86
|
for (const [name, value] of [
|
|
80
87
|
['byte budget', maxBytes],
|
|
81
88
|
['entry budget', maxEntries],
|
|
82
89
|
['single-entry budget', maxSingleBytes],
|
|
90
|
+
['batch write budget', batchMaxWrites],
|
|
91
|
+
['batch payload budget', batchMaxPayloadBytes],
|
|
83
92
|
]) {
|
|
84
93
|
if (!Number.isSafeInteger(value) || value < 0)
|
|
85
94
|
throw new RangeError(`Persistent analysis cache ${name} must be a non-negative integer`);
|
|
@@ -88,7 +97,7 @@ export class PersistentAnalysisCache {
|
|
|
88
97
|
const root = await containedGeneratedPath(state.root, 'analysis-cache');
|
|
89
98
|
await mkdir(root, { recursive: true, mode: 0o700 });
|
|
90
99
|
await assertUnlinked(root);
|
|
91
|
-
return new PersistentAnalysisCache(state, root, maxBytes, maxEntries, maxSingleBytes);
|
|
100
|
+
return new PersistentAnalysisCache(state, root, maxBytes, maxEntries, maxSingleBytes, batchMaxWrites, batchMaxPayloadBytes);
|
|
92
101
|
}
|
|
93
102
|
async hydrate(scope, files, documents, segments, signal) {
|
|
94
103
|
signal?.throwIfAborted();
|
|
@@ -101,7 +110,7 @@ export class PersistentAnalysisCache {
|
|
|
101
110
|
signal?.throwIfAborted();
|
|
102
111
|
const key = sourceDocumentCacheKey(file.bytes, file.displayPath);
|
|
103
112
|
const documentAddress = sourceAddress(key);
|
|
104
|
-
if (sourceEntries.has(documentAddress)) {
|
|
113
|
+
if (!documents.has(key) && sourceEntries.has(documentAddress)) {
|
|
105
114
|
const payload = await this.read(scopeHash, 'source-document', documentAddress, signal);
|
|
106
115
|
if (payload !== undefined) {
|
|
107
116
|
try {
|
|
@@ -118,7 +127,7 @@ export class PersistentAnalysisCache {
|
|
|
118
127
|
}
|
|
119
128
|
for (const provinceDefinition of [false, true]) {
|
|
120
129
|
const address = indexSegmentAddress(file, provinceDefinition);
|
|
121
|
-
if (!indexEntries.has(address))
|
|
130
|
+
if (segments.has(address) || !indexEntries.has(address))
|
|
122
131
|
continue;
|
|
123
132
|
const payload = await this.read(scopeHash, 'index-segment', address, signal);
|
|
124
133
|
if (payload === undefined)
|
|
@@ -140,15 +149,34 @@ export class PersistentAnalysisCache {
|
|
|
140
149
|
this.inventory(scopeHash, 'source-document', signal),
|
|
141
150
|
this.inventory(scopeHash, 'index-segment', signal),
|
|
142
151
|
]);
|
|
152
|
+
const batch = { writes: 0, payloadBytes: 0, limited: false };
|
|
153
|
+
const retain = async (kind, address, payload) => {
|
|
154
|
+
if (batch.writes >= this.batchMaxWrites ||
|
|
155
|
+
payload.length > this.batchMaxPayloadBytes - batch.payloadBytes) {
|
|
156
|
+
batch.limited = true;
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
const written = await this.write(scopeHash, kind, address, payload, signal);
|
|
160
|
+
if (written) {
|
|
161
|
+
batch.writes++;
|
|
162
|
+
batch.payloadBytes += payload.length;
|
|
163
|
+
}
|
|
164
|
+
return written;
|
|
165
|
+
};
|
|
143
166
|
for (const file of files) {
|
|
144
167
|
signal.throwIfAborted();
|
|
168
|
+
if (batch.writes >= this.batchMaxWrites ||
|
|
169
|
+
batch.payloadBytes >= this.batchMaxPayloadBytes) {
|
|
170
|
+
batch.limited = true;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
145
173
|
const key = sourceDocumentCacheKey(file.bytes, file.displayPath);
|
|
146
174
|
const documentAddress = sourceAddress(key);
|
|
147
175
|
const document = documents.exportEncoded(key);
|
|
148
176
|
if (document !== undefined &&
|
|
149
177
|
(!sourceEntries.has(documentAddress) ||
|
|
150
178
|
this.#invalidAddresses.has(`${scopeHash}\0source-document\0${documentAddress}`))) {
|
|
151
|
-
if (await
|
|
179
|
+
if (await retain('source-document', documentAddress, document)) {
|
|
152
180
|
sourceEntries.add(documentAddress);
|
|
153
181
|
}
|
|
154
182
|
}
|
|
@@ -159,11 +187,13 @@ export class PersistentAnalysisCache {
|
|
|
159
187
|
(indexEntries.has(address) &&
|
|
160
188
|
!this.#invalidAddresses.has(`${scopeHash}\0index-segment\0${address}`)))
|
|
161
189
|
continue;
|
|
162
|
-
if (await
|
|
190
|
+
if (await retain('index-segment', address, Buffer.from(segment))) {
|
|
163
191
|
indexEntries.add(address);
|
|
164
192
|
}
|
|
165
193
|
}
|
|
166
194
|
}
|
|
195
|
+
if (batch.limited)
|
|
196
|
+
this.#limitedBatches++;
|
|
167
197
|
await this.prune(signal);
|
|
168
198
|
});
|
|
169
199
|
}
|
|
@@ -177,6 +207,7 @@ export class PersistentAnalysisCache {
|
|
|
177
207
|
evictions: this.#evictions,
|
|
178
208
|
invalidEntries: this.#invalidEntries,
|
|
179
209
|
oversized: this.#oversized,
|
|
210
|
+
limitedBatches: this.#limitedBatches,
|
|
180
211
|
};
|
|
181
212
|
}
|
|
182
213
|
async directory(scopeHash, kind) {
|
|
@@ -1,9 +1,53 @@
|
|
|
1
|
-
import { open } from 'node:fs/promises';
|
|
1
|
+
import { open, realpath, stat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import
|
|
3
|
+
import { iterateFiles } from './file-glob.js';
|
|
4
4
|
import { compareCodeUnits, sha256Bytes } from './canonical.js';
|
|
5
5
|
import { DEFAULT_SCAN_MAX_BYTES, DEFAULT_SCAN_MAX_FILE_BYTES, DEFAULT_SCAN_MAX_FILES, } from './configuration.js';
|
|
6
6
|
import { ServiceError } from './result.js';
|
|
7
|
+
import { isWithin } from './workspace.js';
|
|
8
|
+
const SCAN_IO_CONCURRENCY = 8;
|
|
9
|
+
const SCAN_READ_BATCH_BYTES = 16_777_216;
|
|
10
|
+
export async function verifySource(root, source) {
|
|
11
|
+
const resolved = await realpath(source.absolutePath);
|
|
12
|
+
if (!isWithin(root.path, resolved))
|
|
13
|
+
throw new ServiceError('SCAN_ROOT_ESCAPE', 'Source path resolves outside its authorized root', {
|
|
14
|
+
file: source.relativePath,
|
|
15
|
+
});
|
|
16
|
+
const current = await stat(resolved);
|
|
17
|
+
const expected = source.metadata;
|
|
18
|
+
if (current.dev !== expected.dev ||
|
|
19
|
+
current.ino !== expected.ino ||
|
|
20
|
+
current.size !== expected.size ||
|
|
21
|
+
current.mtimeMs !== expected.mtimeMs ||
|
|
22
|
+
current.ctimeMs !== expected.ctimeMs)
|
|
23
|
+
throw new ServiceError('SCAN_SOURCE_CHANGED', 'Source changed while it was being scanned; retry a focused query', { file: source.relativePath });
|
|
24
|
+
}
|
|
25
|
+
export async function prepareSource(root, relativePath, signal) {
|
|
26
|
+
signal?.throwIfAborted();
|
|
27
|
+
const absolutePath = path.join(root.path, relativePath);
|
|
28
|
+
const canonicalPath = await realpath(absolutePath);
|
|
29
|
+
if (!isWithin(root.path, canonicalPath))
|
|
30
|
+
throw new ServiceError('SCAN_ROOT_ESCAPE', 'Source path resolves outside its authorized root', {
|
|
31
|
+
file: relativePath,
|
|
32
|
+
});
|
|
33
|
+
const handle = await open(canonicalPath, 'r');
|
|
34
|
+
try {
|
|
35
|
+
const source = {
|
|
36
|
+
relativePath,
|
|
37
|
+
absolutePath,
|
|
38
|
+
canonicalPath,
|
|
39
|
+
handle,
|
|
40
|
+
metadata: await handle.stat(),
|
|
41
|
+
};
|
|
42
|
+
await verifySource(root, source);
|
|
43
|
+
signal?.throwIfAborted();
|
|
44
|
+
return source;
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
await handle.close();
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
7
51
|
function normalizeRelative(value) {
|
|
8
52
|
return value.replaceAll('\\', '/').replace(/^\.\//u, '');
|
|
9
53
|
}
|
|
@@ -98,14 +142,12 @@ export class WorkspaceScanner {
|
|
|
98
142
|
options.signal?.throwIfAborted();
|
|
99
143
|
// Re-enumerate every root: installing content or changing load order can
|
|
100
144
|
// invalidate a vanilla inventory just as it can a mod inventory.
|
|
101
|
-
const matches =
|
|
145
|
+
const matches = iterateFiles(options.patterns, {
|
|
102
146
|
cwd: root.path,
|
|
103
|
-
onlyFiles: true,
|
|
104
|
-
unique: true,
|
|
105
|
-
dot: false,
|
|
106
|
-
followSymbolicLinks: false,
|
|
107
147
|
ignore: options.ignore ?? ['**/.hoi4-agent/**'],
|
|
148
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
108
149
|
});
|
|
150
|
+
const relativePaths = [];
|
|
109
151
|
for await (const match of matches) {
|
|
110
152
|
options.signal?.throwIfAborted();
|
|
111
153
|
enumeratedFiles += 1;
|
|
@@ -115,55 +157,99 @@ export class WorkspaceScanner {
|
|
|
115
157
|
limit: maxFiles,
|
|
116
158
|
});
|
|
117
159
|
}
|
|
118
|
-
const relativePath = normalizeRelative(
|
|
160
|
+
const relativePath = normalizeRelative(match);
|
|
119
161
|
if (hiddenByReplacePath(workspace, root, relativePath))
|
|
120
162
|
continue;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
163
|
+
relativePaths.push(relativePath);
|
|
164
|
+
}
|
|
165
|
+
for (let offset = 0; offset < relativePaths.length; offset += SCAN_IO_CONCURRENCY) {
|
|
166
|
+
options.signal?.throwIfAborted();
|
|
167
|
+
const prepared = await Promise.allSettled(relativePaths
|
|
168
|
+
.slice(offset, offset + SCAN_IO_CONCURRENCY)
|
|
169
|
+
.map((relativePath) => prepareSource(root, relativePath, options.signal)));
|
|
170
|
+
const opened = prepared.flatMap((entry) => entry.status === 'fulfilled' ? [entry.value] : []);
|
|
124
171
|
try {
|
|
125
|
-
const
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
172
|
+
const failed = prepared.find((entry) => entry.status === 'rejected');
|
|
173
|
+
if (failed?.status === 'rejected')
|
|
174
|
+
throw failed.reason;
|
|
175
|
+
const readable = opened.filter(({ metadata }) => metadata.isFile());
|
|
176
|
+
let reserved = 0;
|
|
177
|
+
for (const source of readable) {
|
|
178
|
+
if (source.metadata.size > maxBytes - totalBytes - reserved ||
|
|
179
|
+
source.metadata.size > this.serverMaxFileBytes)
|
|
180
|
+
throw new ServiceError('SCAN_BYTE_LIMIT', 'Scan exceeds the configured byte limit', {
|
|
181
|
+
file: source.relativePath,
|
|
182
|
+
fileBytes: source.metadata.size,
|
|
183
|
+
bytes: totalBytes + reserved,
|
|
184
|
+
limit: maxBytes,
|
|
185
|
+
perFileLimit: this.serverMaxFileBytes,
|
|
186
|
+
});
|
|
187
|
+
reserved += source.metadata.size;
|
|
188
|
+
}
|
|
189
|
+
let readOffset = 0;
|
|
190
|
+
while (readOffset < readable.length) {
|
|
191
|
+
const group = [];
|
|
192
|
+
let groupBytes = 0;
|
|
193
|
+
while (readOffset < readable.length) {
|
|
194
|
+
const source = readable[readOffset];
|
|
195
|
+
if (group.length > 0 && groupBytes + source.metadata.size > SCAN_READ_BATCH_BYTES)
|
|
196
|
+
break;
|
|
197
|
+
group.push(source);
|
|
198
|
+
groupBytes += source.metadata.size;
|
|
199
|
+
readOffset++;
|
|
200
|
+
}
|
|
201
|
+
const reads = await Promise.allSettled(group.map(async (source) => {
|
|
202
|
+
let bytes;
|
|
203
|
+
try {
|
|
204
|
+
bytes = await readBoundedFile(source.handle, source.metadata.size, options.signal);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (error instanceof ServiceError && error.code === 'SCAN_BYTE_LIMIT')
|
|
208
|
+
throw new ServiceError('SCAN_SOURCE_CHANGED', 'Source grew while it was being scanned; retry a focused query', { file: source.relativePath });
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
if (bytes.length !== source.metadata.size)
|
|
212
|
+
throw new ServiceError('SCAN_SOURCE_CHANGED', 'Source changed while it was being scanned; retry a focused query', { file: source.relativePath });
|
|
213
|
+
await verifySource(root, source);
|
|
214
|
+
options.signal?.throwIfAborted();
|
|
215
|
+
return bytes;
|
|
216
|
+
}));
|
|
217
|
+
const readFailure = reads.find((entry) => entry.status === 'rejected');
|
|
218
|
+
if (readFailure?.status === 'rejected')
|
|
219
|
+
throw readFailure.reason;
|
|
220
|
+
for (const [index, read] of reads.entries()) {
|
|
221
|
+
if (read.status !== 'fulfilled')
|
|
222
|
+
continue;
|
|
223
|
+
const source = group[index];
|
|
224
|
+
const cacheKey = this.cacheKey(source.absolutePath);
|
|
225
|
+
const observed = read.value;
|
|
226
|
+
const sha256 = sha256Bytes(observed);
|
|
227
|
+
const cached = this.#sourceCache.get(cacheKey);
|
|
228
|
+
const bytes = cached?.sha256 === sha256 && cached.bytes.equals(observed)
|
|
229
|
+
? cached.bytes
|
|
230
|
+
: observed;
|
|
231
|
+
this.retainSource(cacheKey, { sha256, bytes });
|
|
232
|
+
totalBytes += bytes.length;
|
|
233
|
+
result.push({
|
|
234
|
+
absolutePath: source.absolutePath,
|
|
235
|
+
displayPath: `${rootLabel(root)}:${source.relativePath}`,
|
|
236
|
+
relativePath: source.relativePath,
|
|
237
|
+
rootKind: root.kind,
|
|
238
|
+
loadOrder: root.loadOrder,
|
|
239
|
+
size: bytes.length,
|
|
240
|
+
modifiedMs: source.metadata.mtimeMs,
|
|
241
|
+
sha256,
|
|
242
|
+
bytes,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
137
245
|
}
|
|
138
|
-
const cached = this.#sourceCache.get(cacheKey);
|
|
139
|
-
// Metadata is not content identity (including on aliased/networked
|
|
140
|
-
// filesystems). Verify bytes before reusing parsed/indexed facts.
|
|
141
|
-
const observed = await readBoundedFile(handle, Math.min(remaining, this.serverMaxFileBytes), options.signal);
|
|
142
|
-
const sha256 = sha256Bytes(observed);
|
|
143
|
-
const bytes = cached?.sha256 === sha256 && cached.bytes.equals(observed) ? cached.bytes : observed;
|
|
144
|
-
this.retainSource(cacheKey, {
|
|
145
|
-
sha256,
|
|
146
|
-
bytes,
|
|
147
|
-
});
|
|
148
|
-
totalBytes += bytes.length;
|
|
149
|
-
const scanned = {
|
|
150
|
-
absolutePath,
|
|
151
|
-
displayPath: `${rootLabel(root)}:${relativePath}`,
|
|
152
|
-
relativePath,
|
|
153
|
-
rootKind: root.kind,
|
|
154
|
-
loadOrder: root.loadOrder,
|
|
155
|
-
size: bytes.length,
|
|
156
|
-
modifiedMs: metadata.mtimeMs,
|
|
157
|
-
sha256,
|
|
158
|
-
bytes,
|
|
159
|
-
};
|
|
160
|
-
result.push(scanned);
|
|
161
246
|
}
|
|
162
247
|
finally {
|
|
163
|
-
await handle.close();
|
|
248
|
+
await Promise.all(opened.map(({ handle }) => handle.close()));
|
|
164
249
|
}
|
|
165
250
|
}
|
|
166
251
|
}
|
|
252
|
+
options.signal?.throwIfAborted();
|
|
167
253
|
result.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.relativePath, right.relativePath));
|
|
168
254
|
const groups = new Map();
|
|
169
255
|
for (const file of result) {
|
|
@@ -51,6 +51,10 @@ export class SourceDocumentCache {
|
|
|
51
51
|
this.#entries.set(key, encoded);
|
|
52
52
|
return decodedDocument(encoded);
|
|
53
53
|
}
|
|
54
|
+
/** Availability check without deserialization or cache-hit accounting. */
|
|
55
|
+
has(key) {
|
|
56
|
+
return this.#entries.has(key);
|
|
57
|
+
}
|
|
54
58
|
put(key, document) {
|
|
55
59
|
// Avoid allocating a large serialized copy just to discover that it cannot
|
|
56
60
|
// be retained. This conservative estimate affects caching, never parsing.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import
|
|
2
|
+
import { escapeFileGlob } from '../core/file-glob.js';
|
|
3
3
|
import { ArtifactStore, boundedSourceHashEvidence, publicArtifactLink, } from '../core/artifacts.js';
|
|
4
4
|
import { canonicalJson, compareCodeUnits, hashCanonical, sha256Bytes } from '../core/canonical.js';
|
|
5
5
|
import { sortDiagnostics } from '../core/diagnostics.js';
|
|
@@ -426,7 +426,7 @@ function provinceBitmapPatterns(defaultMaps, mapRoots) {
|
|
|
426
426
|
const selectedPath = path.posix.normalize(path.posix.join(normalizedRoot, normalizedName));
|
|
427
427
|
if (selectedPath !== normalizedRoot && !selectedPath.startsWith(`${normalizedRoot}/`))
|
|
428
428
|
continue;
|
|
429
|
-
patterns.add(
|
|
429
|
+
patterns.add(escapeFileGlob(selectedPath));
|
|
430
430
|
}
|
|
431
431
|
}
|
|
432
432
|
return [...patterns].sort((left, right) => compareCodeUnits(left, right));
|
|
@@ -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
|
|
39
|
+
description: 'Find exact Clausewitz definitions and usages; navigate nested keyPath blocks or list bounded child structure with view=structure.',
|
|
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);
|
|
@@ -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
|
-
:
|
|
39
|
-
|
|
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
|
}
|