cozyclay 2.0.2 → 2.1.1

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 (262) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +17 -2
  3. package/THIRD_PARTY_NOTICES.md +8 -0
  4. package/bin/agent/agent-routes.mjs +211 -36
  5. package/bin/agent/agent-runner.mjs +98 -4
  6. package/bin/agent/codex-client.mjs +2 -1
  7. package/bin/agent/fal-contract.mjs +0 -17
  8. package/bin/agent/h3-preservation.mjs +6 -8
  9. package/bin/agent/provider-keys.mjs +1 -1
  10. package/bin/agent/providers.mjs +124 -3
  11. package/bin/agent/script-tool.mjs +74 -0
  12. package/bin/agent/session-store.mjs +5 -5
  13. package/bin/agent/studio-prompt.mjs +2 -1
  14. package/bin/agent/studio-rebase.mjs +314 -0
  15. package/bin/agent/studio-tools.mjs +21 -14
  16. package/bin/agent/supervisor.mjs +311 -0
  17. package/bin/agent/video-adapters.mjs +0 -7
  18. package/bin/codex-auth.mjs +4 -4
  19. package/bin/live/cli.mjs +23 -8
  20. package/bin/live/client.mjs +1 -1
  21. package/bin/live/studio.mjs +5 -2
  22. package/bin/mcp-runtime.mjs +2 -2
  23. package/bin/telemetry-state.mjs +3 -3
  24. package/dist/app/index.html +6 -6
  25. package/dist/assets/analytics-C9pMpW7s.js +1 -0
  26. package/dist/assets/app-B_2XB9rw.css +1 -0
  27. package/dist/assets/app-DAuvKPP8.js +4920 -0
  28. package/dist/assets/{demo-ByeYp-dY.js → demo-CvKuBgy5.js} +1 -1
  29. package/dist/assets/{first-shot-handoff-DgT28ZiV.js → first-shot-handoff-msLI71g1.js} +1 -1
  30. package/dist/assets/{landing-BaXkpFip.js → landing-ChUvB_zL.js} +1 -1
  31. package/dist/assets/scene-asset-sync-DKkpjKBV.js +19 -0
  32. package/dist/assets/{shot-prompt-CbqWcWYi.css → scene-asset-sync-DjJ_cJ2R.css} +1 -1
  33. package/dist/assets/{ticket-BcmsnHn5.js → ticket-BPk2Ni95.js} +1 -1
  34. package/dist/assets/workflow-jJRBR94Z.js +179 -0
  35. package/dist/cozyclay-package.json +1 -1
  36. package/dist/index.html +328 -222
  37. package/dist/llms.txt +14 -0
  38. package/dist/sitemap.xml +1 -1
  39. package/dist/workflow/index.html +4 -4
  40. package/mcp/LIVE-PROTOCOL.md +23 -2
  41. package/mcp/README.md +37 -2
  42. package/mcp/ardy-prompts.mjs +1 -1
  43. package/mcp/image-file.mjs +132 -0
  44. package/mcp/live-hub.mjs +2 -2
  45. package/mcp/package.json +1 -0
  46. package/mcp/runtime/package-lock.json +7 -0
  47. package/mcp/runtime/package.json +1 -0
  48. package/mcp/server.mjs +0 -6
  49. package/mcp/tool-handlers.mjs +289 -87
  50. package/package.json +11 -2
  51. package/src/App.jsx +813 -278
  52. package/src/agent-image-client.js +94 -0
  53. package/src/analytics.js +47 -3
  54. package/src/app-stage.jsx +129 -2745
  55. package/src/ardy/auto-physics.js +6 -16
  56. package/src/ardy/collision-blockers.js +3 -7
  57. package/src/ardy/fix-collisions.js +5 -35
  58. package/src/ardy/ik-drag.js +86 -5
  59. package/src/ardy/ik-key-json.js +3 -1
  60. package/src/ardy/ik.js +15 -13
  61. package/src/ardy/motion-calibration.js +1 -1
  62. package/src/ardy/npz.js +0 -8
  63. package/src/ardy/physics-panel.jsx +3 -2
  64. package/src/ardy/physics-review.js +3 -2
  65. package/src/ardy/physics-support.js +1 -1
  66. package/src/ardy/physics-surface.js +1 -1
  67. package/src/ardy/platform-fit-panel.jsx +2 -1
  68. package/src/ardy/playback.js +1 -5
  69. package/src/ardy/range-pin.js +2 -2
  70. package/src/ardy/root-drop.js +0 -1
  71. package/src/ardy/timeline.css +184 -6
  72. package/src/ardy/timeline.jsx +121 -36
  73. package/src/ardy/to-cskel27.js +0 -2
  74. package/src/ardy/waypoints.js +0 -3
  75. package/src/ardy-bridge-contract.js +281 -0
  76. package/src/asset-pane.jsx +19 -9
  77. package/src/auto-color.js +1 -1
  78. package/src/burn-in.js +1 -1
  79. package/src/camera-block.js +1 -1
  80. package/src/camera-follow.js +75 -32
  81. package/src/camera-move.js +0 -6
  82. package/src/camera-tutorial.jsx +6 -7
  83. package/src/character-kind.js +79 -0
  84. package/src/command-bus.js +76 -17
  85. package/src/commands/cast.js +31 -7
  86. package/src/commands/elements/object.js +1 -1
  87. package/src/commands/elements/shot.js +1 -1
  88. package/src/commands/elements.js +1 -1
  89. package/src/commands/export.js +3 -3
  90. package/src/commands/motion.js +2 -2
  91. package/src/commands/objects.js +105 -10
  92. package/src/commands/project.js +15 -2
  93. package/src/commands/sculpt-images.js +53 -0
  94. package/src/commands/shot.js +88 -10
  95. package/src/commands/view.js +10 -4
  96. package/src/coplanar-depth.js +216 -0
  97. package/src/cuts.js +7 -6
  98. package/src/domains/cast.js +60 -43
  99. package/src/domains/motion/motion-intent-codec.js +50 -0
  100. package/src/domains/motion/motion-range-pin-rules.js +23 -0
  101. package/src/domains/motion/motion-video-capture.js +664 -0
  102. package/src/domains/motion.js +253 -742
  103. package/src/domains/objects.js +106 -5
  104. package/src/domains/scenes.js +79 -28
  105. package/src/domains/shots.js +33 -7
  106. package/src/error-boundary.jsx +1 -1
  107. package/src/execution-telemetry.js +5 -5
  108. package/src/facing-marks.js +14 -1
  109. package/src/first-success-guide.jsx +35 -4
  110. package/src/gizmo-claim.js +3 -1
  111. package/src/grid-floor.jsx +1 -4
  112. package/src/hierarchy-model.js +48 -35
  113. package/src/hierarchy-panel.css +1 -0
  114. package/src/hierarchy-panel.jsx +54 -102
  115. package/src/history.js +0 -2
  116. package/src/i2v-motion-client.js +1 -4
  117. package/src/ime.js +6 -0
  118. package/src/keyframe-pack.js +15 -6
  119. package/src/line-edit.js +4 -4
  120. package/src/live-control.js +6 -6
  121. package/src/matte-editor.js +2 -8
  122. package/src/matte.js +0 -19
  123. package/src/mesh-graph-clone.js +16 -2
  124. package/src/mesh-sniff.js +0 -1
  125. package/src/monotone-cubic.js +40 -0
  126. package/src/motion-store.js +4 -4
  127. package/src/motion-trail.js +5 -11
  128. package/src/multimodel-ingest.js +1 -3
  129. package/src/object-catalog.jsx +7 -5
  130. package/src/object-gizmo.jsx +45 -31
  131. package/src/object-path.js +310 -67
  132. package/src/object-travel.js +224 -0
  133. package/src/otio.js +8 -3
  134. package/src/panels/CharacterTransformPanel.jsx +24 -6
  135. package/src/panels/ObjectTransformPanel.jsx +4 -3
  136. package/src/panels/PoseImagePanel.jsx +205 -0
  137. package/src/panels/PromptBlocksPanel.jsx +3 -1
  138. package/src/panels/ProxyFigurePanel.jsx +56 -0
  139. package/src/panels/ReferenceImageField.jsx +1 -1
  140. package/src/panels/RigControlPanel.jsx +24 -3
  141. package/src/panels/RigPanel.jsx +2 -1
  142. package/src/panels/SculptPanel.jsx +405 -0
  143. package/src/panels/SubjectBox.jsx +1 -1
  144. package/src/panels/SubjectsPanel.jsx +0 -2
  145. package/src/panels/VideoCapturePanel.jsx +2 -1
  146. package/src/panels/pose-image.css +42 -0
  147. package/src/panels/pose.css +3 -0
  148. package/src/panels/sculpt.css +162 -0
  149. package/src/plan-diagram.js +184 -0
  150. package/src/planview.jsx +127 -80
  151. package/src/playground.js +3 -5
  152. package/src/pose-fade.js +77 -0
  153. package/src/pose-gizmo.css +23 -0
  154. package/src/pose-gizmo.js +96 -0
  155. package/src/posestudio.jsx +134 -83
  156. package/src/project-browser.css +53 -0
  157. package/src/project-browser.jsx +49 -4
  158. package/src/project.js +55 -24
  159. package/src/props.jsx +177 -36
  160. package/src/proxy-figure.jsx +69 -0
  161. package/src/range-pin-panel.jsx +0 -1
  162. package/src/receipt-findings.js +20 -0
  163. package/src/render-passes.js +0 -1
  164. package/src/resource-status.jsx +1 -1
  165. package/src/root-path.js +48 -0
  166. package/src/route-owner.js +52 -0
  167. package/src/scene-asset-cache.js +1 -2
  168. package/src/scene-assets.js +12 -4
  169. package/src/scene-fbx.js +1 -1
  170. package/src/scene-mesh-cache.js +0 -1
  171. package/src/scene-objects.js +319 -19
  172. package/src/scenes.js +58 -9
  173. package/src/sculpt-blueprint.js +65 -0
  174. package/src/sculpt-compare.js +384 -0
  175. package/src/sculpt-geometry.js +141 -0
  176. package/src/sculpt-mode.js +168 -0
  177. package/src/sculpt-recipe.js +385 -0
  178. package/src/sculpt-run.js +351 -0
  179. package/src/sculpt-stage.jsx +221 -0
  180. package/src/settings-menu.jsx +1 -1
  181. package/src/shell/BoardSlot.jsx +341 -0
  182. package/src/shell/BottomDock.jsx +60 -16
  183. package/src/shell/CompositionGuideButton.jsx +20 -0
  184. package/src/shell/DetailsSlot.jsx +26 -6
  185. package/src/shell/MenuBar.jsx +78 -6
  186. package/src/shell/OutlinerSlot.jsx +3 -1
  187. package/src/shell/ShotPreviewHeader.jsx +17 -0
  188. package/src/shell/TopBar.jsx +18 -3
  189. package/src/shell/ViewportToolbar.jsx +36 -31
  190. package/src/shell/dock.css +239 -0
  191. package/src/shell/glass-regions.css +3 -2
  192. package/src/shell/log-store.js +4 -4
  193. package/src/shell/mode.css +20 -0
  194. package/src/shell/studio-shell-context.js +4 -1
  195. package/src/shell/viewport.css +12 -0
  196. package/src/shot-authoring.js +69 -6
  197. package/src/stable-items.js +1 -1
  198. package/src/stage-attach-frames.js +169 -0
  199. package/src/stage-canvas-rigs.jsx +402 -0
  200. package/src/stage-capture.jsx +216 -0
  201. package/src/stage-characters.jsx +302 -0
  202. package/src/stage-editor-gizmos.jsx +1229 -0
  203. package/src/stage-labels-ko.js +204 -0
  204. package/src/stage-startup-defaults.js +182 -0
  205. package/src/starter-scenes.js +1 -1
  206. package/src/storyboard.js +24 -5
  207. package/src/studio-actions.js +42 -9
  208. package/src/studio-agent-commands.js +213 -23
  209. package/src/studio-agent-context.js +11 -9
  210. package/src/studio-agent-motion.js +2 -1
  211. package/src/studio-agent-protocol.js +60 -24
  212. package/src/studio-app-binding.js +56 -23
  213. package/src/studio-contact-sheet.js +3 -3
  214. package/src/studio-elements.js +11 -2
  215. package/src/studio-geometry-facts.js +114 -0
  216. package/src/styles.css +92 -9
  217. package/src/ui-scrub.js +4 -4
  218. package/src/ui.jsx +6 -4
  219. package/src/use-asset-shelf.js +60 -0
  220. package/src/use-camera-tutorial.js +50 -0
  221. package/src/use-embed-bridge.js +12 -0
  222. package/src/use-first-success-prompt.js +7 -0
  223. package/src/use-i2v-capture-modal.js +15 -0
  224. package/src/use-sculpt-mode.js +95 -0
  225. package/src/workflow/AgentPanel.jsx +106 -18
  226. package/src/workflow/WorkflowBuilder.jsx +1 -1
  227. package/src/workflow/agent-client.js +113 -45
  228. package/src/workflow/agent-panel.css +35 -0
  229. package/src/workflow/attachment-image.js +1 -1
  230. package/src/workflow/canvas-commands.js +3 -3
  231. package/src/workflow/cozy-scene-node.js +3 -3
  232. package/src/workflow/local-workflow.js +2 -2
  233. package/src/workflow/motion-input.js +3 -3
  234. package/src/workflow/node-schema.js +1 -1
  235. package/src/workflow/scene-asset-sync.js +2 -9
  236. package/src/workflow/shot-prompt-node.js +1 -1
  237. package/src/workflow/video-contract.js +1 -1
  238. package/src/workflow/workflow-resources.js +0 -8
  239. package/src/workflow-send.js +87 -0
  240. package/tools/ardy/__pycache__/cclay_gvhmr_worker.cpython-313.pyc +0 -0
  241. package/tools/check-eslint.mjs +57 -0
  242. package/tools/check-knip.mjs +60 -0
  243. package/tools/check-loc.mjs +54 -0
  244. package/tools/demo-worker/api-client.mjs +7 -13
  245. package/tools/demo-worker/generate.mjs +2 -4
  246. package/tools/eslint-baseline.json +23 -0
  247. package/tools/gt-render/browser.mjs +23 -0
  248. package/tools/headless-studio.mjs +85 -0
  249. package/tools/kimodo/effector-constraints.mjs +4 -4
  250. package/tools/kimodo/generate.mjs +1 -1
  251. package/tools/knip-baseline.json +331 -0
  252. package/tools/loc-baseline.json +126 -0
  253. package/tools/process-supervisor.mjs +1 -1
  254. package/tools/projflow/replay.mjs +4 -4
  255. package/tools/projflow/runner.mjs +3 -3
  256. package/tools/qa/studio-control-count.mjs +2 -0
  257. package/tools/run-tests.mjs +95 -7
  258. package/dist/assets/analytics-MVZOEW8O.js +0 -1
  259. package/dist/assets/app-BxBLC6GT.js +0 -4876
  260. package/dist/assets/app-CJdxvnrl.css +0 -1
  261. package/dist/assets/shot-prompt-hN0j_O-p.js +0 -17
  262. package/dist/assets/workflow-B5XZwVfe.js +0 -179
@@ -0,0 +1,314 @@
1
+ // Auto-rebase policy for a Studio command refused at admission.
2
+ //
3
+ // STALE_SCENE and TARGET_BUSY are admission refusals: the editor applied
4
+ // nothing. The agent used to pay a whole model turn per refusal (inspect, then
5
+ // resubmit). This module decides, conservatively and from evidence, when the
6
+ // identical command may be re-sent once, and what to tell the model when it may
7
+ // not. Pure functions over plain context/entity rows; no I/O but `fetchRows`.
8
+ //
9
+ // What "unchanged" means. The editor mints one `token` per entity and rotates
10
+ // it whenever any non-display field of that entity changes (position, rotation,
11
+ // scale, colour, parent, motion, ...; display-only name/tint do not rotate it).
12
+ // The agent's baseline is the token it last SAW for an id:
13
+ // - the turn's own context (detailed rows),
14
+ // - inspect_studio rows,
15
+ // - the state right after the agent's own applied command (read back from the
16
+ // authoritative context at the revision that command published),
17
+ // - the fresh state quoted in an earlier teaching error.
18
+ // An entity the agent merely saw as an index row (id/name/position) has no
19
+ // token and cannot be proven unchanged.
20
+ const IDENTITY = ["workspaceId", "documentEpoch", "sceneId", "sceneEpoch"];
21
+ /** Tools whose targets are entities with tokens. Shot/stage patches, framing,
22
+ * actions, undo and view changes carry no per-entity fingerprint: never retried. */
23
+ const REBASE_TOOLS = new Set(["arrange_objects", "arrange_characters", "patch_elements"]);
24
+ const DISPLAY_SKIP = new Set(["token", "detailsOmitted"]);
25
+ const MAX_LINES = 8, MAX_FIELDS = 6, MESSAGE_CAP = 900;
26
+
27
+ const round = value => typeof value === "number" ? Math.round(value * 100) / 100 : value;
28
+ const norm = value => Array.isArray(value) ? value.map(norm)
29
+ : value && typeof value === "object" ? Object.fromEntries(Object.keys(value).sort().map(key => [key, norm(value[key])])) : round(value);
30
+ const same = (a, b) => JSON.stringify(norm(a)) === JSON.stringify(norm(b));
31
+ const show = value => {
32
+ const n = norm(value);
33
+ if (n && typeof n === "object" && ["x", "y", "z"].every(axis => typeof n[axis] === "number")) return `(${n.x}, ${n.y}, ${n.z})`;
34
+ const text = JSON.stringify(n);
35
+ return text === undefined ? "absent" : text.length > 48 ? `${text.slice(0, 47)}…` : text;
36
+ };
37
+ const sameHost = (a, b) => Boolean(a && b) && IDENTITY.every(key => a[key] === b[key]);
38
+ const nameKey = value => typeof value === "string" ? value.normalize("NFC").trim().toLowerCase() : null;
39
+
40
+ /** The agent's record of what it last saw per entity id. */
41
+ function createSeen(baseline) {
42
+ const rows = new Map(); // id -> detailed row carrying a token
43
+ const known = new Set(); // every id the agent has been shown, detailed or indexed
44
+ const index = new Map(); // id -> last compact row (name/position) the agent saw
45
+ const seen = {
46
+ hasBaseline: false,
47
+ rows, known,
48
+ observeRow(row) {
49
+ if (!row || typeof row.id !== "string") return;
50
+ known.add(row.id);
51
+ if (typeof row.token === "string") { rows.set(row.id, structuredClone(row)); index.set(row.id, { name: row.name, position: row.position }); }
52
+ else seen.observeIndexRow(row);
53
+ },
54
+ observeIndexRow(row) {
55
+ if (!row || typeof row.id !== "string") return;
56
+ known.add(row.id);
57
+ const before = rows.get(row.id);
58
+ // A compact row that disagrees with the stored detail is newer knowledge
59
+ // without a token: the stored token no longer describes what was seen.
60
+ if (before && (!same(before.name, row.name) || !same(before.position, row.position))) rows.delete(row.id);
61
+ index.set(row.id, { name: row.name, position: row.position });
62
+ },
63
+ /** Everything in a context (or only `only` ids) was shown to the agent. */
64
+ observeContext(context, only = null) {
65
+ if (!context || typeof context !== "object") return;
66
+ const wanted = id => !only || only.has(id);
67
+ const detailed = new Set();
68
+ for (const row of Array.isArray(context.entities) ? context.entities : []) { detailed.add(row?.id); if (wanted(row?.id)) seen.observeRow(row); }
69
+ for (const row of Array.isArray(context.entityIndex) ? context.entityIndex : []) if (!detailed.has(row?.id) && wanted(row?.id)) seen.observeIndexRow(row);
70
+ if (!only) seen.hasBaseline = true;
71
+ },
72
+ /** Result of any tool: inspect rows and context, read defensively from either shape. */
73
+ observeResult(result) {
74
+ if (!result || typeof result !== "object") return;
75
+ if (result.context) seen.observeContext(result.context);
76
+ for (const row of Array.isArray(result.entities) ? result.entities : []) seen.observeRow(row);
77
+ },
78
+ /** The agent no longer holds a verifiable view of these ids. */
79
+ forget(ids) { for (const id of ids) rows.delete(id); },
80
+ drop(ids) { for (const id of ids) { rows.delete(id); known.delete(id); index.delete(id); } },
81
+ };
82
+ if (baseline) seen.observeContext(baseline);
83
+ return seen;
84
+ }
85
+
86
+ /** Every id-shaped string an argument tree mentions. Over-approximate on purpose:
87
+ * a string that merely equals an entity id makes that entity a target. */
88
+ function referencedStrings(value, out = new Set()) {
89
+ if (typeof value === "string") out.add(value);
90
+ else if (Array.isArray(value)) for (const item of value) referencedStrings(item, out);
91
+ else if (value && typeof value === "object") for (const item of Object.values(value)) referencedStrings(item, out);
92
+ return out;
93
+ }
94
+
95
+ function eligibility(name, args) {
96
+ if (!REBASE_TOOLS.has(name)) return `${name} reads state with no per-entity fingerprint, so it is never replayed automatically`;
97
+ if (name === "patch_elements" && args.ops.some(op => op?.target?.kind !== "object" && op?.target?.kind !== "character")) return "shot and stage edits have no per-entity fingerprint, so they are never replayed automatically";
98
+ return null;
99
+ }
100
+
101
+ function diffRow(base, current) {
102
+ if (current.detailsOmitted) return [];
103
+ const fields = [...new Set([...Object.keys(base), ...Object.keys(current)])].filter(key => !DISPLAY_SKIP.has(key));
104
+ const lines = [];
105
+ for (const key of fields) if (!same(base[key], current[key])) lines.push(`${key} ${show(base[key])} -> ${show(current[key])}`);
106
+ return lines;
107
+ }
108
+
109
+ const rowsById = rows => new Map((Array.isArray(rows) ? rows : []).filter(row => typeof row?.id === "string").map(row => [row.id, row]));
110
+
111
+ /** Compare the agent's baseline with the fresh authoritative context. */
112
+ function surveyChanges(seen, fresh, extraRows = new Map()) {
113
+ const detailed = rowsById(fresh?.entities);
114
+ for (const [id, row] of extraRows) detailed.set(id, row);
115
+ const indexed = rowsById(fresh?.entityIndex);
116
+ const changed = [], removed = [], unchecked = [];
117
+ for (const [id, base] of seen.rows) {
118
+ const current = detailed.get(id);
119
+ if (current) { if (current.token !== base.token || !same(base.name, current.name)) changed.push({ id, fields: diffRow(base, current) }); }
120
+ else if (indexed.has(id)) unchecked.push(id);
121
+ else removed.push(id);
122
+ }
123
+ for (const id of seen.known) if (!seen.rows.has(id) && !detailed.has(id) && !indexed.has(id)) removed.push(id);
124
+ const added = [...new Set([...detailed.keys(), ...indexed.keys()])].filter(id => !seen.known.has(id));
125
+ return { changed, removed: [...new Set(removed)], added, unchecked };
126
+ }
127
+
128
+ const describeChanges = (survey, only = null) => {
129
+ const wanted = id => !only || only.has(id);
130
+ const lines = [];
131
+ for (const { id, fields } of survey.changed) if (wanted(id)) lines.push(`${id}: ${fields.length ? fields.slice(0, MAX_FIELDS).join("; ") : "changed (fields not itemised)"}`);
132
+ const removed = survey.removed.filter(wanted);
133
+ if (removed.length) lines.push(`removed: ${removed.slice(0, 8).join(", ")}`);
134
+ if (!only && survey.added.length) lines.push(`added: ${survey.added.slice(0, 8).join(", ")}${survey.added.length > 8 ? ` (+${survey.added.length - 8})` : ""}`);
135
+ return lines.length > MAX_LINES ? [...lines.slice(0, MAX_LINES), `(+${lines.length - MAX_LINES} more)`] : lines;
136
+ };
137
+
138
+ /**
139
+ * Decide whether the refused command may be re-sent once.
140
+ * Retry only when ALL hold:
141
+ * 1. the tool is arrange_objects, arrange_characters, or patch_elements on object/character targets;
142
+ * 2. the refusal proved nothing was applied (the caller checks the receipt);
143
+ * 3. the fresh context is the SAME document identity and its revision differs from the one refused;
144
+ * 4. every id the arguments mention that the agent knows is still present, with the SAME token and
145
+ * name the agent last saw (a token the agent never saw counts as changed);
146
+ * 5. for create ops, no entity the agent has not seen carries the same name.
147
+ */
148
+ async function decideRebase({ name, args, seen, fresh, host, refusedRevision, fetchRows }) {
149
+ const survey = () => surveyChanges(seen, fresh, extra);
150
+ const extra = new Map();
151
+ const result = (retry, reason, targets = [], only = null) => ({ retry, reason, targets, survey: survey(), lines: describeChanges(survey(), only) });
152
+ if (!fresh || typeof fresh !== "object" || !fresh.revision) return { retry: false, reason: "no fresh context", targets: [], survey: null, lines: [] };
153
+ if (!sameHost(host, fresh.host)) return result(false, "the live document or scene changed, not just its contents");
154
+ if (fresh.revision.scene === refusedRevision) return result(false, "the scene revision did not move, so a resend would be refused the same way");
155
+ const blocked = eligibility(name, args);
156
+ if (blocked) return result(false, blocked);
157
+
158
+ const freshIds = new Set([...rowsById(fresh.entities).keys(), ...rowsById(fresh.entityIndex).keys()]);
159
+ const mentioned = referencedStrings(args);
160
+ const targets = [...new Set([...mentioned].filter(value => seen.known.has(value) || freshIds.has(value)))];
161
+ const ops = Array.isArray(args.ops) ? args.ops : [];
162
+ const creates = ops.filter(op => op?.op === "create");
163
+ if (!targets.length && !seen.hasBaseline) return result(false, "the agent has no recorded view of the scene to compare against");
164
+
165
+ const detailed = rowsById(fresh.entities);
166
+ const needDetail = targets.filter(id => !detailed.has(id) && freshIds.has(id));
167
+ if (needDetail.length && typeof fetchRows === "function") {
168
+ try { for (const row of await fetchRows(needDetail)) if (typeof row?.id === "string" && typeof row.token === "string") extra.set(row.id, row); } catch { /* unverifiable below */ }
169
+ }
170
+ const current = id => extra.get(id) ?? detailed.get(id);
171
+ const reasons = [];
172
+ for (const id of targets) {
173
+ const base = seen.rows.get(id), row = current(id);
174
+ if (!freshIds.has(id)) reasons.push(`${id} no longer exists`);
175
+ else if (!seen.known.has(id)) reasons.push(`${id} is new since you last looked`);
176
+ else if (!base) reasons.push(`${id} was not read with detail since it last changed (or you edited it before the edit reached you); its state cannot be proven unchanged`);
177
+ else if (!row) reasons.push(`${id} could not be re-read`);
178
+ else if (row.token !== base.token) reasons.push(`${id} was edited by someone else`);
179
+ else if (!same(base.name, row.name)) reasons.push(`${id} was renamed`);
180
+ }
181
+ if (reasons.length) {
182
+ const only = new Set(targets);
183
+ return { ...result(false, reasons.join("; "), targets, only), failedTargets: reasons };
184
+ }
185
+ const taken = new Map();
186
+ for (const row of [...rowsById(fresh.entities).values(), ...rowsById(fresh.entityIndex).values()]) if (!seen.known.has(row.id) && nameKey(row.name)) taken.set(nameKey(row.name), row.id);
187
+ const collisions = creates.map(op => nameKey(op.name)).filter(key => key && taken.has(key));
188
+ if (collisions.length) return result(false, `an object named "${collisions[0]}" appeared since you last looked (${taken.get(collisions[0])})`, targets);
189
+ return result(true, "targets unchanged", targets);
190
+ }
191
+
192
+ const trim = text => text.length > MESSAGE_CAP ? `${text.slice(0, MESSAGE_CAP - 1)}…` : text;
193
+
194
+ /** The teaching text appended to a STALE_SCENE the agent must handle itself. */
195
+ function staleTeaching({ original, verdict, refusedRevision, freshRevision, name }) {
196
+ const detail = verdict.lines.length ? ` What changed since you last looked: ${verdict.lines.join(" | ")}.` : "";
197
+ const why = verdict.reason === "no fresh context" ? "" : ` Not retried automatically: ${verdict.reason}.`;
198
+ return trim(`${original}${why}${detail} Your admission is already refreshed to revision ${freshRevision}${refusedRevision !== undefined ? ` (was ${refusedRevision})` : ""}: adapt ${name} to the state above and resubmit it; inspect again only for ids not listed.`);
199
+ }
200
+
201
+ /** The teaching text for a TARGET_BUSY that outlived the wait. */
202
+ function busyTeaching({ original, waitedMs, ids }) {
203
+ const gesture = /gesture/i.test(original);
204
+ const target = ids.length ? ` (it targets ${ids.slice(0, 6).join(", ")})` : "";
205
+ return trim(gesture
206
+ ? `${original} The user is dragging or editing in the editor right now${target}; nothing was applied. Waited ${waitedMs} ms and retried once, still busy. Do not loop on this tool: tell the user you are waiting, then retry after they release.`
207
+ : `${original} Nothing was applied. Waited ${waitedMs} ms and retried once, still busy${target}. Do not loop on this tool: finish or cancel the open operation, or ask the user.`);
208
+ }
209
+
210
+ /** Mark a result that only succeeded because the sidecar re-sent it. */
211
+ function annotateRebased(result, info) {
212
+ const message = trim(info.reason === "target_busy"
213
+ ? `Editor was mid-gesture; waited ${info.waitedMs} ms and re-sent the command once.`
214
+ : `Scene revision moved ${info.fromRevision} -> ${info.toRevision} by edits that left the targets unchanged; command re-sent once.`).slice(0, 120);
215
+ const warnings = [...(Array.isArray(result?.warnings) ? result.warnings : []), { code: "AUTO_REBASED", message }];
216
+ return { ...result, autoRebased: true, autoRebase: info, warnings };
217
+ }
218
+
219
+ // One bounded wait for a TARGET_BUSY editor gesture to end (under the 1.5 s ceiling).
220
+ export const BUSY_WAIT_MS = 1200;
221
+ const sleepUnlessAborted = (ms, signal) => new Promise(resolve => {
222
+ if (signal?.aborted) return resolve(false);
223
+ const done = value => { clearTimeout(timer); signal?.removeEventListener?.("abort", onAbort); resolve(value); };
224
+ const onAbort = () => done(false);
225
+ const timer = setTimeout(() => done(true), ms);
226
+ signal?.addEventListener?.("abort", onAbort, { once: true });
227
+ });
228
+
229
+ /**
230
+ * The recovery wrapper around one admission attempt (`attempt(name, args, scratch)`
231
+ * in studio-tools.mjs, which fills `scratch.fresh`, `.refusedRevision`, `.generation`).
232
+ * Exactly one re-send per call, whichever refusal: a second one is the model's.
233
+ */
234
+ export function createRebaser({ session, liveHub, workspaceHandle, mutationNames, attempt }) {
235
+ const seen = createSeen(session?.baseline);
236
+ const admission = session?.admission;
237
+ /** An inspect result re-admits at its revision (either result shape) and shows the agent entity state. */
238
+ const afterInspect = result => {
239
+ const revision = result?.context?.revision?.scene ?? result?.revision?.scene ?? result?.revision;
240
+ if (Number.isSafeInteger(revision) && admission) admission.revision = revision;
241
+ seen.observeResult(result);
242
+ };
243
+ // The agent's own applied command is the newest thing it knows about the
244
+ // entities it touched. When the receipt carries their tokens use them;
245
+ // otherwise read the authoritative context, trusting it only at the revision
246
+ // that command published (nothing slipped in between).
247
+ const observeOwnEdit = async result => {
248
+ const affected = new Set(Array.isArray(result?.affectedIds) ? result.affectedIds : []);
249
+ if (!affected.size || result?.ok === false) return;
250
+ const tokened = new Set();
251
+ for (const item of Array.isArray(result.delta) ? result.delta : []) {
252
+ if (affected.has(item?.id) && typeof item.after?.token === "string") { tokened.add(item.id); seen.observeRow({ ...(seen.rows.get(item.id) ?? {}), ...item.after, id: item.id }); }
253
+ }
254
+ const rest = new Set([...affected].filter(id => !tokened.has(id)));
255
+ if (!rest.size) return;
256
+ seen.forget(rest);
257
+ if (typeof admission?.snapshot !== "function") return;
258
+ try {
259
+ const snapshot = await admission.snapshot();
260
+ if (snapshot?.revision?.scene !== admission.revision) return;
261
+ seen.observeContext(snapshot, rest);
262
+ const present = new Set([...(snapshot.entities ?? []), ...(snapshot.entityIndex ?? [])].map(row => row.id));
263
+ seen.drop([...rest].filter(id => !present.has(id)));
264
+ } catch { /* the edit stays unverifiable; a later stale is simply not retried */ }
265
+ };
266
+ const fetchRows = async ids => {
267
+ const read = await liveHub.command("inspect_studio", { scope: "entities", ids: ids.slice(0, 32), limit: Math.min(32, ids.length) }, workspaceHandle);
268
+ return Array.isArray(read?.entities) ? read.entities : [];
269
+ };
270
+ const run = async (name, args) => {
271
+ const scratch = {};
272
+ try { return await attempt(name, args, scratch); }
273
+ catch (error) {
274
+ const refused = error?.receipt?.mutated === undefined || error.receipt.mutated === false; // nothing applied, provably
275
+ if (!admission || !mutationNames.has(name) || name === "verify_result" || !refused || scratch.generation) throw error;
276
+ const targetIds = () => [...referencedStrings(args)].filter(id => seen.known.has(id));
277
+ if (error?.code === "TARGET_BUSY") {
278
+ const waitedMs = session.busyWaitMs ?? BUSY_WAIT_MS;
279
+ if (!await (session.sleep ?? sleepUnlessAborted)(waitedMs, session.signal)) throw error;
280
+ try { return annotateRebased(await attempt(name, args, {}), { reason: "target_busy", waitedMs, retried: true }); }
281
+ catch (second) {
282
+ if (second?.code !== "TARGET_BUSY") throw second;
283
+ throw Object.assign(new Error(busyTeaching({ original: second.message, waitedMs, ids: targetIds() })), { code: "TARGET_BUSY", receipt: second.receipt, autoRebased: false });
284
+ }
285
+ }
286
+ if (error?.code !== "STALE_SCENE" || !scratch.fresh) throw error;
287
+ const fromRevision = scratch.refusedRevision, fresh = scratch.fresh;
288
+ let verdict;
289
+ try { verdict = await decideRebase({ name, args, seen, fresh, host: admission.host, refusedRevision: fromRevision, fetchRows }); }
290
+ catch { throw error; }
291
+ if (!verdict.retry) {
292
+ // The teaching error quotes the fresh state of what it lists, so that is
293
+ // now what the agent has seen of those ids.
294
+ seen.observeContext(fresh, new Set([...verdict.survey?.changed.map(item => item.id) ?? [], ...verdict.targets]));
295
+ seen.drop(verdict.survey?.removed ?? []);
296
+ throw Object.assign(new Error(staleTeaching({ original: error.message, verdict, refusedRevision: fromRevision, freshRevision: fresh.revision?.scene ?? admission.revision, name })), { code: "STALE_SCENE", receipt: error.receipt, autoRebased: false });
297
+ }
298
+ const unrelated = (verdict.survey?.changed.length ?? 0) + (verdict.survey?.added.length ?? 0) + (verdict.survey?.removed.length ?? 0);
299
+ const again = {};
300
+ let result;
301
+ try { result = await attempt(name, args, again); }
302
+ catch (second) {
303
+ // The one re-send was spent. Say what moved so the model need not guess.
304
+ if (second?.code !== "STALE_SCENE" || !again.fresh) throw second;
305
+ let more = null;
306
+ try { more = await decideRebase({ name, args, seen, fresh: again.fresh, host: admission.host, refusedRevision: again.refusedRevision, fetchRows }); } catch { /* plain error below */ }
307
+ const verdict2 = { ...(more ?? { lines: [], survey: null, targets: [] }), reason: `it was already re-sent once automatically (revision ${fromRevision} -> ${fresh.revision.scene}) and the scene moved again` };
308
+ throw Object.assign(new Error(staleTeaching({ original: second.message, verdict: verdict2, refusedRevision: again.refusedRevision, freshRevision: again.fresh.revision?.scene ?? admission.revision, name })), { code: "STALE_SCENE", receipt: second.receipt, autoRebased: false });
309
+ }
310
+ return annotateRebased(result, { reason: "stale_scene", fromRevision, toRevision: fresh.revision.scene, retried: true, targets: verdict.targets, unrelatedChanges: unrelated });
311
+ }
312
+ };
313
+ return { seen, afterInspect, observeOwnEdit, run };
314
+ }
@@ -1,15 +1,16 @@
1
1
  import { validateStudioCommand, validateStudioIdentity, STUDIO_TOOL_SCHEMAS, STUDIO_TOOLS, StudioProtocolError } from "../../src/studio-agent-protocol.js";
2
2
  import { MAX_COMMAND_TIMEOUT_MS } from "../../mcp/live-hub.mjs";
3
3
  import { generationArgs } from '../../src/motion/generation.js';
4
+ import { createRebaser } from "./studio-rebase.mjs";
4
5
 
5
- const STUDIO_TOOL_RECEIPT_NOTE = " The result may be a receipt with status \"partial\": ops[].droppedPaths names exactly which authored path each op refused, and delta[].after carries the value actually landed for that target -- quote both the requested and the landed value when you report this, never say only that some paths were not applied. A STALE_SCENE error means inspect_studio once for the fresh revision, then resubmit the identical operation with that revision; it is not a permanent failure.";
6
+ const STUDIO_TOOL_RECEIPT_NOTE = " The result may be a receipt with status \"partial\": ops[].droppedPaths names exactly which authored path each op refused, and delta[].after carries the value actually landed for that target -- quote both the requested and the landed value when you report this, never say only that some paths were not applied. The sidecar re-sends a command once, by itself, when the scene revision moved but every target is unchanged or the editor was briefly mid-gesture; such a receipt carries autoRebased: true and an AUTO_REBASED warning -- mention it. A STALE_SCENE error that still reaches you lists what changed (ids and fields) and the admission is already refreshed: adapt the operation to that and resubmit it; call inspect_studio again only for ids the list does not cover; it is not a permanent failure. A TARGET_BUSY error means the user is mid-gesture; do not loop on it.";
6
7
  const STUDIO_INSPECT_NOTE = ' The per-turn context contains entityIndex and actionIndex. Read authored state and its set schemas with scope "document", optionally selecting kinds or ids. Legacy scene/shot/motion/selection scopes are document projections too; runtime facts remain in context. Scope "entities" pages entity detail by ids or query. Pass nextCursor as cursor for the next page; on STALE_CURSOR restart without it. Scope "catalogue" lists placeable kinds and patchable paths.';
7
8
  const STUDIO_MUTATION_TOOLS = new Set(["operate_studio", "arrange_objects", "arrange_characters", "patch_elements", "frame_shot", "verify_result", "undo_edit", "run_action"]);
8
9
  const schema = name => ({ type: "function", name, description: `Studio ${name.replaceAll("_", " ")} command.${name === "generate_motion" ? " Timing: give EITHER source.durationSeconds (total) with NO per-beat seconds, OR seconds on EVERY beat with NO durationSeconds. Generation is an alias for motion.generate in the editor, including the character's root path, pose controls and take preservation. A completed receipt is undoable with undo_edit; a started receipt carries jobId for run_action job.await or job.cancel. Generation does not certify motion quality: use verify_result for motion checks and report its evidence and any warnings. One generation per user message: a second call fails with GENERATION_LIMIT." : ""}${name === "verify_result" ? " Pass exactly one of receiptId or targets (not both). An earlier receipt stays verifiable after later edits: it answers stale: true with evidenceRevision (the revision that receipt describes) beside revision (the current one), and a requested frame is captured from the current scene; report the evidence as stale, never as current." : ""}${name === "inspect_studio" ? `${STUDIO_INSPECT_NOTE} scope "actions" lists the editor actions for run_action with their availability (the reason when unavailable); with ids it answers those actions' descriptions and input schemas.` : ""}${name === "run_action" ? " Run one editor action by id (actionIndex in the context lists them) with args matching its input schema; read the schema first with inspect_studio scope \"actions\" and ids instead of guessing. A mutating action answers with a receipt (action, summary, delta) that undo_edit reverts; a job action answers status \"started\", or \"completed\" with its output when it runs to its end; only an action declared generation \"motion\" counts as this message's one generation. A document action (scenes, the project file) answers status \"completed\" and is not undoable; when it opens another scene, its host names that scene and later commands are admitted there." : ""}${STUDIO_MUTATION_TOOLS.has(name) ? STUDIO_TOOL_RECEIPT_NOTE : ""}`, parameters: STUDIO_TOOL_SCHEMAS[name] });
9
10
  export const studioToolSchemas = () => STUDIO_TOOLS.map(schema);
10
11
  const text = value => typeof value === "string" ? value : JSON.stringify(value);
11
12
 
12
- export function createStudioTools({ liveHub, workspaceHandle, session, resolveImage } = {}) {
13
+ export function createStudioTools({ liveHub, workspaceHandle, session, resolveImage, previsMode } = {}) {
13
14
  if (!liveHub?.command || !workspaceHandle) throw new StudioProtocolError("LIVE_HUB_UNAVAILABLE", "An exact Studio workspace is required.");
14
15
  const mutationNames = STUDIO_MUTATION_TOOLS;
15
16
  // The turn's command index (the context's actionIndex, from the editor's own
@@ -21,17 +22,23 @@ export function createStudioTools({ liveHub, workspaceHandle, session, resolveIm
21
22
  // One motion generation per user message, shared with generate_motion when
22
23
  // the route passes the turn's gate; a bare tools instance keeps its own.
23
24
  const generationGate = session?.generation ?? { used: false, failures: 0 };
24
- const invoke = async (name, args) => {
25
+ // What the agent last saw per entity: the basis for deciding that a stale
26
+ // revision left its targets untouched (studio-rebase.mjs).
27
+ const rebaser = createRebaser({ session, liveHub, workspaceHandle, mutationNames, attempt: (name, args, scratch) => attempt(name, args, scratch) });
28
+ // One admission attempt. `scratch` carries what the recovery path needs from
29
+ // a refusal: the revision it was admitted at and the context re-read for it.
30
+ const attempt = async (name, args, scratch = {}) => {
25
31
  const command = validateStudioCommand({ name, args });
26
- if (name === 'generate_motion' && command.args.source.kind === 'generate') return invoke('run_action', { action: 'motion.generate', args: generationArgs(command.args) });
32
+ if (name === 'generate_motion' && command.args.source.kind === 'generate') return rebaser.run('run_action', { action: 'motion.generate', args: generationArgs(command.args) });
27
33
  const action = name === "run_action" ? declared.get(command.args.action) : undefined;
28
34
  const generation = action?.generation === "motion";
29
35
  if (generation && (generationGate.used || generationGate.pending)) throw new StudioProtocolError("GENERATION_LIMIT", "One motion generation per user message. Report this result and ask the user before generating again.");
30
36
  if (generation && (generationGate.failures ?? 0) >= 2) throw new StudioProtocolError("GENERATION_LIMIT", "Two motion generation attempts already failed in this user message. Report both failures to the user and ask before generating again.");
31
37
  const payload = mutationNames.has(name) && session?.admission
32
- ? { name, args: command.args, commandId: session.admission.commandId(), host: session.admission.host, expectedRevision: session.admission.revision,
38
+ ? { name, args: command.args, commandId: session.admission.commandId(), host: session.admission.host, expectedRevision: session.admission.revision, ...(session.admission.turnId ? { turnId: session.admission.turnId } : {}),
33
39
  ...(generation && session.onJob ? { wait: false } : {}) }
34
40
  : command.args;
41
+ Object.assign(scratch, { generation, refusedRevision: payload.expectedRevision });
35
42
  let result;
36
43
  if (generation) generationGate.pending = true;
37
44
  try {
@@ -45,7 +52,7 @@ export function createStudioTools({ liveHub, workspaceHandle, session, resolveIm
45
52
  // A STALE_SCENE re-admits whichever family met it, so the retry the
46
53
  // model is told to make is admitted at the live revision.
47
54
  if (generation) generationGate.failures = (generationGate.failures ?? 0) + 1;
48
- if (session?.admission && (error?.code === "STALE_SCENE" || (mutationNames.has(name) && error?.code === "UNCERTAIN_APPLY"))) await session.admission.refresh();
55
+ if (session?.admission && (error?.code === "STALE_SCENE" || (mutationNames.has(name) && error?.code === "UNCERTAIN_APPLY"))) scratch.fresh = await session.admission.refresh();
49
56
  throw error;
50
57
  } finally { if (generation) generationGate.pending = false; }
51
58
  if (result?.ok === false) {
@@ -54,7 +61,7 @@ export function createStudioTools({ liveHub, workspaceHandle, session, resolveIm
54
61
  const code = result.code ?? result.error?.code;
55
62
  const message = result.message ?? result.error?.message ?? "Studio command failed";
56
63
  if (generation) generationGate.failures = (generationGate.failures ?? 0) + 1;
57
- if (session?.admission && (code === "STALE_SCENE" || result.mutated === true)) await session.admission.refresh();
64
+ if (session?.admission && (code === "STALE_SCENE" || result.mutated === true)) scratch.fresh = await session.admission.refresh();
58
65
  // An acknowledged Stop must settle its held card with the bus outcome,
59
66
  // not turn a proved cancellation into an interrupted/unknown tool.
60
67
  if (code === 'CANCELLED' && session?.onJob) return result;
@@ -66,16 +73,16 @@ export function createStudioTools({ liveHub, workspaceHandle, session, resolveIm
66
73
  if (name === "run_action" && result?.host && session?.admission && result.host.workspaceId === session.admission.host.workspaceId) {
67
74
  session.admission.host = validateStudioIdentity(result.host);
68
75
  }
69
- if (name === "inspect_studio" && Number.isSafeInteger(result?.context?.revision?.scene) && session?.admission) {
70
- session.admission.revision = result.context.revision.scene;
71
- }
76
+ // An inspect reports the live revision at its top level (older editors embedded a context).
77
+ if (name === "inspect_studio") rebaser.afterInspect(result);
72
78
  if (mutationNames.has(name) && session?.admission) {
73
- if (Number.isSafeInteger(result?.revision?.after)) session.admission.revision = result.revision.after;
74
- else await session.admission.refresh();
79
+ if (Number.isSafeInteger(result?.revision?.after)) session.admission.revision = result.revision.after; else await session.admission.refresh();
80
+ await rebaser.observeOwnEdit(result);
75
81
  }
76
82
  return result;
77
83
  };
78
- const tools = STUDIO_TOOLS.map(name => ({ ...schema(name), handler: args => invoke(name, args) }));
84
+ const availableTools = previsMode === "storyboard" ? STUDIO_TOOLS.filter(name => name !== "generate_motion") : STUDIO_TOOLS;
85
+ const tools = availableTools.map(name => ({ ...schema(name), handler: args => rebaser.run(name, args) }));
79
86
  tools.resolveImage = async (imageId, correlation = {}) => {
80
87
  if (typeof resolveImage !== "function") return { visualStatus: "unavailable", reason: "image resolver unavailable" };
81
88
  try {
@@ -84,7 +91,7 @@ export function createStudioTools({ liveHub, workspaceHandle, session, resolveIm
84
91
  return { visualStatus: "attached", dataUrl: result.dataUrl, imageId, revision: result.revision ?? null, receiptId: result.receiptId ?? correlation.receiptId ?? null };
85
92
  } catch { return { visualStatus: "unavailable", reason: "image attachment failed", imageId }; }
86
93
  };
87
- tools.internal = { invoke };
94
+ tools.internal = { invoke: rebaser.run };
88
95
  return tools;
89
96
  }
90
97
  export function studioToolResult(result) { return text(result); }