hoi4-agent-tools 3.1.0 → 3.2.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 +9 -0
- package/README.md +6 -2
- package/dist/hoi4_agent_tools/core/analysis-evidence.js +9 -0
- package/dist/hoi4_agent_tools/core/analysis-job-operations.js +38 -0
- package/dist/hoi4_agent_tools/core/condition-evaluator.js +16 -0
- package/dist/hoi4_agent_tools/core/impact-comparison.js +49 -0
- package/dist/hoi4_agent_tools/core/impact-graph.js +301 -0
- package/dist/hoi4_agent_tools/core/impact-semantic.js +256 -0
- package/dist/hoi4_agent_tools/core/job-worker-host.js +2 -0
- package/dist/hoi4_agent_tools/core/job-worker.js +2 -0
- package/dist/hoi4_agent_tools/core/operation-tasks.js +5 -0
- package/dist/hoi4_agent_tools/core/source-overlay.js +125 -0
- package/dist/hoi4_agent_tools/decision/ai.js +104 -0
- package/dist/hoi4_agent_tools/decision/comparison.js +106 -0
- package/dist/hoi4_agent_tools/decision/cost.js +177 -0
- package/dist/hoi4_agent_tools/decision/evaluation.js +256 -0
- package/dist/hoi4_agent_tools/decision/lifecycle.js +114 -0
- package/dist/hoi4_agent_tools/decision/service.js +172 -0
- package/dist/hoi4_agent_tools/decision/source-inventory.js +182 -0
- package/dist/hoi4_agent_tools/impact/scenario-suites.js +74 -0
- package/dist/hoi4_agent_tools/impact/service.js +127 -0
- 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/server/request-lifecycle.js +2 -0
- package/dist/hoi4_agent_tools/mcp/server/task-tool-catalog.js +2 -0
- package/dist/hoi4_agent_tools/mcp/tools/analysis.js +65 -0
- package/dist/hoi4_agent_tools/schemas/analysis.js +228 -0
- package/dist/hoi4_agent_tools/version.js +1 -1
- package/docs/README.md +1 -0
- package/docs/analysis.md +100 -0
- package/docs/jobs.md +2 -1
- package/package.json +5 -2
- 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 +290 -0
- 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 +203 -0
- package/schemas/map-operation.schema.json +1 -1
- package/schemas/operation-result.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/server.json +3 -3
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { compareCodeUnits } from './canonical.js';
|
|
2
|
+
import { assignments, firstScalar, nodeLocation, parseClausewitz, } from './source/index.js';
|
|
3
|
+
const eventKeys = new Set([
|
|
4
|
+
'country_event',
|
|
5
|
+
'news_event',
|
|
6
|
+
'state_event',
|
|
7
|
+
'unit_leader_event',
|
|
8
|
+
'operative_leader_event',
|
|
9
|
+
]);
|
|
10
|
+
const scalarTargets = new Map([
|
|
11
|
+
['activate_decision', 'decision'],
|
|
12
|
+
['remove_decision', 'decision'],
|
|
13
|
+
['complete_decision', 'decision'],
|
|
14
|
+
['add_ideas', 'idea'],
|
|
15
|
+
['remove_ideas', 'idea'],
|
|
16
|
+
['has_idea', 'idea'],
|
|
17
|
+
['has_completed_focus', 'focus'],
|
|
18
|
+
['load_focus_tree', 'focus_tree'],
|
|
19
|
+
['has_tech', 'technology'],
|
|
20
|
+
['country_event', 'event'],
|
|
21
|
+
['news_event', 'event'],
|
|
22
|
+
['state_event', 'event'],
|
|
23
|
+
]);
|
|
24
|
+
const stateKeyOperations = new Map([
|
|
25
|
+
['set_variable', { kind: 'variable', role: 'write' }],
|
|
26
|
+
['add_to_variable', { kind: 'variable', role: 'write' }],
|
|
27
|
+
['subtract_from_variable', { kind: 'variable', role: 'write' }],
|
|
28
|
+
['multiply_variable', { kind: 'variable', role: 'write' }],
|
|
29
|
+
['divide_variable', { kind: 'variable', role: 'write' }],
|
|
30
|
+
['check_variable', { kind: 'variable', role: 'read' }],
|
|
31
|
+
['has_variable', { kind: 'variable', role: 'read' }],
|
|
32
|
+
['set_country_flag', { kind: 'flag', role: 'write', scope: 'country' }],
|
|
33
|
+
['clr_country_flag', { kind: 'flag', role: 'write', scope: 'country' }],
|
|
34
|
+
['has_country_flag', { kind: 'flag', role: 'read', scope: 'country' }],
|
|
35
|
+
['set_state_flag', { kind: 'flag', role: 'write', scope: 'state' }],
|
|
36
|
+
['clr_state_flag', { kind: 'flag', role: 'write', scope: 'state' }],
|
|
37
|
+
['has_state_flag', { kind: 'flag', role: 'read', scope: 'state' }],
|
|
38
|
+
['set_global_flag', { kind: 'flag', role: 'write', scope: 'global' }],
|
|
39
|
+
['clr_global_flag', { kind: 'flag', role: 'write', scope: 'global' }],
|
|
40
|
+
['has_global_flag', { kind: 'flag', role: 'read', scope: 'global' }],
|
|
41
|
+
['save_event_target_as', { kind: 'event_target', role: 'write' }],
|
|
42
|
+
['save_global_event_target_as', { kind: 'event_target', role: 'write' }],
|
|
43
|
+
['clear_global_event_target', { kind: 'event_target', role: 'write' }],
|
|
44
|
+
['has_event_target', { kind: 'event_target', role: 'read' }],
|
|
45
|
+
]);
|
|
46
|
+
function stateKeyTarget(assignment, kind) {
|
|
47
|
+
if (assignment.value.type === 'scalar')
|
|
48
|
+
return assignment.value.value;
|
|
49
|
+
if (kind !== 'variable')
|
|
50
|
+
return undefined;
|
|
51
|
+
const explicit = firstScalar(assignment.value, 'var');
|
|
52
|
+
if (explicit !== undefined)
|
|
53
|
+
return explicit.value;
|
|
54
|
+
return assignments(assignment.value).find(({ key }) => !['compare', 'value', 'tooltip', 'var', 'value_name'].includes(key.value))?.key.value;
|
|
55
|
+
}
|
|
56
|
+
function normalized(file) {
|
|
57
|
+
return file.relativePath.replaceAll('\\', '/').toLowerCase();
|
|
58
|
+
}
|
|
59
|
+
function ownerFor(snapshot, file, assignment, ancestors) {
|
|
60
|
+
if (assignment.value.type !== 'block')
|
|
61
|
+
return undefined;
|
|
62
|
+
const key = assignment.key.value;
|
|
63
|
+
const sourcePath = normalized(file);
|
|
64
|
+
let owner;
|
|
65
|
+
if (sourcePath.startsWith('events/') && ancestors.length === 0 && eventKeys.has(key)) {
|
|
66
|
+
const id = firstScalar(assignment.value, 'id')?.value;
|
|
67
|
+
if (id !== undefined)
|
|
68
|
+
owner = { kind: 'event', id };
|
|
69
|
+
}
|
|
70
|
+
else if (key === 'focus') {
|
|
71
|
+
const id = firstScalar(assignment.value, 'id')?.value;
|
|
72
|
+
if (id !== undefined)
|
|
73
|
+
owner = {
|
|
74
|
+
kind: ancestors.includes('continuous_focus_palette') ? 'continuous_focus' : 'focus',
|
|
75
|
+
id,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
else if (sourcePath.startsWith('common/decisions/') &&
|
|
79
|
+
!sourcePath.startsWith('common/decisions/categories/') &&
|
|
80
|
+
ancestors.length === 1) {
|
|
81
|
+
owner = { kind: 'decision', id: key };
|
|
82
|
+
}
|
|
83
|
+
else if (sourcePath.startsWith('common/decisions/categories/') && ancestors.length === 0) {
|
|
84
|
+
owner = { kind: 'decision_category', id: key };
|
|
85
|
+
}
|
|
86
|
+
else if (sourcePath.startsWith('common/ideas/') &&
|
|
87
|
+
ancestors.length === 2 &&
|
|
88
|
+
ancestors[0] === 'ideas') {
|
|
89
|
+
owner = { kind: 'idea', id: key };
|
|
90
|
+
}
|
|
91
|
+
else if (sourcePath.startsWith('common/technologies/') &&
|
|
92
|
+
ancestors.length === 1 &&
|
|
93
|
+
ancestors[0] === 'technologies') {
|
|
94
|
+
owner = { kind: 'technology', id: key };
|
|
95
|
+
}
|
|
96
|
+
else if (sourcePath.startsWith('common/scripted_effects/') && ancestors.length === 0) {
|
|
97
|
+
owner = { kind: 'scripted_effect', id: key };
|
|
98
|
+
}
|
|
99
|
+
else if (sourcePath.startsWith('common/scripted_triggers/') && ancestors.length === 0) {
|
|
100
|
+
owner = { kind: 'scripted_trigger', id: key };
|
|
101
|
+
}
|
|
102
|
+
else if (sourcePath.startsWith('common/scripted_guis/') &&
|
|
103
|
+
ancestors.length === 1 &&
|
|
104
|
+
ancestors[0] === 'scripted_gui') {
|
|
105
|
+
owner = { kind: 'scripted_gui', id: key };
|
|
106
|
+
}
|
|
107
|
+
return owner === undefined || snapshot.index.find(owner.kind, owner.id)?.path !== file.displayPath
|
|
108
|
+
? undefined
|
|
109
|
+
: owner;
|
|
110
|
+
}
|
|
111
|
+
function staticTarget(value) {
|
|
112
|
+
return (/^[A-Za-z0-9_.:-]+$/u.test(value) &&
|
|
113
|
+
!/^(?:event_target|scope|var|constant):/iu.test(value) &&
|
|
114
|
+
!/^(?:ROOT|FROM|PREV|THIS)$/iu.test(value));
|
|
115
|
+
}
|
|
116
|
+
/** Extracts typed script edges while preserving unknown dynamic references. */
|
|
117
|
+
export function scanImpactSemanticReferences(snapshot, maxReferences = 200_000) {
|
|
118
|
+
if (!Number.isSafeInteger(maxReferences) || maxReferences < 1 || maxReferences > 1_000_000)
|
|
119
|
+
throw new RangeError('Semantic reference limit is outside the supported range');
|
|
120
|
+
const references = [];
|
|
121
|
+
const unresolved = [];
|
|
122
|
+
let scannedFiles = 0;
|
|
123
|
+
let complete = true;
|
|
124
|
+
const emit = (file, document, node, owner, toKind, target, referenceKind) => {
|
|
125
|
+
if (!staticTarget(target)) {
|
|
126
|
+
if (unresolved.length < maxReferences)
|
|
127
|
+
unresolved.push({
|
|
128
|
+
path: file.displayPath,
|
|
129
|
+
location: nodeLocation(document, node, owner.id),
|
|
130
|
+
reason: `Dynamic ${referenceKind} target ${target} cannot be resolved statically`,
|
|
131
|
+
});
|
|
132
|
+
else
|
|
133
|
+
complete = false;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (references.length >= maxReferences) {
|
|
137
|
+
complete = false;
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
references.push({
|
|
141
|
+
kind: referenceKind,
|
|
142
|
+
from: owner.id,
|
|
143
|
+
toKind,
|
|
144
|
+
to: target,
|
|
145
|
+
path: file.displayPath,
|
|
146
|
+
location: nodeLocation(document, node, owner.id),
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
const walk = (file, document, block, ancestors, owner) => {
|
|
150
|
+
for (const assignment of assignments(block)) {
|
|
151
|
+
const identified = ownerFor(snapshot, file, assignment, ancestors);
|
|
152
|
+
const current = identified ?? owner;
|
|
153
|
+
if (identified?.kind === 'decision' && ancestors.length === 1)
|
|
154
|
+
emit(file, document, assignment, identified, 'decision_category', ancestors[0], 'decision_category_membership');
|
|
155
|
+
if (current !== undefined && identified === undefined) {
|
|
156
|
+
const key = assignment.key.value;
|
|
157
|
+
if (assignment.value.type === 'scalar') {
|
|
158
|
+
const value = assignment.value.value;
|
|
159
|
+
const localisation = (current.kind === 'event' &&
|
|
160
|
+
(key === 'title' ||
|
|
161
|
+
key === 'desc' ||
|
|
162
|
+
(key === 'name' && ancestors.includes('option')) ||
|
|
163
|
+
(key === 'text' && ancestors.includes('desc')))) ||
|
|
164
|
+
key === 'custom_effect_tooltip';
|
|
165
|
+
if (localisation)
|
|
166
|
+
emit(file, document, assignment, current, 'localisation', value, 'localisation_key');
|
|
167
|
+
if ((key === 'picture' ||
|
|
168
|
+
(key === 'icon' && ['decision', 'decision_category'].includes(current.kind))) &&
|
|
169
|
+
value.startsWith('GFX_'))
|
|
170
|
+
emit(file, document, assignment, current, 'sprite', value, 'display_sprite');
|
|
171
|
+
if (key === 'scripted_gui' && ['decision', 'decision_category'].includes(current.kind))
|
|
172
|
+
emit(file, document, assignment, current, 'scripted_gui', value, 'scripted_gui_binding');
|
|
173
|
+
if (current.kind === 'scripted_gui' && key === 'parent_window_window')
|
|
174
|
+
emit(file, document, assignment, current, 'gui_element', value, key);
|
|
175
|
+
if (current.kind === 'scripted_gui' && key === 'parent_scripted_gui')
|
|
176
|
+
emit(file, document, assignment, current, 'scripted_gui', value, key);
|
|
177
|
+
}
|
|
178
|
+
if (key.startsWith('event_target:'))
|
|
179
|
+
emit(file, document, assignment, current, 'event_target', key.slice('event_target:'.length), 'event_target_read');
|
|
180
|
+
const stateOperation = stateKeyOperations.get(key);
|
|
181
|
+
if (stateOperation !== undefined) {
|
|
182
|
+
const target = stateKeyTarget(assignment, stateOperation.kind);
|
|
183
|
+
if (target === undefined) {
|
|
184
|
+
if (unresolved.length < maxReferences)
|
|
185
|
+
unresolved.push({
|
|
186
|
+
path: file.displayPath,
|
|
187
|
+
location: nodeLocation(document, assignment, current.id),
|
|
188
|
+
reason: `Cannot identify ${key} state key`,
|
|
189
|
+
});
|
|
190
|
+
else
|
|
191
|
+
complete = false;
|
|
192
|
+
}
|
|
193
|
+
else
|
|
194
|
+
emit(file, document, assignment, current, stateOperation.kind, stateOperation.scope === undefined ? target : `${stateOperation.scope}:${target}`, `${stateOperation.kind}_${stateOperation.role}`);
|
|
195
|
+
}
|
|
196
|
+
const targetKind = scalarTargets.get(key);
|
|
197
|
+
if (key === 'set_technology' && assignment.value.type === 'block')
|
|
198
|
+
for (const technology of assignments(assignment.value))
|
|
199
|
+
if (technology.key.value !== 'popup')
|
|
200
|
+
emit(file, document, technology, current, 'technology', technology.key.value, 'set_technology');
|
|
201
|
+
if (targetKind !== undefined) {
|
|
202
|
+
if (assignment.value.type === 'scalar') {
|
|
203
|
+
emit(file, document, assignment, current, targetKind, assignment.value.value, key);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
const id = firstScalar(assignment.value, 'id') ?? firstScalar(assignment.value, 'decision');
|
|
207
|
+
if (id !== undefined)
|
|
208
|
+
emit(file, document, assignment, current, targetKind, id.value, key);
|
|
209
|
+
else
|
|
210
|
+
for (const entry of assignment.value.entries)
|
|
211
|
+
if (entry.type === 'scalar')
|
|
212
|
+
emit(file, document, assignment, current, targetKind, entry.value, key);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const helperKind = snapshot.index.find('scripted_effect', key) !== undefined
|
|
216
|
+
? 'scripted_effect'
|
|
217
|
+
: snapshot.index.find('scripted_trigger', key) !== undefined
|
|
218
|
+
? 'scripted_trigger'
|
|
219
|
+
: undefined;
|
|
220
|
+
if (helperKind !== undefined)
|
|
221
|
+
emit(file, document, assignment, current, helperKind, key, 'scripted_helper_call');
|
|
222
|
+
}
|
|
223
|
+
if (assignment.value.type === 'block')
|
|
224
|
+
walk(file, document, assignment.value, [...ancestors, assignment.key.value], current);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
for (const file of snapshot.files) {
|
|
228
|
+
if (file.shadowedBy !== undefined)
|
|
229
|
+
continue;
|
|
230
|
+
const path = normalized(file);
|
|
231
|
+
if (!path.endsWith('.txt') ||
|
|
232
|
+
!(path.startsWith('events/') ||
|
|
233
|
+
path.startsWith('common/national_focus/') ||
|
|
234
|
+
path.startsWith('common/continuous_focus/') ||
|
|
235
|
+
path.startsWith('common/decisions/') ||
|
|
236
|
+
path.startsWith('common/ideas/') ||
|
|
237
|
+
path.startsWith('common/technologies/') ||
|
|
238
|
+
path.startsWith('common/scripted_effects/') ||
|
|
239
|
+
path.startsWith('common/scripted_triggers/') ||
|
|
240
|
+
path.startsWith('common/scripted_guis/')))
|
|
241
|
+
continue;
|
|
242
|
+
if (snapshot.index.isSourceSkipped(file.displayPath)) {
|
|
243
|
+
complete = false;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
scannedFiles += 1;
|
|
247
|
+
const document = parseClausewitz(file.bytes, file.displayPath);
|
|
248
|
+
walk(file, document, document.root, []);
|
|
249
|
+
}
|
|
250
|
+
references.sort((left, right) => compareCodeUnits(left.path, right.path) ||
|
|
251
|
+
compareCodeUnits(left.from, right.from) ||
|
|
252
|
+
compareCodeUnits(left.toKind, right.toKind) ||
|
|
253
|
+
compareCodeUnits(left.to, right.to) ||
|
|
254
|
+
compareCodeUnits(left.kind, right.kind));
|
|
255
|
+
return { references, unresolved, scannedFiles, complete };
|
|
256
|
+
}
|
|
@@ -10,6 +10,7 @@ import { registerGuiJobs } from '../gui/job-operations.js';
|
|
|
10
10
|
import { registerMapJobs } from '../map/job-operations.js';
|
|
11
11
|
import { registerProbabilityJobs } from '../probability/job-operations.js';
|
|
12
12
|
import { registerTechnologyJobs } from '../technology/job-operations.js';
|
|
13
|
+
import { registerAnalysisJobs } from './analysis-job-operations.js';
|
|
13
14
|
// Fixed internal process entry point. It accepts only a trusted host's structured IPC
|
|
14
15
|
// message, never a script path, module name, callback, command, or transport request.
|
|
15
16
|
const inputSchema = z
|
|
@@ -44,6 +45,7 @@ process.once('message', (value) => {
|
|
|
44
45
|
const jobs = await JobService.create(resolver);
|
|
45
46
|
const operations = new JobOperations();
|
|
46
47
|
registerEventJobs(operations, engine);
|
|
48
|
+
registerAnalysisJobs(operations, engine);
|
|
47
49
|
registerTechnologyJobs(operations, engine);
|
|
48
50
|
registerProbabilityJobs(operations, engine);
|
|
49
51
|
registerMapJobs(operations, engine);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod/v4';
|
|
2
|
+
import { decisionInspectRequestSchema, impactInspectRequestSchema } from '../schemas/analysis.js';
|
|
2
3
|
import { eventCompareRequestSchema, eventInspectRequestSchema, eventRenderRequestSchema, } from '../schemas/event.js';
|
|
3
4
|
import { focusInspectRequestSchema, focusRenderRequestSchema, focusRewriteRequestSchema, } from '../schemas/focus-requests.js';
|
|
4
5
|
import { guiInspectRequestSchema, guiRenderRequestSchema, guiRewriteRequestSchema, } from '../schemas/gui-requests.js';
|
|
@@ -25,6 +26,8 @@ const authenticatedTaskIdSchema = z
|
|
|
25
26
|
export const operationTaskCallSchema = z
|
|
26
27
|
.object({
|
|
27
28
|
name: z.enum([
|
|
29
|
+
'hoi4.impact_inspect',
|
|
30
|
+
'hoi4.decision_inspect',
|
|
28
31
|
'hoi4.event_inspect',
|
|
29
32
|
'hoi4.event_render',
|
|
30
33
|
'hoi4.event_compare',
|
|
@@ -53,6 +56,8 @@ export const operationTaskCallSchema = z
|
|
|
53
56
|
})
|
|
54
57
|
.strict();
|
|
55
58
|
const taskArgumentSchemas = {
|
|
59
|
+
'hoi4.impact_inspect': impactInspectRequestSchema,
|
|
60
|
+
'hoi4.decision_inspect': decisionInspectRequestSchema,
|
|
56
61
|
'hoi4.event_inspect': eventInspectRequestSchema,
|
|
57
62
|
'hoi4.event_render': eventRenderRequestSchema,
|
|
58
63
|
'hoi4.event_compare': eventCompareRequestSchema,
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { compareCodeUnits, hashCanonical, sha256Bytes } from './canonical.js';
|
|
3
|
+
import { SymbolIndex } from './index.js';
|
|
4
|
+
import { isWithin } from './workspace.js';
|
|
5
|
+
const MAX_EDITS = 64;
|
|
6
|
+
const MAX_EDIT_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
const MAX_TOTAL_BYTES = 32 * 1024 * 1024;
|
|
8
|
+
const SOURCE_EXTENSION = /\.(?:txt|gui|gfx|yml|yaml)$/iu;
|
|
9
|
+
function checkedRelativePath(relativePath) {
|
|
10
|
+
if (relativePath.length === 0 ||
|
|
11
|
+
relativePath.length > 4096 ||
|
|
12
|
+
relativePath.includes('\\') ||
|
|
13
|
+
relativePath.includes('\0') ||
|
|
14
|
+
path.posix.isAbsolute(relativePath) ||
|
|
15
|
+
/^[A-Za-z]:/u.test(relativePath))
|
|
16
|
+
throw new RangeError('Proposed source path is not workspace-relative');
|
|
17
|
+
const parts = relativePath.split('/');
|
|
18
|
+
if (parts.some((part) => part.length === 0 || part === '.' || part === '..'))
|
|
19
|
+
throw new RangeError('Proposed source path is not normalized');
|
|
20
|
+
if (!SOURCE_EXTENSION.test(relativePath))
|
|
21
|
+
throw new RangeError('Proposed source must be a supported text source');
|
|
22
|
+
return relativePath;
|
|
23
|
+
}
|
|
24
|
+
function revision(files) {
|
|
25
|
+
return hashCanonical(files.map(({ absolutePath, displayPath, relativePath, rootKind, loadOrder, shadowedBy, sha256 }) => ({
|
|
26
|
+
absolutePath,
|
|
27
|
+
displayPath,
|
|
28
|
+
relativePath,
|
|
29
|
+
rootKind,
|
|
30
|
+
loadOrder,
|
|
31
|
+
shadowedBy: shadowedBy ?? null,
|
|
32
|
+
sha256,
|
|
33
|
+
})));
|
|
34
|
+
}
|
|
35
|
+
/** Builds a source-only proposal in memory under the authorized mod root. */
|
|
36
|
+
export function buildProposedSourceOverlay(baseline, workspace, edits) {
|
|
37
|
+
if (edits.length < 1 || edits.length > MAX_EDITS)
|
|
38
|
+
throw new RangeError(`Proposed overlay requires 1-${MAX_EDITS} edits`);
|
|
39
|
+
if (baseline.workspaceId !== workspace.id)
|
|
40
|
+
throw new Error('Proposed overlay workspace does not match its baseline snapshot');
|
|
41
|
+
const modRoot = workspace.roots.find(({ kind }) => kind === 'mod');
|
|
42
|
+
if (modRoot?.path !== workspace.modRoot)
|
|
43
|
+
throw new Error('Authorized mod root is unavailable');
|
|
44
|
+
const files = baseline.files.map((file) => {
|
|
45
|
+
const copy = { ...file };
|
|
46
|
+
delete copy.shadowedBy;
|
|
47
|
+
return copy;
|
|
48
|
+
});
|
|
49
|
+
const changes = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
let totalBytes = 0;
|
|
52
|
+
for (const edit of edits) {
|
|
53
|
+
const relativePath = checkedRelativePath(edit.relativePath);
|
|
54
|
+
const key = relativePath.toLowerCase();
|
|
55
|
+
if (seen.has(key))
|
|
56
|
+
throw new Error(`Duplicate proposed source path: ${relativePath}`);
|
|
57
|
+
seen.add(key);
|
|
58
|
+
const existingIndex = files.findIndex((file) => file.rootKind === 'mod' && file.relativePath.toLowerCase() === relativePath.toLowerCase());
|
|
59
|
+
const existing = existingIndex < 0 ? undefined : files[existingIndex];
|
|
60
|
+
const sourcePath = existing?.relativePath ?? relativePath;
|
|
61
|
+
const absolutePath = path.resolve(modRoot.path, ...sourcePath.split('/'));
|
|
62
|
+
if (!isWithin(modRoot.path, absolutePath) || absolutePath === modRoot.path)
|
|
63
|
+
throw new RangeError('Proposed source path escapes the authorized mod root');
|
|
64
|
+
const displayPath = existing?.displayPath ?? `mod:${sourcePath}`;
|
|
65
|
+
if (existing !== undefined)
|
|
66
|
+
files.splice(existingIndex, 1);
|
|
67
|
+
if (edit.content === null) {
|
|
68
|
+
if (existing === undefined)
|
|
69
|
+
throw new Error(`Proposed removal has no mod source: ${relativePath}`);
|
|
70
|
+
changes.push({
|
|
71
|
+
path: existing.displayPath,
|
|
72
|
+
change: 'removed',
|
|
73
|
+
beforeHash: existing.sha256,
|
|
74
|
+
afterHash: null,
|
|
75
|
+
});
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const bytes = Buffer.from(edit.content, 'utf8');
|
|
79
|
+
totalBytes += bytes.length;
|
|
80
|
+
if (bytes.length > MAX_EDIT_BYTES || totalBytes > MAX_TOTAL_BYTES)
|
|
81
|
+
throw new RangeError('Proposed source content exceeds the overlay limit');
|
|
82
|
+
const sha256 = sha256Bytes(bytes);
|
|
83
|
+
files.push({
|
|
84
|
+
absolutePath,
|
|
85
|
+
displayPath,
|
|
86
|
+
relativePath: sourcePath,
|
|
87
|
+
rootKind: 'mod',
|
|
88
|
+
loadOrder: modRoot.loadOrder,
|
|
89
|
+
size: bytes.length,
|
|
90
|
+
modifiedMs: 0,
|
|
91
|
+
sha256,
|
|
92
|
+
bytes,
|
|
93
|
+
});
|
|
94
|
+
changes.push({
|
|
95
|
+
path: displayPath,
|
|
96
|
+
change: existing === undefined ? 'added' : 'replaced',
|
|
97
|
+
beforeHash: existing?.sha256 ?? null,
|
|
98
|
+
afterHash: sha256,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
files.sort((left, right) => left.loadOrder - right.loadOrder || compareCodeUnits(left.relativePath, right.relativePath));
|
|
102
|
+
const groups = new Map();
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
const group = groups.get(file.relativePath.toLowerCase()) ?? [];
|
|
105
|
+
group.push(file);
|
|
106
|
+
groups.set(file.relativePath.toLowerCase(), group);
|
|
107
|
+
}
|
|
108
|
+
for (const group of groups.values()) {
|
|
109
|
+
group.sort((left, right) => right.loadOrder - left.loadOrder || compareCodeUnits(left.displayPath, right.displayPath));
|
|
110
|
+
for (const file of group.slice(1))
|
|
111
|
+
file.shadowedBy = group[0].displayPath;
|
|
112
|
+
}
|
|
113
|
+
const index = SymbolIndex.build(files);
|
|
114
|
+
const snapshot = {
|
|
115
|
+
workspaceId: baseline.workspaceId,
|
|
116
|
+
revision: revision(files),
|
|
117
|
+
files,
|
|
118
|
+
index,
|
|
119
|
+
complete: index.complete,
|
|
120
|
+
skippedSourceCount: index.skippedSourceCount,
|
|
121
|
+
skippedSources: index.skippedSources,
|
|
122
|
+
diagnostics: index.diagnostics,
|
|
123
|
+
};
|
|
124
|
+
return { baselineRevision: baseline.revision, snapshot, changes };
|
|
125
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { ProbabilityAnalyzer } from '../probability/service.js';
|
|
2
|
+
import { decisionSourceInventory } from './source-inventory.js';
|
|
3
|
+
function selected(snapshot, id) {
|
|
4
|
+
return decisionSourceInventory(snapshot).decisions.find(({ id: candidate }) => candidate === id);
|
|
5
|
+
}
|
|
6
|
+
function summary(result) {
|
|
7
|
+
return {
|
|
8
|
+
analysisId: result.analysisId,
|
|
9
|
+
status: result.status,
|
|
10
|
+
adapter: result.adapter.id,
|
|
11
|
+
sourceHash: result.metadata.sourceHash,
|
|
12
|
+
scenarios: result.scenarios.map((scenario) => ({
|
|
13
|
+
id: scenario.id,
|
|
14
|
+
candidates: scenario.candidates.map((candidate) => ({
|
|
15
|
+
id: candidate.id,
|
|
16
|
+
eligibility: candidate.eligibility,
|
|
17
|
+
supportLevel: candidate.supportLevel,
|
|
18
|
+
...(candidate.rawValue === undefined ? {} : { rawValue: candidate.rawValue }),
|
|
19
|
+
unresolved: candidate.unresolved,
|
|
20
|
+
})),
|
|
21
|
+
unresolved: scenario.unresolved,
|
|
22
|
+
})),
|
|
23
|
+
unresolved: result.unresolved,
|
|
24
|
+
...(result.comparison === undefined ? {} : { comparison: result.comparison }),
|
|
25
|
+
resources: result.resources,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Uses the existing score-only probability adapters; never invents decision selection odds. */
|
|
29
|
+
export async function inspectDecisionAi(engine, before, id, scenarios, overlay, principal, signal) {
|
|
30
|
+
const source = selected(before, id);
|
|
31
|
+
const proposed = overlay === undefined ? undefined : selected(overlay.snapshot, id);
|
|
32
|
+
const baselineWeighted = (source?.fields.ai_will_do.length ?? 0) > 0;
|
|
33
|
+
const proposedWeighted = (proposed?.fields.ai_will_do.length ?? 0) > 0;
|
|
34
|
+
if (!baselineWeighted && !proposedWeighted)
|
|
35
|
+
return undefined;
|
|
36
|
+
const subject = source ?? proposed;
|
|
37
|
+
const adapter = subject.kind === 'mission' ? 'mission_ai_will_do' : 'decision_ai_will_do';
|
|
38
|
+
const scenarioSet = {
|
|
39
|
+
schemaVersion: '1.0',
|
|
40
|
+
id: `decision-${id}`,
|
|
41
|
+
scenarios: scenarios.map(({ closedFlags: _closedFlags, ...scenario }) => scenario),
|
|
42
|
+
};
|
|
43
|
+
const analyzer = new ProbabilityAnalyzer(engine);
|
|
44
|
+
const workspaceId = before.workspaceId;
|
|
45
|
+
const context = {
|
|
46
|
+
workspaceId,
|
|
47
|
+
adapter,
|
|
48
|
+
scenarioSet,
|
|
49
|
+
outputs: ['json'],
|
|
50
|
+
...(principal === undefined ? {} : { principal }),
|
|
51
|
+
...(signal === undefined ? {} : { signal }),
|
|
52
|
+
};
|
|
53
|
+
const sourceFile = source === undefined
|
|
54
|
+
? undefined
|
|
55
|
+
: before.files.find(({ displayPath }) => displayPath === source.path);
|
|
56
|
+
const baselineSelector = sourceFile === undefined
|
|
57
|
+
? undefined
|
|
58
|
+
: { path: sourceFile.relativePath, identifier: id, expectedSourceHash: sourceFile.sha256 };
|
|
59
|
+
const proposedFile = proposed === undefined
|
|
60
|
+
? undefined
|
|
61
|
+
: overlay?.snapshot.files.find(({ displayPath }) => displayPath === proposed.path);
|
|
62
|
+
const proposedSelector = proposedFile === undefined
|
|
63
|
+
? undefined
|
|
64
|
+
: { virtualPatch: proposedFile.bytes.toString('utf8'), identifier: id };
|
|
65
|
+
const isolatedProposal = overlay?.changes.length === 1 &&
|
|
66
|
+
proposedFile !== undefined &&
|
|
67
|
+
overlay.changes[0]?.path === proposedFile.displayPath;
|
|
68
|
+
if (baselineWeighted &&
|
|
69
|
+
proposedWeighted &&
|
|
70
|
+
isolatedProposal &&
|
|
71
|
+
baselineSelector &&
|
|
72
|
+
proposedSelector) {
|
|
73
|
+
const compared = await analyzer.compare({
|
|
74
|
+
...context,
|
|
75
|
+
before: baselineSelector,
|
|
76
|
+
after: proposedSelector,
|
|
77
|
+
});
|
|
78
|
+
return { mode: 'compare', ...summary(compared) };
|
|
79
|
+
}
|
|
80
|
+
if (baselineWeighted && baselineSelector) {
|
|
81
|
+
const evaluated = await analyzer.evaluate({ ...context, source: baselineSelector });
|
|
82
|
+
return {
|
|
83
|
+
mode: 'baseline',
|
|
84
|
+
...summary(evaluated),
|
|
85
|
+
...(overlay === undefined
|
|
86
|
+
? {}
|
|
87
|
+
: {
|
|
88
|
+
proposedUnresolved: 'The proposal changes multiple sources or adds/removes the weighted decision; use probability_compare with explicit source selectors',
|
|
89
|
+
}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (proposedWeighted && proposedSelector) {
|
|
93
|
+
const evaluated = await analyzer.evaluate({ ...context, source: proposedSelector });
|
|
94
|
+
return {
|
|
95
|
+
mode: 'proposed',
|
|
96
|
+
...summary(evaluated),
|
|
97
|
+
baselineUnresolved: 'The baseline has no matching weighted decision',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
mode: 'unresolved',
|
|
102
|
+
reason: 'Weighted decision source could not be selected for probability analysis',
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { hashCanonical } from '../core/canonical.js';
|
|
2
|
+
import { inspectDecisionCost } from './cost.js';
|
|
3
|
+
import { evaluateDecisionGates } from './evaluation.js';
|
|
4
|
+
import { inspectDecisionLifecycle } from './lifecycle.js';
|
|
5
|
+
import { decisionSourceInventory, decisionTargetCatalog, } from './source-inventory.js';
|
|
6
|
+
export function inspectDecisionScenario(snapshot, id, scenario) {
|
|
7
|
+
const source = decisionSourceInventory(snapshot).decisions.find((candidate) => candidate.id === id);
|
|
8
|
+
if (source === undefined)
|
|
9
|
+
return null;
|
|
10
|
+
return {
|
|
11
|
+
targeting: decisionTargetCatalog(source),
|
|
12
|
+
gates: evaluateDecisionGates(snapshot, id, scenario),
|
|
13
|
+
cost: inspectDecisionCost(snapshot, source, scenario),
|
|
14
|
+
lifecycle: inspectDecisionLifecycle(snapshot, source, scenario),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
function differences(before, after) {
|
|
18
|
+
if (before === null || after === null)
|
|
19
|
+
return before === after ? [] : ['definition'];
|
|
20
|
+
const fields = [
|
|
21
|
+
['targeting', before.targeting, after.targeting],
|
|
22
|
+
['category_allowed', before.gates.categoryAllowed.state, after.gates.categoryAllowed.state],
|
|
23
|
+
['decision_allowed', before.gates.decisionAllowed.state, after.gates.decisionAllowed.state],
|
|
24
|
+
['category_visible', before.gates.categoryVisible.state, after.gates.categoryVisible.state],
|
|
25
|
+
[
|
|
26
|
+
'decision_visible',
|
|
27
|
+
before.gates.decisionVisible?.state ?? null,
|
|
28
|
+
after.gates.decisionVisible?.state ?? null,
|
|
29
|
+
],
|
|
30
|
+
[
|
|
31
|
+
'category_available',
|
|
32
|
+
before.gates.categoryAvailable.state,
|
|
33
|
+
after.gates.categoryAvailable.state,
|
|
34
|
+
],
|
|
35
|
+
[
|
|
36
|
+
'decision_available',
|
|
37
|
+
before.gates.decisionAvailable.state,
|
|
38
|
+
after.gates.decisionAvailable.state,
|
|
39
|
+
],
|
|
40
|
+
['eligible', before.gates.eligible, after.gates.eligible],
|
|
41
|
+
['visible', before.gates.visible, after.gates.visible],
|
|
42
|
+
['available', before.gates.available, after.gates.available],
|
|
43
|
+
['target_selection', before.gates.targetSelection.state, after.gates.targetSelection.state],
|
|
44
|
+
['target_trigger', before.gates.targetTrigger.state, after.gates.targetTrigger.state],
|
|
45
|
+
['cost_kind', before.cost.kind, after.cost.kind],
|
|
46
|
+
['cost_amount', before.cost.amount, after.cost.amount],
|
|
47
|
+
['affordable', before.cost.affordable, after.cost.affordable],
|
|
48
|
+
[
|
|
49
|
+
'payment_effects',
|
|
50
|
+
before.cost.paymentEffects.map(({ resource, amount, operation, scope }) => ({
|
|
51
|
+
resource,
|
|
52
|
+
amount,
|
|
53
|
+
operation,
|
|
54
|
+
scope,
|
|
55
|
+
})),
|
|
56
|
+
after.cost.paymentEffects.map(({ resource, amount, operation, scope }) => ({
|
|
57
|
+
resource,
|
|
58
|
+
amount,
|
|
59
|
+
operation,
|
|
60
|
+
scope,
|
|
61
|
+
})),
|
|
62
|
+
],
|
|
63
|
+
['lifecycle', before.lifecycle, after.lifecycle],
|
|
64
|
+
];
|
|
65
|
+
return fields
|
|
66
|
+
.filter(([, left, right]) => hashCanonical(left) !== hashCanonical(right))
|
|
67
|
+
.map(([field]) => field);
|
|
68
|
+
}
|
|
69
|
+
/** Compare declared scenarios against two revision-pinned source snapshots. */
|
|
70
|
+
export function compareDecisionScenarios(before, after, id, scenarios) {
|
|
71
|
+
if (before.workspaceId !== after.workspaceId)
|
|
72
|
+
throw new Error('Decision comparison crosses workspace boundaries');
|
|
73
|
+
if (scenarios.length < 1 || scenarios.length > 64)
|
|
74
|
+
throw new RangeError('Decision comparison requires 1-64 scenarios');
|
|
75
|
+
if (id.length === 0 || id.length > 512)
|
|
76
|
+
throw new RangeError('Decision identifier is invalid');
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
const cases = scenarios.map((scenario) => {
|
|
79
|
+
if (seen.has(scenario.id))
|
|
80
|
+
throw new Error(`Duplicate decision scenario: ${scenario.id}`);
|
|
81
|
+
seen.add(scenario.id);
|
|
82
|
+
const baseline = inspectDecisionScenario(before, id, scenario);
|
|
83
|
+
const proposed = inspectDecisionScenario(after, id, scenario);
|
|
84
|
+
return {
|
|
85
|
+
scenarioId: scenario.id,
|
|
86
|
+
before: baseline,
|
|
87
|
+
after: proposed,
|
|
88
|
+
changed: differences(baseline, proposed),
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
return {
|
|
92
|
+
id,
|
|
93
|
+
beforeRevision: before.revision,
|
|
94
|
+
afterRevision: after.revision,
|
|
95
|
+
complete: before.complete &&
|
|
96
|
+
after.complete &&
|
|
97
|
+
cases.every(({ before: baseline, after: proposed }) => (baseline !== null || proposed !== null) &&
|
|
98
|
+
(baseline?.gates.complete ?? true) &&
|
|
99
|
+
(proposed?.gates.complete ?? true) &&
|
|
100
|
+
(baseline?.cost.unresolved.length ?? 0) === 0 &&
|
|
101
|
+
(proposed?.cost.unresolved.length ?? 0) === 0 &&
|
|
102
|
+
(baseline?.lifecycle.unresolved.length ?? 0) === 0 &&
|
|
103
|
+
(proposed?.lifecycle.unresolved.length ?? 0) === 0),
|
|
104
|
+
cases,
|
|
105
|
+
};
|
|
106
|
+
}
|