@hraness/slopcamera 3.2.8 → 3.3.2

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 (153) hide show
  1. package/README.md +29 -9
  2. package/apps/desktop/application/context.ts +6 -0
  3. package/apps/desktop/application/default-registry.ts +26 -0
  4. package/apps/desktop/application/operation.ts +14 -1
  5. package/apps/desktop/application/operations/index.ts +4 -0
  6. package/apps/desktop/application/operations/spatial-behavior.ts +151 -0
  7. package/apps/desktop/application/operations/spatial-direction.ts +222 -0
  8. package/apps/desktop/application/operations/spatial-rendered-audit.ts +174 -0
  9. package/apps/desktop/application/operations/spatial-review.ts +194 -0
  10. package/apps/desktop/application/operations/spatial-scene.ts +25 -1
  11. package/apps/desktop/application/registry.ts +2 -2
  12. package/apps/desktop/application/spatial-asset-admission.ts +201 -0
  13. package/apps/desktop/application/spatial-assets.ts +160 -31
  14. package/apps/desktop/application/spatial-render.ts +217 -20
  15. package/apps/desktop/application/spatial-rendered-audit.ts +273 -0
  16. package/apps/desktop/application/spatial-review.ts +361 -0
  17. package/apps/desktop/application/spatial-spz.ts +48 -3
  18. package/apps/desktop/application/spatial-world-import.ts +26 -5
  19. package/apps/desktop/application/spatial-world-metadata.ts +105 -0
  20. package/apps/desktop/cli/args.ts +514 -11
  21. package/apps/desktop/cli/capability-manifest.ts +374 -0
  22. package/apps/desktop/cli/cinema-renderer.ts +608 -0
  23. package/apps/desktop/cli/command-host-resources.ts +29 -1
  24. package/apps/desktop/cli/commands.ts +1188 -4
  25. package/apps/desktop/cli/gateway-review-provider.ts +334 -0
  26. package/apps/desktop/cli/help.ts +172 -6
  27. package/apps/desktop/cli/main.ts +3 -3
  28. package/apps/desktop/cli/portable-surface.ts +6 -1
  29. package/apps/desktop/cli/spatial-asset-service.ts +124 -0
  30. package/apps/desktop/cli/spatial-design-service.ts +237 -0
  31. package/apps/desktop/cli/spatial-generate-service.ts +370 -0
  32. package/apps/desktop/cli/spatial-scene-service.ts +271 -4
  33. package/apps/desktop/cli/workflow-code.ts +1 -1
  34. package/apps/desktop/code/semantic-builder.ts +24 -0
  35. package/apps/desktop/contracts/cinema.ts +736 -0
  36. package/apps/desktop/contracts/index.ts +1 -0
  37. package/apps/desktop/contracts/spatial-asset.ts +16 -0
  38. package/apps/desktop/contracts/spatial-world.ts +26 -3
  39. package/apps/desktop/core/cinema-plan.ts +1320 -0
  40. package/apps/desktop/core/index.ts +1 -0
  41. package/apps/desktop/core/storage.ts +75 -0
  42. package/apps/desktop/dist/cli/main.js +725 -273
  43. package/apps/desktop/html-overlay/spatial.ts +619 -57
  44. package/apps/desktop/workflows/cinematic-world.ts +127 -0
  45. package/apps/desktop/workflows/index.ts +6 -0
  46. package/dist/cli.js +25 -429
  47. package/dist/code/advanced.js +1 -269
  48. package/dist/code/index.js +16 -2239
  49. package/dist/generate.js +1 -23
  50. package/dist/host-resources.js +1 -25
  51. package/dist/index-0bbesn4m.js +35 -0
  52. package/dist/index-296qhpf2.js +3 -0
  53. package/dist/index-3cxf2c58.js +3 -0
  54. package/dist/index-77fjfg9f.js +3 -0
  55. package/dist/index-80a36bc0.js +3 -0
  56. package/dist/index-a3vvs3rf.js +3 -0
  57. package/dist/index-b3geqczn.js +5 -0
  58. package/dist/index-grk47tgs.js +3 -0
  59. package/dist/index-gs48afpt.js +7 -0
  60. package/dist/index-qry58nj2.js +10 -0
  61. package/dist/index-zhg36tf9.js +3 -0
  62. package/dist/index.js +1 -226
  63. package/dist/operations.js +1 -28
  64. package/dist/skill-install-gx7afxsd.js +2 -0
  65. package/dist/vectorize/worker.js +1 -141
  66. package/dist/workflow.js +1 -15
  67. package/docs/README.md +5 -1
  68. package/docs/studio.md +12 -0
  69. package/package.json +7 -5
  70. package/skills/slopcamera/SKILL.md +7 -1
  71. package/skills/slopcamera/references/directed-scenes.md +48 -2
  72. package/skills/slopcamera/references/gateway-media.md +15 -1
  73. package/skills/slopcamera/references/image-galleries.md +166 -0
  74. package/skills/slopcamera/references/install.md +1 -1
  75. package/skills/slopcamera/references/music-video.md +4 -0
  76. package/skills/slopcamera/references/native-studio.md +19 -0
  77. package/skills/slopcamera/references/parametric-design.md +14 -0
  78. package/skills/slopcamera/references/scene-building.md +172 -0
  79. package/skills/slopcamera/references/social-collage-banners.md +204 -0
  80. package/skills/slopcamera/references/workflows-sdk.md +3 -3
  81. package/skills/slopcamera/scripts/compose-social-collage-banner.ts +728 -0
  82. package/src/capability-manifest.ts +361 -0
  83. package/src/cli.ts +222 -4
  84. package/src/code/canonical-json.ts +9 -0
  85. package/src/generate.ts +2 -2
  86. package/src/icon.ts +1111 -0
  87. package/src/image-gallery.ts +1049 -0
  88. package/src/index.ts +11 -0
  89. package/src/mcp/boundary.ts +11 -11
  90. package/src/mcp/index.ts +3 -0
  91. package/src/mcp/server.ts +2 -1
  92. package/src/mcp/tools.ts +1649 -132
  93. package/src/mcp/types.ts +19 -0
  94. package/src/operations.ts +412 -0
  95. package/src/portable-capability-manifest.ts +67 -0
  96. package/src/scene-gallery.ts +184 -0
  97. package/src/spatial-scene/asset-admission.ts +183 -0
  98. package/src/spatial-scene/audit-rendered.ts +692 -0
  99. package/src/spatial-scene/audit.ts +617 -0
  100. package/src/spatial-scene/behavior-audit.ts +252 -0
  101. package/src/spatial-scene/behavior-bake.ts +232 -0
  102. package/src/spatial-scene/behavior-fns.ts +585 -0
  103. package/src/spatial-scene/behavior-gallery.ts +108 -0
  104. package/src/spatial-scene/behavior-stdlib.ts +323 -0
  105. package/src/spatial-scene/behavior-trace.ts +325 -0
  106. package/src/spatial-scene/behavior.ts +617 -0
  107. package/src/spatial-scene/build.ts +632 -0
  108. package/src/spatial-scene/camera-rig.ts +125 -0
  109. package/src/spatial-scene/camera-track.ts +4 -2
  110. package/src/spatial-scene/character.ts +124 -0
  111. package/src/spatial-scene/contracts.ts +75 -7
  112. package/src/spatial-scene/design-templates.ts +351 -0
  113. package/src/spatial-scene/design.ts +327 -0
  114. package/src/spatial-scene/direction-compile.ts +584 -0
  115. package/src/spatial-scene/direction.ts +122 -0
  116. package/src/spatial-scene/effects.ts +149 -0
  117. package/src/spatial-scene/evaluate.ts +109 -29
  118. package/src/spatial-scene/gallery.ts +277 -0
  119. package/src/spatial-scene/generate.ts +239 -0
  120. package/src/spatial-scene/geometry-native.ts +165 -0
  121. package/src/spatial-scene/geometry.ts +1216 -0
  122. package/src/spatial-scene/gltf.ts +600 -77
  123. package/src/spatial-scene/identity.ts +55 -2
  124. package/src/spatial-scene/index.ts +36 -0
  125. package/src/spatial-scene/inspect.ts +35 -6
  126. package/src/spatial-scene/material-lighting.ts +609 -0
  127. package/src/spatial-scene/math.ts +21 -4
  128. package/src/spatial-scene/motion-evidence.ts +88 -0
  129. package/src/spatial-scene/parametric.ts +765 -0
  130. package/src/spatial-scene/particle-preparation.ts +183 -0
  131. package/src/spatial-scene/particle.ts +195 -0
  132. package/src/spatial-scene/patch.ts +109 -15
  133. package/src/spatial-scene/performance.ts +1613 -0
  134. package/src/spatial-scene/probe.ts +137 -0
  135. package/src/spatial-scene/recipe-pack.ts +86 -0
  136. package/src/spatial-scene/render-effects.ts +240 -0
  137. package/src/spatial-scene/review.ts +639 -0
  138. package/src/spatial-scene/simulation-bake.ts +206 -0
  139. package/src/spatial-scene/simulation.ts +187 -0
  140. package/src/spatial-scene/solve.ts +366 -0
  141. package/src/spatial-scene/temporal-audit.ts +354 -0
  142. package/src/version.ts +1 -1
  143. package/dist/index-42zsesc1.js +0 -2257
  144. package/dist/index-7131pg1c.js +0 -1820
  145. package/dist/index-7308egqr.js +0 -48
  146. package/dist/index-8txs6fkn.js +0 -712
  147. package/dist/index-fava6pge.js +0 -192
  148. package/dist/index-h1k0fnjq.js +0 -2363
  149. package/dist/index-r7gdhmsp.js +0 -424
  150. package/dist/index-sh6xbav6.js +0 -1219
  151. package/dist/index-z1w83f81.js +0 -4
  152. package/dist/index-zfnddgay.js +0 -2029
  153. package/dist/skill-install-dh0ntqba.js +0 -10
package/src/mcp/tools.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import { rename, rm, writeFile } from "node:fs/promises"
2
2
  import { dirname, join } from "node:path"
3
+ import { SlopcameraCodeError } from "../code/errors.js"
3
4
  import { SlopcameraCloudError } from "../cloud-errors.js"
4
5
  import {
5
6
  generateSlopcameraImageFile,
6
7
  type SlopcameraGenerateDependencies,
7
8
  } from "../generate.js"
9
+ import { generateSlopcameraImageGallery } from "../image-gallery.js"
8
10
  import { builtInIcons } from "../icons.js"
9
11
  import { lintDiagram } from "../lint.js"
10
12
  import {
@@ -14,6 +16,7 @@ import {
14
16
  searchSlopcameraOperations,
15
17
  withSlopcameraOperationHostAdmission,
16
18
  type CheckSlopcameraOperationInput,
19
+ type GallerySlopcameraOperationInput,
17
20
  type GenerateSlopcameraOperationInput,
18
21
  type SlopcameraOperationCode,
19
22
  type RenderSlopcameraOperationInput,
@@ -21,11 +24,62 @@ import {
21
24
  } from "../operations.js"
22
25
  import {
23
26
  createDefaultHostResourceCoordinator,
27
+ HostResourceError,
24
28
  type HostResourceCoordinator,
25
29
  type HostResourceLease,
26
30
  } from "../host-resources.js"
27
31
  import { DiagramValidationError, parseDiagramSpec } from "../parse.js"
28
32
  import { renderPng, renderSvg } from "../render.js"
33
+ import {
34
+ auditSpatialScene,
35
+ normalizeSpatialAuditAssetBounds,
36
+ SPATIAL_AUDIT_LIMITS,
37
+ type SpatialAuditEntity,
38
+ } from "../spatial-scene/audit.js"
39
+ import {
40
+ checkSpatialBehavior,
41
+ } from "../spatial-scene/behavior.js"
42
+ import {
43
+ auditSpatialBehaviorTrace,
44
+ } from "../spatial-scene/behavior-audit.js"
45
+ import {
46
+ spatialBehaviorFnSignatures,
47
+ } from "../spatial-scene/behavior-fns.js"
48
+ import { SpatialBehaviorBakeSchema } from "../spatial-scene/behavior-trace.js"
49
+ import {
50
+ SPATIAL_SCENE_LIMITS,
51
+ type SpatialSceneV1,
52
+ } from "../spatial-scene/contracts.js"
53
+ import {
54
+ checkSpatialDirection,
55
+ compileSpatialDirection,
56
+ } from "../spatial-scene/direction-compile.js"
57
+ import {
58
+ createSpatialEvaluationContext,
59
+ evaluateSpatialScene,
60
+ evaluateSpatialSceneInContext,
61
+ } from "../spatial-scene/evaluate.js"
62
+ import {
63
+ planSpatialDirectionGallery,
64
+ SpatialGalleryAxisSchema,
65
+ } from "../spatial-scene/gallery.js"
66
+ import {
67
+ parseSpatialScene,
68
+ parseSpatialValue,
69
+ SpatialSceneError,
70
+ spatialValueSha256,
71
+ } from "../spatial-scene/identity.js"
72
+ import { inspectSpatialScene } from "../spatial-scene/inspect.js"
73
+ import { diffSpatialScenes } from "../spatial-scene/patch.js"
74
+ import {
75
+ checkSpatialRenderEffects,
76
+ planSpatialRenderEffects,
77
+ } from "../spatial-scene/render-effects.js"
78
+ import {
79
+ auditSpatialTemporalEvidence,
80
+ SPATIAL_TEMPORAL_AUDIT_LIMITS,
81
+ SpatialTemporalContactsFileSchema,
82
+ } from "../spatial-scene/temporal-audit.js"
29
83
  import { serializeTldr } from "../tldr.js"
30
84
  import type {
31
85
  DiagramConfig,
@@ -53,6 +107,9 @@ export const mcpMaximumRenderedPixels = 16_777_216
53
107
  export const mcpMaximumShapes = 64
54
108
  export const mcpMaximumEdges = 128
55
109
  export const mcpMaximumReturnedFindings = 40
110
+ export const mcpMaximumReturnedEntities = 256
111
+ export const mcpMaximumReturnedAuditSamples = 8_192
112
+ export const mcpMaximumReturnedDiffEntries = 1_024
56
113
 
57
114
  const defaultScale = 2
58
115
  const maximumShapeIdsPerFinding = 12
@@ -69,6 +126,30 @@ const findingSchema = {
69
126
  },
70
127
  } as const
71
128
 
129
+ const scenePathSchema = {
130
+ type: "string",
131
+ description:
132
+ "Root-relative path to a spatial scene JSON source (1 MiB maximum).",
133
+ } as const
134
+
135
+ const sceneCameraIdSchema = {
136
+ type: "string",
137
+ maxLength: 128,
138
+ pattern: "^camera_[a-zA-Z0-9][a-zA-Z0-9_-]*$",
139
+ description: "Identifier of a camera declared by the scene (camera_…).",
140
+ } as const
141
+
142
+ const entityTruncationSummarySchema = {
143
+ type: "object",
144
+ additionalProperties: false,
145
+ required: ["entityCount", "returnedEntityCount", "entitiesTruncated"],
146
+ properties: {
147
+ entityCount: { type: "integer", minimum: 0 },
148
+ returnedEntityCount: { type: "integer", minimum: 0 },
149
+ entitiesTruncated: { type: "boolean" },
150
+ },
151
+ } as const
152
+
72
153
  function deepFreeze<T>(value: T): T {
73
154
  if (typeof value !== "object" || value === null || Object.isFrozen(value)) {
74
155
  return value
@@ -299,180 +380,1063 @@ export const slopcameraMcpTools: readonly McpToolDefinition[] = deepFreeze([
299
380
  openWorldHint: true,
300
381
  },
301
382
  },
302
- ])
303
-
304
- class ToolFailure extends Error {
305
- readonly code: string
306
- readonly issues?: readonly string[]
307
-
308
- constructor(code: string, message: string, issues?: readonly string[]) {
309
- super(message)
310
- this.name = "ToolFailure"
311
- this.code = code
312
- if (issues !== undefined) this.issues = issues
313
- }
314
- }
315
-
316
- interface ParsedCheckArguments {
317
- readonly path: string
318
- }
319
-
320
- interface ParsedRenderArguments extends ParsedCheckArguments {
321
- readonly outDirectory?: string
322
- readonly scale: number
323
- }
324
-
325
- interface ParsedSearchArguments {
326
- readonly query: string
327
- readonly limit: number
328
- }
329
-
330
- interface ParsedExecuteArguments {
331
- readonly operation: SlopcameraOperationCode
332
- readonly input: unknown
333
- }
334
-
335
- interface LoadedDiagram {
336
- readonly source: WorkspaceSource
337
- readonly spec: DiagramSpec
338
- }
339
-
340
- interface PublicArtifactPaths {
341
- readonly tldr: string
342
- readonly lightSvg: string
343
- readonly darkSvg: string
344
- readonly lightPng: string
345
- readonly darkPng: string
346
- }
347
-
348
- function isRecord(value: unknown): value is Record<string, unknown> {
349
- return typeof value === "object" && value !== null && !Array.isArray(value)
350
- }
351
-
352
- function safeFragment(value: string, maximumLength = 160): string {
353
- return value
354
- .replace(/[\u0000-\u001f\u007f]/g, " ")
355
- .replace(/\s+/g, " ")
356
- .trim()
357
- .slice(0, maximumLength)
358
- }
359
-
360
- function safeIssues(issues: readonly string[]): readonly string[] {
361
- return issues.slice(0, 24).map((issue) => safeFragment(issue, 240))
362
- }
363
-
364
- function rejectUnknownKeys(
365
- value: Record<string, unknown>,
366
- allowed: ReadonlySet<string>,
367
- ): void {
368
- const unknown = Object.keys(value).filter((key) => !allowed.has(key))
369
- if (unknown.length > 0) {
383
+ {
384
+ name: "check_scene",
385
+ title: "Check scene",
386
+ description:
387
+ "Parse and validate one root-relative Slopcamera spatial scene JSON source without changing files.",
388
+ inputSchema: {
389
+ type: "object",
390
+ additionalProperties: false,
391
+ required: ["path"],
392
+ properties: { path: scenePathSchema },
393
+ },
394
+ outputSchema: {
395
+ type: "object",
396
+ additionalProperties: false,
397
+ required: ["ok", "source", "sceneId", "sceneSha256", "summary"],
398
+ properties: {
399
+ ok: { const: true },
400
+ source: { type: "string" },
401
+ sceneId: { type: "string" },
402
+ sceneSha256: { type: "string" },
403
+ summary: {
404
+ type: "object",
405
+ additionalProperties: false,
406
+ required: [
407
+ "durationUs",
408
+ "entityCount",
409
+ "cameraCount",
410
+ "assetCount",
411
+ "animationCount",
412
+ "generatorCount",
413
+ "overrideCount",
414
+ ],
415
+ properties: {
416
+ durationUs: { type: "integer", minimum: 0 },
417
+ entityCount: { type: "integer", minimum: 0 },
418
+ cameraCount: { type: "integer", minimum: 0 },
419
+ assetCount: { type: "integer", minimum: 0 },
420
+ animationCount: { type: "integer", minimum: 0 },
421
+ generatorCount: { type: "integer", minimum: 0 },
422
+ overrideCount: { type: "integer", minimum: 0 },
423
+ },
424
+ },
425
+ },
426
+ },
427
+ annotations: {
428
+ title: "Check scene",
429
+ readOnlyHint: true,
430
+ destructiveHint: false,
431
+ idempotentHint: true,
432
+ openWorldHint: false,
433
+ },
434
+ },
435
+ {
436
+ name: "inspect_scene",
437
+ title: "Inspect scene",
438
+ description:
439
+ "Snapshot one root-relative spatial scene's entities, editable controls, placements, assets, and generators without changing files.",
440
+ inputSchema: {
441
+ type: "object",
442
+ additionalProperties: false,
443
+ required: ["path"],
444
+ properties: { path: scenePathSchema },
445
+ },
446
+ outputSchema: {
447
+ type: "object",
448
+ additionalProperties: false,
449
+ required: ["ok", "source", "inspection", "summary"],
450
+ properties: {
451
+ ok: { const: true },
452
+ source: { type: "string" },
453
+ inspection: { type: "object" },
454
+ summary: entityTruncationSummarySchema,
455
+ },
456
+ },
457
+ annotations: {
458
+ title: "Inspect scene",
459
+ readOnlyHint: true,
460
+ destructiveHint: false,
461
+ idempotentHint: true,
462
+ openWorldHint: false,
463
+ },
464
+ },
465
+ {
466
+ name: "audit_scene",
467
+ title: "Audit scene",
468
+ description:
469
+ "Sample one root-relative spatial scene against a camera and report per-entity geometric coverage, visibility findings, and bounds gaps. Optional asset_bounds accepts a Record<assetId,{min,max}> map, a slopcamera.spatial-asset-admission document, a {manifest,facts} pair, or an array of those.",
470
+ inputSchema: {
471
+ type: "object",
472
+ additionalProperties: false,
473
+ required: ["path", "camera_id"],
474
+ properties: {
475
+ path: scenePathSchema,
476
+ camera_id: sceneCameraIdSchema,
477
+ times_us: {
478
+ type: "array",
479
+ items: {
480
+ type: "integer",
481
+ minimum: 0,
482
+ maximum: SPATIAL_SCENE_LIMITS.durationUs,
483
+ },
484
+ minItems: 1,
485
+ maxItems: SPATIAL_AUDIT_LIMITS.samples,
486
+ description:
487
+ "Optional sample times in microseconds. Defaults to evenly spaced coverage of the scene duration.",
488
+ },
489
+ asset_bounds: {
490
+ description:
491
+ "Optional decoded scene-space asset bounds: a Record<assetId,{min,max}> map, a slopcamera.spatial-asset-admission document, a {manifest,facts} pair, or an array of those documents.",
492
+ },
493
+ },
494
+ },
495
+ outputSchema: {
496
+ type: "object",
497
+ additionalProperties: false,
498
+ required: ["ok", "source", "report", "summary"],
499
+ properties: {
500
+ ok: { const: true },
501
+ source: { type: "string" },
502
+ report: { type: "object" },
503
+ summary: {
504
+ type: "object",
505
+ additionalProperties: false,
506
+ required: [
507
+ "findingCount",
508
+ "returnedFindingCount",
509
+ "findingsTruncated",
510
+ "entityCount",
511
+ "returnedEntityCount",
512
+ "entitiesTruncated",
513
+ ],
514
+ properties: {
515
+ findingCount: { type: "integer", minimum: 0 },
516
+ returnedFindingCount: { type: "integer", minimum: 0 },
517
+ findingsTruncated: { type: "boolean" },
518
+ entityCount: { type: "integer", minimum: 0 },
519
+ returnedEntityCount: { type: "integer", minimum: 0 },
520
+ entitiesTruncated: { type: "boolean" },
521
+ },
522
+ },
523
+ },
524
+ },
525
+ annotations: {
526
+ title: "Audit scene",
527
+ readOnlyHint: true,
528
+ destructiveHint: false,
529
+ idempotentHint: true,
530
+ openWorldHint: false,
531
+ },
532
+ },
533
+ {
534
+ name: "diff_scenes",
535
+ title: "Diff scenes",
536
+ description:
537
+ "Compare two root-relative spatial scene JSON sources and report added, removed, and changed collection entries without changing files.",
538
+ inputSchema: {
539
+ type: "object",
540
+ additionalProperties: false,
541
+ required: ["path", "other"],
542
+ properties: {
543
+ path: scenePathSchema,
544
+ other: {
545
+ type: "string",
546
+ description:
547
+ "Root-relative path to the second spatial scene JSON source (1 MiB maximum).",
548
+ },
549
+ },
550
+ },
551
+ outputSchema: {
552
+ type: "object",
553
+ additionalProperties: false,
554
+ required: [
555
+ "ok",
556
+ "source",
557
+ "other",
558
+ "sceneSha256",
559
+ "otherSha256",
560
+ "diff",
561
+ "summary",
562
+ ],
563
+ properties: {
564
+ ok: { const: true },
565
+ source: { type: "string" },
566
+ other: { type: "string" },
567
+ sceneSha256: { type: "string" },
568
+ otherSha256: { type: "string" },
569
+ diff: { type: "array" },
570
+ summary: {
571
+ type: "object",
572
+ additionalProperties: false,
573
+ required: ["entryCount", "returnedEntryCount", "diffTruncated"],
574
+ properties: {
575
+ entryCount: { type: "integer", minimum: 0 },
576
+ returnedEntryCount: { type: "integer", minimum: 0 },
577
+ diffTruncated: { type: "boolean" },
578
+ },
579
+ },
580
+ },
581
+ },
582
+ annotations: {
583
+ title: "Diff scenes",
584
+ readOnlyHint: true,
585
+ destructiveHint: false,
586
+ idempotentHint: true,
587
+ openWorldHint: false,
588
+ },
589
+ },
590
+ {
591
+ name: "evaluate_scene",
592
+ title: "Evaluate scene",
593
+ description:
594
+ "Evaluate one root-relative spatial scene at one camera and integer microsecond time into an immutable snapshot without changing files.",
595
+ inputSchema: {
596
+ type: "object",
597
+ additionalProperties: false,
598
+ required: ["path", "camera_id", "time_us"],
599
+ properties: {
600
+ path: scenePathSchema,
601
+ camera_id: sceneCameraIdSchema,
602
+ time_us: {
603
+ type: "integer",
604
+ minimum: 0,
605
+ maximum: SPATIAL_SCENE_LIMITS.durationUs,
606
+ description: "Evaluation time in integer microseconds.",
607
+ },
608
+ },
609
+ },
610
+ outputSchema: {
611
+ type: "object",
612
+ additionalProperties: false,
613
+ required: ["ok", "source", "snapshot", "summary"],
614
+ properties: {
615
+ ok: { const: true },
616
+ source: { type: "string" },
617
+ snapshot: { type: "object" },
618
+ summary: entityTruncationSummarySchema,
619
+ },
620
+ },
621
+ annotations: {
622
+ title: "Evaluate scene",
623
+ readOnlyHint: true,
624
+ destructiveHint: false,
625
+ idempotentHint: true,
626
+ openWorldHint: false,
627
+ },
628
+ },
629
+ {
630
+ name: "check_scene_direction",
631
+ title: "Check scene direction",
632
+ description:
633
+ "Validate one authored direction document against one root-relative spatial scene: stale project digests, unresolved entity references, and intervals outside scene duration report as errors without compiling or changing files.",
634
+ inputSchema: {
635
+ type: "object",
636
+ additionalProperties: false,
637
+ required: ["scene", "direction"],
638
+ properties: {
639
+ scene: { ...scenePathSchema, description: "Root-relative path to the spatial scene JSON source (1 MiB maximum)." },
640
+ direction: { type: "string", description: "Root-relative path to the slopcamera.spatial-direction JSON document." },
641
+ },
642
+ },
643
+ outputSchema: {
644
+ type: "object",
645
+ additionalProperties: false,
646
+ required: ["ok", "source", "report", "summary"],
647
+ properties: {
648
+ ok: { const: true },
649
+ source: { type: "string" },
650
+ report: { type: "object" },
651
+ summary: {
652
+ type: "object",
653
+ additionalProperties: false,
654
+ required: ["findingCount", "returnedFindingCount", "findingsTruncated", "errorCount", "warningCount"],
655
+ properties: {
656
+ findingCount: { type: "integer", minimum: 0 },
657
+ returnedFindingCount: { type: "integer", minimum: 0 },
658
+ findingsTruncated: { type: "boolean" },
659
+ errorCount: { type: "integer", minimum: 0 },
660
+ warningCount: { type: "integer", minimum: 0 },
661
+ },
662
+ },
663
+ },
664
+ },
665
+ annotations: {
666
+ title: "Check scene direction",
667
+ readOnlyHint: true,
668
+ destructiveHint: false,
669
+ idempotentHint: true,
670
+ openWorldHint: false,
671
+ },
672
+ },
673
+ {
674
+ name: "plan_scene_direction",
675
+ title: "Plan scene direction",
676
+ description:
677
+ "Compile one authored direction document into a deterministic, unverified proposal document — performance actions, camera rigs, shots and looks bound to the actual scene digest. Proposals are advisory only; nothing is applied to the scene.",
678
+ inputSchema: {
679
+ type: "object",
680
+ additionalProperties: false,
681
+ required: ["scene", "direction"],
682
+ properties: {
683
+ scene: { ...scenePathSchema, description: "Root-relative path to the spatial scene JSON source (1 MiB maximum)." },
684
+ direction: { type: "string", description: "Root-relative path to the slopcamera.spatial-direction JSON document." },
685
+ camera_id: { ...sceneCameraIdSchema, description: "Optional camera used to resolve coverage; defaults to the sole or first sorted scene camera." },
686
+ },
687
+ },
688
+ outputSchema: {
689
+ type: "object",
690
+ additionalProperties: false,
691
+ required: ["ok", "source", "compilation", "summary"],
692
+ properties: {
693
+ ok: { const: true },
694
+ source: { type: "string" },
695
+ compilation: { type: "object" },
696
+ summary: {
697
+ type: "object",
698
+ additionalProperties: false,
699
+ required: ["advisoryCount", "unresolvedIntentCount", "proposalCounts"],
700
+ properties: {
701
+ advisoryCount: { type: "integer", minimum: 0 },
702
+ unresolvedIntentCount: { type: "integer", minimum: 0 },
703
+ proposalCounts: {
704
+ type: "object",
705
+ additionalProperties: false,
706
+ required: ["performance", "cameraRigs", "shots", "lookIntents"],
707
+ properties: {
708
+ performance: { type: "integer", minimum: 0 },
709
+ cameraRigs: { type: "integer", minimum: 0 },
710
+ shots: { type: "integer", minimum: 0 },
711
+ lookIntents: { type: "integer", minimum: 0 },
712
+ },
713
+ },
714
+ },
715
+ },
716
+ },
717
+ },
718
+ annotations: {
719
+ title: "Plan scene direction",
720
+ readOnlyHint: true,
721
+ destructiveHint: false,
722
+ idempotentHint: true,
723
+ openWorldHint: false,
724
+ },
725
+ },
726
+ {
727
+ name: "plan_scene_gallery",
728
+ title: "Plan scene gallery",
729
+ description:
730
+ "Compile a bounded set of single-axis direction variants into separate content-addressed candidate compilations plus a preview-reel plan. The planner never selects or promotes a candidate.",
731
+ inputSchema: {
732
+ type: "object",
733
+ additionalProperties: false,
734
+ required: ["scene", "direction", "axis"],
735
+ properties: {
736
+ scene: { ...scenePathSchema, description: "Root-relative path to the spatial scene JSON source (1 MiB maximum)." },
737
+ direction: { type: "string", description: "Root-relative path to the slopcamera.spatial-direction JSON document." },
738
+ axis: { type: "string", enum: [...SpatialGalleryAxisSchema.options], description: "Variation axis for the bounded candidate set." },
739
+ camera_id: { ...sceneCameraIdSchema, description: "Optional camera used to resolve coverage." },
740
+ },
741
+ },
742
+ outputSchema: {
743
+ type: "object",
744
+ additionalProperties: false,
745
+ required: ["ok", "source", "gallery", "summary"],
746
+ properties: {
747
+ ok: { const: true },
748
+ source: { type: "string" },
749
+ gallery: { type: "object" },
750
+ summary: {
751
+ type: "object",
752
+ additionalProperties: false,
753
+ required: ["axis", "candidateCount", "selected"],
754
+ properties: {
755
+ axis: { type: "string" },
756
+ candidateCount: { type: "integer", minimum: 0 },
757
+ selected: { const: false },
758
+ },
759
+ },
760
+ },
761
+ },
762
+ annotations: {
763
+ title: "Plan scene gallery",
764
+ readOnlyHint: true,
765
+ destructiveHint: false,
766
+ idempotentHint: true,
767
+ openWorldHint: false,
768
+ },
769
+ },
770
+ {
771
+ name: "check_scene_effects",
772
+ title: "Check scene effects",
773
+ description:
774
+ "Validate one slopcamera.spatial-render-effects document or {document, documentSha256} binding against one scene: scene-digest bindings, entity and asset closure, and nested plan/particle/simulation digests report as findings before any host work.",
775
+ inputSchema: {
776
+ type: "object",
777
+ additionalProperties: false,
778
+ required: ["scene", "effects"],
779
+ properties: {
780
+ scene: { ...scenePathSchema, description: "Root-relative path to the spatial scene JSON source (1 MiB maximum)." },
781
+ effects: { type: "string", description: "Root-relative path to the render-effects document or binding JSON." },
782
+ },
783
+ },
784
+ outputSchema: {
785
+ type: "object",
786
+ additionalProperties: false,
787
+ required: ["ok", "source", "report", "summary"],
788
+ properties: {
789
+ ok: { const: true },
790
+ source: { type: "string" },
791
+ report: { type: "object" },
792
+ summary: {
793
+ type: "object",
794
+ additionalProperties: false,
795
+ required: ["findingCount", "returnedFindingCount", "findingsTruncated", "errorCount", "warningCount"],
796
+ properties: {
797
+ findingCount: { type: "integer", minimum: 0 },
798
+ returnedFindingCount: { type: "integer", minimum: 0 },
799
+ findingsTruncated: { type: "boolean" },
800
+ errorCount: { type: "integer", minimum: 0 },
801
+ warningCount: { type: "integer", minimum: 0 },
802
+ },
803
+ },
804
+ },
805
+ },
806
+ annotations: {
807
+ title: "Check scene effects",
808
+ readOnlyHint: true,
809
+ destructiveHint: false,
810
+ idempotentHint: true,
811
+ openWorldHint: false,
812
+ },
813
+ },
814
+ {
815
+ name: "plan_scene_effects",
816
+ title: "Plan scene effects",
817
+ description:
818
+ "Assemble a canonical {document, documentSha256} render-effects binding for one scene from a draft JSON naming renderPlan, particleSystems and simulationBakes. Entity and asset closure rejects before the document is formed; the scene digest binds here, never by caller assertion.",
819
+ inputSchema: {
820
+ type: "object",
821
+ additionalProperties: false,
822
+ required: ["scene", "draft"],
823
+ properties: {
824
+ scene: { ...scenePathSchema, description: "Root-relative path to the spatial scene JSON source (1 MiB maximum)." },
825
+ draft: { type: "string", description: "Root-relative path to the effects draft JSON ({renderPlan, particleSystems?, simulationBakes?})." },
826
+ },
827
+ },
828
+ outputSchema: {
829
+ type: "object",
830
+ additionalProperties: false,
831
+ required: ["ok", "source", "binding", "summary"],
832
+ properties: {
833
+ ok: { const: true },
834
+ source: { type: "string" },
835
+ binding: { type: "object" },
836
+ summary: {
837
+ type: "object",
838
+ additionalProperties: false,
839
+ required: ["documentSha256", "particleSystemCount", "simulationBakeCount"],
840
+ properties: {
841
+ documentSha256: { type: "string" },
842
+ particleSystemCount: { type: "integer", minimum: 0 },
843
+ simulationBakeCount: { type: "integer", minimum: 0 },
844
+ },
845
+ },
846
+ },
847
+ },
848
+ annotations: {
849
+ title: "Plan scene effects",
850
+ readOnlyHint: true,
851
+ destructiveHint: false,
852
+ idempotentHint: true,
853
+ openWorldHint: false,
854
+ },
855
+ },
856
+ {
857
+ name: "check_scene_behavior",
858
+ title: "Check scene behavior",
859
+ description:
860
+ "Validate one slopcamera.spatial-behavior document against one scene: bake-safe organism profile (input/const/fn/repeat/each/organism cells only), manifest-closure digests, entity and scene-digest bindings, channel declarations, fn-catalog resolution, wiring, and budget bounds report as findings before any host work.",
861
+ inputSchema: {
862
+ type: "object",
863
+ additionalProperties: false,
864
+ required: ["scene", "behavior"],
865
+ properties: {
866
+ scene: { ...scenePathSchema, description: "Root-relative path to the spatial scene JSON source (1 MiB maximum)." },
867
+ behavior: { type: "string", description: "Root-relative path to the spatial behavior document JSON." },
868
+ },
869
+ },
870
+ outputSchema: {
871
+ type: "object",
872
+ additionalProperties: false,
873
+ required: ["ok", "source", "report", "summary"],
874
+ properties: {
875
+ ok: { const: true },
876
+ source: { type: "string" },
877
+ report: { type: "object" },
878
+ summary: {
879
+ type: "object",
880
+ additionalProperties: false,
881
+ required: ["findingCount", "returnedFindingCount", "findingsTruncated", "errorCount", "warningCount"],
882
+ properties: {
883
+ findingCount: { type: "integer", minimum: 0 },
884
+ returnedFindingCount: { type: "integer", minimum: 0 },
885
+ findingsTruncated: { type: "boolean" },
886
+ errorCount: { type: "integer", minimum: 0 },
887
+ warningCount: { type: "integer", minimum: 0 },
888
+ },
889
+ },
890
+ },
891
+ },
892
+ annotations: {
893
+ title: "Check scene behavior",
894
+ readOnlyHint: true,
895
+ destructiveHint: false,
896
+ idempotentHint: true,
897
+ openWorldHint: false,
898
+ },
899
+ },
900
+ {
901
+ name: "audit_scene_behavior",
902
+ title: "Audit scene behavior trace",
903
+ description:
904
+ "Analyze a baked behavior trace for pathological patterns: state thrash (rapid transitions), exact periodicity (trivially cyclic channels), dead channels (constant-valued), and unreachable states (declared in transitions but never visited). Advisory findings for gallery review — never selects or promotes.",
905
+ inputSchema: {
906
+ type: "object",
907
+ additionalProperties: false,
908
+ required: ["bake"],
909
+ properties: {
910
+ bake: { type: "string", description: "Root-relative path to the behavior bake JSON (slopcamera.spatial-behavior-bake)." },
911
+ },
912
+ },
913
+ outputSchema: {
914
+ type: "object",
915
+ additionalProperties: false,
916
+ required: ["ok", "source", "report", "summary"],
917
+ properties: {
918
+ ok: { const: true },
919
+ source: { type: "string" },
920
+ report: { type: "object" },
921
+ summary: {
922
+ type: "object",
923
+ additionalProperties: false,
924
+ required: ["findingCount", "channelCount", "emittedCount"],
925
+ properties: {
926
+ findingCount: { type: "integer", minimum: 0 },
927
+ channelCount: { type: "integer", minimum: 0 },
928
+ emittedCount: { type: "integer", minimum: 0 },
929
+ },
930
+ },
931
+ },
932
+ },
933
+ annotations: {
934
+ title: "Audit scene behavior trace",
935
+ readOnlyHint: true,
936
+ destructiveHint: false,
937
+ idempotentHint: true,
938
+ openWorldHint: false,
939
+ },
940
+ },
941
+ {
942
+ name: "audit_scene_temporal",
943
+ title: "Audit scene temporal evidence",
944
+ description:
945
+ "Evaluate one root-relative spatial scene at bounded sample times under one camera and report deterministic temporal findings: visibility flicker, transform discontinuity, foot-slide against declared contact windows, camera acceleration/jerk/angular velocity, exposure and focus jumps, and resource spikes.",
946
+ inputSchema: {
947
+ type: "object",
948
+ additionalProperties: false,
949
+ required: ["path", "camera_id"],
950
+ properties: {
951
+ path: scenePathSchema,
952
+ camera_id: sceneCameraIdSchema,
953
+ times_us: {
954
+ type: "array",
955
+ items: { type: "integer", minimum: 0, maximum: SPATIAL_SCENE_LIMITS.durationUs },
956
+ minItems: 2,
957
+ maxItems: SPATIAL_TEMPORAL_AUDIT_LIMITS.samples,
958
+ description: "Optional sample times in microseconds (2–256). Defaults to evenly spaced coverage of the scene duration.",
959
+ },
960
+ contacts: { type: "string", description: "Optional root-relative path to a {contacts: [...]} ground-contact evidence JSON." },
961
+ cut_before_us: {
962
+ type: "array",
963
+ items: { type: "integer", minimum: 0, maximum: SPATIAL_SCENE_LIMITS.durationUs },
964
+ maxItems: SPATIAL_TEMPORAL_AUDIT_LIMITS.cutBoundaries,
965
+ description: "Optional intentional cut boundaries; discontinuities exactly at a cut are not findings.",
966
+ },
967
+ },
968
+ },
969
+ outputSchema: {
970
+ type: "object",
971
+ additionalProperties: false,
972
+ required: ["ok", "source", "report", "summary"],
973
+ properties: {
974
+ ok: { const: true },
975
+ source: { type: "string" },
976
+ report: { type: "object" },
977
+ summary: {
978
+ type: "object",
979
+ additionalProperties: false,
980
+ required: ["findingCount", "returnedFindingCount", "findingsTruncated", "sampleCount"],
981
+ properties: {
982
+ findingCount: { type: "integer", minimum: 0 },
983
+ returnedFindingCount: { type: "integer", minimum: 0 },
984
+ findingsTruncated: { type: "boolean" },
985
+ sampleCount: { type: "integer", minimum: 2 },
986
+ },
987
+ },
988
+ },
989
+ },
990
+ annotations: {
991
+ title: "Audit scene temporal evidence",
992
+ readOnlyHint: true,
993
+ destructiveHint: false,
994
+ idempotentHint: true,
995
+ openWorldHint: false,
996
+ },
997
+ },
998
+ ])
999
+
1000
+ class ToolFailure extends Error {
1001
+ readonly code: string
1002
+ readonly issues?: readonly string[]
1003
+
1004
+ constructor(code: string, message: string, issues?: readonly string[]) {
1005
+ super(message)
1006
+ this.name = "ToolFailure"
1007
+ this.code = code
1008
+ if (issues !== undefined) this.issues = issues
1009
+ }
1010
+ }
1011
+
1012
+ interface ParsedCheckArguments {
1013
+ readonly path: string
1014
+ }
1015
+
1016
+ interface ParsedRenderArguments extends ParsedCheckArguments {
1017
+ readonly outDirectory?: string
1018
+ readonly scale: number
1019
+ }
1020
+
1021
+ interface ParsedSearchArguments {
1022
+ readonly query: string
1023
+ readonly limit: number
1024
+ }
1025
+
1026
+ interface ParsedExecuteArguments {
1027
+ readonly operation: SlopcameraOperationCode
1028
+ readonly input: unknown
1029
+ }
1030
+
1031
+ interface ParsedSceneSourceArguments {
1032
+ readonly path: string
1033
+ }
1034
+
1035
+ interface ParsedAuditSceneArguments extends ParsedSceneSourceArguments {
1036
+ readonly cameraId: string
1037
+ readonly timesUs?: readonly number[]
1038
+ readonly assetBounds?: unknown
1039
+ }
1040
+
1041
+ interface ParsedDiffScenesArguments extends ParsedSceneSourceArguments {
1042
+ readonly other: string
1043
+ }
1044
+
1045
+ interface ParsedEvaluateSceneArguments extends ParsedSceneSourceArguments {
1046
+ readonly cameraId: string
1047
+ readonly timeUs: number
1048
+ }
1049
+
1050
+ interface ParsedSceneDirectionArguments {
1051
+ readonly scene: string
1052
+ readonly direction: string
1053
+ readonly cameraId?: string
1054
+ readonly axis?: string
1055
+ }
1056
+
1057
+ interface ParsedSceneEffectsArguments {
1058
+ readonly scene: string
1059
+ readonly effects: string
1060
+ }
1061
+
1062
+ interface ParsedSceneBehaviorArguments {
1063
+ readonly scene: string
1064
+ readonly behavior: string
1065
+ }
1066
+
1067
+ interface ParsedSceneBehaviorAuditArguments {
1068
+ readonly bake: string
1069
+ }
1070
+
1071
+ interface ParsedSceneTemporalArguments extends ParsedSceneSourceArguments {
1072
+ readonly cameraId: string
1073
+ readonly timesUs?: readonly number[]
1074
+ readonly contacts?: string
1075
+ readonly cutBeforeUs?: readonly number[]
1076
+ }
1077
+
1078
+ interface LoadedScene {
1079
+ readonly source: WorkspaceSource
1080
+ readonly scene: SpatialSceneV1
1081
+ }
1082
+
1083
+ interface LoadedDiagram {
1084
+ readonly source: WorkspaceSource
1085
+ readonly spec: DiagramSpec
1086
+ }
1087
+
1088
+ interface PublicArtifactPaths {
1089
+ readonly tldr: string
1090
+ readonly lightSvg: string
1091
+ readonly darkSvg: string
1092
+ readonly lightPng: string
1093
+ readonly darkPng: string
1094
+ }
1095
+
1096
+ function isRecord(value: unknown): value is Record<string, unknown> {
1097
+ return typeof value === "object" && value !== null && !Array.isArray(value)
1098
+ }
1099
+
1100
+ function safeFragment(value: string, maximumLength = 160): string {
1101
+ return value
1102
+ .replace(/[\u0000-\u001f\u007f]/g, " ")
1103
+ .replace(/\s+/g, " ")
1104
+ .trim()
1105
+ .slice(0, maximumLength)
1106
+ }
1107
+
1108
+ function safeIssues(issues: readonly string[]): readonly string[] {
1109
+ return issues.slice(0, 24).map((issue) => safeFragment(issue, 240))
1110
+ }
1111
+
1112
+ function rejectUnknownKeys(
1113
+ value: Record<string, unknown>,
1114
+ allowed: ReadonlySet<string>,
1115
+ ): void {
1116
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key))
1117
+ if (unknown.length > 0) {
1118
+ throw new ToolFailure(
1119
+ "INVALID_ARGUMENTS",
1120
+ `Unsupported argument: ${safeFragment(unknown[0] ?? "unknown")}.`,
1121
+ )
1122
+ }
1123
+ }
1124
+
1125
+ function parsePath(value: unknown): string {
1126
+ if (typeof value !== "string" || value.length === 0) {
1127
+ throw new ToolFailure(
1128
+ "INVALID_ARGUMENTS",
1129
+ "path must be a non-empty root-relative string.",
1130
+ )
1131
+ }
1132
+ if (!value.toLowerCase().endsWith(".diagram.json")) {
1133
+ throw new ToolFailure(
1134
+ "INVALID_ARGUMENTS",
1135
+ "path must end in .diagram.json.",
1136
+ )
1137
+ }
1138
+ return value
1139
+ }
1140
+
1141
+ function parseCheckArguments(value: unknown): ParsedCheckArguments {
1142
+ if (!isRecord(value)) {
1143
+ throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
1144
+ }
1145
+ rejectUnknownKeys(value, new Set(["path"]))
1146
+ return { path: parsePath(value.path) }
1147
+ }
1148
+
1149
+ function parseRenderArguments(value: unknown): ParsedRenderArguments {
1150
+ if (!isRecord(value)) {
1151
+ throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
1152
+ }
1153
+ rejectUnknownKeys(value, new Set(["path", "out_dir", "scale"]))
1154
+ const outDirectory = value.out_dir
1155
+ if (
1156
+ outDirectory !== undefined &&
1157
+ (typeof outDirectory !== "string" || outDirectory.length === 0)
1158
+ ) {
1159
+ throw new ToolFailure(
1160
+ "INVALID_ARGUMENTS",
1161
+ "out_dir must be a non-empty root-relative string when present.",
1162
+ )
1163
+ }
1164
+ const scale = value.scale ?? defaultScale
1165
+ if (
1166
+ typeof scale !== "number" ||
1167
+ !Number.isFinite(scale) ||
1168
+ scale <= 0 ||
1169
+ scale > mcpMaximumScale
1170
+ ) {
1171
+ throw new ToolFailure(
1172
+ "RENDER_LIMIT",
1173
+ `scale must be greater than zero and no more than ${mcpMaximumScale}.`,
1174
+ )
1175
+ }
1176
+ return {
1177
+ path: parsePath(value.path),
1178
+ ...(outDirectory === undefined ? {} : { outDirectory }),
1179
+ scale,
1180
+ }
1181
+ }
1182
+
1183
+ function parseSearchArguments(value: unknown): ParsedSearchArguments {
1184
+ if (!isRecord(value)) {
1185
+ throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
1186
+ }
1187
+ rejectUnknownKeys(value, new Set(["query", "limit"]))
1188
+ const query = value.query ?? ""
1189
+ const limit = value.limit ?? slopcameraOperationCodes.length
1190
+ if (
1191
+ typeof query !== "string" ||
1192
+ query.length > 200 ||
1193
+ /[\u0000-\u001f\u007f]/u.test(query) ||
1194
+ !Number.isInteger(limit) ||
1195
+ (limit as number) < 1 ||
1196
+ (limit as number) > 20
1197
+ ) {
1198
+ throw new ToolFailure(
1199
+ "INVALID_ARGUMENTS",
1200
+ "query must be a bounded string and limit must be an integer from 1 through 20.",
1201
+ )
1202
+ }
1203
+ return { query, limit: limit as number }
1204
+ }
1205
+
1206
+ function parseExecuteArguments(value: unknown): ParsedExecuteArguments {
1207
+ if (!isRecord(value)) {
1208
+ throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
1209
+ }
1210
+ rejectUnknownKeys(value, new Set(["operation", "input"]))
1211
+ if (
1212
+ typeof value.operation !== "string" ||
1213
+ !slopcameraOperationCodes.includes(value.operation as SlopcameraOperationCode) ||
1214
+ !isRecord(value.input)
1215
+ ) {
1216
+ throw new ToolFailure(
1217
+ "INVALID_ARGUMENTS",
1218
+ "operation must be an exact Slopcamera operation code and input must be an object.",
1219
+ )
1220
+ }
1221
+ return {
1222
+ operation: value.operation as SlopcameraOperationCode,
1223
+ input: value.input,
1224
+ }
1225
+ }
1226
+
1227
+ const sceneCameraIdPattern = /^camera_[a-zA-Z0-9][a-zA-Z0-9_-]*$/u
1228
+
1229
+ function parseScenePath(value: unknown, label: string): string {
1230
+ if (typeof value !== "string" || value.length === 0) {
1231
+ throw new ToolFailure(
1232
+ "INVALID_ARGUMENTS",
1233
+ `${label} must be a non-empty root-relative string.`,
1234
+ )
1235
+ }
1236
+ if (!value.toLowerCase().endsWith(".json")) {
1237
+ throw new ToolFailure(
1238
+ "INVALID_ARGUMENTS",
1239
+ `${label} must end in .json.`,
1240
+ )
1241
+ }
1242
+ return value
1243
+ }
1244
+
1245
+ function parseSceneCameraId(value: unknown): string {
1246
+ if (
1247
+ typeof value !== "string" ||
1248
+ value.length > 128 ||
1249
+ !sceneCameraIdPattern.test(value)
1250
+ ) {
1251
+ throw new ToolFailure(
1252
+ "INVALID_ARGUMENTS",
1253
+ "camera_id must be a scene camera identifier (camera_ followed by letters, digits, _ or -).",
1254
+ )
1255
+ }
1256
+ return value
1257
+ }
1258
+
1259
+ function parseSceneSourceArguments(value: unknown): ParsedSceneSourceArguments {
1260
+ if (!isRecord(value)) {
1261
+ throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
1262
+ }
1263
+ rejectUnknownKeys(value, new Set(["path"]))
1264
+ return { path: parseScenePath(value.path, "path") }
1265
+ }
1266
+
1267
+ function parseAuditSceneArguments(value: unknown): ParsedAuditSceneArguments {
1268
+ if (!isRecord(value)) {
1269
+ throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
1270
+ }
1271
+ rejectUnknownKeys(value, new Set(["path", "camera_id", "times_us", "asset_bounds"]))
1272
+ let timesUs: readonly number[] | undefined
1273
+ if (value.times_us !== undefined) {
1274
+ if (
1275
+ !Array.isArray(value.times_us) ||
1276
+ value.times_us.length === 0 ||
1277
+ value.times_us.length > SPATIAL_AUDIT_LIMITS.samples ||
1278
+ value.times_us.some((timeUs) => (
1279
+ !Number.isSafeInteger(timeUs) ||
1280
+ (timeUs as number) < 0 ||
1281
+ (timeUs as number) > SPATIAL_SCENE_LIMITS.durationUs
1282
+ ))
1283
+ ) {
1284
+ throw new ToolFailure(
1285
+ "INVALID_ARGUMENTS",
1286
+ `times_us must be an array of 1–${SPATIAL_AUDIT_LIMITS.samples} integer microsecond times within the scene duration bound.`,
1287
+ )
1288
+ }
1289
+ timesUs = [...value.times_us] as number[]
1290
+ }
1291
+ return {
1292
+ path: parseScenePath(value.path, "path"),
1293
+ cameraId: parseSceneCameraId(value.camera_id),
1294
+ ...(timesUs === undefined ? {} : { timesUs }),
1295
+ ...("asset_bounds" in value ? { assetBounds: value.asset_bounds } : {}),
1296
+ }
1297
+ }
1298
+
1299
+ function parseDiffScenesArguments(value: unknown): ParsedDiffScenesArguments {
1300
+ if (!isRecord(value)) {
1301
+ throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
1302
+ }
1303
+ rejectUnknownKeys(value, new Set(["path", "other"]))
1304
+ return {
1305
+ path: parseScenePath(value.path, "path"),
1306
+ other: parseScenePath(value.other, "other"),
1307
+ }
1308
+ }
1309
+
1310
+ function parseEvaluateSceneArguments(value: unknown): ParsedEvaluateSceneArguments {
1311
+ if (!isRecord(value)) {
1312
+ throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
1313
+ }
1314
+ rejectUnknownKeys(value, new Set(["path", "camera_id", "time_us"]))
1315
+ const timeUs = value.time_us
1316
+ if (
1317
+ typeof timeUs !== "number" ||
1318
+ !Number.isSafeInteger(timeUs) ||
1319
+ timeUs < 0 ||
1320
+ timeUs > SPATIAL_SCENE_LIMITS.durationUs
1321
+ ) {
370
1322
  throw new ToolFailure(
371
1323
  "INVALID_ARGUMENTS",
372
- `Unsupported argument: ${safeFragment(unknown[0] ?? "unknown")}.`,
1324
+ `time_us must be an integer microsecond time from 0 through ${SPATIAL_SCENE_LIMITS.durationUs}.`,
373
1325
  )
374
1326
  }
1327
+ return {
1328
+ path: parseScenePath(value.path, "path"),
1329
+ cameraId: parseSceneCameraId(value.camera_id),
1330
+ timeUs,
1331
+ }
375
1332
  }
376
1333
 
377
- function parsePath(value: unknown): string {
378
- if (typeof value !== "string" || value.length === 0) {
1334
+ function parseSceneDirectionArguments(
1335
+ value: unknown,
1336
+ requireAxis: boolean,
1337
+ ): ParsedSceneDirectionArguments {
1338
+ if (!isRecord(value)) {
1339
+ throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
1340
+ }
1341
+ rejectUnknownKeys(value, new Set(["scene", "direction", "camera_id", "axis"]))
1342
+ if (requireAxis && !SpatialGalleryAxisSchema.safeParse(value.axis).success) {
379
1343
  throw new ToolFailure(
380
1344
  "INVALID_ARGUMENTS",
381
- "path must be a non-empty root-relative string.",
1345
+ `axis must be one of ${SpatialGalleryAxisSchema.options.join(", ")}.`,
382
1346
  )
383
1347
  }
384
- if (!value.toLowerCase().endsWith(".diagram.json")) {
1348
+ if (!requireAxis && value.axis !== undefined) {
385
1349
  throw new ToolFailure(
386
1350
  "INVALID_ARGUMENTS",
387
- "path must end in .diagram.json.",
1351
+ "axis is only valid for plan_scene_gallery.",
388
1352
  )
389
1353
  }
390
- return value
1354
+ return {
1355
+ scene: parseScenePath(value.scene, "scene"),
1356
+ direction: parseScenePath(value.direction, "direction"),
1357
+ ...(value.camera_id === undefined ? {} : { cameraId: parseSceneCameraId(value.camera_id) }),
1358
+ ...(value.axis === undefined ? {} : { axis: value.axis as string }),
1359
+ }
391
1360
  }
392
1361
 
393
- function parseCheckArguments(value: unknown): ParsedCheckArguments {
1362
+ function parseSceneEffectsArguments(
1363
+ value: unknown,
1364
+ effectsKey: "effects" | "draft",
1365
+ ): ParsedSceneEffectsArguments {
394
1366
  if (!isRecord(value)) {
395
1367
  throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
396
1368
  }
397
- rejectUnknownKeys(value, new Set(["path"]))
398
- return { path: parsePath(value.path) }
1369
+ rejectUnknownKeys(value, new Set(["scene", effectsKey]))
1370
+ return {
1371
+ scene: parseScenePath(value.scene, "scene"),
1372
+ effects: parseScenePath(value[effectsKey], effectsKey),
1373
+ }
399
1374
  }
400
1375
 
401
- function parseRenderArguments(value: unknown): ParsedRenderArguments {
1376
+ function parseSceneBehaviorArguments(
1377
+ value: unknown,
1378
+ ): ParsedSceneBehaviorArguments {
402
1379
  if (!isRecord(value)) {
403
1380
  throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
404
1381
  }
405
- rejectUnknownKeys(value, new Set(["path", "out_dir", "scale"]))
406
- const outDirectory = value.out_dir
407
- if (
408
- outDirectory !== undefined &&
409
- (typeof outDirectory !== "string" || outDirectory.length === 0)
410
- ) {
411
- throw new ToolFailure(
412
- "INVALID_ARGUMENTS",
413
- "out_dir must be a non-empty root-relative string when present.",
414
- )
415
- }
416
- const scale = value.scale ?? defaultScale
417
- if (
418
- typeof scale !== "number" ||
419
- !Number.isFinite(scale) ||
420
- scale <= 0 ||
421
- scale > mcpMaximumScale
422
- ) {
423
- throw new ToolFailure(
424
- "RENDER_LIMIT",
425
- `scale must be greater than zero and no more than ${mcpMaximumScale}.`,
426
- )
427
- }
1382
+ rejectUnknownKeys(value, new Set(["scene", "behavior"]))
428
1383
  return {
429
- path: parsePath(value.path),
430
- ...(outDirectory === undefined ? {} : { outDirectory }),
431
- scale,
1384
+ scene: parseScenePath(value.scene, "scene"),
1385
+ behavior: parseScenePath(value.behavior, "behavior"),
432
1386
  }
433
1387
  }
434
1388
 
435
- function parseSearchArguments(value: unknown): ParsedSearchArguments {
1389
+ function parseSceneBehaviorAuditArguments(
1390
+ value: unknown,
1391
+ ): ParsedSceneBehaviorAuditArguments {
436
1392
  if (!isRecord(value)) {
437
1393
  throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
438
1394
  }
439
- rejectUnknownKeys(value, new Set(["query", "limit"]))
440
- const query = value.query ?? ""
441
- const limit = value.limit ?? slopcameraOperationCodes.length
442
- if (
443
- typeof query !== "string" ||
444
- query.length > 200 ||
445
- /[\u0000-\u001f\u007f]/u.test(query) ||
446
- !Number.isInteger(limit) ||
447
- (limit as number) < 1 ||
448
- (limit as number) > 20
449
- ) {
450
- throw new ToolFailure(
451
- "INVALID_ARGUMENTS",
452
- "query must be a bounded string and limit must be an integer from 1 through 20.",
453
- )
1395
+ rejectUnknownKeys(value, new Set(["bake"]))
1396
+ return {
1397
+ bake: parseScenePath(value.bake, "bake"),
454
1398
  }
455
- return { query, limit: limit as number }
456
1399
  }
457
1400
 
458
- function parseExecuteArguments(value: unknown): ParsedExecuteArguments {
1401
+ function parseSceneTemporalArguments(value: unknown): ParsedSceneTemporalArguments {
459
1402
  if (!isRecord(value)) {
460
1403
  throw new ToolFailure("INVALID_ARGUMENTS", "Tool arguments must be an object.")
461
1404
  }
462
- rejectUnknownKeys(value, new Set(["operation", "input"]))
463
- if (
464
- typeof value.operation !== "string" ||
465
- !slopcameraOperationCodes.includes(value.operation as SlopcameraOperationCode) ||
466
- !isRecord(value.input)
467
- ) {
1405
+ rejectUnknownKeys(value, new Set(["path", "camera_id", "times_us", "contacts", "cut_before_us"]))
1406
+ const boundedTimes = (key: "times_us" | "cut_before_us", maximum: number): readonly number[] | undefined => {
1407
+ const entries = value[key]
1408
+ if (entries === undefined) return undefined
1409
+ if (
1410
+ !Array.isArray(entries) ||
1411
+ entries.length === 0 ||
1412
+ entries.length > maximum ||
1413
+ entries.some((timeUs) => (
1414
+ !Number.isSafeInteger(timeUs) ||
1415
+ (timeUs as number) < 0 ||
1416
+ (timeUs as number) > SPATIAL_SCENE_LIMITS.durationUs
1417
+ ))
1418
+ ) {
1419
+ throw new ToolFailure(
1420
+ "INVALID_ARGUMENTS",
1421
+ `${key} must be an array of 1–${maximum} integer microsecond times within the scene duration bound.`,
1422
+ )
1423
+ }
1424
+ return [...entries] as number[]
1425
+ }
1426
+ const timesUs = boundedTimes("times_us", SPATIAL_TEMPORAL_AUDIT_LIMITS.samples)
1427
+ const cutBeforeUs = boundedTimes("cut_before_us", SPATIAL_TEMPORAL_AUDIT_LIMITS.cutBoundaries)
1428
+ if (timesUs !== undefined && timesUs.length < 2) {
468
1429
  throw new ToolFailure(
469
1430
  "INVALID_ARGUMENTS",
470
- "operation must be an exact Slopcamera operation code and input must be an object.",
1431
+ "times_us must contain at least 2 sample times.",
471
1432
  )
472
1433
  }
473
1434
  return {
474
- operation: value.operation as SlopcameraOperationCode,
475
- input: value.input,
1435
+ path: parseScenePath(value.path, "path"),
1436
+ cameraId: parseSceneCameraId(value.camera_id),
1437
+ ...(timesUs === undefined ? {} : { timesUs }),
1438
+ ...(value.contacts === undefined ? {} : { contacts: parseScenePath(value.contacts, "contacts") }),
1439
+ ...(cutBeforeUs === undefined ? {} : { cutBeforeUs }),
476
1440
  }
477
1441
  }
478
1442
 
@@ -530,6 +1494,10 @@ function assertRenderLimits(spec: DiagramSpec, scale: number): void {
530
1494
  }
531
1495
  }
532
1496
 
1497
+ function boundedSlice<T>(items: readonly T[], maximum: number): readonly T[] {
1498
+ return items.length > maximum ? items.slice(0, maximum) : items
1499
+ }
1500
+
533
1501
  function publicFinding(finding: LintFinding): LintFinding {
534
1502
  return {
535
1503
  code: safeFragment(finding.code, 64),
@@ -603,6 +1571,12 @@ function failureResult(error: unknown): McpToolResult {
603
1571
  code = "INVALID_DIAGRAM"
604
1572
  message = "Diagram source did not pass validation."
605
1573
  issues = safeIssues(error.issues)
1574
+ } else if (error instanceof SlopcameraCodeError) {
1575
+ code = error.code.toUpperCase().replace(/-/g, "_")
1576
+ message = safeFragment(error.message, 320)
1577
+ } else if (error instanceof HostResourceError) {
1578
+ code = `HOST_RESOURCE_${error.code}`
1579
+ message = "Host-resource admission failed safely."
606
1580
  } else if (
607
1581
  typeof error === "object" &&
608
1582
  error !== null &&
@@ -680,6 +1654,44 @@ async function loadDiagram(
680
1654
  return { source, spec }
681
1655
  }
682
1656
 
1657
+ async function loadScene(
1658
+ boundary: WorkspaceBoundary,
1659
+ path: string,
1660
+ ): Promise<LoadedScene> {
1661
+ const source = await boundary.readSource(path, "Scene source")
1662
+ const parsed = parseJsonText(source.text)
1663
+ try {
1664
+ return { source, scene: parseSpatialScene(parsed) }
1665
+ } catch (error) {
1666
+ if (error instanceof SpatialSceneError) {
1667
+ const where = error.path === "scene" ? "" : ` at ${error.path}`
1668
+ throw new ToolFailure(
1669
+ "INVALID_SCENE",
1670
+ `Scene source did not pass validation${where}: ${safeFragment(error.message, 240)}`,
1671
+ )
1672
+ }
1673
+ throw error
1674
+ }
1675
+ }
1676
+
1677
+ function parseJsonText(text: string): unknown {
1678
+ try {
1679
+ return JSON.parse(text)
1680
+ } catch {
1681
+ throw new ToolFailure("INVALID_JSON", "Source is not valid JSON.")
1682
+ }
1683
+ }
1684
+
1685
+ /** Reads one bounded root-relative JSON document without interpreting it. */
1686
+ async function loadJson(
1687
+ boundary: WorkspaceBoundary,
1688
+ path: string,
1689
+ label: string,
1690
+ ): Promise<{ readonly source: WorkspaceSource; readonly value: unknown }> {
1691
+ const source = await boundary.readSource(path, label)
1692
+ return { source, value: parseJsonText(source.text) }
1693
+ }
1694
+
683
1695
  export class SlopcameraMcpToolRuntime {
684
1696
  readonly boundary: WorkspaceBoundary
685
1697
  readonly generateDependencies: SlopcameraGenerateDependencies
@@ -717,6 +1729,25 @@ export class SlopcameraMcpToolRuntime {
717
1729
  })
718
1730
  }
719
1731
 
1732
+ /**
1733
+ * Scene tools are dedicated read-only surface, not registry operations, so
1734
+ * they claim the diagram-check claim shape (cpu + local-io) directly.
1735
+ */
1736
+ private async withSceneAdmission<T>(
1737
+ callback: (lease: HostResourceLease) => T | Promise<T>,
1738
+ ): Promise<T> {
1739
+ return await this.hostResourceCoordinator.withLease(
1740
+ [
1741
+ { resource: "cpu", amount: 1 },
1742
+ { resource: "local-io", amount: 1 },
1743
+ ],
1744
+ async (lease) => {
1745
+ await lease.assertOwned()
1746
+ return await callback(lease)
1747
+ },
1748
+ )
1749
+ }
1750
+
720
1751
  private enqueueRender<T>(operation: () => Promise<T>): Promise<T> {
721
1752
  const result = this.renderQueue.then(operation, operation)
722
1753
  this.renderQueue = result.then(
@@ -759,6 +1790,84 @@ export class SlopcameraMcpToolRuntime {
759
1790
  const options = parseExecuteArguments(argumentsValue)
760
1791
  return await this.execute(options)
761
1792
  }
1793
+ if (name === "check_scene") {
1794
+ const options = parseSceneSourceArguments(argumentsValue)
1795
+ return await this.withSceneAdmission(
1796
+ async () => await this.checkScene(options),
1797
+ )
1798
+ }
1799
+ if (name === "inspect_scene") {
1800
+ const options = parseSceneSourceArguments(argumentsValue)
1801
+ return await this.withSceneAdmission(
1802
+ async () => await this.inspectScene(options),
1803
+ )
1804
+ }
1805
+ if (name === "audit_scene") {
1806
+ const options = parseAuditSceneArguments(argumentsValue)
1807
+ return await this.withSceneAdmission(
1808
+ async () => await this.auditScene(options),
1809
+ )
1810
+ }
1811
+ if (name === "diff_scenes") {
1812
+ const options = parseDiffScenesArguments(argumentsValue)
1813
+ return await this.withSceneAdmission(
1814
+ async () => await this.diffScenes(options),
1815
+ )
1816
+ }
1817
+ if (name === "evaluate_scene") {
1818
+ const options = parseEvaluateSceneArguments(argumentsValue)
1819
+ return await this.withSceneAdmission(
1820
+ async () => await this.evaluateScene(options),
1821
+ )
1822
+ }
1823
+ if (name === "check_scene_direction") {
1824
+ const options = parseSceneDirectionArguments(argumentsValue, false)
1825
+ return await this.withSceneAdmission(
1826
+ async () => await this.checkSceneDirection(options),
1827
+ )
1828
+ }
1829
+ if (name === "plan_scene_direction") {
1830
+ const options = parseSceneDirectionArguments(argumentsValue, false)
1831
+ return await this.withSceneAdmission(
1832
+ async () => await this.planSceneDirection(options),
1833
+ )
1834
+ }
1835
+ if (name === "plan_scene_gallery") {
1836
+ const options = parseSceneDirectionArguments(argumentsValue, true)
1837
+ return await this.withSceneAdmission(
1838
+ async () => await this.planSceneGallery(options),
1839
+ )
1840
+ }
1841
+ if (name === "check_scene_effects") {
1842
+ const options = parseSceneEffectsArguments(argumentsValue, "effects")
1843
+ return await this.withSceneAdmission(
1844
+ async () => await this.checkSceneEffects(options),
1845
+ )
1846
+ }
1847
+ if (name === "plan_scene_effects") {
1848
+ const options = parseSceneEffectsArguments(argumentsValue, "draft")
1849
+ return await this.withSceneAdmission(
1850
+ async () => await this.planSceneEffects(options),
1851
+ )
1852
+ }
1853
+ if (name === "check_scene_behavior") {
1854
+ const options = parseSceneBehaviorArguments(argumentsValue)
1855
+ return await this.withSceneAdmission(
1856
+ async () => await this.checkSceneBehavior(options),
1857
+ )
1858
+ }
1859
+ if (name === "audit_scene_behavior") {
1860
+ const options = parseSceneBehaviorAuditArguments(argumentsValue)
1861
+ return await this.withSceneAdmission(
1862
+ async () => await this.auditSceneBehavior(options),
1863
+ )
1864
+ }
1865
+ if (name === "audit_scene_temporal") {
1866
+ const options = parseSceneTemporalArguments(argumentsValue)
1867
+ return await this.withSceneAdmission(
1868
+ async () => await this.auditSceneTemporal(options),
1869
+ )
1870
+ }
762
1871
  throw new ToolFailure("UNKNOWN_TOOL", "Requested tool is not available.")
763
1872
  } catch (error) {
764
1873
  return failureResult(error)
@@ -854,6 +1963,30 @@ export class SlopcameraMcpToolRuntime {
854
1963
  },
855
1964
  ))
856
1965
  }
1966
+ if (options.operation === "slopcamera.image.gallery") {
1967
+ const input = parseSlopcameraOperationInput(
1968
+ options.operation,
1969
+ options.input,
1970
+ ) as GallerySlopcameraOperationInput
1971
+ return await this.withHostAdmission(options.operation, async () => {
1972
+ const output = await this.boundary.prepareOutputDirectory(input.outputDir)
1973
+ const receipt = await generateSlopcameraImageGallery(
1974
+ { ...input, outputDir: output.absolutePath },
1975
+ this.generateDependencies,
1976
+ )
1977
+ return successResult(
1978
+ `Executed ${options.operation}: ${receipt.counts.generated}/${receipt.counts.requested} candidates in ${output.relativePath}.`,
1979
+ {
1980
+ ok: true,
1981
+ operation: options.operation,
1982
+ result: {
1983
+ ...receipt,
1984
+ outputDir: output.relativePath,
1985
+ },
1986
+ },
1987
+ )
1988
+ })
1989
+ }
857
1990
  return await this.withHostAdmission(options.operation, async () => {
858
1991
  const input = parseSlopcameraOperationInput(
859
1992
  options.operation,
@@ -960,4 +2093,388 @@ export class SlopcameraMcpToolRuntime {
960
2093
  summary,
961
2094
  })
962
2095
  }
2096
+
2097
+ private async checkScene(
2098
+ options: ParsedSceneSourceArguments,
2099
+ ): Promise<McpToolResult> {
2100
+ const { source, scene } = await loadScene(this.boundary, options.path)
2101
+ const summary = {
2102
+ durationUs: scene.durationUs,
2103
+ entityCount: scene.entities.length,
2104
+ cameraCount: scene.cameras.length,
2105
+ assetCount: scene.assets.length,
2106
+ animationCount: scene.animations.length,
2107
+ generatorCount: scene.generators.length,
2108
+ overrideCount: scene.overrides.length,
2109
+ }
2110
+ return successResult(
2111
+ `Checked ${source.relativePath}: scene ${safeFragment(scene.sceneId, 128)} is valid (${summary.entityCount} entities, ${summary.cameraCount} cameras).`,
2112
+ {
2113
+ ok: true,
2114
+ source: source.relativePath,
2115
+ sceneId: scene.sceneId,
2116
+ sceneSha256: spatialValueSha256(scene),
2117
+ summary,
2118
+ },
2119
+ )
2120
+ }
2121
+
2122
+ private async inspectScene(
2123
+ options: ParsedSceneSourceArguments,
2124
+ ): Promise<McpToolResult> {
2125
+ const { source, scene } = await loadScene(this.boundary, options.path)
2126
+ const inspection = inspectSpatialScene(scene)
2127
+ const entities = boundedSlice(
2128
+ inspection.entities,
2129
+ mcpMaximumReturnedEntities,
2130
+ )
2131
+ const summary = {
2132
+ entityCount: inspection.entities.length,
2133
+ returnedEntityCount: entities.length,
2134
+ entitiesTruncated: entities.length < inspection.entities.length,
2135
+ }
2136
+ return successResult(
2137
+ `Inspected ${source.relativePath}: ${summary.entityCount} entities${summary.entitiesTruncated ? `, first ${summary.returnedEntityCount} returned in structured content` : ""}.`,
2138
+ {
2139
+ ok: true,
2140
+ source: source.relativePath,
2141
+ inspection: { ...inspection, entities },
2142
+ summary,
2143
+ },
2144
+ )
2145
+ }
2146
+
2147
+ private async auditScene(
2148
+ options: ParsedAuditSceneArguments,
2149
+ ): Promise<McpToolResult> {
2150
+ const { source, scene } = await loadScene(this.boundary, options.path)
2151
+ const report = auditSpatialScene(scene, {
2152
+ cameraId: options.cameraId,
2153
+ ...(options.timesUs === undefined ? {} : { timesUs: options.timesUs }),
2154
+ ...(options.assetBounds === undefined
2155
+ ? {}
2156
+ : {
2157
+ assetBounds: normalizeSpatialAuditAssetBounds(options.assetBounds),
2158
+ }),
2159
+ })
2160
+ // Report rows are capped by count and by cumulative samples so one result
2161
+ // stays bounded even at the portable entitySamples limit.
2162
+ let sampleBudget = mcpMaximumReturnedAuditSamples
2163
+ const entities: SpatialAuditEntity[] = []
2164
+ for (const entity of report.entities) {
2165
+ if (
2166
+ entities.length >= mcpMaximumReturnedEntities ||
2167
+ entity.samples.length > sampleBudget
2168
+ ) {
2169
+ break
2170
+ }
2171
+ sampleBudget -= entity.samples.length
2172
+ entities.push(entity)
2173
+ }
2174
+ const findings = boundedSlice(
2175
+ report.findings,
2176
+ mcpMaximumReturnedFindings,
2177
+ )
2178
+ const findingCount = report.findings.length + report.omittedFindings
2179
+ const summary = {
2180
+ findingCount,
2181
+ returnedFindingCount: findings.length,
2182
+ findingsTruncated: findings.length < findingCount,
2183
+ entityCount: report.entities.length,
2184
+ returnedEntityCount: entities.length,
2185
+ entitiesTruncated: entities.length < report.entities.length,
2186
+ }
2187
+ return successResult(
2188
+ `Audited ${source.relativePath} under ${options.cameraId}: ${findingCount} finding${findingCount === 1 ? "" : "s"} across ${report.entities.length} entities and ${report.timesUs.length} samples${summary.findingsTruncated || summary.entitiesTruncated ? " (truncated)" : ""}.`,
2189
+ {
2190
+ ok: true,
2191
+ source: source.relativePath,
2192
+ report: { ...report, entities, findings },
2193
+ summary,
2194
+ },
2195
+ )
2196
+ }
2197
+
2198
+ private async diffScenes(
2199
+ options: ParsedDiffScenesArguments,
2200
+ ): Promise<McpToolResult> {
2201
+ const { source, scene } = await loadScene(this.boundary, options.path)
2202
+ const other = await loadScene(this.boundary, options.other)
2203
+ const entries = diffSpatialScenes(scene, other.scene)
2204
+ const diff = boundedSlice(entries, mcpMaximumReturnedDiffEntries)
2205
+ const summary = {
2206
+ entryCount: entries.length,
2207
+ returnedEntryCount: diff.length,
2208
+ diffTruncated: diff.length < entries.length,
2209
+ }
2210
+ return successResult(
2211
+ `Diffed ${source.relativePath} against ${other.source.relativePath}: ${summary.entryCount} entr${summary.entryCount === 1 ? "y" : "ies"}${summary.diffTruncated ? `, first ${summary.returnedEntryCount} returned in structured content` : ""}.`,
2212
+ {
2213
+ ok: true,
2214
+ source: source.relativePath,
2215
+ other: other.source.relativePath,
2216
+ sceneSha256: spatialValueSha256(scene),
2217
+ otherSha256: spatialValueSha256(other.scene),
2218
+ diff,
2219
+ summary,
2220
+ },
2221
+ )
2222
+ }
2223
+
2224
+ private async evaluateScene(
2225
+ options: ParsedEvaluateSceneArguments,
2226
+ ): Promise<McpToolResult> {
2227
+ const { source, scene } = await loadScene(this.boundary, options.path)
2228
+ const snapshot = evaluateSpatialScene(scene, {
2229
+ timeUs: options.timeUs,
2230
+ cameraId: options.cameraId,
2231
+ })
2232
+ const entities = boundedSlice(
2233
+ snapshot.entities,
2234
+ mcpMaximumReturnedEntities,
2235
+ )
2236
+ const summary = {
2237
+ entityCount: snapshot.entities.length,
2238
+ returnedEntityCount: entities.length,
2239
+ entitiesTruncated: entities.length < snapshot.entities.length,
2240
+ }
2241
+ return successResult(
2242
+ `Evaluated ${source.relativePath} at timeUs ${options.timeUs} under ${options.cameraId}: ${summary.entityCount} entities${summary.entitiesTruncated ? `, first ${summary.returnedEntityCount} returned in structured content` : ""}.`,
2243
+ {
2244
+ ok: true,
2245
+ source: source.relativePath,
2246
+ snapshot: { ...snapshot, entities },
2247
+ summary,
2248
+ },
2249
+ )
2250
+ }
2251
+
2252
+ private async checkSceneDirection(
2253
+ options: ParsedSceneDirectionArguments,
2254
+ ): Promise<McpToolResult> {
2255
+ const { source, scene } = await loadScene(this.boundary, options.scene)
2256
+ const direction = await loadJson(this.boundary, options.direction, "Direction source")
2257
+ const report = checkSpatialDirection({ direction: direction.value, scene })
2258
+ const findings = boundedSlice(report.findings, mcpMaximumReturnedFindings)
2259
+ const summary = {
2260
+ findingCount: report.findings.length,
2261
+ returnedFindingCount: findings.length,
2262
+ findingsTruncated: findings.length < report.findings.length,
2263
+ errorCount: report.counts.errors,
2264
+ warningCount: report.counts.warnings,
2265
+ }
2266
+ return successResult(
2267
+ `Checked direction ${direction.source.relativePath} against ${source.relativePath}: ${summary.findingCount} finding${summary.findingCount === 1 ? "" : "s"} (${summary.errorCount} errors).`,
2268
+ {
2269
+ ok: summary.errorCount === 0,
2270
+ source: source.relativePath,
2271
+ report: { ...report, findings },
2272
+ summary,
2273
+ },
2274
+ )
2275
+ }
2276
+
2277
+ private async planSceneDirection(
2278
+ options: ParsedSceneDirectionArguments,
2279
+ ): Promise<McpToolResult> {
2280
+ const { source, scene } = await loadScene(this.boundary, options.scene)
2281
+ const direction = await loadJson(this.boundary, options.direction, "Direction source")
2282
+ const compilation = compileSpatialDirection({
2283
+ direction: direction.value,
2284
+ scene,
2285
+ ...(options.cameraId === undefined ? {} : { cameraId: options.cameraId }),
2286
+ })
2287
+ const summary = {
2288
+ advisoryCount: compilation.advisories.length,
2289
+ unresolvedIntentCount: compilation.unresolvedIntents.length,
2290
+ proposalCounts: {
2291
+ performance: compilation.proposals.performance.length,
2292
+ cameraRigs: compilation.proposals.cameraRigs.length,
2293
+ shots: compilation.proposals.shots.length,
2294
+ lookIntents: compilation.proposals.lookIntents.length,
2295
+ },
2296
+ }
2297
+ return successResult(
2298
+ `Compiled direction ${direction.source.relativePath}: ${compilation.proposals.shots.length} shot proposals, ${compilation.advisories.length} advisories; all proposals remain unverified.`,
2299
+ {
2300
+ ok: true,
2301
+ source: source.relativePath,
2302
+ compilation,
2303
+ summary,
2304
+ },
2305
+ )
2306
+ }
2307
+
2308
+ private async planSceneGallery(
2309
+ options: ParsedSceneDirectionArguments,
2310
+ ): Promise<McpToolResult> {
2311
+ const { source, scene } = await loadScene(this.boundary, options.scene)
2312
+ const direction = await loadJson(this.boundary, options.direction, "Direction source")
2313
+ const gallery = planSpatialDirectionGallery({
2314
+ axis: options.axis,
2315
+ direction: direction.value,
2316
+ scene,
2317
+ ...(options.cameraId === undefined ? {} : { cameraId: options.cameraId }),
2318
+ })
2319
+ const summary = {
2320
+ axis: gallery.axis,
2321
+ candidateCount: gallery.candidates.length,
2322
+ selected: false as const,
2323
+ }
2324
+ return successResult(
2325
+ `Planned a ${gallery.axis} gallery from ${direction.source.relativePath}: ${gallery.candidates.length} content-addressed candidates; none selected.`,
2326
+ {
2327
+ ok: true,
2328
+ source: source.relativePath,
2329
+ gallery,
2330
+ summary,
2331
+ },
2332
+ )
2333
+ }
2334
+
2335
+ private async checkSceneEffects(
2336
+ options: ParsedSceneEffectsArguments,
2337
+ ): Promise<McpToolResult> {
2338
+ const { source, scene } = await loadScene(this.boundary, options.scene)
2339
+ const effects = await loadJson(this.boundary, options.effects, "Effects source")
2340
+ const report = checkSpatialRenderEffects({ effects: effects.value, scene })
2341
+ const findings = boundedSlice(report.findings, mcpMaximumReturnedFindings)
2342
+ const summary = {
2343
+ findingCount: report.findings.length,
2344
+ returnedFindingCount: findings.length,
2345
+ findingsTruncated: findings.length < report.findings.length,
2346
+ errorCount: report.counts.errors,
2347
+ warningCount: report.counts.warnings,
2348
+ }
2349
+ return successResult(
2350
+ `Checked effects ${effects.source.relativePath} against ${source.relativePath}: ${summary.findingCount} finding${summary.findingCount === 1 ? "" : "s"} (${summary.errorCount} errors).`,
2351
+ {
2352
+ ok: summary.errorCount === 0,
2353
+ source: source.relativePath,
2354
+ report: { ...report, findings },
2355
+ summary,
2356
+ },
2357
+ )
2358
+ }
2359
+
2360
+ private async planSceneEffects(
2361
+ options: ParsedSceneEffectsArguments,
2362
+ ): Promise<McpToolResult> {
2363
+ const { source, scene } = await loadScene(this.boundary, options.scene)
2364
+ const draft = await loadJson(this.boundary, options.effects, "Effects draft")
2365
+ if (!isRecord(draft.value)) {
2366
+ throw new ToolFailure("INVALID_ARGUMENTS", "Effects draft must be a JSON object with renderPlan, particleSystems and simulationBakes fields.")
2367
+ }
2368
+ const binding = planSpatialRenderEffects({ ...draft.value, scene })
2369
+ const summary = {
2370
+ documentSha256: binding.documentSha256,
2371
+ particleSystemCount: binding.document.particleSystems.length,
2372
+ simulationBakeCount: binding.document.simulationBakes.length,
2373
+ }
2374
+ return successResult(
2375
+ `Bound effects draft ${draft.source.relativePath} to scene ${summary.documentSha256.slice(0, 16)}…: ${summary.particleSystemCount} particle systems, ${summary.simulationBakeCount} simulation bakes.`,
2376
+ {
2377
+ ok: true,
2378
+ source: source.relativePath,
2379
+ binding,
2380
+ summary,
2381
+ },
2382
+ )
2383
+ }
2384
+
2385
+ private async checkSceneBehavior(
2386
+ options: ParsedSceneBehaviorArguments,
2387
+ ): Promise<McpToolResult> {
2388
+ const { source, scene } = await loadScene(this.boundary, options.scene)
2389
+ const behavior = await loadJson(this.boundary, options.behavior, "Behavior source")
2390
+ const report = checkSpatialBehavior({ behavior: behavior.value, scene }, spatialBehaviorFnSignatures())
2391
+ const findings = boundedSlice(report.findings, mcpMaximumReturnedFindings)
2392
+ const summary = {
2393
+ findingCount: report.findings.length,
2394
+ returnedFindingCount: findings.length,
2395
+ findingsTruncated: findings.length < report.findings.length,
2396
+ errorCount: report.counts.errors,
2397
+ warningCount: report.counts.warnings,
2398
+ }
2399
+ return successResult(
2400
+ `Checked behavior ${behavior.source.relativePath} against ${source.relativePath}: ${summary.findingCount} finding${summary.findingCount === 1 ? "" : "s"} (${summary.errorCount} errors).`,
2401
+ {
2402
+ ok: summary.errorCount === 0,
2403
+ source: source.relativePath,
2404
+ report: { ...report, findings },
2405
+ summary,
2406
+ },
2407
+ )
2408
+ }
2409
+
2410
+ private async auditSceneBehavior(
2411
+ options: ParsedSceneBehaviorAuditArguments,
2412
+ ): Promise<McpToolResult> {
2413
+ const bake = await loadJson(this.boundary, options.bake, "Behavior bake source")
2414
+ const document = parseSpatialValue(SpatialBehaviorBakeSchema, bake.value, "behavior bake")
2415
+ const report = auditSpatialBehaviorTrace({
2416
+ behaviorSha256: document.behaviorSha256, emittedSha256: document.receipt.emittedSha256,
2417
+ emitted: document.emitted, rangeUs: document.rangeUs,
2418
+ })
2419
+ const findings = boundedSlice(report.findings, mcpMaximumReturnedFindings)
2420
+ const summary = {
2421
+ findingCount: report.findings.length,
2422
+ channelCount: report.channelCount,
2423
+ emittedCount: report.emittedCount,
2424
+ }
2425
+ return successResult(
2426
+ `Audited behavior bake ${bake.source.relativePath}: ${summary.findingCount} finding${summary.findingCount === 1 ? "" : "s"} across ${summary.channelCount} channel${summary.channelCount === 1 ? "" : "s"}.`,
2427
+ {
2428
+ ok: true,
2429
+ source: bake.source.relativePath,
2430
+ report: { ...report, findings },
2431
+ summary,
2432
+ },
2433
+ )
2434
+ }
2435
+
2436
+ private async auditSceneTemporal(
2437
+ options: ParsedSceneTemporalArguments,
2438
+ ): Promise<McpToolResult> {
2439
+ const { source, scene } = await loadScene(this.boundary, options.path)
2440
+ const times = options.timesUs ?? (() => {
2441
+ const count = Math.min(17, Math.max(2, Math.ceil(scene.durationUs / 1_000_000) + 1))
2442
+ return Array.from({ length: count }, (_value, index) => Math.floor((scene.durationUs * index) / (count - 1)))
2443
+ })()
2444
+ const contacts = options.contacts === undefined
2445
+ ? []
2446
+ : parseSpatialValue(
2447
+ SpatialTemporalContactsFileSchema,
2448
+ (await loadJson(this.boundary, options.contacts, "Contacts source")).value,
2449
+ "temporal contacts",
2450
+ ).contacts
2451
+ const context = createSpatialEvaluationContext(scene)
2452
+ const snapshots = [...times]
2453
+ .sort((left, right) => left - right)
2454
+ .map((timeUs) => evaluateSpatialSceneInContext(context, { cameraId: options.cameraId, timeUs }))
2455
+ const report = auditSpatialTemporalEvidence({
2456
+ snapshots,
2457
+ options: {
2458
+ contacts,
2459
+ ...(options.cutBeforeUs === undefined ? {} : { cutBeforeUs: [...options.cutBeforeUs] }),
2460
+ },
2461
+ })
2462
+ const findings = boundedSlice(report.findings, mcpMaximumReturnedFindings)
2463
+ const findingCount = report.findings.length + report.omittedFindings
2464
+ const summary = {
2465
+ findingCount,
2466
+ returnedFindingCount: findings.length,
2467
+ findingsTruncated: findings.length < findingCount,
2468
+ sampleCount: report.sampleCount,
2469
+ }
2470
+ return successResult(
2471
+ `Audited ${source.relativePath} over ${report.sampleCount} samples under ${options.cameraId}: ${findingCount} temporal finding${findingCount === 1 ? "" : "s"}${summary.findingsTruncated ? " (truncated)" : ""}.`,
2472
+ {
2473
+ ok: true,
2474
+ source: source.relativePath,
2475
+ report: { ...report, findings },
2476
+ summary,
2477
+ },
2478
+ )
2479
+ }
963
2480
  }