hoi4-agent-tools 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +21 -18
  3. package/dist/hoi4_agent_tools/core/engine.js +12 -0
  4. package/dist/hoi4_agent_tools/core/workspace.js +31 -3
  5. package/dist/hoi4_agent_tools/event/algorithms.js +208 -0
  6. package/dist/hoi4_agent_tools/event/artifact-validation.js +572 -0
  7. package/dist/hoi4_agent_tools/event/compare.js +413 -0
  8. package/dist/hoi4_agent_tools/event/graph.js +1586 -0
  9. package/dist/hoi4_agent_tools/event/index.js +10 -0
  10. package/dist/hoi4_agent_tools/event/layout.js +218 -0
  11. package/dist/hoi4_agent_tools/event/limits.js +41 -0
  12. package/dist/hoi4_agent_tools/event/model.js +2 -0
  13. package/dist/hoi4_agent_tools/event/queries.js +643 -0
  14. package/dist/hoi4_agent_tools/event/render.js +443 -0
  15. package/dist/hoi4_agent_tools/event/scc.js +72 -0
  16. package/dist/hoi4_agent_tools/event/service.js +1147 -0
  17. package/dist/hoi4_agent_tools/event/source-analysis.js +1458 -0
  18. package/dist/hoi4_agent_tools/focus/native-effects.js +3 -1
  19. package/dist/hoi4_agent_tools/gui/layout.js +122 -27
  20. package/dist/hoi4_agent_tools/gui/renderer.js +16 -0
  21. package/dist/hoi4_agent_tools/mcp/resources/register.js +1 -1
  22. package/dist/hoi4_agent_tools/mcp/server/create.js +4 -2
  23. package/dist/hoi4_agent_tools/mcp/server/result.js +8 -14
  24. package/dist/hoi4_agent_tools/mcp/tools/event.js +459 -0
  25. package/dist/hoi4_agent_tools/mcp/tools/focus.js +6 -3
  26. package/dist/hoi4_agent_tools/mcp/tools/gui.js +6 -3
  27. package/dist/hoi4_agent_tools/mcp/tools/map.js +6 -3
  28. package/dist/hoi4_agent_tools/runtime.js +112 -2
  29. package/dist/hoi4_agent_tools/schemas/common.js +5 -1
  30. package/dist/hoi4_agent_tools/schemas/event.js +95 -0
  31. package/dist/hoi4_agent_tools/version.js +1 -1
  32. package/docs/README.md +1 -0
  33. package/docs/development.md +10 -0
  34. package/docs/events.md +113 -0
  35. package/docs/focus.md +1 -1
  36. package/docs/gui.md +3 -1
  37. package/docs/map.md +1 -1
  38. package/docs/setup.md +10 -7
  39. package/package.json +7 -3
  40. package/server.json +3 -3
  41. package/dist/hoi4_agent_tools/mcp/tools/mods.js +0 -54
@@ -0,0 +1,459 @@
1
+ import { z } from 'zod/v4';
2
+ import { publicArtifactLink } from '../../core/artifacts.js';
3
+ import { compareCodeUnits } from '../../core/canonical.js';
4
+ import { emptyServiceResult } from '../../core/result.js';
5
+ import { EventChainViewer, eventDiagnostics, } from '../../event/service.js';
6
+ import { eventDirectionSchema, eventGraphReferenceSchema, eventImpactSubjectSchema, eventInspectModeSchema, eventProposedSourceSchema, eventRenderViewSchema, eventSelectorSchema, eventStateSubjectSchema, } from '../../schemas/event.js';
7
+ import { workspaceIdSchema } from '../../schemas/common.js';
8
+ import { compactValidatedInputSchema } from '../server/context-schemas.js';
9
+ import { nonNegativeIntegerSchema, sha256Schema } from '../server/output-schemas.js';
10
+ import { progressReporter } from '../server/progress.js';
11
+ import { errorResult, setInlineFilesScanned, strictOperationResultSchema, toolResult, } from '../server/result.js';
12
+ const nestedSelectorSchema = compactValidatedInputSchema(eventSelectorSchema, 'Event selector; see docs/events.md.');
13
+ const nestedStateSubjectSchema = compactValidatedInputSchema(eventStateSubjectSchema, 'State kind and name.');
14
+ const nestedImpactSubjectSchema = compactValidatedInputSchema(eventImpactSubjectSchema, 'Impact kind and name.');
15
+ const nestedGraphReferenceSchema = compactValidatedInputSchema(eventGraphReferenceSchema, 'Cached revision or graph artifact URI.');
16
+ const nestedProposedSourceSchema = compactValidatedInputSchema(eventProposedSourceSchema, 'In-memory source overlay; never written.');
17
+ const eventInspectInputSchema = z
18
+ .object({
19
+ workspaceId: workspaceIdSchema,
20
+ mode: eventInspectModeSchema,
21
+ selector: nestedSelectorSchema.optional(),
22
+ from: nestedSelectorSchema.optional(),
23
+ to: nestedSelectorSchema.optional(),
24
+ direction: eventDirectionSchema.optional(),
25
+ maxDepth: z.number().int().min(1).max(64).optional(),
26
+ maxNodes: z.number().int().min(1).max(5_000).optional(),
27
+ maxEdges: z.number().int().min(1).max(20_000).optional(),
28
+ expandHelpers: z.boolean().optional(),
29
+ stateSubject: nestedStateSubjectSchema.optional(),
30
+ impactSubject: nestedImpactSubjectSchema.optional(),
31
+ refresh: z.boolean().optional(),
32
+ })
33
+ .strict()
34
+ .superRefine((value, context) => {
35
+ if (value.mode === 'trace' && value.selector === undefined) {
36
+ context.addIssue({ code: 'custom', path: ['selector'], message: 'Trace requires selector' });
37
+ }
38
+ if (value.mode === 'explain_path' && (value.from === undefined || value.to === undefined)) {
39
+ context.addIssue({
40
+ code: 'custom',
41
+ path: value.from === undefined ? ['from'] : ['to'],
42
+ message: 'Path explanation requires from and to',
43
+ });
44
+ }
45
+ if (value.mode === 'impact' && value.impactSubject === undefined) {
46
+ context.addIssue({
47
+ code: 'custom',
48
+ path: ['impactSubject'],
49
+ message: 'Impact analysis requires impactSubject',
50
+ });
51
+ }
52
+ });
53
+ const eventRenderInputSchema = z
54
+ .object({
55
+ workspaceId: workspaceIdSchema,
56
+ view: eventRenderViewSchema,
57
+ selector: nestedSelectorSchema.optional(),
58
+ direction: eventDirectionSchema.optional(),
59
+ maxDepth: z.number().int().min(1).max(64).optional(),
60
+ maxNodes: z.number().int().min(1).max(240).optional(),
61
+ expandHelpers: z.boolean().optional(),
62
+ includeHtml: z.boolean().optional(),
63
+ refresh: z.boolean().optional(),
64
+ })
65
+ .strict();
66
+ const eventCompareInputSchema = z
67
+ .object({
68
+ workspaceId: workspaceIdSchema,
69
+ before: nestedGraphReferenceSchema.optional(),
70
+ after: nestedGraphReferenceSchema.optional(),
71
+ proposedSources: z.array(nestedProposedSourceSchema).min(1).max(64).optional(),
72
+ render: z.boolean().optional(),
73
+ maxRenderNodes: z.number().int().min(1).max(240).optional(),
74
+ refresh: z.boolean().optional(),
75
+ })
76
+ .strict()
77
+ .superRefine((value, context) => {
78
+ if (value.after !== undefined && value.proposedSources !== undefined) {
79
+ context.addIssue({
80
+ code: 'custom',
81
+ path: ['after'],
82
+ message: 'after and proposedSources are mutually exclusive',
83
+ });
84
+ }
85
+ });
86
+ const eventGraphCountsSchema = z
87
+ .object({
88
+ events: nonNegativeIntegerSchema,
89
+ options: nonNegativeIntegerSchema,
90
+ entries: nonNegativeIntegerSchema,
91
+ helpers: nonNegativeIntegerSchema,
92
+ unresolvedNodes: nonNegativeIntegerSchema,
93
+ terminals: nonNegativeIntegerSchema,
94
+ edges: nonNegativeIntegerSchema,
95
+ derivedEdges: nonNegativeIntegerSchema,
96
+ stateAccesses: nonNegativeIntegerSchema,
97
+ issues: nonNegativeIntegerSchema,
98
+ diagnostics: nonNegativeIntegerSchema,
99
+ blockingDiagnostics: nonNegativeIntegerSchema,
100
+ skippedSources: nonNegativeIntegerSchema,
101
+ artifacts: nonNegativeIntegerSchema,
102
+ })
103
+ .strict();
104
+ const inspectBoundarySchema = z
105
+ .object({
106
+ direction: eventDirectionSchema,
107
+ maxDepth: nonNegativeIntegerSchema,
108
+ maxNodes: nonNegativeIntegerSchema,
109
+ maxEdges: nonNegativeIntegerSchema,
110
+ expandHelpers: z.boolean(),
111
+ refresh: z.boolean(),
112
+ })
113
+ .strict();
114
+ const renderHashSchema = z
115
+ .object({
116
+ json: sha256Schema,
117
+ svg: sha256Schema,
118
+ png: sha256Schema,
119
+ html: sha256Schema.optional(),
120
+ })
121
+ .strict();
122
+ const eventInspectOutputSchema = strictOperationResultSchema(z
123
+ .object({
124
+ mode: eventInspectModeSchema,
125
+ revision: sha256Schema,
126
+ graphHash: sha256Schema,
127
+ counts: eventGraphCountsSchema,
128
+ boundary: inspectBoundarySchema,
129
+ })
130
+ .strict());
131
+ const eventRenderOutputSchema = strictOperationResultSchema(z
132
+ .object({
133
+ view: eventRenderViewSchema,
134
+ revision: sha256Schema,
135
+ graphHash: sha256Schema,
136
+ layoutHash: sha256Schema,
137
+ hashes: renderHashSchema,
138
+ counts: eventGraphCountsSchema.extend({
139
+ selectedNodes: nonNegativeIntegerSchema,
140
+ omittedNodes: nonNegativeIntegerSchema,
141
+ branchRenders: nonNegativeIntegerSchema,
142
+ }),
143
+ boundary: z
144
+ .object({
145
+ direction: eventDirectionSchema,
146
+ maxDepth: nonNegativeIntegerSchema,
147
+ maxNodes: nonNegativeIntegerSchema,
148
+ expandHelpers: z.boolean(),
149
+ includeHtml: z.boolean(),
150
+ refresh: z.boolean(),
151
+ })
152
+ .strict(),
153
+ })
154
+ .strict());
155
+ const eventCompareOutputSchema = strictOperationResultSchema(z
156
+ .object({
157
+ beforeRevision: sha256Schema,
158
+ afterRevision: sha256Schema,
159
+ beforeGraphHash: sha256Schema,
160
+ afterGraphHash: sha256Schema,
161
+ renderHashes: renderHashSchema.optional(),
162
+ counts: z
163
+ .object({
164
+ changes: nonNegativeIntegerSchema,
165
+ addedNodes: nonNegativeIntegerSchema,
166
+ removedNodes: nonNegativeIntegerSchema,
167
+ changedNodes: nonNegativeIntegerSchema,
168
+ addedEdges: nonNegativeIntegerSchema,
169
+ removedEdges: nonNegativeIntegerSchema,
170
+ changedEdges: nonNegativeIntegerSchema,
171
+ addedStateAccesses: nonNegativeIntegerSchema,
172
+ removedStateAccesses: nonNegativeIntegerSchema,
173
+ changedStateAccesses: nonNegativeIntegerSchema,
174
+ addedStateLinks: nonNegativeIntegerSchema,
175
+ removedStateLinks: nonNegativeIntegerSchema,
176
+ changedStateLinks: nonNegativeIntegerSchema,
177
+ addedDiagnostics: nonNegativeIntegerSchema,
178
+ resolvedDiagnostics: nonNegativeIntegerSchema,
179
+ addedUnresolved: nonNegativeIntegerSchema,
180
+ resolvedUnresolved: nonNegativeIntegerSchema,
181
+ disconnectedRoots: nonNegativeIntegerSchema,
182
+ disconnectedBranches: nonNegativeIntegerSchema,
183
+ disconnectedTerminals: nonNegativeIntegerSchema,
184
+ beforeSkippedSources: nonNegativeIntegerSchema,
185
+ afterSkippedSources: nonNegativeIntegerSchema,
186
+ artifacts: nonNegativeIntegerSchema,
187
+ })
188
+ .strict(),
189
+ boundary: z
190
+ .object({
191
+ proposedSources: nonNegativeIntegerSchema,
192
+ render: z.boolean(),
193
+ maxRenderNodes: nonNegativeIntegerSchema,
194
+ refresh: z.boolean(),
195
+ })
196
+ .strict(),
197
+ })
198
+ .strict());
199
+ const readOnlyEventTool = {
200
+ readOnlyHint: true,
201
+ destructiveHint: false,
202
+ idempotentHint: true,
203
+ openWorldHint: false,
204
+ };
205
+ function graphCounts(graph, artifacts) {
206
+ const diagnostics = eventDiagnostics(graph);
207
+ return {
208
+ events: graph.statistics.eventCount,
209
+ options: graph.statistics.optionCount,
210
+ entries: graph.statistics.entryCount,
211
+ helpers: graph.statistics.helperCount,
212
+ unresolvedNodes: graph.statistics.unresolvedNodeCount,
213
+ terminals: graph.statistics.terminalCount,
214
+ edges: graph.statistics.edgeCount,
215
+ derivedEdges: graph.statistics.derivedEdgeCount,
216
+ stateAccesses: graph.statistics.stateAccessCount,
217
+ issues: graph.statistics.issueCount,
218
+ diagnostics: diagnostics.length,
219
+ blockingDiagnostics: diagnostics.filter(({ severity }) => severity === 'error' || severity === 'blocker').length,
220
+ skippedSources: graph.skippedSourceCount,
221
+ artifacts,
222
+ };
223
+ }
224
+ function graphValidation(graph) {
225
+ const counts = graphCounts(graph, 0);
226
+ return {
227
+ passed: graph.complete && counts.blockingDiagnostics === 0,
228
+ checks: [
229
+ {
230
+ id: 'event-analysis',
231
+ passed: graph.complete && counts.blockingDiagnostics === 0,
232
+ message: graph.complete
233
+ ? `${counts.blockingDiagnostics} blocking event-chain diagnostics; full evidence is linked`
234
+ : `${graph.skippedSourceCount} event-analysis source(s) were skipped; full evidence is linked`,
235
+ },
236
+ ],
237
+ };
238
+ }
239
+ function inspectRequest(input, context, signal) {
240
+ return {
241
+ workspaceId: input.workspaceId,
242
+ mode: input.mode,
243
+ ...(input.selector === undefined
244
+ ? {}
245
+ : { selector: input.selector }),
246
+ ...(input.from === undefined
247
+ ? {}
248
+ : { from: input.from }),
249
+ ...(input.to === undefined ? {} : { to: input.to }),
250
+ ...(input.direction === undefined ? {} : { direction: input.direction }),
251
+ ...(input.maxDepth === undefined ? {} : { maxDepth: input.maxDepth }),
252
+ ...(input.maxNodes === undefined ? {} : { maxNodes: input.maxNodes }),
253
+ ...(input.maxEdges === undefined ? {} : { maxEdges: input.maxEdges }),
254
+ ...(input.expandHelpers === undefined ? {} : { expandHelpers: input.expandHelpers }),
255
+ ...(input.stateSubject === undefined ? {} : { stateSubject: input.stateSubject }),
256
+ ...(input.impactSubject === undefined ? {} : { impactSubject: input.impactSubject }),
257
+ ...(input.refresh === undefined ? {} : { refresh: input.refresh }),
258
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
259
+ signal,
260
+ };
261
+ }
262
+ function renderRequest(input, context, signal) {
263
+ return {
264
+ workspaceId: input.workspaceId,
265
+ view: input.view,
266
+ ...(input.selector === undefined
267
+ ? {}
268
+ : { selector: input.selector }),
269
+ ...(input.direction === undefined ? {} : { direction: input.direction }),
270
+ ...(input.maxDepth === undefined ? {} : { maxDepth: input.maxDepth }),
271
+ ...(input.maxNodes === undefined ? {} : { maxNodes: input.maxNodes }),
272
+ ...(input.expandHelpers === undefined ? {} : { expandHelpers: input.expandHelpers }),
273
+ ...(input.includeHtml === undefined ? {} : { includeHtml: input.includeHtml }),
274
+ ...(input.refresh === undefined ? {} : { refresh: input.refresh }),
275
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
276
+ signal,
277
+ };
278
+ }
279
+ function compareRequest(input, context, signal) {
280
+ return {
281
+ workspaceId: input.workspaceId,
282
+ ...(input.before === undefined
283
+ ? {}
284
+ : { before: input.before }),
285
+ ...(input.after === undefined
286
+ ? {}
287
+ : { after: input.after }),
288
+ ...(input.proposedSources === undefined
289
+ ? {}
290
+ : {
291
+ proposedSources: input.proposedSources,
292
+ }),
293
+ ...(input.render === undefined ? {} : { render: input.render }),
294
+ ...(input.maxRenderNodes === undefined ? {} : { maxRenderNodes: input.maxRenderNodes }),
295
+ ...(input.refresh === undefined ? {} : { refresh: input.refresh }),
296
+ ...(context.principal === undefined ? {} : { principal: context.principal }),
297
+ signal,
298
+ };
299
+ }
300
+ export function registerEventTools(server, engine, context) {
301
+ const viewer = new EventChainViewer(engine);
302
+ server.registerTool('hoi4.event_inspect', {
303
+ title: 'Inspect event chains',
304
+ description: 'Scan, find roots, trace, explain paths, inspect state flow, lint, or assess impact. Full source-linked reports are resources.',
305
+ inputSchema: eventInspectInputSchema,
306
+ outputSchema: eventInspectOutputSchema,
307
+ annotations: readOnlyEventTool,
308
+ }, async (input, extra) => {
309
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
310
+ try {
311
+ const progress = progressReporter(extra);
312
+ await progress.report(0, 3, 'Analyzing event chain');
313
+ const inspected = await viewer.inspect(inspectRequest({ ...input, workspaceId }, context, progress.signal));
314
+ await progress.report(2, 3, 'Linking complete event analysis');
315
+ const result = emptyServiceResult(workspaceId, {
316
+ mode: inspected.mode,
317
+ revision: inspected.graph.revision,
318
+ graphHash: inspected.graphHash,
319
+ counts: graphCounts(inspected.graph, inspected.artifacts.length),
320
+ boundary: {
321
+ direction: input.direction ?? 'both',
322
+ maxDepth: input.maxDepth ?? 8,
323
+ maxNodes: input.maxNodes ?? 500,
324
+ maxEdges: input.maxEdges ?? 2_000,
325
+ expandHelpers: input.expandHelpers ?? (input.mode === 'explain_path' ? true : false),
326
+ refresh: input.refresh ?? input.mode === 'scan',
327
+ },
328
+ });
329
+ result.code = inspected.graph.complete ? 'EVENT_INSPECTED' : 'EVENT_INSPECTED_PARTIAL';
330
+ setInlineFilesScanned(result, inspected.graph.filesScanned);
331
+ result.artifacts = inspected.artifacts.map(publicArtifactLink);
332
+ result.validation = graphValidation(inspected.graph);
333
+ await progress.report(3, 3, 'Event inspection complete');
334
+ return toolResult(result);
335
+ }
336
+ catch (error) {
337
+ return errorResult(error, workspaceId);
338
+ }
339
+ });
340
+ server.registerTool('hoi4.event_render', {
341
+ title: 'Render event chains',
342
+ description: 'Render deterministic JSON, SVG, PNG, and optional HTML for an event-chain view. Complete artifacts retain source links.',
343
+ inputSchema: eventRenderInputSchema,
344
+ outputSchema: eventRenderOutputSchema,
345
+ annotations: readOnlyEventTool,
346
+ }, async (input, extra) => {
347
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
348
+ try {
349
+ const progress = progressReporter(extra);
350
+ await progress.report(0, 3, 'Rendering event-chain view');
351
+ const rendered = await viewer.renderAndStore(renderRequest({ ...input, workspaceId }, context, progress.signal));
352
+ await progress.report(2, 3, 'Linking complete event renders');
353
+ const result = emptyServiceResult(workspaceId, {
354
+ view: rendered.render.view,
355
+ revision: rendered.graph.revision,
356
+ graphHash: rendered.graphHash,
357
+ layoutHash: rendered.render.layout.layoutHash,
358
+ hashes: rendered.render.hashes,
359
+ counts: {
360
+ ...graphCounts(rendered.graph, rendered.artifacts.length),
361
+ selectedNodes: rendered.render.selectedNodeIds.length,
362
+ omittedNodes: rendered.render.omittedNodeCount,
363
+ branchRenders: rendered.branches.length,
364
+ },
365
+ boundary: {
366
+ direction: input.direction ?? 'both',
367
+ maxDepth: input.maxDepth ?? 4,
368
+ maxNodes: input.maxNodes ?? 120,
369
+ expandHelpers: input.expandHelpers ?? false,
370
+ includeHtml: input.includeHtml ?? false,
371
+ refresh: input.refresh ?? false,
372
+ },
373
+ });
374
+ result.code = rendered.graph.complete ? 'EVENT_RENDERED' : 'EVENT_RENDERED_PARTIAL';
375
+ setInlineFilesScanned(result, rendered.graph.filesScanned);
376
+ result.artifacts = rendered.artifacts.map(publicArtifactLink);
377
+ result.validation = graphValidation(rendered.graph);
378
+ await progress.report(3, 3, 'Event render complete');
379
+ return toolResult(result);
380
+ }
381
+ catch (error) {
382
+ return errorResult(error, workspaceId);
383
+ }
384
+ });
385
+ server.registerTool('hoi4.event_compare', {
386
+ title: 'Compare event chains',
387
+ description: 'Compare cached, artifact-backed, current, or in-memory proposed event graphs without writing source. Full changes are resources.',
388
+ inputSchema: eventCompareInputSchema,
389
+ outputSchema: eventCompareOutputSchema,
390
+ annotations: readOnlyEventTool,
391
+ }, async (input, extra) => {
392
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
393
+ try {
394
+ const progress = progressReporter(extra);
395
+ await progress.report(0, 3, 'Comparing event-chain graphs');
396
+ const compared = await viewer.compareAndStore(compareRequest({ ...input, workspaceId }, context, progress.signal));
397
+ await progress.report(2, 3, 'Linking complete event comparison');
398
+ const { comparison } = compared;
399
+ const result = emptyServiceResult(workspaceId, {
400
+ beforeRevision: comparison.beforeRevision,
401
+ afterRevision: comparison.afterRevision,
402
+ beforeGraphHash: comparison.beforeGraphHash,
403
+ afterGraphHash: comparison.afterGraphHash,
404
+ ...(compared.render === undefined ? {} : { renderHashes: compared.render.hashes }),
405
+ counts: {
406
+ changes: comparison.changes.length,
407
+ addedNodes: comparison.addedNodeIds.length,
408
+ removedNodes: comparison.removedNodeIds.length,
409
+ changedNodes: comparison.changedNodeIds.length,
410
+ addedEdges: comparison.addedEdgeIds.length,
411
+ removedEdges: comparison.removedEdgeIds.length,
412
+ changedEdges: comparison.changedEdgeIds.length,
413
+ addedStateAccesses: comparison.addedStateAccessIds.length,
414
+ removedStateAccesses: comparison.removedStateAccessIds.length,
415
+ changedStateAccesses: comparison.changedStateAccessIds.length,
416
+ addedStateLinks: comparison.addedStateLinkIds.length,
417
+ removedStateLinks: comparison.removedStateLinkIds.length,
418
+ changedStateLinks: comparison.changedStateLinkIds.length,
419
+ addedDiagnostics: comparison.addedIssueIds.length,
420
+ resolvedDiagnostics: comparison.resolvedIssueIds.length,
421
+ addedUnresolved: comparison.addedUnresolvedIds.length,
422
+ resolvedUnresolved: comparison.resolvedUnresolvedIds.length,
423
+ disconnectedRoots: comparison.newlyDisconnectedRootIds.length,
424
+ disconnectedBranches: comparison.newlyDisconnectedBranchIds.length,
425
+ disconnectedTerminals: comparison.newlyDisconnectedTerminalIds.length,
426
+ beforeSkippedSources: compared.before.skippedSourceCount,
427
+ afterSkippedSources: compared.after.skippedSourceCount,
428
+ artifacts: compared.artifacts.length,
429
+ },
430
+ boundary: {
431
+ proposedSources: input.proposedSources?.length ?? 0,
432
+ render: input.render ?? true,
433
+ maxRenderNodes: input.maxRenderNodes ?? 120,
434
+ refresh: compared.refresh,
435
+ },
436
+ });
437
+ result.code =
438
+ compared.before.complete && compared.after.complete
439
+ ? 'EVENT_COMPARED'
440
+ : 'EVENT_COMPARED_PARTIAL';
441
+ setInlineFilesScanned(result, [...new Set([...compared.before.filesScanned, ...compared.after.filesScanned])].sort(compareCodeUnits));
442
+ result.artifacts = compared.artifacts.map(publicArtifactLink);
443
+ const beforeValidation = graphValidation(compared.before);
444
+ const afterValidation = graphValidation(compared.after);
445
+ result.validation = {
446
+ passed: beforeValidation.passed && afterValidation.passed,
447
+ checks: [...beforeValidation.checks, ...afterValidation.checks].map((check, index) => ({
448
+ ...check,
449
+ id: `${index === 0 ? 'before' : 'after'}-${check.id}`,
450
+ })),
451
+ };
452
+ await progress.report(3, 3, 'Event comparison complete');
453
+ return toolResult(result);
454
+ }
455
+ catch (error) {
456
+ return errorResult(error, workspaceId);
457
+ }
458
+ });
459
+ }
@@ -451,7 +451,8 @@ export function registerFocusTools(server, engine, context) {
451
451
  outputSchema: focusInspectOutputSchema,
452
452
  annotations: artifactProducing,
453
453
  }, async (input, extra) => {
454
- const { workspaceId, relativePath } = input;
454
+ const { workspaceId: requestedWorkspaceId, relativePath } = input;
455
+ const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
455
456
  try {
456
457
  const progress = progressReporter(extra);
457
458
  await progress.report(0, 3, 'Building shared workspace index');
@@ -667,7 +668,8 @@ export function registerFocusTools(server, engine, context) {
667
668
  outputSchema: focusRenderOutputSchema,
668
669
  annotations: artifactProducing,
669
670
  }, async (input, extra) => {
670
- const { workspaceId, relativePath } = input;
671
+ const { workspaceId: requestedWorkspaceId, relativePath } = input;
672
+ const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
671
673
  try {
672
674
  const progress = progressReporter(extra);
673
675
  await progress.report(0, 3, 'Importing and indexing focus source');
@@ -790,7 +792,8 @@ export function registerFocusTools(server, engine, context) {
790
792
  openWorldHint: false,
791
793
  },
792
794
  }, async (input, extra) => {
793
- const { workspaceId, relativePath, createIfMissing, horizontalSpacing, verticalSpacing, padding, reviewScale, } = input;
795
+ const { workspaceId: requestedWorkspaceId, relativePath, createIfMissing, horizontalSpacing, verticalSpacing, padding, reviewScale, } = input;
796
+ const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
794
797
  try {
795
798
  requireServerScope(context, 'hoi4:write');
796
799
  const progress = progressReporter(extra);
@@ -149,7 +149,8 @@ export function registerGuiTools(server, engine, context) {
149
149
  inputSchema: guiInspectInput,
150
150
  outputSchema: guiScanOutputSchema,
151
151
  annotations: artifactProducing,
152
- }, async ({ workspaceId, windowName, scenario, relatedScenarios }, extra) => {
152
+ }, async ({ workspaceId: requestedWorkspaceId, windowName, scenario, relatedScenarios }, extra) => {
153
+ const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
153
154
  try {
154
155
  const progress = progressReporter(extra);
155
156
  await progress.report(0, 3, 'Building shared index and GUI inspection');
@@ -253,7 +254,8 @@ export function registerGuiTools(server, engine, context) {
253
254
  }
254
255
  });
255
256
  const render = async (input, extra, code) => {
256
- const { workspaceId, windowName } = input;
257
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
258
+ const { windowName } = input;
257
259
  try {
258
260
  const progress = progressReporter(extra);
259
261
  await progress.report(0, 4, 'Building shared index and GUI source graph');
@@ -444,7 +446,8 @@ export function registerGuiTools(server, engine, context) {
444
446
  openWorldHint: false,
445
447
  },
446
448
  }, async (input, extra) => {
447
- const { workspaceId, relativePath, windowName, scenario } = input;
449
+ const workspaceId = engine.resolver.resolveWorkspaceId(input.workspaceId, context.principal);
450
+ const { relativePath, windowName, scenario } = input;
448
451
  try {
449
452
  requireServerScope(context, 'hoi4:write');
450
453
  const progress = progressReporter(extra);
@@ -364,7 +364,8 @@ export function registerMapTools(server, engine, context) {
364
364
  .strict(),
365
365
  outputSchema: mapWorkspaceInspectOutputSchema,
366
366
  annotations: artifactProducing,
367
- }, async ({ workspaceId, provinceIds, stateIds, regionIds, allocationRequests }, extra) => {
367
+ }, async ({ workspaceId: requestedWorkspaceId, provinceIds, stateIds, regionIds, allocationRequests }, extra) => {
368
+ const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
368
369
  try {
369
370
  const progress = progressReporter(extra);
370
371
  await progress.report(0, 3, 'Inspecting and validating the map');
@@ -512,7 +513,8 @@ export function registerMapTools(server, engine, context) {
512
513
  .strict(),
513
514
  outputSchema: mapRenderOutputSchema,
514
515
  annotations: artifactProducing,
515
- }, async ({ workspaceId, layer, overlays, scale }, extra) => {
516
+ }, async ({ workspaceId: requestedWorkspaceId, layer, overlays, scale }, extra) => {
517
+ const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
516
518
  try {
517
519
  const progress = progressReporter(extra);
518
520
  await progress.report(0, 3, 'Rendering offline map layer');
@@ -557,7 +559,8 @@ export function registerMapTools(server, engine, context) {
557
559
  idempotentHint: false,
558
560
  openWorldHint: false,
559
561
  },
560
- }, async ({ workspaceId, operations, diffScale }, extra) => {
562
+ }, async ({ workspaceId: requestedWorkspaceId, operations, diffScale }, extra) => {
563
+ const workspaceId = engine.resolver.resolveWorkspaceId(requestedWorkspaceId, context.principal);
561
564
  try {
562
565
  requireServerScope(context, 'hoi4:write');
563
566
  const progress = progressReporter(extra);
@@ -1,9 +1,11 @@
1
+ import { stat } from 'node:fs/promises';
1
2
  import path from 'node:path';
2
3
  import { homedir } from 'node:os';
3
- import { loadConfiguration } from './core/configuration.js';
4
+ import { loadConfiguration, serverConfigurationSchema, } from './core/configuration.js';
4
5
  import { CoreEngine } from './core/engine.js';
5
6
  import { ServiceError } from './core/result.js';
6
7
  import { WorkspaceResolver } from './core/workspace.js';
8
+ import { CONFIG_VERSION } from './version.js';
7
9
  export function defaultConfigurationPath() {
8
10
  const configRoot = process.env.APPDATA ?? process.env.XDG_CONFIG_HOME ?? path.join(homedir(), '.config');
9
11
  return path.join(configRoot, 'hoi4-agent-tools', 'config.json');
@@ -21,8 +23,116 @@ export function configurationPath(arguments_ = process.argv.slice(2)) {
21
23
  return path.resolve(process.env.HOI4_AGENT_CONFIG);
22
24
  return defaultConfigurationPath();
23
25
  }
26
+ async function isDirectory(candidate) {
27
+ try {
28
+ return (await stat(candidate)).isDirectory();
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ async function isFile(candidate) {
35
+ try {
36
+ return (await stat(candidate)).isFile();
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ async function findCurrentModRoot(start = process.cwd()) {
43
+ for (let cursor = path.resolve(start);;) {
44
+ if ((await isFile(path.join(cursor, 'descriptor.mod'))) ||
45
+ ((await isDirectory(path.join(cursor, 'common'))) &&
46
+ ((await isDirectory(path.join(cursor, 'history'))) ||
47
+ (await isDirectory(path.join(cursor, 'interface'))) ||
48
+ (await isDirectory(path.join(cursor, 'map')))))) {
49
+ return cursor;
50
+ }
51
+ const parent = path.dirname(cursor);
52
+ if (parent === cursor)
53
+ break;
54
+ cursor = parent;
55
+ }
56
+ throw new ServiceError('AUTO_MOD_ROOT_NOT_FOUND', 'Start the MCP from inside a Hearts of Iron IV mod or configure a server workspace', { currentDirectory: path.resolve(start) });
57
+ }
58
+ async function firstExistingDirectory(candidates) {
59
+ for (const candidate of candidates) {
60
+ if (await isDirectory(candidate))
61
+ return path.resolve(candidate);
62
+ }
63
+ return undefined;
64
+ }
65
+ function automaticGameRootCandidates() {
66
+ const home = homedir();
67
+ return [
68
+ ...(process.env.HOI4_GAME_ROOT === undefined ? [] : [process.env.HOI4_GAME_ROOT]),
69
+ path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Steam', 'steamapps', 'common', 'Hearts of Iron IV'),
70
+ path.join(home, '.local', 'share', 'Steam', 'steamapps', 'common', 'Hearts of Iron IV'),
71
+ path.join(home, '.steam', 'steam', 'steamapps', 'common', 'Hearts of Iron IV'),
72
+ path.join(home, 'Library', 'Application Support', 'Steam', 'steamapps', 'common', 'Hearts of Iron IV'),
73
+ ];
74
+ }
75
+ function automaticStorageRoots() {
76
+ const home = homedir();
77
+ const dataRoot = process.env.XDG_DATA_HOME ?? process.env.LOCALAPPDATA ?? path.join(home, '.local', 'share');
78
+ const stateRoot = process.env.XDG_STATE_HOME ?? process.env.LOCALAPPDATA ?? path.join(home, '.local', 'state');
79
+ return {
80
+ serverStateRoot: path.join(stateRoot, 'hoi4-agent-tools', 'state'),
81
+ workspaceStorageRoot: path.join(dataRoot, 'hoi4-agent-tools', 'workspaces'),
82
+ };
83
+ }
84
+ function automaticWorkspaceId(modRoot) {
85
+ const slug = path
86
+ .basename(modRoot)
87
+ .normalize('NFKD')
88
+ .toLowerCase()
89
+ .replace(/[^a-z0-9]+/gu, '_')
90
+ .replace(/^_+|_+$/gu, '')
91
+ .slice(0, 48);
92
+ return `auto_${slug.length === 0 ? 'mod' : slug}`;
93
+ }
94
+ /** Build an in-memory configuration when a local agent starts inside a mod. */
95
+ export async function automaticConfiguration(start = process.cwd()) {
96
+ const modRoot = await findCurrentModRoot(start);
97
+ const gameRoot = await firstExistingDirectory(automaticGameRootCandidates());
98
+ const storage = automaticStorageRoots();
99
+ return serverConfigurationSchema.parse({
100
+ version: CONFIG_VERSION,
101
+ serverStateRoot: storage.serverStateRoot,
102
+ workspaceStorageRoot: storage.workspaceStorageRoot,
103
+ ...(gameRoot === undefined ? {} : { gameRoot }),
104
+ workspaces: [
105
+ {
106
+ id: automaticWorkspaceId(modRoot),
107
+ name: path.basename(modRoot),
108
+ root: modRoot,
109
+ },
110
+ ],
111
+ });
112
+ }
113
+ async function configurationExists(configPath) {
114
+ try {
115
+ return (await stat(configPath)).isFile();
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
24
121
  export async function createEngine(configPath = configurationPath()) {
25
- const configuration = await loadConfiguration(configPath);
122
+ let configuration;
123
+ try {
124
+ configuration = await loadConfiguration(configPath);
125
+ }
126
+ catch (error) {
127
+ const useAutomaticConfiguration = configPath === defaultConfigurationPath() &&
128
+ process.env.HOI4_AGENT_CONFIG === undefined &&
129
+ error instanceof ServiceError &&
130
+ error.code === 'CONFIG_READ_FAILED' &&
131
+ !(await configurationExists(configPath));
132
+ if (!useAutomaticConfiguration)
133
+ throw error;
134
+ configuration = await automaticConfiguration();
135
+ }
26
136
  const resolver = await WorkspaceResolver.create(configuration);
27
137
  const engine = new CoreEngine(resolver);
28
138
  await engine.initialize();