hoi4-agent-tools 1.0.0 → 1.1.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.
@@ -2,7 +2,7 @@ export const FOCUS_GRAPH_MAX_DEPTH = 256;
2
2
  export const FOCUS_GRAPH_MAX_NODES = 10_000;
3
3
  export const FOCUS_GRAPH_MAX_EDGES = 50_000;
4
4
  export const FOCUS_PAIR_COMPARISON_MAX = 2_000_000;
5
- export const FOCUS_LAYOUT_WORK_MAX = 500_000;
5
+ export const FOCUS_LAYOUT_WORK_MAX = 8_000_000;
6
6
  export const FOCUS_LINT_WORK_MAX = 2_000_000;
7
7
  export const FOCUS_DIAGNOSTIC_MAX = 2_000;
8
8
  export const FOCUS_CYCLE_SAMPLE_MAX = 128;
@@ -2,7 +2,7 @@ import { compareCodeUnits } from '../core/canonical.js';
2
2
  import { sortDiagnostics } from '../core/diagnostics.js';
3
3
  import { DiagnosticCollector } from '../core/diagnostic-collector.js';
4
4
  import { ServiceError } from '../core/result.js';
5
- import { focusConnectorSegmentsProperlyCross, focusNodesVisiblyOverlap } from './geometry.js';
5
+ import { flattenFocusConnectorCurve, focusConnectorCurve, focusConnectorPolylinesIntersect, focusNodesVisiblyOverlap, } from './geometry.js';
6
6
  import { layoutFocusTree } from './layout.js';
7
7
  import { FOCUS_CYCLE_SAMPLE_MAX, FOCUS_DIAGNOSTIC_MAX, FOCUS_GRAPH_MAX_DEPTH, FOCUS_GRAPH_MAX_EDGES, FOCUS_GRAPH_MAX_NODES, FOCUS_LINT_WORK_MAX, FOCUS_PAIR_COMPARISON_MAX, } from './limits.js';
8
8
  import { layoutNodeMap, } from './model.js';
@@ -641,7 +641,15 @@ export function lintFocusTree(plan, options = {}) {
641
641
  const child = positions.get(focus.id);
642
642
  return parent === undefined || child === undefined
643
643
  ? []
644
- : [{ parentId, childId: focus.id, parent, child }];
644
+ : [
645
+ {
646
+ parentId,
647
+ childId: focus.id,
648
+ parent,
649
+ child,
650
+ points: flattenFocusConnectorCurve(focusConnectorCurve(parent, child)),
651
+ },
652
+ ];
645
653
  }));
646
654
  const connectorPairCount = (connectors.length * (connectors.length - 1)) / 2;
647
655
  if (connectorPairCount > FOCUS_PAIR_COMPARISON_MAX) {
@@ -668,7 +676,7 @@ export function lintFocusTree(plan, options = {}) {
668
676
  first.childId === second.childId) {
669
677
  continue;
670
678
  }
671
- if (!focusConnectorSegmentsProperlyCross(first.parent, first.child, second.parent, second.child))
679
+ if (!focusConnectorPolylinesIntersect(first.points, second.points))
672
680
  continue;
673
681
  const focus = focuses.get(second.childId);
674
682
  if (focus === undefined)
@@ -27,6 +27,7 @@ function planningNode(focus) {
27
27
  aiStrategyIds: [...focus.ai.strategyIds],
28
28
  ...(focus.payoff === undefined ? {} : { payoff: focus.payoff }),
29
29
  ...(focus.terminalKind === undefined ? {} : { terminalKind: focus.terminalKind }),
30
+ ...(focus.position.mode === 'auto' ? { autoPosition: structuredClone(focus.position) } : {}),
30
31
  };
31
32
  }
32
33
  export function createFocusPlanningSidecar(plan, sourceHash = plan.provenance.sourceHash) {
@@ -139,8 +140,12 @@ export function enrichFocusPlanFromSidecar(plan, sidecar) {
139
140
  delete focus.laneId;
140
141
  else
141
142
  focus.laneId = saved.laneId;
142
- if (focus.position.mode !== 'auto')
143
+ if (saved.autoPosition !== undefined) {
144
+ focus.position = structuredClone(saved.autoPosition);
145
+ }
146
+ else if (focus.position.mode !== 'auto') {
143
147
  focus.position.pinned = saved.pinned;
148
+ }
144
149
  focus.visibility = saved.visibility;
145
150
  if (saved.reveal === undefined)
146
151
  delete focus.reveal;
@@ -6,7 +6,7 @@ import { ServiceError } from '../core/result.js';
6
6
  import { DeterministicSvgTextRenderer } from '../core/svg-text.js';
7
7
  import { PACKAGE_VERSION } from '../version.js';
8
8
  import { compileFocusTreeWithSourceMap } from './compiler.js';
9
- import { FOCUS_HORIZONTAL_GRID_PIXELS, FOCUS_NODE_HEIGHT_PIXELS, FOCUS_NODE_WIDTH_PIXELS, FOCUS_VERTICAL_GRID_PIXELS, focusNodeOrigin, } from './geometry.js';
9
+ import { FOCUS_HORIZONTAL_GRID_PIXELS, FOCUS_NODE_HEIGHT_PIXELS, FOCUS_NODE_WIDTH_PIXELS, FOCUS_VERTICAL_GRID_PIXELS, focusConnectorCurve, focusConnectorSvgPath, focusNodeOrigin, } from './geometry.js';
10
10
  import { createFocusPlanningSidecar, serializeFocusPlanningSidecar } from './planning.js';
11
11
  import { FOCUS_GRAPH_MAX_EDGES, FOCUS_GRAPH_MAX_NODES } from './lint.js';
12
12
  import { layoutNodeMap, } from './model.js';
@@ -173,6 +173,7 @@ function svgDocument(plan, layout, diagnostics, options) {
173
173
  node.id,
174
174
  focusNodeOrigin(node, minimumX, minimumY, padding, horizontal, vertical),
175
175
  ]));
176
+ const layoutNodes = new Map(nodes.map((node) => [node.id, node]));
176
177
  const focusMap = new Map(plan.focuses.map((focus) => [focus.id, focus]));
177
178
  const diagnosticMap = new Map();
178
179
  const toolText = new DeterministicSvgTextRenderer();
@@ -214,16 +215,19 @@ function svgDocument(plan, layout, diagnostics, options) {
214
215
  '<g id="prerequisites">',
215
216
  ];
216
217
  for (const edge of prerequisiteEdges) {
217
- const parent = pixel.get(edge.parentId);
218
- const child = pixel.get(edge.childId);
218
+ const parent = layoutNodes.get(edge.parentId);
219
+ const child = layoutNodes.get(edge.childId);
219
220
  if (parent === undefined || child === undefined)
220
221
  continue;
221
- const startX = parent.x + nodeWidth / 2;
222
- const startY = parent.y + nodeHeight;
223
- const endX = child.x + nodeWidth / 2;
224
- const endY = child.y;
225
- const middleY = (startY + endY) / 2;
226
- parts.push(`<path d="M ${startX} ${startY} C ${startX} ${middleY}, ${endX} ${middleY}, ${endX} ${endY}" fill="none" stroke="#8da2bc" stroke-width="2" marker-end="url(#arrow)" data-parent="${escapeXml(edge.parentId)}" data-child="${escapeXml(edge.childId)}" data-prerequisite-group="${edge.groupIndex}"/>`);
222
+ const curve = focusConnectorCurve(parent, child, {
223
+ horizontalSpacing: horizontal,
224
+ verticalSpacing: vertical,
225
+ nodeWidth,
226
+ nodeHeight,
227
+ originX: padding - minimumX * horizontal,
228
+ originY: padding - minimumY * vertical,
229
+ });
230
+ parts.push(`<path d="${focusConnectorSvgPath(curve)}" fill="none" stroke="#8da2bc" stroke-width="2" marker-end="url(#arrow)" data-parent="${escapeXml(edge.parentId)}" data-child="${escapeXml(edge.childId)}" data-prerequisite-group="${edge.groupIndex}"/>`);
227
231
  }
228
232
  parts.push('</g>', '<g id="mutual-exclusions">');
229
233
  for (const [leftId, rightId] of exclusionEdges) {
@@ -309,6 +313,7 @@ function svgDocument(plan, layout, diagnostics, options) {
309
313
  hash: layout.layoutHash,
310
314
  nodes,
311
315
  decisions: layout.decisions,
316
+ metrics: layout.metrics ?? null,
312
317
  },
313
318
  focuses: [...plan.focuses]
314
319
  .sort((left, right) => compareCodeUnits(left.id, right.id))
@@ -0,0 +1,8 @@
1
+ import { z } from 'zod/v4';
2
+ /**
3
+ * Keep a large nested payload schema out of tools/list while retaining its complete runtime parser.
4
+ * The MCP SDK publishes the input side of a pipe and executes both sides when a tool is called.
5
+ */
6
+ export function compactValidatedInputSchema(schema, description) {
7
+ return z.unknown().describe(description).pipe(schema);
8
+ }
@@ -12,7 +12,6 @@ export function createMcpServer(engine, context = {}) {
12
12
  websiteUrl: 'https://github.com/klimPaskov/hoi4-agent-tools',
13
13
  }, {
14
14
  capabilities: { logging: {} },
15
- instructions: 'Use this server when a configured HOI4 mod needs a large national or continuous focus, scripted GUI, or map created, inspected, rendered, or cleaned up. Start with hoi4.mods, then use the matching inspect, render, and rewrite tools. Each rewrite performs the complete edit in one call. Read linked hoi4-agent:// artifact resources for complete plans, diagnostics, layouts, previews, and diffs; when a resource content item has a non-null continuationUri in its namespaced _meta byte-range record, follow it until null. Generated GUI renders are offline representations, never game screenshots.',
16
15
  });
17
16
  registerModsTool(server, engine, context);
18
17
  registerFocusTools(server, engine, context);
@@ -3,15 +3,15 @@ import { canonicalJson } from '../../core/canonical.js';
3
3
  import { ServiceError } from '../../core/result.js';
4
4
  import { diagnosticSchema } from '../../schemas/common.js';
5
5
  import { artifactLinkSchema, validationSummarySchema } from '../../schemas/transaction.js';
6
- export const MAX_INLINE_FILES_SCANNED = 1_000;
7
- export const MAX_INLINE_PROPOSED_FILES = 1_000;
8
- export const MAX_INLINE_CHANGED_FILES = 1_000;
9
- export const MAX_INLINE_DIAGNOSTICS = 100;
10
- export const MAX_INLINE_ARTIFACT_LINKS = 512;
11
- export const MAX_INLINE_BLOCKERS = 100;
6
+ export const MAX_INLINE_FILES_SCANNED = 64;
7
+ export const MAX_INLINE_PROPOSED_FILES = 64;
8
+ export const MAX_INLINE_CHANGED_FILES = 64;
9
+ export const MAX_INLINE_DIAGNOSTICS = 20;
10
+ export const MAX_INLINE_ARTIFACT_LINKS = 32;
11
+ export const MAX_INLINE_BLOCKERS = 20;
12
12
  export const MAX_INLINE_VALIDATION_CHECKS = 5;
13
13
  // Leave headroom for the enclosing JSON-RPC response and transport metadata.
14
- export const MAX_TOOL_RESULT_BYTES = 500_000;
14
+ export const MAX_TOOL_RESULT_BYTES = 32_768;
15
15
  const deferredFilesDiagnostic = Symbol('deferredFilesDiagnostic');
16
16
  const operationResultBaseSchema = z
17
17
  .object({
@@ -39,15 +39,43 @@ export const operationResultSchema = operationResultBaseSchema
39
39
  .extend({ data: z.record(z.string().max(256), z.unknown()) })
40
40
  .strict();
41
41
  const emptyToolDataSchema = z.object({}).strict();
42
+ const compactPublicOperationResultSchema = z
43
+ .object({
44
+ status: z.enum(['ok', 'blocked', 'error']),
45
+ code: z.string().max(256),
46
+ workspaceId: z.string().max(64),
47
+ filesScanned: z.array(z.string().max(4096)).max(MAX_INLINE_FILES_SCANNED),
48
+ proposedFiles: z.array(z.string().max(1024)).max(MAX_INLINE_PROPOSED_FILES),
49
+ changedFiles: z.array(z.string().max(1024)).max(MAX_INLINE_CHANGED_FILES),
50
+ diagnostics: z.array(z.unknown()).max(MAX_INLINE_DIAGNOSTICS),
51
+ artifacts: z.array(z.unknown()).max(MAX_INLINE_ARTIFACT_LINKS),
52
+ validation: z
53
+ .object({
54
+ passed: z.boolean(),
55
+ checks: z.array(z.unknown()).max(MAX_INLINE_VALIDATION_CHECKS),
56
+ })
57
+ .strict(),
58
+ blockers: z.array(z.unknown()).max(MAX_INLINE_BLOCKERS),
59
+ data: z.record(z.string().max(256), z.unknown()),
60
+ })
61
+ .strict()
62
+ .describe('Stable operation envelope; data is tool-specific and artifacts link bulk evidence.');
42
63
  /**
43
- * Advertise an exact success payload for one tool while retaining the shared empty error payload.
44
- * The generic operation-result schema remains available for stored envelopes, but is never used as
45
- * a public tool output contract.
64
+ * Advertise one compact operation envelope while retaining exact per-tool runtime validation.
65
+ * The generic operation-result schema remains available for stored envelopes.
46
66
  */
47
67
  export function strictOperationResultSchema(successDataSchema) {
48
- return operationResultBaseSchema
68
+ const exactSchema = operationResultBaseSchema
49
69
  .extend({ data: z.union([emptyToolDataSchema, successDataSchema]) })
50
70
  .strict();
71
+ return compactPublicOperationResultSchema.superRefine((value, context) => {
72
+ const parsed = exactSchema.safeParse(value);
73
+ if (parsed.success)
74
+ return;
75
+ for (const issue of parsed.error.issues) {
76
+ context.addIssue({ code: 'custom', path: issue.path, message: issue.message });
77
+ }
78
+ });
51
79
  }
52
80
  /** Keep source inventories compact; complete inventories belong in linked resources. */
53
81
  export function setInlineFilesScanned(result, files) {
@@ -82,11 +110,14 @@ function preserveDeferredFilesDiagnostic(result) {
82
110
  };
83
111
  }
84
112
  function boundedResult(result, actualBytes) {
113
+ const hasLinkedEvidence = result.artifacts.length > 0;
85
114
  const truncationDiagnostic = {
86
115
  code: 'MCP_RESPONSE_TRUNCATED',
87
116
  severity: 'warning',
88
117
  category: 'configuration',
89
- message: 'Tool output exceeded the wire budget; complete details remain available through linked resources',
118
+ message: hasLinkedEvidence
119
+ ? 'Tool output exceeded the wire budget; inspect the linked evidence for complete details'
120
+ : 'Tool output exceeded the wire budget; nonessential inline details were omitted',
90
121
  details: { actualBytes, maxBytes: MAX_TOOL_RESULT_BYTES },
91
122
  };
92
123
  return {
@@ -106,7 +137,9 @@ function boundedResult(result, actualBytes) {
106
137
  : [
107
138
  {
108
139
  code: 'MCP_RESPONSE_TRUNCATED',
109
- message: 'Tool output exceeded the wire budget; inspect the linked resource for complete details',
140
+ message: hasLinkedEvidence
141
+ ? 'Tool output exceeded the wire budget; inspect the linked evidence for complete details'
142
+ : 'Tool output exceeded the wire budget; nonessential inline details were omitted',
110
143
  details: {
111
144
  actualBytes,
112
145
  maxBytes: MAX_TOOL_RESULT_BYTES,
@@ -196,32 +229,60 @@ function jsonBytesWithin(value, budget) {
196
229
  return visit(value);
197
230
  }
198
231
  function compactDetails(details) {
199
- return jsonBytesWithin(details, 4_096) ? details : { truncated: true };
232
+ return jsonBytesWithin(details, 2_048) ? details : { truncated: true };
200
233
  }
201
234
  function compactDescriptions(result) {
202
235
  const compactLocation = (location) => location === undefined
203
236
  ? undefined
204
237
  : {
205
238
  ...location,
206
- path: location.path.slice(0, 4_096),
207
- ...(location.symbol === undefined ? {} : { symbol: location.symbol.slice(0, 1_024) }),
239
+ path: location.path.slice(0, 1_024),
240
+ ...(location.symbol === undefined ? {} : { symbol: location.symbol.slice(0, 256) }),
208
241
  };
209
- const validationTruncation = result.validation.checks.length > MAX_INLINE_VALIDATION_CHECKS
210
- ? {
211
- code: 'MCP_INLINE_VALIDATION_TRUNCATED',
212
- severity: 'info',
213
- category: 'configuration',
214
- message: `Inline validation is limited to ${MAX_INLINE_VALIDATION_CHECKS} checks; use the linked artifact for the complete validation record`,
215
- details: {
216
- total: result.validation.checks.length,
217
- returned: MAX_INLINE_VALIDATION_CHECKS,
218
- },
219
- }
220
- : undefined;
221
- const compactDiagnostics = result.diagnostics.map((diagnostic) => ({
242
+ const collectionTotals = {
243
+ filesScanned: result.filesScanned.length,
244
+ proposedFiles: result.proposedFiles.length,
245
+ changedFiles: result.changedFiles.length,
246
+ diagnostics: result.diagnostics.length,
247
+ artifacts: result.artifacts.length,
248
+ validationChecks: result.validation.checks.length,
249
+ blockers: result.blockers.length,
250
+ };
251
+ const collectionLimits = {
252
+ filesScanned: MAX_INLINE_FILES_SCANNED,
253
+ proposedFiles: MAX_INLINE_PROPOSED_FILES,
254
+ changedFiles: MAX_INLINE_CHANGED_FILES,
255
+ diagnostics: MAX_INLINE_DIAGNOSTICS,
256
+ artifacts: MAX_INLINE_ARTIFACT_LINKS,
257
+ validationChecks: MAX_INLINE_VALIDATION_CHECKS,
258
+ blockers: MAX_INLINE_BLOCKERS,
259
+ };
260
+ const truncatedCollections = Object.fromEntries(Object.entries(collectionTotals).flatMap(([name, total]) => {
261
+ const limit = collectionLimits[name];
262
+ return total > limit ? [[name, { total, limit }]] : [];
263
+ }));
264
+ if (Object.keys(truncatedCollections).length > 0 &&
265
+ result.diagnostics.length > MAX_INLINE_DIAGNOSTICS - 1) {
266
+ truncatedCollections.diagnostics = {
267
+ total: result.diagnostics.length,
268
+ limit: MAX_INLINE_DIAGNOSTICS - 1,
269
+ };
270
+ }
271
+ const needsTruncationDiagnostic = Object.keys(truncatedCollections).length > 0;
272
+ const diagnosticSlots = Math.max(0, MAX_INLINE_DIAGNOSTICS - (needsTruncationDiagnostic ? 1 : 0));
273
+ const hardDiagnostics = result.diagnostics.filter(({ severity }) => severity === 'error' || severity === 'blocker');
274
+ const priorityDiagnostics = result.diagnostics.filter(({ code, severity }) => severity === 'error' || severity === 'blocker' || code === 'MCP_INLINE_FILES_TRUNCATED');
275
+ const otherDiagnostics = result.diagnostics.filter((diagnostic) => !priorityDiagnostics.includes(diagnostic));
276
+ const retainedPriorityDiagnostics = priorityDiagnostics.slice(0, diagnosticSlots);
277
+ const retainedDiagnostics = [
278
+ ...retainedPriorityDiagnostics,
279
+ ...otherDiagnostics.slice(0, diagnosticSlots - retainedPriorityDiagnostics.length),
280
+ ];
281
+ const omittedHardDiagnostics = hardDiagnostics.filter((diagnostic) => !retainedDiagnostics.includes(diagnostic)).length;
282
+ const compactDiagnostics = retainedDiagnostics.map((diagnostic) => ({
222
283
  ...diagnostic,
223
284
  code: diagnostic.code.slice(0, 256),
224
- message: diagnostic.message.slice(0, 4_096),
285
+ message: diagnostic.message.slice(0, 1_024),
225
286
  ...(diagnostic.operationId === undefined
226
287
  ? {}
227
288
  : { operationId: diagnostic.operationId.slice(0, 256) }),
@@ -231,35 +292,53 @@ function compactDescriptions(result) {
231
292
  ...(diagnostic.related === undefined
232
293
  ? {}
233
294
  : {
234
- related: diagnostic.related.slice(0, 5).map((location) => compactLocation(location)),
295
+ related: diagnostic.related.slice(0, 3).map((location) => compactLocation(location)),
235
296
  }),
236
297
  ...(diagnostic.details === undefined ? {} : { details: compactDetails(diagnostic.details) }),
237
298
  }));
299
+ const truncationDiagnostic = needsTruncationDiagnostic
300
+ ? {
301
+ code: 'MCP_INLINE_COLLECTIONS_TRUNCATED',
302
+ severity: omittedHardDiagnostics > 0 ? 'blocker' : 'info',
303
+ category: 'configuration',
304
+ message: result.artifacts.length > 0
305
+ ? 'Large collections were bounded inline; linked resources retain bulk evidence'
306
+ : 'Large collections were bounded inline',
307
+ details: {
308
+ collections: truncatedCollections,
309
+ diagnosticsReturned: retainedDiagnostics.length,
310
+ omittedHardDiagnostics,
311
+ },
312
+ }
313
+ : undefined;
238
314
  return {
239
315
  ...result,
240
- diagnostics: validationTruncation === undefined
316
+ filesScanned: result.filesScanned.slice(0, MAX_INLINE_FILES_SCANNED),
317
+ proposedFiles: result.proposedFiles.slice(0, MAX_INLINE_PROPOSED_FILES),
318
+ changedFiles: result.changedFiles.slice(0, MAX_INLINE_CHANGED_FILES),
319
+ diagnostics: truncationDiagnostic === undefined
241
320
  ? compactDiagnostics
242
- : [validationTruncation, ...compactDiagnostics].slice(0, MAX_INLINE_DIAGNOSTICS),
321
+ : [truncationDiagnostic, ...compactDiagnostics],
322
+ artifacts: result.artifacts.slice(0, MAX_INLINE_ARTIFACT_LINKS).map((artifact) => ({
323
+ ...artifact,
324
+ ...(artifact.description === undefined
325
+ ? {}
326
+ : { description: artifact.description.slice(0, 256) }),
327
+ })),
243
328
  validation: {
244
329
  ...result.validation,
245
330
  checks: result.validation.checks.slice(0, MAX_INLINE_VALIDATION_CHECKS).map((check) => ({
246
331
  ...check,
247
332
  id: check.id.slice(0, 256),
248
- message: check.message.slice(0, 4_096),
333
+ message: check.message.slice(0, 1_024),
249
334
  })),
250
335
  },
251
- blockers: result.blockers.map((blocker) => ({
336
+ blockers: result.blockers.slice(0, MAX_INLINE_BLOCKERS).map((blocker) => ({
252
337
  ...blocker,
253
338
  code: blocker.code.slice(0, 256),
254
- message: blocker.message.slice(0, 4_096),
339
+ message: blocker.message.slice(0, 1_024),
255
340
  ...(blocker.details === undefined ? {} : { details: compactDetails(blocker.details) }),
256
341
  })),
257
- artifacts: result.artifacts.map((artifact) => ({
258
- ...artifact,
259
- ...(artifact.description === undefined
260
- ? {}
261
- : { description: artifact.description.slice(0, 1_024) }),
262
- })),
263
342
  };
264
343
  }
265
344
  function buildToolResult(selected) {
@@ -286,23 +365,15 @@ function buildToolResult(selected) {
286
365
  }
287
366
  export function toolResult(result) {
288
367
  const completeResult = preserveDeferredFilesDiagnostic(result);
289
- const exceedsArrayBudget = completeResult.filesScanned.length > MAX_INLINE_FILES_SCANNED ||
290
- completeResult.proposedFiles.length > MAX_INLINE_PROPOSED_FILES ||
291
- completeResult.changedFiles.length > MAX_INLINE_CHANGED_FILES ||
292
- completeResult.diagnostics.length > MAX_INLINE_DIAGNOSTICS ||
293
- completeResult.artifacts.length > MAX_INLINE_ARTIFACT_LINKS ||
294
- completeResult.blockers.length > MAX_INLINE_BLOCKERS;
295
368
  const compact = compactDescriptions(completeResult);
296
369
  const estimatedBytes = jsonBytesWithin(compact, MAX_TOOL_RESULT_BYTES)
297
370
  ? Buffer.byteLength(canonicalJson(compact), 'utf8')
298
371
  : MAX_TOOL_RESULT_BYTES + 1;
299
- const selected = estimatedBytes > MAX_TOOL_RESULT_BYTES || exceedsArrayBudget
300
- ? boundedResult(completeResult, estimatedBytes)
301
- : compact;
372
+ const selected = estimatedBytes > MAX_TOOL_RESULT_BYTES ? boundedResult(compact, estimatedBytes) : compact;
302
373
  let output = buildToolResult(selected);
303
374
  const wireBytes = Buffer.byteLength(canonicalJson(output), 'utf8');
304
375
  if (wireBytes > MAX_TOOL_RESULT_BYTES) {
305
- output = buildToolResult(boundedResult(completeResult, wireBytes));
376
+ output = buildToolResult(boundedResult(compact, wireBytes));
306
377
  }
307
378
  const finalBytes = Buffer.byteLength(canonicalJson(output), 'utf8');
308
379
  if (finalBytes > MAX_TOOL_RESULT_BYTES) {
@@ -6,14 +6,15 @@ import { RenderBudget } from '../../core/render-budget.js';
6
6
  import { emptyServiceResult, ServiceError } from '../../core/result.js';
7
7
  import { parseClausewitz } from '../../core/source/index.js';
8
8
  import { readDependenciesFromScannedFiles } from '../../core/transactions.js';
9
- import { FocusWorkbench, FOCUS_RENDER_MAX_OUTPUT_SCALE, FOCUS_RENDER_MIN_OUTPUT_SCALE, focusPlanHash, focusPresentationEvidence, focusPlanningSidecarPath, importContinuousFocusPalettes, importFocusTrees, linkContinuousFocusPalettes, parseFocusPlanningSidecar, renderContinuousFocusPalette, renderFocusTree, resolveFocusPresentation, enrichFocusPlanFromSidecar, } from '../../focus/index.js';
10
- import { continuousFocusPaletteSchema, focusLayoutSchema, focusTreePlanSchema, } from '../../schemas/focus.js';
9
+ import { FocusWorkbench, FOCUS_RENDER_MAX_OUTPUT_SCALE, FOCUS_RENDER_MIN_OUTPUT_SCALE, assertCompactLayoutQuality, compactFocusTreePlanAsync, focusPlanHash, focusPresentationEvidence, focusPlanningSidecarPath, importContinuousFocusPalettes, importFocusTrees, linkContinuousFocusPalettes, parseFocusPlanningSidecar, renderContinuousFocusPalette, renderFocusTree, resolveFocusPresentation, enrichFocusPlanFromSidecar, } from '../../focus/index.js';
10
+ import { continuousFocusPaletteSchema, focusLayoutMetricsSchema, focusLayoutSchema, focusTreePlanSchema, } from '../../schemas/focus.js';
11
11
  import { workspaceIdSchema, workspaceRelativePathSchema } from '../../schemas/common.js';
12
12
  import { PACKAGE_VERSION } from '../../version.js';
13
13
  import { nonNegativeIntegerSchema, renderHashesSchema, sha256Schema, } from '../server/output-schemas.js';
14
14
  import { progressReporter } from '../server/progress.js';
15
15
  import { errorResult, setInlineFilesScanned, strictOperationResultSchema, toolResult, } from '../server/result.js';
16
16
  import { requireServerScope } from '../server/base-tools.js';
17
+ import { compactValidatedInputSchema } from '../server/context-schemas.js';
17
18
  import { autonomousFailureContext, autonomousResultArtifacts, executePlannedTransaction, } from '../server/transaction-execution.js';
18
19
  const focusInspectOutputSchema = strictOperationResultSchema(z
19
20
  .object({
@@ -33,6 +34,7 @@ const focusInspectOutputSchema = strictOperationResultSchema(z
33
34
  resolvedTitleCount: nonNegativeIntegerSchema,
34
35
  layoutHash: sha256Schema,
35
36
  layoutDecisionCount: nonNegativeIntegerSchema,
37
+ layoutMetrics: focusLayoutMetricsSchema,
36
38
  diagnosticCount: nonNegativeIntegerSchema,
37
39
  })
38
40
  .strict())
@@ -114,6 +116,7 @@ const focusPlanOutputSchema = strictOperationResultSchema(z.discriminatedUnion('
114
116
  })
115
117
  .strict(),
116
118
  ]));
119
+ const compactFocusPlanSchema = compactValidatedInputSchema(z.union([focusTreePlanSchema, continuousFocusPaletteSchema]), `Complete focus plan: https://github.com/klimPaskov/hoi4-agent-tools/blob/v${PACKAGE_VERSION}/docs/focus.md`);
117
120
  const focusInspectInput = z
118
121
  .object({
119
122
  mode: z.enum(['national', 'continuous']).optional(),
@@ -208,7 +211,9 @@ const focusPlanInput = z
208
211
  mode: z.enum(['national', 'continuous']).optional(),
209
212
  workspaceId: workspaceIdSchema,
210
213
  relativePath: workspaceRelativePathSchema,
211
- plan: z.union([focusTreePlanSchema, continuousFocusPaletteSchema]),
214
+ treeId: z.string().min(1).max(256).optional(),
215
+ layoutMode: z.enum(['authored', 'compact']).default('authored'),
216
+ plan: compactFocusPlanSchema.optional(),
212
217
  createIfMissing: z.boolean().default(false),
213
218
  horizontalSpacing: z.number().int().min(80).max(1000).optional(),
214
219
  verticalSpacing: z.number().int().min(60).max(1000).optional(),
@@ -223,13 +228,31 @@ const focusPlanInput = z
223
228
  .superRefine((value, context) => {
224
229
  const mode = value.mode ?? 'national';
225
230
  const schema = mode === 'continuous' ? continuousFocusPaletteSchema : focusTreePlanSchema;
226
- if (!schema.safeParse(value.plan).success)
231
+ if (value.plan !== undefined && !schema.safeParse(value.plan).success)
227
232
  context.addIssue({
228
233
  code: 'custom',
229
234
  path: ['plan'],
230
235
  message: `plan must be a ${mode} focus plan`,
231
236
  });
232
237
  if (mode === 'continuous') {
238
+ if (value.plan === undefined)
239
+ context.addIssue({
240
+ code: 'custom',
241
+ path: ['plan'],
242
+ message: 'plan is required in continuous mode',
243
+ });
244
+ if (value.treeId !== undefined)
245
+ context.addIssue({
246
+ code: 'custom',
247
+ path: ['treeId'],
248
+ message: 'treeId is only valid in national mode',
249
+ });
250
+ if (value.layoutMode !== 'authored')
251
+ context.addIssue({
252
+ code: 'custom',
253
+ path: ['layoutMode'],
254
+ message: 'compact layout mode is only valid in national mode',
255
+ });
233
256
  for (const [field, fieldValue] of [
234
257
  ['horizontalSpacing', value.horizontalSpacing],
235
258
  ['verticalSpacing', value.verticalSpacing],
@@ -244,6 +267,35 @@ const focusPlanInput = z
244
267
  });
245
268
  }
246
269
  }
270
+ else {
271
+ if (value.plan === undefined && value.layoutMode !== 'compact')
272
+ context.addIssue({
273
+ code: 'custom',
274
+ path: ['plan'],
275
+ message: 'plan is required unless layoutMode is compact',
276
+ });
277
+ if (value.plan === undefined && value.treeId === undefined)
278
+ context.addIssue({
279
+ code: 'custom',
280
+ path: ['treeId'],
281
+ message: 'treeId is required for plan-free compact reflow',
282
+ });
283
+ if (value.plan === undefined && value.createIfMissing)
284
+ context.addIssue({
285
+ code: 'custom',
286
+ path: ['createIfMissing'],
287
+ message: 'plan-free compact reflow requires an existing focus source',
288
+ });
289
+ if (value.plan !== undefined &&
290
+ value.treeId !== undefined &&
291
+ 'id' in value.plan &&
292
+ value.plan.id !== value.treeId)
293
+ context.addIssue({
294
+ code: 'custom',
295
+ path: ['treeId'],
296
+ message: 'treeId must match the supplied national focus plan',
297
+ });
298
+ }
247
299
  });
248
300
  const artifactProducing = {
249
301
  readOnlyHint: false,
@@ -584,6 +636,7 @@ export function registerFocusTools(server, engine, context) {
584
636
  resolvedTitleCount: plan.focuses.filter(({ id }) => presentation.entries[id]?.titleSourceLocation !== undefined).length,
585
637
  layoutHash: layout.layoutHash,
586
638
  layoutDecisionCount: layout.decisions.length,
639
+ layoutMetrics: layout.metrics,
587
640
  diagnosticCount,
588
641
  })),
589
642
  palettes: [],
@@ -727,7 +780,7 @@ export function registerFocusTools(server, engine, context) {
727
780
  });
728
781
  server.registerTool('hoi4.focus_rewrite', {
729
782
  title: 'Create or clean up focus content',
730
- description: 'Create or clean up a national tree or continuous palette from a complete plan, validate the result, apply it in one call, and return before-and-after review artifacts. Omit mode for national behavior. National plans accept bounded horizontalSpacing, verticalSpacing, padding, and reviewScale controls. Set createIfMissing for a new file.',
783
+ description: 'Create or clean up a national tree or continuous palette, validate it, and apply it in one call. Supply a complete plan; set layoutMode compact for automatic arrangement. Existing national trees can omit the plan and use treeId plus layoutMode compact. Set createIfMissing for a new file.',
731
784
  inputSchema: focusPlanInput,
732
785
  outputSchema: focusPlanOutputSchema,
733
786
  annotations: {
@@ -901,7 +954,7 @@ export function registerFocusTools(server, engine, context) {
901
954
  : 'Continuous focus rewrite blocked');
902
955
  return toolResult(result);
903
956
  }
904
- const plan = input.plan;
957
+ const suppliedPlan = input.plan;
905
958
  if (createIfMissing &&
906
959
  (!relativePath.toLowerCase().endsWith('.txt') ||
907
960
  !underConfiguredRoot(relativePath, workspace.registration.roots.focus)))
@@ -919,6 +972,30 @@ export function registerFocusTools(server, engine, context) {
919
972
  const paletteImport = continuousPalettes(snapshot);
920
973
  if (imported !== undefined)
921
974
  imported.result.plans = imported.result.plans.map((current) => linkContinuousFocusPalettes(current, paletteImport.palettes));
975
+ let compactPlanning;
976
+ let compactSourcePlan;
977
+ let plan;
978
+ if (suppliedPlan !== undefined) {
979
+ if (input.layoutMode === 'compact') {
980
+ compactSourcePlan = suppliedPlan;
981
+ compactPlanning = await compactFocusTreePlanAsync(suppliedPlan, {
982
+ signal: progress.signal,
983
+ });
984
+ plan = compactPlanning.plan;
985
+ }
986
+ else {
987
+ plan = suppliedPlan;
988
+ }
989
+ }
990
+ else {
991
+ if (imported === undefined)
992
+ throw new ServiceError('FOCUS_COMPACT_SOURCE_REQUIRED', 'Plan-free compact reflow requires an existing national focus source', { relativePath });
993
+ compactSourcePlan = selectPlan(imported.result, input.treeId);
994
+ compactPlanning = await compactFocusTreePlanAsync(compactSourcePlan, {
995
+ signal: progress.signal,
996
+ });
997
+ plan = compactPlanning.plan;
998
+ }
922
999
  const currentPlan = imported?.result.plans.find(({ id }) => id === plan.id);
923
1000
  if (currentPlan === undefined && !createIfMissing) {
924
1001
  if (imported === undefined)
@@ -954,7 +1031,9 @@ export function registerFocusTools(server, engine, context) {
954
1031
  });
955
1032
  const currentLayout = currentPlan === undefined
956
1033
  ? undefined
957
- : await workbench.layoutAsync(currentPlan, { signal: progress.signal });
1034
+ : currentPlan === compactSourcePlan && compactPlanning !== undefined
1035
+ ? compactPlanning.currentLayout
1036
+ : await workbench.layoutAsync(currentPlan, { signal: progress.signal });
958
1037
  const currentDiagnostics = currentPlan === undefined || currentLayout === undefined
959
1038
  ? []
960
1039
  : workbench.lint(currentPlan, {
@@ -980,6 +1059,11 @@ export function registerFocusTools(server, engine, context) {
980
1059
  importedPlanHash: proposedPlanHash,
981
1060
  },
982
1061
  };
1062
+ const proposedLayout = compactPlanning?.proposedLayout ??
1063
+ (await workbench.layoutAsync(reviewPlan, { signal: progress.signal }));
1064
+ if (input.layoutMode === 'compact') {
1065
+ assertCompactLayoutQuality(currentLayout, proposedLayout);
1066
+ }
983
1067
  const proposed = await workbench.renderAndStore(workspaceId, reviewPlan, {
984
1068
  ...(context.principal === undefined ? {} : { principal: context.principal }),
985
1069
  index: snapshot.index,
@@ -994,6 +1078,7 @@ export function registerFocusTools(server, engine, context) {
994
1078
  },
995
1079
  budget: renderBudget,
996
1080
  signal: progress.signal,
1081
+ layout: proposedLayout,
997
1082
  });
998
1083
  const beforePng = currentRender?.png ??
999
1084
  (await createTransparentPng(proposed.bundle.width, proposed.bundle.height, 'new national focus baseline', renderBudget));
@@ -1043,6 +1128,7 @@ export function registerFocusTools(server, engine, context) {
1043
1128
  plan,
1044
1129
  createIfMissing,
1045
1130
  authority: 'plan',
1131
+ layout: proposed.layout,
1046
1132
  ...(context.principal === undefined ? {} : { principal: context.principal }),
1047
1133
  index: snapshot.index,
1048
1134
  references: catalog,