@dev.fast/whiteboard 0.5.1 → 0.5.2-preview.20261104.146

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 (117) hide show
  1. package/dist/{account-alias-Bw3PT3Qo.js → account-alias-BlYamXAG.js} +5 -5
  2. package/dist/{agent-cli-kHRrwO4n.js → agent-cli-D7fF5xmm.js} +9 -9
  3. package/dist/agent-cli-DLibAL4u.js +2 -0
  4. package/dist/{agent-client-CMacGbMp.js → agent-client-BCEbSPWU.js} +4 -4
  5. package/dist/{authoring-tools-CiLy46kh.js → authoring-tools-CkfxTVJm.js} +12 -8
  6. package/dist/{background-server-eZWXoVcI.js → background-server-BraP8pJ9.js} +4 -4
  7. package/dist/{background-server-BLNHbksB.js → background-server-CwToVN1g.js} +1 -1
  8. package/dist/build-info.json +1 -1
  9. package/dist/{cli-Bxmiqutp.js → cli-BpadBaGU.js} +1 -1
  10. package/dist/{cli-runner-DQPuu7kJ.js → cli-runner-BebYF50a.js} +22 -22
  11. package/dist/cli.js +2 -2
  12. package/dist/{fuzzy-match-DZjeOaPY.js → fuzzy-match-CxcsLQGx.js} +3 -3
  13. package/dist/{headless-host-Dl2KZ9IT.js → headless-host-FAlbekFD.js} +561 -415
  14. package/dist/{headless-host-B4r0s1ks.js → headless-host-cLipIufH.js} +1 -1
  15. package/dist/{import-YAIU6rB0.js → import-CZ1958L4.js} +234 -62
  16. package/dist/{local-data-C58vzLZR.js → local-data-DdFkxFK_.js} +41 -16
  17. package/dist/{mcp-COYWiGVn.js → mcp-Claiy9xE.js} +9 -6
  18. package/dist/{package-paths-Cg6fnaGp.js → package-paths-Dk7MOXxN.js} +1 -1
  19. package/dist/{profile--BYIjPkc.js → profile-Bsgzi9PA.js} +2 -2
  20. package/dist/profile-BxdzBxHW.js +2 -0
  21. package/dist/{remote-attach-C1prLjmj.js → remote-attach-LGTpwpUv.js} +4 -4
  22. package/dist/{remote-extensions-Bw029iIS.js → remote-extensions-BvV29_gk.js} +1 -1
  23. package/dist/{remote-extensions-MUUgbLJn.js → remote-extensions-L95HNwIH.js} +2 -2
  24. package/dist/{remote-language-server-z3rdHcLL.js → remote-language-server-BAznkpeu.js} +4 -4
  25. package/dist/{remote-uninstall-B3Slwor1.js → remote-uninstall-CLP79Cuk.js} +5 -5
  26. package/dist/{request-origin-B3_XI2VA.js → request-origin-D6B-zrL8.js} +27 -5
  27. package/dist/{review-app-launcher-BZEbDKuK.js → review-app-launcher-tTe8bTwg.js} +2 -2
  28. package/dist/{review-home-paths-LdGS4SpR.js → review-home-paths-BmZX2qiz.js} +1 -1
  29. package/dist/{review-telemetry-x8u-9EZb.js → review-telemetry-DACDMfqq.js} +4 -4
  30. package/dist/server/desktop-host.js +26 -38
  31. package/dist/{server-discovery-WmuFl2S3.js → server-discovery-CV5qf1J8.js} +2 -2
  32. package/dist/sharing/index.d.ts +137 -14
  33. package/dist/sharing/index.js +1 -1
  34. package/dist/{src-B2b8cCoI.js → src-BQDlJXkG.js} +44 -5
  35. package/dist/{src-DLWol7GZ.js → src-CrrW7lcY.js} +16 -1
  36. package/dist/{stored-document-migration-iwIQ4l5G.js → stored-document-migration-D1wK4yID.js} +3 -3
  37. package/dist/{thread-state-B7L1mpN_.js → thread-state-WTqSborE.js} +1 -1
  38. package/dist/{tool-failure-RLR8m9hq.js → tool-failure-YOFGHJCZ.js} +319 -55
  39. package/instructions/animation.md +69 -5
  40. package/instructions/animations/before-after-structure/README.md +27 -0
  41. package/instructions/animations/before-after-structure/animation.html +89 -0
  42. package/instructions/animations/before-after-structure/block.json +10 -0
  43. package/instructions/animations/before-after-structure/frames.json +313 -0
  44. package/instructions/animations/cache-fill-evict/README.md +24 -0
  45. package/instructions/animations/cache-fill-evict/animation.html +81 -0
  46. package/instructions/animations/cache-fill-evict/block.json +13 -0
  47. package/instructions/animations/cache-fill-evict/frames.json +79 -0
  48. package/instructions/animations/cache-fill-evict/scenarios.json +17 -0
  49. package/instructions/animations/log-replay/README.md +25 -0
  50. package/instructions/animations/log-replay/animation.html +86 -0
  51. package/instructions/animations/log-replay/block.json +10 -0
  52. package/instructions/animations/log-replay/frames.json +143 -0
  53. package/instructions/animations/log-replay/scenarios.json +18 -0
  54. package/instructions/animations/queue-workers/README.md +23 -0
  55. package/instructions/animations/queue-workers/animation.html +107 -0
  56. package/instructions/animations/queue-workers/block.json +13 -0
  57. package/instructions/animations/queue-workers/frames.json +119 -0
  58. package/instructions/animations/queue-workers/scenarios.json +18 -0
  59. package/instructions/animations/race-two-lanes/README.md +22 -0
  60. package/instructions/animations/race-two-lanes/animation.html +97 -0
  61. package/instructions/animations/race-two-lanes/block.json +1 -0
  62. package/instructions/animations/race-two-lanes/frames.json +234 -0
  63. package/instructions/animations/race-two-lanes/scenarios.json +19 -0
  64. package/instructions/animations/request-across-layers/README.md +24 -0
  65. package/instructions/animations/request-across-layers/animation.html +85 -0
  66. package/instructions/animations/request-across-layers/block.json +17 -0
  67. package/instructions/animations/request-across-layers/frames.json +58 -0
  68. package/instructions/animations/request-across-layers/scenarios.json +12 -0
  69. package/instructions/animations/request-timing/README.md +22 -0
  70. package/instructions/animations/request-timing/animation.html +120 -0
  71. package/instructions/animations/request-timing/block.json +21 -0
  72. package/instructions/animations/request-timing/markers.json +32 -0
  73. package/instructions/animations/request-timing/scenarios.json +4 -0
  74. package/instructions/animations/retry-backoff/README.md +22 -0
  75. package/instructions/animations/retry-backoff/animation.html +122 -0
  76. package/instructions/animations/retry-backoff/block.json +10 -0
  77. package/instructions/animations/retry-backoff/frames.json +65 -0
  78. package/instructions/animations/retry-backoff/scenarios.json +12 -0
  79. package/instructions/animations/state-machine/README.md +24 -0
  80. package/instructions/animations/state-machine/animation.html +95 -0
  81. package/instructions/animations/state-machine/block.json +9 -0
  82. package/instructions/animations/state-machine/frames.json +79 -0
  83. package/instructions/animations/state-machine/scenarios.json +12 -0
  84. package/instructions/animations/video-platform-architecture/README.md +24 -0
  85. package/instructions/animations/video-platform-architecture/animation.html +76 -0
  86. package/instructions/animations/video-platform-architecture/block.json +17 -0
  87. package/instructions/animations/video-platform-architecture/frames.json +97 -0
  88. package/instructions/animations/video-platform-architecture/scenarios.json +28 -0
  89. package/instructions/whiteboard.d.ts +168 -14
  90. package/package.json +4 -4
  91. package/src/animation-stage-kit.ts +150 -10
  92. package/src/cli-runner.ts +4 -1
  93. package/src/fixtures/blocks/clip.json +19 -0
  94. package/src/review-api/README.md +1 -0
  95. package/src/review-api/agent-cli.ts +3 -2
  96. package/src/review-api/animation-drafts.ts +151 -47
  97. package/src/review-api/animation-references.ts +92 -0
  98. package/src/review-api/animation-render.ts +106 -0
  99. package/src/review-api/authoring-tools.ts +10 -3
  100. package/src/review-api/document-text.ts +27 -12
  101. package/src/review-api/http.ts +52 -6
  102. package/src/review-api/instructions.ts +1 -1
  103. package/src/review-api/mcp.ts +10 -5
  104. package/src/review-api/public-tools.ts +71 -2
  105. package/src/review-api/store.ts +89 -12
  106. package/src/server/desktop-server.ts +17 -5
  107. package/src/server/headless-host.ts +4 -0
  108. package/src/server/review-gateway.ts +1 -0
  109. package/src/server/review-server-core.ts +71 -12
  110. package/src/sharing/client.ts +129 -32
  111. package/src/sharing/export.ts +42 -5
  112. package/src/sharing/file.ts +8 -0
  113. package/src/sharing/host.ts +202 -73
  114. package/src/sharing/import.ts +125 -33
  115. package/src/ui-telemetry-events.ts +25 -1
  116. package/dist/agent-cli-B8KBHuh_.js +0 -2
  117. package/dist/profile-BjcXi-oH.js +0 -2
@@ -0,0 +1,92 @@
1
+ // The reference animations shipped in instructions/animations: worked examples
2
+ // an agent copies as a starting point. Each folder is an animation draft plus a
3
+ // README, read by the same code that publishes a draft.
4
+
5
+ import { readFile, readdir } from "node:fs/promises";
6
+ import path from "node:path";
7
+
8
+ import { ReviewInputError } from "@dev.fast/whiteboard-document";
9
+ import { isMissingFileError } from "@review/fs-utils.js";
10
+ import { findReviewPackageRoot } from "@review/package-paths.js";
11
+
12
+ import { readAnimationFolder } from "./animation-drafts.js";
13
+
14
+ export type AnimationReference = {
15
+ name: string;
16
+ directory: string;
17
+ title: string;
18
+ summary: string;
19
+ };
20
+
21
+ /** The README's H1 and the paragraph right after it. */
22
+ function readmeHeading(file: string, text: string) {
23
+ const lines = text.split(/\r?\n/);
24
+ const start = lines.findIndex((line) => line.trim());
25
+ const title = lines[start]?.match(/^# (.+)$/)?.[1]?.trim();
26
+ const rest = lines.slice(start + 1);
27
+ const from = rest.findIndex((line) => line.trim());
28
+ const paragraph = [];
29
+
30
+ for (const line of from < 0 ? [] : rest.slice(from)) {
31
+ if (!line.trim() || line.startsWith("#")) break;
32
+ paragraph.push(line.trim());
33
+ }
34
+
35
+ const summary = paragraph.join(" ");
36
+
37
+ if (!title || !summary)
38
+ throw new ReviewInputError(
39
+ `${file} must open with "# Title" followed by a one-sentence summary paragraph.`,
40
+ );
41
+
42
+ return { title, summary };
43
+ }
44
+
45
+ /** Every reference animation, sorted by name, each checked as a publish would
46
+ * be. Strict (the default) throws on a broken reference, for the tests that
47
+ * keep them valid; otherwise a broken one is left out, so an agent's checkout
48
+ * and instructions never fail on a reference. */
49
+ export async function readAnimationReferences(
50
+ root = findReviewPackageRoot(import.meta.url),
51
+ { strict = true } = {},
52
+ ): Promise<AnimationReference[]> {
53
+ const parent = path.join(root, "instructions", "animations");
54
+ let entries;
55
+
56
+ try {
57
+ entries = await readdir(parent, { withFileTypes: true });
58
+ } catch (error) {
59
+ if (isMissingFileError(error)) return [];
60
+ throw error;
61
+ }
62
+
63
+ const names = entries
64
+ .filter((entry) => entry.isDirectory())
65
+ .map((entry) => entry.name)
66
+ .sort();
67
+
68
+ const references = await Promise.all(
69
+ names.map(async (name): Promise<AnimationReference | undefined> => {
70
+ const directory = path.join(parent, name);
71
+ const readme = path.join(directory, "README.md");
72
+
73
+ try {
74
+ await readAnimationFolder(directory);
75
+
76
+ return {
77
+ name,
78
+ directory,
79
+ ...readmeHeading(readme, await readFile(readme, "utf8")),
80
+ };
81
+ } catch (error) {
82
+ if (!strict) return undefined;
83
+
84
+ throw new ReviewInputError(
85
+ `Reference animation ${name}: ${error instanceof Error ? error.message : String(error)}`,
86
+ );
87
+ }
88
+ }),
89
+ );
90
+
91
+ return references.filter((reference) => reference !== undefined);
92
+ }
@@ -0,0 +1,106 @@
1
+ // An agent's look at an animation it wrote: one frame of it (or a clip at
2
+ // one marker), shown by the attached Desktop as the reader sees it and
3
+ // captured as a PNG. The server checks the stop against the document; the
4
+ // Desktop shows and captures it.
5
+
6
+ import {
7
+ type Element,
8
+ ReviewInputError,
9
+ elements,
10
+ } from "@dev.fast/whiteboard-document";
11
+ import { z } from "zod";
12
+
13
+ import type { Snapshot } from "./store.js";
14
+
15
+ export const renderQuerySchema = z.object({
16
+ targetId: z.string().min(1),
17
+ /** A frame key, or a clip's marker key; omitted, the first stop. */
18
+ frame: z.string().min(1).optional(),
19
+ });
20
+
21
+ /** What the Desktop answers: the stop it showed, and the figure's PNG. */
22
+ export const renderedFrameSchema = z.object({
23
+ frame: z.object({
24
+ key: z.string(),
25
+ title: z.string(),
26
+ position: z.number().int().nonnegative(),
27
+ count: z.number().int().positive(),
28
+ }),
29
+ scenario: z.string().optional(),
30
+ /** The figure ran past the window, so the capture cuts it. */
31
+ clipped: z.boolean(),
32
+ /** The error the stage reported, if its script threw. */
33
+ error: z.string().nullable(),
34
+ /** The capture, base64. */
35
+ png: z.string().min(1),
36
+ });
37
+
38
+ export type RenderedFrame = z.infer<typeof renderedFrameSchema>;
39
+
40
+ export interface RenderRequest {
41
+ reviewId: string;
42
+ targetId: string;
43
+ frame: string;
44
+ }
45
+
46
+ /** The host's way to a Desktop that shows the frame and captures it. */
47
+ export type RenderAnimation = (
48
+ request: RenderRequest,
49
+ ) => Promise<RenderedFrame>;
50
+
51
+ /** The stop a render shows: a frame of an animation, or a marker of a clip. */
52
+ export interface RenderTarget {
53
+ type: "animation" | "clip";
54
+ key: string;
55
+ }
56
+
57
+ type StageBlock = Extract<Element, { type: "animation" | "clip" }>;
58
+
59
+ const isStageBlock = (element: Element): element is StageBlock =>
60
+ element.type === "animation" || element.type === "clip";
61
+
62
+ /** The stop to render: `frame` checked against the block's frames or markers, or
63
+ * the first stop of the first scenario when omitted. */
64
+ export function renderTarget(
65
+ snapshot: Snapshot,
66
+ targetId: string,
67
+ frame: string | undefined,
68
+ ): RenderTarget {
69
+ const target = elements(snapshot.document).find(
70
+ (element) => element.id === targetId,
71
+ );
72
+
73
+ if (!target)
74
+ throw new ReviewInputError("Target not found in this version.", 404);
75
+
76
+ if (!isStageBlock(target))
77
+ throw new ReviewInputError(
78
+ `${targetId} is a ${target.type}; only an animation or a clip renders a frame.`,
79
+ );
80
+
81
+ const noun = target.type === "clip" ? "marker" : "frame";
82
+
83
+ const keys =
84
+ target.type === "clip"
85
+ ? target.markers.map((marker) => marker.key)
86
+ : target.frames.map((item) => item.key);
87
+
88
+ if (frame === undefined) {
89
+ const first =
90
+ target.type === "animation"
91
+ ? (target.scenarios?.[0]?.frames[0] ?? keys[0])
92
+ : keys[0];
93
+
94
+ if (first === undefined)
95
+ throw new ReviewInputError(`${targetId} has no ${noun}s to render.`);
96
+
97
+ return { type: target.type, key: first };
98
+ }
99
+
100
+ if (!keys.includes(frame))
101
+ throw new ReviewInputError(
102
+ `${frame} is not a ${noun} of ${targetId}. Its ${noun}s: ${keys.join(", ")}.`,
103
+ );
104
+
105
+ return { type: target.type, key: frame };
106
+ }
@@ -52,7 +52,7 @@ export function authoringTools(
52
52
  "While a reader may be watching, write small and often: one paragraph per edit. Insert a new diagram whole. Change one already drawn a unit at a time (a flow_node, flow_edge or step, with parentId naming the diagram), and link each added flow_node to a node already drawn.",
53
53
  ...(animationsEnabled
54
54
  ? [
55
- "An animation is edited as files: review_checkout writes it to a folder, you edit the files with your own file tools, and an edit with fromFiles naming that folder publishes it in place of content or changes. Publish the same folder again after each round of edits.",
55
+ "An animation or clip is edited as files: review_checkout writes it to a folder, you edit the files with your own file tools, and an edit with fromFiles naming that folder publishes it in place of content or changes. Publish the same folder again after each round of edits.",
56
56
  ]
57
57
  : []),
58
58
  ].join("\n"),
@@ -177,11 +177,18 @@ export function authoringTools(
177
177
  ? [
178
178
  tool(
179
179
  "checkout",
180
- "Write an animation to a folder of files to edit with your own file tools: animation.html (its html), frames.json (its frames, each with its data), scenarios.json and block.json (title, code, height, pins). Then publish the folder with review_edit, giving fromFiles instead of content or changes. dir defaults to a folder Whiteboard manages; checking out again overwrites it. A publish is refused if the animation changed after the checkout.",
180
+ "Write an animation or clip to a folder of files to edit with your own file tools: animation.html (its html), frames.json (an animation's frames, each with its data) or markers.json (a clip's markers), scenarios.json and block.json (title, code, height, pins). Then publish the folder with review_edit, giving fromFiles instead of content or changes. dir defaults to a folder Whiteboard manages; checking out again overwrites it. A publish is refused if the block changed after the checkout.",
181
181
  z.strictObject({ ...review, targetId: id, dir: id.optional() }),
182
182
  "GET",
183
183
  "/:reviewId/inspect",
184
184
  ),
185
+ tool(
186
+ "render",
187
+ "See an animation or clip as the reader does: Desktop shows one of its frames (frame: a frame key, or a clip's marker key; omitted, the first) on the open whiteboard and returns it as a PNG image, with the error its script threw, if any. The PNG is also written to the path the result names. After publishing, render each frame you changed and look at it, instead of taking screenshots of the screen. The whiteboard must be open in Desktop: review_create opens it, and review_open opens an existing one.",
188
+ z.strictObject({ ...review, targetId: id, frame: id.optional() }),
189
+ "GET",
190
+ "/:reviewId/render",
191
+ ),
185
192
  ]
186
193
  : []),
187
194
  tool("list", "List saved reviews.", z.strictObject({}), "GET", ""),
@@ -244,7 +251,7 @@ function withDraftFiles(edit: ReturnType<typeof publishedEdits>) {
244
251
  .string()
245
252
  .min(1)
246
253
  .describe(
247
- "An animation's draft folder, from review_checkout or written yourself: publishes its files as the content (insert, replace) or changes (update).",
254
+ "An animation's or clip's draft folder, from review_checkout or written yourself: publishes its files as the content (insert, replace) or changes (update). frames.json makes it an animation, markers.json a clip.",
248
255
  );
249
256
 
250
257
  const [insert, replace, update, move, remove] = edit.options;
@@ -201,21 +201,36 @@ export function documentText(
201
201
  );
202
202
  break;
203
203
  case "animation":
204
- for (const [index, frame] of element.frames.entries()) {
205
- detail(
206
- `Frame ${index + 1} [${frame.key}]: ${frame.title}${frame.caption ? ` — ${frame.caption}` : ""}`,
207
- );
204
+ case "clip":
205
+ if (element.type === "animation") {
206
+ for (const [index, frame] of element.frames.entries()) {
207
+ detail(
208
+ `Frame ${index + 1} [${frame.key}]: ${frame.title}${frame.caption ? ` — ${frame.caption}` : ""}`,
209
+ );
208
210
 
209
- if (frame.code) write(depth + 2, `Code: ${frame.code.join(", ")}`);
211
+ if (frame.code) write(depth + 2, `Code: ${frame.code.join(", ")}`);
210
212
 
211
- if (detailed && frame.data !== undefined)
212
- write(depth + 2, `Data: ${JSON.stringify(frame.data)}`);
213
- }
213
+ if (detailed && frame.data !== undefined)
214
+ write(depth + 2, `Data: ${JSON.stringify(frame.data)}`);
215
+ }
214
216
 
215
- for (const scenario of element.scenarios ?? [])
216
- detail(
217
- `Scenario [${scenario.key}] ${scenario.label}: ${scenario.frames.join(" → ")}`,
218
- );
217
+ for (const scenario of element.scenarios ?? [])
218
+ detail(
219
+ `Scenario [${scenario.key}] ${scenario.label}: ${scenario.frames.join(" → ")}`,
220
+ );
221
+ } else {
222
+ for (const [index, marker] of element.markers.entries()) {
223
+ detail(
224
+ `Marker ${index + 1} [${marker.key}]: ${marker.title}${marker.caption ? ` — ${marker.caption}` : ""}`,
225
+ );
226
+
227
+ if (marker.code)
228
+ write(depth + 2, `Code: ${marker.code.join(", ")}`);
229
+ }
230
+
231
+ for (const scenario of element.scenarios ?? [])
232
+ detail(`Scenario [${scenario.key}] ${scenario.label}`);
233
+ }
219
234
 
220
235
  if (runs) {
221
236
  const run =
@@ -57,6 +57,7 @@ import { resolveReviewStackLayers } from "@review/review-stack.js";
57
57
  import { readBoundedRequestJson } from "@review/server/hono-http.js";
58
58
  import { HttpJsonError } from "@review/server/http-json.js";
59
59
  import {
60
+ type RemoteShare,
60
61
  type SharingHostEvents,
61
62
  mountSharingHost,
62
63
  } from "@review/sharing/host.js";
@@ -67,6 +68,11 @@ import { type Context, Hono, type MiddlewareHandler } from "hono";
67
68
  import { z } from "zod";
68
69
 
69
70
  import { anchorQuotes } from "./anchor-quotes.js";
71
+ import {
72
+ type RenderAnimation,
73
+ renderQuerySchema,
74
+ renderTarget,
75
+ } from "./animation-render.js";
70
76
  import type { AskRecord } from "./ask-history.js";
71
77
  import { authoringTools } from "./authoring-tools.js";
72
78
  import { documentText } from "./document-text.js";
@@ -172,7 +178,7 @@ export interface ReviewApiHooks {
172
178
  via: ReviewRequestVia;
173
179
  agentKind?: ReviewSessionAgent;
174
180
  }) => void;
175
- sharing?: SharingHostEvents;
181
+ sharing?: SharingHostEvents & { remoteShare?: RemoteShare };
176
182
  }
177
183
 
178
184
  /** A person's save carries the whole board, which edits one at a time can
@@ -207,6 +213,8 @@ export function createReviewApi(
207
213
  /** Desktop's Ask: local agents answering questions about a selection. */
208
214
  ask?: AskHost,
209
215
  animationsEnabled: () => boolean = () => false,
216
+ /** Shows a frame of an animation in the attached Desktop and captures it. */
217
+ render?: RenderAnimation,
210
218
  ) {
211
219
  const app = new Hono();
212
220
  app.onError((error, context) => {
@@ -2085,6 +2093,32 @@ export function createReviewApi(
2085
2093
  },
2086
2094
  );
2087
2095
  });
2096
+ // One frame of an animation (or a clip at a marker) as the reader sees it:
2097
+ // the attached Desktop shows it on the open board and captures it.
2098
+ app.get("/:id/render", async (context) => {
2099
+ const query = renderQuerySchema.parse(context.req.query());
2100
+ const id = context.req.param("id");
2101
+ const target = renderTarget(readReview(id), query.targetId, query.frame);
2102
+
2103
+ if (!render)
2104
+ throw new ReviewInputError(
2105
+ "Rendering a frame needs Whiteboard Desktop, which this server cannot reach.",
2106
+ 409,
2107
+ );
2108
+
2109
+ const rendered = await render({
2110
+ reviewId: id,
2111
+ targetId: query.targetId,
2112
+ frame: target.key,
2113
+ });
2114
+
2115
+ return context.json({
2116
+ reviewId: id,
2117
+ targetId: query.targetId,
2118
+ type: target.type,
2119
+ ...rendered,
2120
+ });
2121
+ });
2088
2122
  app.get("/:id", async (context) => {
2089
2123
  const query = readQuerySchemas.get.parse(context.req.query());
2090
2124
 
@@ -2170,12 +2204,15 @@ export function createReviewApi(
2170
2204
  )
2171
2205
  : undefined;
2172
2206
 
2207
+ const animated = (type: string | undefined) =>
2208
+ type === "animation" || type === "clip";
2209
+
2173
2210
  if (
2174
- incoming.some((element) => element.type === "animation") ||
2175
- target?.type === "animation"
2211
+ incoming.some((element) => animated(element.type)) ||
2212
+ animated(target?.type)
2176
2213
  )
2177
2214
  throw new ReviewInputError(
2178
- "Animation authoring is disabled. Enable Animations in Settings under Experimental Features to edit animations. Existing animations can still be viewed.",
2215
+ "Animation authoring is disabled. Enable Animations in Settings under Experimental Features to edit animations and clips. Existing ones can still be viewed.",
2179
2216
  409,
2180
2217
  );
2181
2218
  }
@@ -2196,7 +2233,7 @@ export function createReviewApi(
2196
2233
  ...(threadId && { threadId }),
2197
2234
  };
2198
2235
 
2199
- // A copy's kept commits go when it is deleted for good.
2236
+ // A link's kept commits go when its last copy is deleted for good.
2200
2237
  const copy =
2201
2238
  input.operation.type === "delete" && store.has(input.operation.reviewId)
2202
2239
  ? store.read(input.operation.reviewId)
@@ -2206,7 +2243,16 @@ export function createReviewApi(
2206
2243
  const key = copy?.shared?.key;
2207
2244
  const kept = copy && boardRepositoryId(copy);
2208
2245
 
2209
- if (result.deleted && key && kept)
2246
+ if (
2247
+ result.deleted &&
2248
+ key &&
2249
+ kept &&
2250
+ !store
2251
+ .copiesOf(key)
2252
+ .some(
2253
+ ({ reviewId }) => boardRepositoryId(store.read(reviewId)) === kept,
2254
+ )
2255
+ )
2210
2256
  await Promise.resolve()
2211
2257
  .then(() => forgetSharedCommits(store.repositoryPath(kept), key))
2212
2258
  .catch((error) =>
@@ -30,7 +30,7 @@ export interface InstructionContext {
30
30
  /** Keep disabled experimental components out of agent guidance. */
31
31
  export const desktopBlockTypes = (animationsEnabled: boolean) =>
32
32
  writableBlockTypes.filter(
33
- (type) => animationsEnabled || type !== "animation",
33
+ (type) => animationsEnabled || (type !== "animation" && type !== "clip"),
34
34
  );
35
35
 
36
36
  const COMPONENT_EXAMPLE =
@@ -17,7 +17,7 @@ import { authoringTools } from "./authoring-tools.js";
17
17
  import type { ReviewApiClient } from "./client.js";
18
18
  import { ReviewApiError } from "./client.js";
19
19
  import { mcpClientAgent } from "./mcp-client-agent.js";
20
- import { callPublicTool, publicTool } from "./public-tools.js";
20
+ import { callPublicTool, publicTool, renderedImage } from "./public-tools.js";
21
21
  import { RECOVERY } from "./recovery.js";
22
22
  import { REVIEW_STATUS_TOOL } from "./status-tool.js";
23
23
  import { type ToolFailure, toolFailure } from "./tool-failure.js";
@@ -191,11 +191,14 @@ export async function serveReviewMcp(
191
191
  `Unknown Whiteboard tool: ${request.params.name}`,
192
192
  );
193
193
 
194
- const result = await callPublicTool(
195
- client,
194
+ const { image, result } = renderedImage(
196
195
  tool,
197
- request.params.arguments ?? {},
198
- ctx.mcpReq.signal,
196
+ await callPublicTool(
197
+ client,
198
+ tool,
199
+ request.params.arguments ?? {},
200
+ ctx.mcpReq.signal,
201
+ ),
199
202
  );
200
203
 
201
204
  const text = toolResultText(tool, result);
@@ -203,6 +206,8 @@ export async function serveReviewMcp(
203
206
 
204
207
  const response: CallToolResult = {
205
208
  content: [
209
+ // The frame an agent rendered, ahead of what the host says about it.
210
+ ...(image ? [{ type: "image" as const, ...image }] : []),
206
211
  {
207
212
  type: "text",
208
213
  text:
@@ -1,5 +1,9 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+
1
4
  import { type JsonValue, isJsonObject, isStringValue } from "@dev.fast/json";
2
5
  import { boardTitle } from "@dev.fast/review-protocol";
6
+ import { devReviewHome } from "@dev.fast/trace-core";
3
7
  import { ReviewInputError } from "@dev.fast/whiteboard-document";
4
8
 
5
9
  import {
@@ -12,7 +16,7 @@ import {
12
16
  checkoutAnimation,
13
17
  recordPublish,
14
18
  resolveDraftEdit,
15
- stageReferenceHint,
19
+ stageReferenceText,
16
20
  writeStageReference,
17
21
  } from "./animation-drafts.js";
18
22
  import type { ReviewApiClient } from "./client.js";
@@ -131,7 +135,7 @@ export async function callPublicTool(
131
135
 
132
136
  if (tool.name === "session_get_instructions" && rest.topic === "animation") {
133
137
  const instructions = await callAuthoringTool(client, tool, fields, signal);
134
- const hint = stageReferenceHint(await writeStageReference());
138
+ const hint = stageReferenceText(await writeStageReference());
135
139
 
136
140
  if (instructions instanceof ToolText)
137
141
  return new ToolText(`${instructions.text}\n\n${hint}`);
@@ -171,6 +175,13 @@ export async function callPublicTool(
171
175
  if (result instanceof ToolText) return result;
172
176
  const published = withoutRepositoryIds(publicResult(result));
173
177
 
178
+ if (
179
+ tool.name === "session_render" &&
180
+ isStringValue(sessionId) &&
181
+ isStringValue(rest.targetId)
182
+ )
183
+ return saveRenderedFrame(published, sessionId, rest.targetId);
184
+
174
185
  if (
175
186
  draft &&
176
187
  isStringValue(sessionId) &&
@@ -189,6 +200,64 @@ export async function callPublicTool(
189
200
  return published;
190
201
  }
191
202
 
203
+ /** A tool result with its rendered frame, if any, taken out as an image. */
204
+ export interface RenderedImage {
205
+ image?: { data: string; mimeType: "image/png" };
206
+ result: JsonValue | ToolText;
207
+ }
208
+
209
+ /** A rendered frame's PNG, for an agent that reads images from a tool
210
+ * result, and the rest of the result for its text. */
211
+ export function renderedImage(
212
+ tool: Pick<AuthoringTool, "name">,
213
+ result: JsonValue | ToolText,
214
+ ): RenderedImage {
215
+ if (
216
+ tool.name !== "session_render" ||
217
+ result instanceof ToolText ||
218
+ !isJsonObject(result) ||
219
+ !isStringValue(result.png)
220
+ )
221
+ return { result };
222
+
223
+ const { png, ...rest } = result;
224
+
225
+ return { image: { data: png, mimeType: "image/png" }, result: rest };
226
+ }
227
+
228
+ /** Writes the frame's PNG where the agent can open it with its own tools,
229
+ * beside the animation drafts, and names the file in the result. A folder
230
+ * that cannot be written leaves the image in the result alone. */
231
+ async function saveRenderedFrame(
232
+ result: JsonValue,
233
+ sessionId: string,
234
+ targetId: string,
235
+ ): Promise<JsonValue> {
236
+ if (!isJsonObject(result) || !isStringValue(result.png)) return result;
237
+
238
+ const key =
239
+ isJsonObject(result.frame) && isStringValue(result.frame.key)
240
+ ? result.frame.key
241
+ : "frame";
242
+
243
+ const file = path.join(
244
+ devReviewHome(),
245
+ "animation-renders",
246
+ ...[sessionId, targetId, `${key}.png`].map((part) =>
247
+ part.replace(/[^\w.-]/g, "_"),
248
+ ),
249
+ );
250
+
251
+ try {
252
+ await mkdir(path.dirname(file), { recursive: true });
253
+ await writeFile(file, Buffer.from(result.png, "base64"));
254
+ } catch {
255
+ return result;
256
+ }
257
+
258
+ return { ...result, file };
259
+ }
260
+
192
261
  /** The read a draft compares and records against: session_get's JSON. */
193
262
  const INSPECT: AuthoringTool = {
194
263
  name: "review_get",