agent-whiteboard 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/CHANGELOG.md +470 -0
  2. package/LICENSE +21 -0
  3. package/README.md +313 -0
  4. package/bin/cli.js +30 -0
  5. package/dist/client/apple-touch-icon.png +0 -0
  6. package/dist/client/assets/KaTeX_AMS-Regular-0cdd387c.woff2 +0 -0
  7. package/dist/client/assets/KaTeX_AMS-Regular-30da91e8.woff +0 -0
  8. package/dist/client/assets/KaTeX_AMS-Regular-68534840.ttf +0 -0
  9. package/dist/client/assets/KaTeX_Caligraphic-Bold-07d8e303.ttf +0 -0
  10. package/dist/client/assets/KaTeX_Caligraphic-Bold-1ae6bd74.woff +0 -0
  11. package/dist/client/assets/KaTeX_Caligraphic-Bold-de7701e4.woff2 +0 -0
  12. package/dist/client/assets/KaTeX_Caligraphic-Regular-3398dd02.woff +0 -0
  13. package/dist/client/assets/KaTeX_Caligraphic-Regular-5d53e70a.woff2 +0 -0
  14. package/dist/client/assets/KaTeX_Caligraphic-Regular-ed0b7437.ttf +0 -0
  15. package/dist/client/assets/KaTeX_Fraktur-Bold-74444efd.woff2 +0 -0
  16. package/dist/client/assets/KaTeX_Fraktur-Bold-9163df9c.ttf +0 -0
  17. package/dist/client/assets/KaTeX_Fraktur-Bold-9be7ceb8.woff +0 -0
  18. package/dist/client/assets/KaTeX_Fraktur-Regular-1e6f9579.ttf +0 -0
  19. package/dist/client/assets/KaTeX_Fraktur-Regular-51814d27.woff2 +0 -0
  20. package/dist/client/assets/KaTeX_Fraktur-Regular-5e28753b.woff +0 -0
  21. package/dist/client/assets/KaTeX_Main-Bold-0f60d1b8.woff2 +0 -0
  22. package/dist/client/assets/KaTeX_Main-Bold-138ac28d.ttf +0 -0
  23. package/dist/client/assets/KaTeX_Main-Bold-c76c5d69.woff +0 -0
  24. package/dist/client/assets/KaTeX_Main-BoldItalic-70ee1f64.ttf +0 -0
  25. package/dist/client/assets/KaTeX_Main-BoldItalic-99cd42a3.woff2 +0 -0
  26. package/dist/client/assets/KaTeX_Main-BoldItalic-a6f7ec0d.woff +0 -0
  27. package/dist/client/assets/KaTeX_Main-Italic-0d85ae7c.ttf +0 -0
  28. package/dist/client/assets/KaTeX_Main-Italic-97479ca6.woff2 +0 -0
  29. package/dist/client/assets/KaTeX_Main-Italic-f1d6ef86.woff +0 -0
  30. package/dist/client/assets/KaTeX_Main-Regular-c2342cd8.woff2 +0 -0
  31. package/dist/client/assets/KaTeX_Main-Regular-c6368d87.woff +0 -0
  32. package/dist/client/assets/KaTeX_Main-Regular-d0332f52.ttf +0 -0
  33. package/dist/client/assets/KaTeX_Math-BoldItalic-850c0af5.woff +0 -0
  34. package/dist/client/assets/KaTeX_Math-BoldItalic-dc47344d.woff2 +0 -0
  35. package/dist/client/assets/KaTeX_Math-BoldItalic-f9377ab0.ttf +0 -0
  36. package/dist/client/assets/KaTeX_Math-Italic-08ce98e5.ttf +0 -0
  37. package/dist/client/assets/KaTeX_Math-Italic-7af58c5e.woff2 +0 -0
  38. package/dist/client/assets/KaTeX_Math-Italic-8a8d2445.woff +0 -0
  39. package/dist/client/assets/KaTeX_SansSerif-Bold-1ece03f7.ttf +0 -0
  40. package/dist/client/assets/KaTeX_SansSerif-Bold-e99ae511.woff2 +0 -0
  41. package/dist/client/assets/KaTeX_SansSerif-Bold-ece03cfd.woff +0 -0
  42. package/dist/client/assets/KaTeX_SansSerif-Italic-00b26ac8.woff2 +0 -0
  43. package/dist/client/assets/KaTeX_SansSerif-Italic-3931dd81.ttf +0 -0
  44. package/dist/client/assets/KaTeX_SansSerif-Italic-91ee6750.woff +0 -0
  45. package/dist/client/assets/KaTeX_SansSerif-Regular-11e4dc8a.woff +0 -0
  46. package/dist/client/assets/KaTeX_SansSerif-Regular-68e8c73e.woff2 +0 -0
  47. package/dist/client/assets/KaTeX_SansSerif-Regular-f36ea897.ttf +0 -0
  48. package/dist/client/assets/KaTeX_Script-Regular-036d4e95.woff2 +0 -0
  49. package/dist/client/assets/KaTeX_Script-Regular-1c67f068.ttf +0 -0
  50. package/dist/client/assets/KaTeX_Script-Regular-d96cdf2b.woff +0 -0
  51. package/dist/client/assets/KaTeX_Size1-Regular-6b47c401.woff2 +0 -0
  52. package/dist/client/assets/KaTeX_Size1-Regular-95b6d2f1.ttf +0 -0
  53. package/dist/client/assets/KaTeX_Size1-Regular-c943cc98.woff +0 -0
  54. package/dist/client/assets/KaTeX_Size2-Regular-2014c523.woff +0 -0
  55. package/dist/client/assets/KaTeX_Size2-Regular-a6b2099f.ttf +0 -0
  56. package/dist/client/assets/KaTeX_Size2-Regular-d04c5421.woff2 +0 -0
  57. package/dist/client/assets/KaTeX_Size3-Regular-500e04d5.ttf +0 -0
  58. package/dist/client/assets/KaTeX_Size3-Regular-6ab6b62e.woff +0 -0
  59. package/dist/client/assets/KaTeX_Size4-Regular-99f9c675.woff +0 -0
  60. package/dist/client/assets/KaTeX_Size4-Regular-a4af7d41.woff2 +0 -0
  61. package/dist/client/assets/KaTeX_Size4-Regular-c647367d.ttf +0 -0
  62. package/dist/client/assets/KaTeX_Typewriter-Regular-71d517d6.woff2 +0 -0
  63. package/dist/client/assets/KaTeX_Typewriter-Regular-e14fed02.woff +0 -0
  64. package/dist/client/assets/KaTeX_Typewriter-Regular-f01f3e87.ttf +0 -0
  65. package/dist/client/assets/arc-4691b008.js +1 -0
  66. package/dist/client/assets/architecture-7EHR7CIX-e08620e8.js +1 -0
  67. package/dist/client/assets/architectureDiagram-3BPJPVTR-9e2f2dbd.js +36 -0
  68. package/dist/client/assets/blockDiagram-GPEHLZMM-d9dd3f7a.js +132 -0
  69. package/dist/client/assets/bootstrap.min-21652813.js +6 -0
  70. package/dist/client/assets/c4Diagram-AAUBKEIU-4ed97d79.js +10 -0
  71. package/dist/client/assets/channel-9d07a35d.js +1 -0
  72. package/dist/client/assets/chunk-2J33WTMH-8049adc8.js +1 -0
  73. package/dist/client/assets/chunk-4BX2VUAB-fd7396be.js +1 -0
  74. package/dist/client/assets/chunk-55IACEB6-ae1ac93c.js +1 -0
  75. package/dist/client/assets/chunk-727SXJPM-7e3658d9.js +206 -0
  76. package/dist/client/assets/chunk-AQP2D5EJ-114f64b0.js +231 -0
  77. package/dist/client/assets/chunk-FMBD7UC4-f2647c5e.js +15 -0
  78. package/dist/client/assets/chunk-ND2GUHAM-0a2fcd8c.js +1 -0
  79. package/dist/client/assets/chunk-QZHKN3VN-d00b3f33.js +1 -0
  80. package/dist/client/assets/classDiagram-4FO5ZUOK-ebb5cc4a.js +1 -0
  81. package/dist/client/assets/classDiagram-v2-Q7XG4LA2-ebb5cc4a.js +1 -0
  82. package/dist/client/assets/colors-98f238a0.js +1 -0
  83. package/dist/client/assets/cose-bilkent-S5V4N54A-1f808fd7.js +1 -0
  84. package/dist/client/assets/cytoscape.esm-1491e259.js +331 -0
  85. package/dist/client/assets/dagre-BM42HDAG-e8ec1c9d.js +4 -0
  86. package/dist/client/assets/defaultLocale-a8476f12.js +1 -0
  87. package/dist/client/assets/diagram-2AECGRRQ-83b385bf.js +43 -0
  88. package/dist/client/assets/diagram-5GNKFQAL-d9406c6a.js +10 -0
  89. package/dist/client/assets/diagram-KO2AKTUF-3db5b95a.js +3 -0
  90. package/dist/client/assets/diagram-LMA3HP47-fbd90397.js +24 -0
  91. package/dist/client/assets/diagram-OG6HWLK6-3c697534.js +24 -0
  92. package/dist/client/assets/embed-2b4adf34.js +201 -0
  93. package/dist/client/assets/erDiagram-TEJ5UH35-7f41448f.js +85 -0
  94. package/dist/client/assets/eventmodeling-FCH6USID-e6d9019e.js +1 -0
  95. package/dist/client/assets/flowDiagram-I6XJVG4X-38f05cc7.js +162 -0
  96. package/dist/client/assets/ganttDiagram-6RSMTGT7-39287f5a.js +292 -0
  97. package/dist/client/assets/gitGraph-WXDBUCRP-037e225a.js +1 -0
  98. package/dist/client/assets/gitGraphDiagram-PVQCEYII-3c2e3ef7.js +106 -0
  99. package/dist/client/assets/graph-f3dfaf53.js +1 -0
  100. package/dist/client/assets/index-4abf3da0.js +5 -0
  101. package/dist/client/assets/index-a1656aec.js +1 -0
  102. package/dist/client/assets/index-d674a6a4.css +1 -0
  103. package/dist/client/assets/info-J43DQDTF-51cd3390.js +1 -0
  104. package/dist/client/assets/infoDiagram-5YYISTIA-ef2826b7.js +2 -0
  105. package/dist/client/assets/init-a5b10ee5.js +1 -0
  106. package/dist/client/assets/ishikawaDiagram-YF4QCWOH-d0b8a2e0.js +70 -0
  107. package/dist/client/assets/journeyDiagram-JHISSGLW-586ffcf8.js +139 -0
  108. package/dist/client/assets/kanban-definition-UN3LZRKU-9e58e14f.js +89 -0
  109. package/dist/client/assets/katex-0cba7652.js +257 -0
  110. package/dist/client/assets/katex-4c9ea27f.js +257 -0
  111. package/dist/client/assets/layout-3a4eff80.js +1 -0
  112. package/dist/client/assets/linear-fed00341.js +1 -0
  113. package/dist/client/assets/mermaid-parser.core-7ee09942.js +173 -0
  114. package/dist/client/assets/mermaid.core-9fc8f6d2.js +306 -0
  115. package/dist/client/assets/mindmap-definition-RKZ34NQL-a40dd09f.js +96 -0
  116. package/dist/client/assets/ordinal-db88ae6f.js +1 -0
  117. package/dist/client/assets/packet-YPE3B663-f55b568a.js +1 -0
  118. package/dist/client/assets/pie-LRSECV5Y-88c1a5e6.js +1 -0
  119. package/dist/client/assets/pieDiagram-4H26LBE5-51aae5ea.js +30 -0
  120. package/dist/client/assets/quadrantDiagram-W4KKPZXB-bc4686f9.js +7 -0
  121. package/dist/client/assets/radar-GUYGQ44K-6ede145d.js +1 -0
  122. package/dist/client/assets/range-6bc2a9a0.js +1 -0
  123. package/dist/client/assets/requirementDiagram-4Y6WPE33-8466f5a1.js +84 -0
  124. package/dist/client/assets/sankeyDiagram-5OEKKPKP-fd54c1f6.js +40 -0
  125. package/dist/client/assets/sequenceDiagram-3UESZ5HK-baa63c66.js +162 -0
  126. package/dist/client/assets/stateDiagram-AJRCARHV-bb4f0d0b.js +1 -0
  127. package/dist/client/assets/stateDiagram-v2-BHNVJYJU-9479cd12.js +1 -0
  128. package/dist/client/assets/step-171ad54a.js +1 -0
  129. package/dist/client/assets/time-043b3464.js +1 -0
  130. package/dist/client/assets/timeline-definition-PNZ67QCA-f64b58bd.js +120 -0
  131. package/dist/client/assets/treeView-BLDUP644-73ba1a83.js +1 -0
  132. package/dist/client/assets/treemap-LRROVOQU-98e47821.js +1 -0
  133. package/dist/client/assets/vennDiagram-CIIHVFJN-a410b1f7.js +34 -0
  134. package/dist/client/assets/wardley-L42UT6IY-a27badb9.js +1 -0
  135. package/dist/client/assets/wardleyDiagram-YWT4CUSO-527ffe52.js +78 -0
  136. package/dist/client/assets/xychartDiagram-2RQKCTM6-42b5914b.js +7 -0
  137. package/dist/client/favicon-16x16.png +0 -0
  138. package/dist/client/favicon-32x32.png +0 -0
  139. package/dist/client/favicon.ico +0 -0
  140. package/dist/client/icon-192.png +0 -0
  141. package/dist/client/icon-512.png +0 -0
  142. package/dist/client/index.html +18 -0
  143. package/dist/server/app.js +429 -0
  144. package/dist/server/channel.js +42 -0
  145. package/dist/server/events.js +90 -0
  146. package/dist/server/export-html.js +461 -0
  147. package/dist/server/index.js +79 -0
  148. package/dist/server/interaction.js +135 -0
  149. package/dist/server/mcp.js +421 -0
  150. package/dist/server/paths.js +11 -0
  151. package/dist/server/persist.js +56 -0
  152. package/dist/server/presentation.js +5 -0
  153. package/dist/server/render-core.js +213 -0
  154. package/dist/server/session.js +103 -0
  155. package/dist/server/slideshow.js +83 -0
  156. package/dist/server/snapshot-reader.js +190 -0
  157. package/dist/server/snapshot-writer.js +117 -0
  158. package/dist/server/snapshot.js +47 -0
  159. package/dist/server/step-frames-builder.js +70 -0
  160. package/dist/server/validate.js +140 -0
  161. package/dist/server/viewport-cache.js +69 -0
  162. package/dist/server/ws.js +71 -0
  163. package/package.json +92 -0
@@ -0,0 +1,135 @@
1
+ // Interaction primitive (U7, D4 — v0.26 Sprints 46-47): one arm/await/resolve
2
+ // mechanism for every server-side "wait for the browser user" signal.
3
+ // `wait_done` and `wait_click` are configurations of it, distinguished only
4
+ // by their resolution mode:
5
+ // - broadcast: every pending await() gets its own listener; one
6
+ // resolve() wakes all of them (wait_done — F9).
7
+ // - single-flight: at most one pending await() at a time; a new arm on
8
+ // this interaction, or an external supersede() call from
9
+ // a different one taking over the return channel, cancels
10
+ // the previous one with `type: "superseded"` rather than
11
+ // leaving it to the plain-inactivity `"timeout"` (Sprint
12
+ // 47, OQ11 — wait_done() superseding a pending
13
+ // wait_click() is the one cross-interaction case today).
14
+ // `node_to_frame` (U4e) is the same primitive conceptually — arm on commit,
15
+ // resolve on click — but its resolver runs entirely client-side (the browser
16
+ // calls POST /seek directly instead of round-tripping through the agent), so
17
+ // it has no server-side arm/await state and nothing here to share code with.
18
+ // Its auto-restore (Sprint 47, OQ12) lives purely in canvasStore.ts's reducer.
19
+ import { EventEmitter } from "node:events";
20
+ export const INTERACTION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
21
+ /** Every pending await() resolves independently; resolve() wakes all of them. */
22
+ export function createBroadcastInteraction() {
23
+ const bus = new EventEmitter();
24
+ return {
25
+ resolve(event) {
26
+ bus.emit("resolve", event);
27
+ },
28
+ await() {
29
+ return new Promise((resolve) => {
30
+ const onResolve = (event) => {
31
+ clearTimeout(timer);
32
+ resolve(event);
33
+ };
34
+ const timer = setTimeout(() => {
35
+ bus.off("resolve", onResolve);
36
+ resolve(undefined);
37
+ }, INTERACTION_TIMEOUT_MS);
38
+ bus.once("resolve", onResolve);
39
+ });
40
+ },
41
+ };
42
+ }
43
+ /**
44
+ * At most one pending await() at a time. A new arm — either a fresh await()
45
+ * on this same interaction, or an external supersede() call from a different
46
+ * interaction taking over the return channel (D4, v0.26 Sprint 47 — e.g.
47
+ * wait_done() superseding a pending wait_click()) — cancels the previous one
48
+ * with `supersededEvent`. A pending await() left untouched for the full
49
+ * `INTERACTION_TIMEOUT_MS` resolves on its own with `timeoutEvent` instead.
50
+ */
51
+ export function createSingleFlightInteraction(timeoutEvent, supersededEvent) {
52
+ let pending = null;
53
+ let timer = null;
54
+ function settle(event) {
55
+ const resolve = pending;
56
+ pending = null;
57
+ if (timer) {
58
+ clearTimeout(timer);
59
+ timer = null;
60
+ }
61
+ resolve?.(event);
62
+ }
63
+ return {
64
+ resolve(event) {
65
+ if (!pending)
66
+ return; // no-op when nothing is armed
67
+ settle(event);
68
+ },
69
+ await() {
70
+ if (pending)
71
+ settle(supersededEvent); // a new arm supersedes the previous one
72
+ return new Promise((resolve) => {
73
+ pending = resolve;
74
+ timer = setTimeout(() => settle(timeoutEvent), INTERACTION_TIMEOUT_MS);
75
+ });
76
+ },
77
+ supersede() {
78
+ if (!pending)
79
+ return; // no-op when nothing is armed
80
+ settle(supersededEvent);
81
+ },
82
+ reset() {
83
+ settle(timeoutEvent);
84
+ },
85
+ };
86
+ }
87
+ // ── Done signal (F9, U4a) ────────────────────────────────────────────────────
88
+ let doneArmed = false;
89
+ export function getDoneArmed() {
90
+ return doneArmed;
91
+ }
92
+ // Lazily imported to avoid circular dependency (ws.ts imports interaction.ts indirectly).
93
+ let _broadcastFn = null;
94
+ export function setBroadcastFn(fn) {
95
+ _broadcastFn = fn;
96
+ }
97
+ function broadcastDoneArmed(armed) {
98
+ _broadcastFn?.({ action: "set_done_armed", armed });
99
+ }
100
+ const doneInteraction = createBroadcastInteraction();
101
+ /** Signal that the user clicked Done — wakes all pending waitForDone() calls. */
102
+ export function signalDone() {
103
+ doneInteraction.resolve(undefined);
104
+ }
105
+ /** Resolve when the user clicks Done (or after the timeout). */
106
+ export async function waitForDone() {
107
+ doneArmed = true;
108
+ broadcastDoneArmed(true);
109
+ // Arming Done takes over the return channel from a pending click (D4, one
110
+ // Interaction primitive) — supersede it rather than leaving it to time out.
111
+ clickInteraction.supersede();
112
+ await doneInteraction.await();
113
+ doneArmed = false;
114
+ broadcastDoneArmed(false);
115
+ }
116
+ const CLICK_TIMEOUT_EVENT = { type: "timeout", id: "", label: "", action: null };
117
+ const CLICK_SUPERSEDED_EVENT = { type: "superseded", id: "", label: "", action: null };
118
+ const clickInteraction = createSingleFlightInteraction(CLICK_TIMEOUT_EVENT, CLICK_SUPERSEDED_EVENT);
119
+ /** Signal that the user clicked a node/edge — resolves the pending waitForClick(). No-op if none pending. */
120
+ export function signalClick(event) {
121
+ clickInteraction.resolve(event);
122
+ }
123
+ /**
124
+ * Resolve when the user clicks a node/edge (or after the timeout).
125
+ * At most one call may be pending — a second `wait_click()` (or an arming
126
+ * `wait_done()`) supersedes it, resolving with `type: "superseded"` (v0.26
127
+ * Sprint 47, OQ11) rather than the plain-inactivity `type: "timeout"`.
128
+ */
129
+ export function waitForClick() {
130
+ return clickInteraction.await();
131
+ }
132
+ /** Reset click state — for use in tests only. */
133
+ export function resetClick() {
134
+ clickInteraction.reset();
135
+ }
@@ -0,0 +1,421 @@
1
+ // MCP tool definitions and handlers.
2
+ // Tools: render, clear, export, step.
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { z } from "zod";
5
+ import { clearCanvas, exportCanvas } from "./session.js";
6
+ import { broadcast } from "./ws.js";
7
+ import { nodeActionsSchema, nodeToFrameSchema, validateFrame } from "./validate.js";
8
+ import { getSnapshotsRoot } from "./paths.js";
9
+ import { cancelSlideshow, startSlideshow } from "./slideshow.js";
10
+ import { waitForClick, waitForDone } from "./interaction.js";
11
+ import { findSnapshotById, findSnapshotByIdInWorkspace, listSnapshots } from "./snapshot-reader.js";
12
+ import { generateExportHtml } from "./export-html.js";
13
+ import { appendFrameAndBroadcast, commitRenderResult, commitStepFramesResult, initStepFramesResult, seekAndBroadcast, stepAndBroadcast, validateWorkspaceInput, } from "./render-core.js";
14
+ export function createMcpServer() {
15
+ const server = new McpServer({
16
+ name: "agent-whiteboard",
17
+ version: "0.1.0",
18
+ });
19
+ // render(type, payload) — push content to the canvas.
20
+ server.registerTool("render", {
21
+ description: "Push content to the whiteboard canvas. The payload always replaces the current canvas state.\n" +
22
+ "Supported types:\n" +
23
+ ' • "mermaid" — Mermaid diagram source. Must begin with a diagram keyword (graph, flowchart, sequenceDiagram, classDiagram, erDiagram, gantt, pie, mindmap). Example: render({ type: "mermaid", payload: "graph TD; A --> B" })\n' +
24
+ ' • "svg" — Inline SVG markup. Example: render({ type: "svg", payload: "<svg>...</svg>" })\n' +
25
+ ' • "html" — HTML fragment, styled with Bootstrap 5 (CSS-only build, always available — no need to link or import it). Use its component classes instead of hand-authored CSS: "card"/"card-body"/"card-title" for panels, "alert alert-info"/"alert-warning"/"alert-danger" for callouts, "badge bg-primary"/"bg-success" for tags, "table table-striped"/"table-bordered" for data tables. No Bootstrap JS is loaded — components that depend on it (dropdowns, modals, tooltips, popovers, collapses, carousels, offcanvas) render their static markup but are not interactive, so avoid them. Example: render({ type: "html", payload: "<div class="card"><div class="card-body"><h5 class="card-title">Hello</h5><span class="badge bg-success">Done</span></div></div>" })\n' +
26
+ ' • "katex" — LaTeX string, rendered in display mode. Example: render({ type: "katex", payload: "E = mc^2" })\n' +
27
+ ' • "vega-lite" — Vega-Lite JSON spec (must be valid JSON). Example: render({ type: "vega-lite", payload: "{"$schema":"...","mark":"bar",...}" })\n' +
28
+ "render() is single-frame only. For a step-through sequence (multiple frames navigable via step()/seek()), use init_step_frames()/append_frame()/commit_step_frames() instead (see below).\n" +
29
+ 'options: { "workspace": "my-course", "title": "My diagram" }. workspace is REQUIRED — snapshot routing fails without it. title is optional.\n' +
30
+ 'options.workspace: workspace name for snapshot routing. Must be alphanumeric with dashes, underscores, dots, or spaces — no path separators. No env var fallback: always pass explicitly.\n' +
31
+ 'Example: render({ type: "mermaid", payload: "graph TD; A --> B", options: { workspace: "course_2", title: "System flow" } })',
32
+ inputSchema: z.object({
33
+ type: z
34
+ .enum(["mermaid", "svg", "html", "katex", "vega-lite"])
35
+ .describe("Content type."),
36
+ payload: z
37
+ .string()
38
+ .describe("The content source. For mermaid: must begin with a valid diagram keyword. " +
39
+ "For vega-lite: must be valid JSON. For svg/html/katex: any string."),
40
+ options: z
41
+ .object({
42
+ workspace: z.string().describe("REQUIRED. Workspace name for snapshot routing. " +
43
+ "Alphanumeric, dashes, underscores, dots, spaces — no path separators or '..'. " +
44
+ "Always pass explicitly — no env var fallback."),
45
+ title: z.string().optional(),
46
+ })
47
+ .describe('Required options object. workspace: snapshot destination (required). title: label above canvas (optional).'),
48
+ }),
49
+ }, async ({ type, payload, options }) => {
50
+ const title = options?.title;
51
+ const workspaceResult = validateWorkspaceInput(options?.workspace);
52
+ if (!workspaceResult.ok) {
53
+ return {
54
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: workspaceResult.error }) }],
55
+ };
56
+ }
57
+ const { workspace } = workspaceResult;
58
+ const validationError = await validateFrame({ type, payload });
59
+ if (validationError) {
60
+ return {
61
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: validationError }) }],
62
+ };
63
+ }
64
+ const result = commitRenderResult(type, payload, workspace, title);
65
+ return {
66
+ content: [{ type: "text", text: JSON.stringify(result) }],
67
+ };
68
+ });
69
+ // step(direction) — advance or rewind the step cursor.
70
+ server.registerTool("step", {
71
+ description: 'Advance ("next") or rewind ("prev") the step cursor for a loaded step-frames sequence. ' +
72
+ 'Returns { "ok": true, "current_frame": N, "total_frames": M }. ' +
73
+ 'Returns { "ok": false, "error": "..." } if no step-frames sequence is loaded.',
74
+ inputSchema: z.object({
75
+ direction: z
76
+ .enum(["next", "prev"])
77
+ .describe('"next" to advance, "prev" to rewind.'),
78
+ }),
79
+ }, ({ direction }) => {
80
+ return {
81
+ content: [{ type: "text", text: JSON.stringify(stepAndBroadcast(direction)) }],
82
+ };
83
+ });
84
+ // seek(frame) — jump the step cursor to an arbitrary frame index.
85
+ server.registerTool("seek", {
86
+ description: 'Jump the step-frame cursor to an arbitrary frame index. ' +
87
+ 'Useful for random-access navigation without repeated step() calls. ' +
88
+ 'Returns { "ok": true, "current_frame": N, "total_frames": M }. ' +
89
+ 'Returns { "ok": false, "error": "..." } if no step-frames sequence is loaded or frame is out of range.',
90
+ inputSchema: z.object({
91
+ frame: z.number().int().nonnegative().describe("Zero-based frame index to jump to."),
92
+ }),
93
+ }, ({ frame }) => {
94
+ return {
95
+ content: [{ type: "text", text: JSON.stringify(seekAndBroadcast(frame)) }],
96
+ };
97
+ });
98
+ // clear() — reset the canvas.
99
+ server.registerTool("clear", {
100
+ description: "Reset the whiteboard canvas to a blank state.",
101
+ inputSchema: z.object({}),
102
+ }, () => {
103
+ cancelSlideshow({ persist: false }); // clear() must never produce a snapshot (F10)
104
+ clearCanvas();
105
+ broadcast({ action: "clear" });
106
+ return {
107
+ content: [{ type: "text", text: JSON.stringify({ ok: true }) }],
108
+ };
109
+ });
110
+ // slideshow(slides, delay_ms, workspace) — auto-advance a playlist on a server-side timer.
111
+ server.registerTool("slideshow", {
112
+ description: "Load a playlist of slides and auto-advance the canvas on a server-side timer.\n" +
113
+ 'slides: array of { type, payload, title? } — same types as render().\n' +
114
+ 'delay_ms: interval in milliseconds between slides.\n' +
115
+ "workspace: REQUIRED — same rules as render(). Individual ticks are never persisted; when the " +
116
+ "slideshow ends (runs to completion, is stopped, or is superseded by a new render()/slideshow() call), " +
117
+ "whatever is last on screen is written as a single snapshot to this workspace.\n" +
118
+ "A new call cancels any running slideshow. Use slideshow_stop() to stop early.\n" +
119
+ 'Example: slideshow({ slides: [{ type: "mermaid", payload: "graph TD; A-->B", title: "Slide 1" }], delay_ms: 3000, workspace: "course_2" })',
120
+ inputSchema: z.object({
121
+ slides: z
122
+ .array(z.object({
123
+ type: z.enum(["mermaid", "svg", "html", "katex", "vega-lite"]).describe("Content type."),
124
+ payload: z.string().describe("Content source."),
125
+ title: z.string().optional().describe("Optional label above the canvas."),
126
+ }))
127
+ .min(1)
128
+ .describe("Ordered list of slides."),
129
+ delay_ms: z
130
+ .number()
131
+ .positive()
132
+ .describe("Milliseconds between slide advances."),
133
+ workspace: z.string().describe("REQUIRED. Workspace to persist the finalize snapshot to (see description). " +
134
+ "Alphanumeric, dashes, underscores, dots, spaces — no path separators or '..'."),
135
+ }),
136
+ }, async ({ slides, delay_ms, workspace: rawWorkspace }) => {
137
+ const workspaceResult = validateWorkspaceInput(rawWorkspace);
138
+ if (!workspaceResult.ok) {
139
+ return {
140
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: workspaceResult.error }) }],
141
+ };
142
+ }
143
+ const { workspace } = workspaceResult;
144
+ // Validate each slide payload — same validator REST's /slideshow uses (NF18).
145
+ for (let i = 0; i < slides.length; i++) {
146
+ const s = slides[i];
147
+ const err = await validateFrame({ type: s.type, payload: s.payload });
148
+ if (err) {
149
+ return {
150
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: `slide[${i}]: ${err}` }) }],
151
+ };
152
+ }
153
+ }
154
+ startSlideshow(slides, delay_ms, workspace);
155
+ return {
156
+ content: [{ type: "text", text: JSON.stringify({ ok: true }) }],
157
+ };
158
+ });
159
+ // slideshow_stop() — cancel the running slideshow.
160
+ server.registerTool("slideshow_stop", {
161
+ description: "Cancel the running slideshow timer. The last rendered slide remains on screen. " +
162
+ "No-op if no slideshow is running.",
163
+ inputSchema: z.object({}),
164
+ }, () => {
165
+ cancelSlideshow();
166
+ return {
167
+ content: [{ type: "text", text: JSON.stringify({ ok: true }) }],
168
+ };
169
+ });
170
+ // wait_click(node_actions?) — arm click listener, block until user clicks a node/edge.
171
+ server.registerTool("wait_click", {
172
+ description: "Arm the browser for a single node or edge click on the current Mermaid diagram. " +
173
+ "The browser highlights clickable elements; one click resolves the call.\n" +
174
+ "Optional node_actions: map of node ID → string[] — nodes with registered actions show a popup menu on click; user selects one.\n" +
175
+ "Returns { \"ok\": true, \"type\": \"node\"|\"edge\", \"id\": \"<id>\", \"label\": \"<label>\", \"action\": \"<string or null>\" }.\n" +
176
+ " action is null when no popup was shown or user clicked without selecting; string value when menu item was selected.\n" +
177
+ "On timeout (10 min): returns { \"ok\": true, \"type\": \"timeout\" }.\n" +
178
+ "Applies to graph/flowchart diagrams reliably; other Mermaid types (sequenceDiagram, erDiagram, classDiagram) are best-effort — node IDs may be opaque or extraction may fail.\n" +
179
+ "Only one wait_click() may be pending at a time — a second wait_click() or an arming wait_done() supersedes it, returning { \"ok\": true, \"type\": \"superseded\" } to the superseded call instead of waiting out the full timeout.\n" +
180
+ "Example — plain click: render({ type: \"mermaid\", payload: \"graph TD; A-->B\" }) → wait_click() → handle result\n" +
181
+ "Example — popup menu: wait_click({ node_actions: { \"B\": [\"Explain\", \"Drill down\"] } }) → user clicks B → selects action",
182
+ inputSchema: z.object({
183
+ node_actions: nodeActionsSchema
184
+ .optional()
185
+ .describe("Optional map of node ID → action labels. " +
186
+ "Nodes with a non-empty entry show a popup menu on click; user selects one action. " +
187
+ "Nodes not in the map accept a plain click (no popup). " +
188
+ "Edge clicks are always plain (no popup)."),
189
+ }),
190
+ }, async ({ node_actions }) => {
191
+ // Arm the browser click listener, passing the node_actions map (or empty map for plain click).
192
+ broadcast({ action: "set_node_actions", node_actions: node_actions ?? {}, enabled: true });
193
+ const event = await waitForClick();
194
+ // Disarm the browser.
195
+ broadcast({ action: "set_node_actions", enabled: false });
196
+ if (event.type === "timeout" || event.type === "superseded") {
197
+ return {
198
+ content: [{ type: "text", text: JSON.stringify({ ok: true, type: event.type }) }],
199
+ };
200
+ }
201
+ return {
202
+ content: [{
203
+ type: "text",
204
+ text: JSON.stringify({
205
+ ok: true,
206
+ type: event.type,
207
+ id: event.id,
208
+ label: event.label,
209
+ action: event.action,
210
+ }),
211
+ }],
212
+ };
213
+ });
214
+ // wait_done() — block until the user clicks Done in the browser.
215
+ server.registerTool("wait_done", {
216
+ description: "Block until the user clicks the Done button in the whiteboard browser. " +
217
+ "Call this immediately after render() when you want the user to review the diagram before you continue. " +
218
+ 'Returns { "ok": true } when the user signals they are ready. ' +
219
+ "Example flow: render(...) → wait_done() → continue lesson",
220
+ inputSchema: z.object({}),
221
+ }, async () => {
222
+ await waitForDone();
223
+ return {
224
+ content: [{ type: "text", text: JSON.stringify({ ok: true }) }],
225
+ };
226
+ });
227
+ // init_step_frames(frame_type, workspace, title?) — begin an incremental step-frames build.
228
+ server.registerTool("init_step_frames", {
229
+ description: "Begin a step-frames sequence — a step-through diagram built one frame at a time and navigable afterwards via step()/seek(). This is the only way to create a multi-frame sequence (render() is single-frame only).\n" +
230
+ "Creates an empty skeleton in server memory, pushes a 0-frame placeholder to the browser, and returns a unique ID.\n" +
231
+ "Protocol: init_step_frames() → append_frame() × N (browser updates after each append) → commit_step_frames() (finalizes snapshot + state).\n" +
232
+ "frame_type: content type shared by all frames (e.g. 'mermaid') — every frame in the sequence must currently be this same type.\n" +
233
+ "workspace: same rules as render() — required.\n" +
234
+ "The ID expires after 30 minutes of inactivity (no append_frame or commit_step_frames call).\n" +
235
+ "Each append_frame() call is validated and pushed to the browser individually — the user watches the sequence grow one frame at a time; interleave wait_done() after each append_frame() call for a paced, user-acknowledged reveal.\n" +
236
+ 'Returns { "ok": true, "id": "<uuid>" }. Error if workspace is missing/invalid or frame_type is unsupported.\n' +
237
+ 'Example: init_step_frames({ frame_type: "mermaid", workspace: "my-course", title: "TCP Handshake" })',
238
+ inputSchema: z.object({
239
+ frame_type: z
240
+ .enum(["mermaid", "svg", "html", "katex", "vega-lite"])
241
+ .describe("Content type shared by all frames."),
242
+ workspace: z
243
+ .string()
244
+ .describe("REQUIRED. Workspace name for snapshot routing. " +
245
+ "Alphanumeric, dashes, underscores, dots, spaces — no path separators or '..'."),
246
+ title: z.string().optional().describe("Optional label displayed above the canvas."),
247
+ }),
248
+ }, ({ frame_type, workspace, title }) => {
249
+ const workspaceResult = validateWorkspaceInput(workspace);
250
+ if (!workspaceResult.ok) {
251
+ return {
252
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: workspaceResult.error }) }],
253
+ };
254
+ }
255
+ const { id } = initStepFramesResult(frame_type, workspaceResult.workspace, title);
256
+ return {
257
+ content: [{ type: "text", text: JSON.stringify({ ok: true, id }) }],
258
+ };
259
+ });
260
+ // append_frame(id, payload, label?) — add one frame to an in-progress sequence.
261
+ server.registerTool("append_frame", {
262
+ description: "Append one frame to an in-progress step-frames sequence identified by id.\n" +
263
+ "payload is validated against type ?? the sequence's frame_type (same hard gate as render()).\n" +
264
+ "After each valid append, immediately pushes the accumulated partial step-frames sequence to the browser (live preview positioned at the latest frame).\n" +
265
+ "Invalid payloads are rejected before any broadcast; prior frames and browser state are preserved — fix and retry the frame.\n" +
266
+ 'Returns { "ok": true, "frame_count": N }. Error if id is unknown/expired or payload fails validation.\n' +
267
+ 'Example: append_frame({ id: "<uuid>", payload: "graph TD; A --> B", label: "Step 1" })\n' +
268
+ 'Example — mixing types in one sequence: append_frame({ id: "<uuid>", payload: "E = mc^2", type: "katex" }) inside a sequence whose frame_type is "mermaid".',
269
+ inputSchema: z.object({
270
+ id: z.string().describe("Builder ID returned by init_step_frames()."),
271
+ payload: z.string().describe("Frame content — validated against type ?? the sequence's frame_type."),
272
+ label: z.string().optional().describe("Optional display caption for this frame."),
273
+ type: z
274
+ .enum(["mermaid", "svg", "html", "katex", "vega-lite"])
275
+ .optional()
276
+ .describe("Optional per-frame type override. Defaults to the sequence's frame_type when omitted — set this to mix content types within one sequence."),
277
+ }),
278
+ }, async ({ id, payload, label, type }) => {
279
+ const result = await appendFrameAndBroadcast(id, payload, label, type);
280
+ return {
281
+ content: [{ type: "text", text: JSON.stringify({ ok: result.ok, ...(result.ok ? { frame_count: result.frame_count } : { error: result.error }) }) }],
282
+ };
283
+ });
284
+ // commit_step_frames(id) — finalise and render the assembled sequence.
285
+ server.registerTool("commit_step_frames", {
286
+ description: "Finalise an in-progress step-frames sequence (finalization only — the browser already shows the sequence via append_frame live previews).\n" +
287
+ "Assembles the full step-frames JSON, writes a snapshot, updates in-memory canvas state (so export() works), cancels any running slideshow, and deletes the builder entry.\n" +
288
+ "Still sends a final WebSocket broadcast for consistency (handles clear() called between appends).\n" +
289
+ "After commit, export() returns the assembled full step-frames JSON. step() and seek() work on the committed sequence.\n" +
290
+ "The builder entry is deleted after commit — the ID cannot be reused.\n" +
291
+ "Optional node_to_frame: node ID → frame index map. When set, the browser attaches click listeners automatically — clicking a mapped node jumps directly to its frame (no wait_click() call needed). wait_click() overrides node_to_frame for the duration of its call.\n" +
292
+ 'Returns { "ok": true }. Error if id is unknown/expired or the sequence has zero frames.\n' +
293
+ 'Example: commit_step_frames({ id: "<uuid>" })\n' +
294
+ 'Example with node_to_frame: commit_step_frames({ id: "<uuid>", node_to_frame: { "A": 0, "B": 1, "C": 2 } })',
295
+ inputSchema: z.object({
296
+ id: z.string().describe("Builder ID returned by init_step_frames()."),
297
+ node_to_frame: nodeToFrameSchema
298
+ .optional()
299
+ .describe("Optional node ID → frame index map for autonomous browser navigation on click."),
300
+ }),
301
+ }, ({ id, node_to_frame }) => {
302
+ const result = commitStepFramesResult(id, node_to_frame);
303
+ return {
304
+ content: [{ type: "text", text: JSON.stringify(result) }],
305
+ };
306
+ });
307
+ // export(id?) — return the current canvas source spec, or a past snapshot by UUID.
308
+ server.registerTool("export", {
309
+ description: "Return the current canvas source spec, or a past snapshot by UUID. " +
310
+ 'Without id: returns verbatim last render() payload. Response: { "ok": true, "data": "<source>" }. ' +
311
+ "For step-frames: returns the full original frames JSON string (not the current frame). " +
312
+ 'data is an empty string if the canvas is empty or was cleared. ' +
313
+ 'With optional id (UUID returned by render() or commit_step_frames()): scans snapshot files for a record whose id field matches and returns its payload. ' +
314
+ 'Error if id provided but no matching snapshot found: { "ok": false, "error": "graph not found" }. ' +
315
+ "Old snapshots without an id field are not addressable by this mechanism.",
316
+ inputSchema: z.object({
317
+ id: z.string().optional().describe("Optional UUID returned by a previous render() or commit_step_frames() call. " +
318
+ "When provided, retrieves that specific snapshot's payload instead of the current canvas state."),
319
+ }),
320
+ }, ({ id }) => {
321
+ if (id !== undefined && id !== "") {
322
+ const root = getSnapshotsRoot();
323
+ const payload = findSnapshotById(id, root);
324
+ if (payload === null) {
325
+ return {
326
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: "graph not found" }) }],
327
+ };
328
+ }
329
+ return {
330
+ content: [{ type: "text", text: JSON.stringify({ ok: true, data: payload }) }],
331
+ };
332
+ }
333
+ const data = exportCanvas();
334
+ return {
335
+ content: [
336
+ { type: "text", text: JSON.stringify({ ok: true, data }) },
337
+ ],
338
+ };
339
+ });
340
+ // list_snapshots(workspace) — list a workspace's snapshots for agent-driven export selection.
341
+ server.registerTool("list_snapshots", {
342
+ description: "List the snapshots stored for a workspace, so you can choose which ones to export via export_html().\n" +
343
+ 'Returns { "ok": true, "snapshots": [{ "id", "timestamp", "type", "title"? }] }, sorted newest-first.\n' +
344
+ "Returns an empty array if the workspace has no snapshots or does not exist.\n" +
345
+ 'Example: list_snapshots({ workspace: "my-course" })',
346
+ inputSchema: z.object({
347
+ workspace: z
348
+ .string()
349
+ .describe("REQUIRED. Workspace name to list snapshots for. " +
350
+ "Alphanumeric, dashes, underscores, dots, spaces — no path separators or '..'."),
351
+ }),
352
+ }, ({ workspace }) => {
353
+ const workspaceResult = validateWorkspaceInput(workspace);
354
+ if (!workspaceResult.ok) {
355
+ return {
356
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: workspaceResult.error }) }],
357
+ };
358
+ }
359
+ const root = getSnapshotsRoot();
360
+ const snapshots = listSnapshots(workspaceResult.workspace, root);
361
+ return {
362
+ content: [{ type: "text", text: JSON.stringify({ ok: true, snapshots }) }],
363
+ };
364
+ });
365
+ // export_html(workspace, ids) — agent-facing HTML export, returned inline.
366
+ server.registerTool("export_html", {
367
+ description: "Export 1..N snapshots from a workspace to a single self-contained HTML document " +
368
+ "(agent-facing equivalent of the browser HistoryPanel's 'Export selected'). " +
369
+ "Discover snapshot ids first with list_snapshots(workspace).\n" +
370
+ "Returns the assembled HTML as a string, inline in this response — this tool has no " +
371
+ "filesystem access and never writes to disk. Dependencies (Mermaid, Bootstrap, KaTeX) " +
372
+ "are linked from a CDN rather than embedded, which is what keeps the response small enough " +
373
+ "to return inline; the resulting file requires network access to render correctly when opened. " +
374
+ "If you need a fully offline-capable export (or to save the file to a specific path), use the " +
375
+ "REST endpoint POST /export-html with { items, mode: \"offline\" } instead — it streams the " +
376
+ "response for the caller to save wherever it likes.\n" +
377
+ 'Returns { "ok": true, "html": "<!DOCTYPE html>..." }.\n' +
378
+ 'Example: export_html({ workspace: "my-course", ids: ["<uuid-1>", "<uuid-2>"] })',
379
+ inputSchema: z.object({
380
+ workspace: z
381
+ .string()
382
+ .describe("REQUIRED. Workspace the snapshot ids belong to. " +
383
+ "Alphanumeric, dashes, underscores, dots, spaces — no path separators or '..'."),
384
+ ids: z
385
+ .array(z.string())
386
+ .min(1)
387
+ .describe("Non-empty array of snapshot UUIDs (from list_snapshots()), all scoped to workspace."),
388
+ }),
389
+ }, async ({ workspace, ids }) => {
390
+ const workspaceResult = validateWorkspaceInput(workspace);
391
+ if (!workspaceResult.ok) {
392
+ return {
393
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: workspaceResult.error }) }],
394
+ };
395
+ }
396
+ if (ids.length === 0) {
397
+ return {
398
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: "ids must be a non-empty array" }) }],
399
+ };
400
+ }
401
+ const validatedWorkspace = workspaceResult.workspace;
402
+ const root = getSnapshotsRoot();
403
+ const validItems = [];
404
+ for (const id of ids) {
405
+ const record = findSnapshotByIdInWorkspace(validatedWorkspace, id, root);
406
+ if (record !== null) {
407
+ validItems.push({ workspace: validatedWorkspace, id, record });
408
+ }
409
+ }
410
+ if (validItems.length === 0) {
411
+ return {
412
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: "no valid items to export" }) }],
413
+ };
414
+ }
415
+ const { html } = await generateExportHtml(validItems, "cdn");
416
+ return {
417
+ content: [{ type: "text", text: JSON.stringify({ ok: true, html }) }],
418
+ };
419
+ });
420
+ return server;
421
+ }
@@ -0,0 +1,11 @@
1
+ import { homedir } from "os";
2
+ import { join } from "path";
3
+ /**
4
+ * Canonical snapshots-root resolver (F5, NF22) — the single implementation of
5
+ * `WHITEBOARD_SNAPSHOTS_DIR ?? ~/.agent-whiteboard`, replacing 10 independent
6
+ * copies of this expression previously scattered across app.ts, mcp.ts,
7
+ * snapshot.ts, and viewport-cache.ts.
8
+ */
9
+ export function getSnapshotsRoot() {
10
+ return process.env.WHITEBOARD_SNAPSHOTS_DIR ?? join(homedir(), ".agent-whiteboard");
11
+ }
@@ -0,0 +1,56 @@
1
+ // Persist-trigger vocabulary + the one shared finalize/persist write path
2
+ // (v0.25). Consolidates the near-duplicate saveSnapshot()-plus-backstop logic
3
+ // that used to live independently in render-core.ts (commitRenderResult,
4
+ // commitStepFramesResult) and slideshow.ts (finalizeSlideshow). See
5
+ // docs/04_architecture.md §9.3 D2 and §9.4 (FR20/B15 — slideshow silently
6
+ // never persisted because persistence was opt-in, not a required decision).
7
+ import { saveSnapshot } from "./snapshot-writer.js";
8
+ /**
9
+ * Source of truth for "which trigger does this command use" — the registry
10
+ * `getPersistTrigger()` enforces. Adding a new command without registering an
11
+ * entry here throws loudly instead of silently never persisting (the
12
+ * historical FR20/B15 bug: slideshow() had no entry anywhere and simply
13
+ * never called saveSnapshot()).
14
+ */
15
+ const COMMAND_PERSIST_TRIGGERS = {
16
+ render: "immediate",
17
+ append_frame: "transient",
18
+ commit_step_frames: "on-finalize",
19
+ "slideshow-end": "on-finalize",
20
+ step: "never",
21
+ seek: "never",
22
+ clear: "never",
23
+ "history-load": "never",
24
+ };
25
+ /** Returns the declared trigger for a command, or throws if none is registered. */
26
+ export function getPersistTrigger(command) {
27
+ const trigger = COMMAND_PERSIST_TRIGGERS[command];
28
+ if (trigger === undefined) {
29
+ throw new Error(`persist: no trigger declared for command "${command}" — every command path must register one of immediate | on-finalize | transient | never in server/persist.ts before it can persist anything`);
30
+ }
31
+ return trigger;
32
+ }
33
+ /**
34
+ * The one shared write path. Looks up `command`'s declared trigger:
35
+ * `immediate`/`on-finalize` write now; `transient`/`never` are deliberate
36
+ * no-ops. F10's "a write failure must never block rendering" backstop lives
37
+ * here once — `saveSnapshot()` already catches its own errors internally,
38
+ * this try/catch is a deliberate caller-level guarantee on top of that.
39
+ */
40
+ export function persistContent(command, content) {
41
+ const trigger = getPersistTrigger(command);
42
+ if (trigger === "transient" || trigger === "never")
43
+ return {};
44
+ try {
45
+ const rawPayload = content.frames.length > 1 ? content.rawPayload : undefined;
46
+ const id = saveSnapshot(content.frames, { title: content.title, node_to_frame: content.nodeToFrame, workspace: content.workspace }, rawPayload, content.id);
47
+ return { id };
48
+ }
49
+ catch {
50
+ return {};
51
+ }
52
+ }
53
+ /** Assembles the step-frames JSON payload shape shared by commit/finalize paths. */
54
+ export function assembleStepFramesPayload(frameType, frames) {
55
+ return JSON.stringify({ frame_type: frameType, frames });
56
+ }
@@ -0,0 +1,5 @@
1
+ // Unified content model (U2, v0.26 — docs/04_architecture.md §9.1).
2
+ // Frame is the one atomic renderable; Presentation is an ordered list of them.
3
+ // Not yet the internal canvas-state representation — session.ts/canvasStore.ts
4
+ // adopt this in Sprint 40/41. For now it's the shape validateFrame() validates.
5
+ export {};