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,213 @@
1
+ // Shared core logic for render / step-frames-create / append / commit /
2
+ // workspace-validation — used identically by the REST handlers (app.ts) and
3
+ // the MCP tool handlers (mcp.ts) so the two transports can never drift (NF12).
4
+ import { cancelSlideshow } from "./slideshow.js";
5
+ import { broadcastReplace, broadcastStepFrames } from "./ws.js";
6
+ import { generateSnapshotId } from "./snapshot-writer.js";
7
+ import { assembleStepFramesPayload, persistContent } from "./persist.js";
8
+ import { validateFrame } from "./validate.js";
9
+ import { getCanvas, isStepSequence, seekStepFrame, setCanvas, setLastWorkspace, setStepFrames, stepCursor } from "./session.js";
10
+ import { appendFrame, commitBuilder, createBuilder } from "./step-frames-builder.js";
11
+ import { getViewport } from "./viewport-cache.js";
12
+ // Re-exported so app.ts/mcp.ts don't need to know it actually lives in
13
+ // validate.ts — moved there (v0.28 Sprint 59) so snapshot-writer.ts can also
14
+ // depend on it without a render-core.ts <-> snapshot-writer.ts import cycle.
15
+ export { validateWorkspaceInput } from "./validate.js";
16
+ /**
17
+ * Commits an already-validated render() payload: cancels any running
18
+ * slideshow, stores the canvas state, broadcasts to the browser, updates
19
+ * lastWorkspace, and writes the snapshot (F10 — a write failure must never
20
+ * block rendering). Shared by POST /render and the MCP `render` tool.
21
+ * render() is single-frame only (v0.26 Sprint 45) — multi-frame sequences are
22
+ * built exclusively via init_step_frames()/append_frame()/commit_step_frames().
23
+ */
24
+ export function commitRenderResult(type, payload, workspace, title) {
25
+ cancelSlideshow();
26
+ // Generate the id before broadcasting so the browser can key its viewport
27
+ // report on it — a brand-new render() always mints a fresh id (F19/C3).
28
+ const newId = generateSnapshotId();
29
+ setCanvas(type, payload, title, newId);
30
+ broadcastReplace({ type, payload, title, id: newId, cursor: 0, total: 1 });
31
+ setLastWorkspace(workspace);
32
+ const { id: snapshotId } = persistContent("render", {
33
+ frames: [{ type, payload }],
34
+ title,
35
+ workspace,
36
+ id: newId,
37
+ });
38
+ return { ok: true, ...(snapshotId !== undefined ? { id: snapshotId } : {}) };
39
+ }
40
+ /**
41
+ * Creates an incremental step-frames builder entry and pushes the 0-frame
42
+ * placeholder to the browser. Callers validate `frameType`/`workspace`
43
+ * beforehand (REST via a runtime check, MCP via its zod schema). Shared by
44
+ * POST /step-frames/init and the MCP `init_step_frames` tool.
45
+ */
46
+ export function initStepFramesResult(frameType, workspace, title) {
47
+ const id = createBuilder(frameType, workspace, title);
48
+ broadcastReplace({ type: "step-frames-placeholder", frameCount: 0, title });
49
+ return { id };
50
+ }
51
+ /**
52
+ * Appends a frame to an in-progress step-frames builder and, if accepted,
53
+ * pushes the accumulated partial sequence to the browser (live preview,
54
+ * v0.9). Shared by POST /step-frames/:id/frame and the MCP `append_frame` tool.
55
+ * Persist trigger: "transient" (server/persist.ts) — never touches disk;
56
+ * only `commit_step_frames()` persists.
57
+ */
58
+ export async function appendFrameAndBroadcast(id, payload, label, type) {
59
+ const result = await appendFrame(id, payload, label, type);
60
+ if (result.ok) {
61
+ // Live preview: push the accumulated partial sequence to the browser.
62
+ // Reuse the builder id so the browser fits-to-view once on the first
63
+ // frame and treats subsequent appends as a continuation (F19/C3) — this
64
+ // is distinct from the final snapshot id minted at commit time.
65
+ const { frames, frame_type, title } = result;
66
+ broadcastStepFrames(frames, frame_type, frames.length - 1, id, title);
67
+ }
68
+ return result;
69
+ }
70
+ /**
71
+ * Finalises an in-progress step-frames builder entry: assembles the full
72
+ * JSON, cancels any running slideshow, updates in-memory canvas state,
73
+ * writes the snapshot (F10 backstop), and sends a final broadcast (handles
74
+ * clear() called between appends). Shared by POST /step-frames/:id/commit
75
+ * and the MCP `commit_step_frames` tool.
76
+ * `nodeToFrame` (v0.26 Sprint 45): optional node ID → frame index map for
77
+ * autonomous browser navigation (U4e) — this is its only entry point now that
78
+ * one-shot render(type="step-frames", options.node_to_frame) no longer exists.
79
+ */
80
+ export function commitStepFramesResult(id, nodeToFrame) {
81
+ const result = commitBuilder(id);
82
+ if (!result.ok)
83
+ return result;
84
+ const { entry } = result;
85
+ const { frame_type, workspace, title, frames } = entry;
86
+ const assembledPayload = assembleStepFramesPayload(frame_type, frames);
87
+ const resolvedFrames = frames.map((f) => ({
88
+ type: f.type ?? frame_type,
89
+ payload: f.payload,
90
+ ...(f.label !== undefined ? { label: f.label } : {}),
91
+ }));
92
+ cancelSlideshow();
93
+ const commitId = generateSnapshotId();
94
+ setStepFrames(frames, frame_type, assembledPayload, title, nodeToFrame, commitId);
95
+ setLastWorkspace(workspace);
96
+ const { id: commitSnapshotId } = persistContent("commit_step_frames", {
97
+ frames: resolvedFrames,
98
+ rawPayload: assembledPayload,
99
+ title,
100
+ nodeToFrame,
101
+ workspace,
102
+ id: commitId,
103
+ });
104
+ // Final broadcast for consistency (handles clear() called between appends).
105
+ broadcastStepFrames(frames, frame_type, 0, commitId, title, nodeToFrame);
106
+ return { ok: true, ...(commitSnapshotId !== undefined ? { id: commitSnapshotId } : {}) };
107
+ }
108
+ /**
109
+ * Commits an already-parsed history-load snapshot (NF25, v0.28 Sprint 58):
110
+ * validates every frame, decides single-frame vs step-frames, updates
111
+ * in-memory canvas state, broadcasts to the browser, and updates
112
+ * lastWorkspace. Write-silent — persist trigger "never" (server/persist.ts),
113
+ * same as step()/seek()/clear(). Shared entry point for POST /snapshots/load;
114
+ * the handler is reduced to request-shape parsing + file loading.
115
+ */
116
+ export async function applyLoadedSnapshotResult(frames, workspace, title, nodeToFrame, snapshotId, rawPayload) {
117
+ for (const frame of frames) {
118
+ const validationError = await validateFrame(frame);
119
+ if (validationError) {
120
+ return { ok: false, error: validationError };
121
+ }
122
+ }
123
+ // History-load always redisplays from frame 0 (F10 — cursor is always 0 at
124
+ // write time), so the viewport lookup is always for frame 0 (v0.26.1,
125
+ // B19/FR21 — composite id:frameIndex key). Looked up under the snapshot's
126
+ // own id, not a synthesized one — a synthesized id can never have a cache
127
+ // entry anyway.
128
+ const viewport = snapshotId !== undefined ? getViewport(snapshotId, 0) : undefined;
129
+ // A concrete id is required on every broadcast (v0.26 Sprint 42) — pre-v0.11
130
+ // snapshots may lack one (J1, `02`), so synthesize a fresh one here.
131
+ const resolvedId = snapshotId ?? generateSnapshotId();
132
+ if (frames.length > 1) {
133
+ const stepFrames = frames.map((f) => ({ payload: f.payload, type: f.type, ...(f.label !== undefined ? { label: f.label } : {}) }));
134
+ // rawPayload (v0.26 Sprint 43) carries the verbatim original step-frames
135
+ // envelope — reconstruct only if a hand-edited/pre-migration file lacks it.
136
+ const assembledRawPayload = rawPayload ?? assembleStepFramesPayload(frames[0].type, stepFrames);
137
+ setStepFrames(stepFrames, frames[0].type, assembledRawPayload, title, nodeToFrame, resolvedId);
138
+ broadcastReplace({
139
+ type: frames[0].type,
140
+ payload: frames[0].payload,
141
+ frameLabel: frames[0].label,
142
+ cursor: 0,
143
+ total: frames.length,
144
+ title,
145
+ nodeToFrame,
146
+ id: resolvedId,
147
+ viewport,
148
+ });
149
+ }
150
+ else {
151
+ setCanvas(frames[0].type, frames[0].payload, title, resolvedId);
152
+ broadcastReplace({ type: frames[0].type, payload: frames[0].payload, title, id: resolvedId, cursor: 0, total: 1, viewport });
153
+ }
154
+ setLastWorkspace(workspace);
155
+ persistContent("history-load", { frames, title, nodeToFrame, workspace, id: resolvedId });
156
+ return { ok: true };
157
+ }
158
+ /**
159
+ * Advances/rewinds the step cursor and broadcasts the resulting frame.
160
+ * Shared by POST /step and the MCP `step` tool (NF19 — previously duplicated
161
+ * verbatim between app.ts and mcp.ts, the one hot-path pair render-core.ts's
162
+ * own NF12 header comment didn't yet cover).
163
+ */
164
+ export function stepAndBroadcast(direction) {
165
+ const result = stepCursor(direction);
166
+ if (!result) {
167
+ return { ok: false, error: "no step-frames sequence is loaded" };
168
+ }
169
+ const state = getCanvas();
170
+ if (isStepSequence(state)) {
171
+ const { frames, title, id } = state.presentation;
172
+ // Same id as when this sequence was created — tells the browser this is
173
+ // a continuation, not a new diagram (F19/C3). Each frame now re-fits or
174
+ // restores its own saved viewport independently (v0.26.1, bug B19/FR21) —
175
+ // no longer "must not re-fit" for the whole sequence.
176
+ const resolvedId = id ?? generateSnapshotId();
177
+ const viewport = getViewport(resolvedId, result.currentFrame);
178
+ broadcastStepFrames(frames, state.frameType, result.currentFrame, resolvedId, title, state.nodeToFrame, viewport);
179
+ }
180
+ return { ok: true, current_frame: result.currentFrame, total_frames: result.totalFrames };
181
+ }
182
+ /**
183
+ * Jumps the step cursor to an arbitrary frame index and broadcasts it.
184
+ * Shared by POST /seek and the MCP `seek` tool (NF19, see `stepAndBroadcast`).
185
+ */
186
+ export function seekAndBroadcast(frame) {
187
+ const state = getCanvas();
188
+ if (!isStepSequence(state)) {
189
+ return { ok: false, error: "no step-frames sequence is loaded" };
190
+ }
191
+ const { frames, title, id } = state.presentation;
192
+ const total = frames.length;
193
+ if (frame < 0 || frame >= total) {
194
+ return { ok: false, error: `frame out of range: must be 0–${total - 1}` };
195
+ }
196
+ seekStepFrame(frame);
197
+ const f = frames[frame];
198
+ const resolvedId = id ?? generateSnapshotId();
199
+ broadcastReplace({
200
+ type: f.type,
201
+ payload: f.payload,
202
+ frameLabel: f.label,
203
+ cursor: frame,
204
+ total,
205
+ title,
206
+ nodeToFrame: state.nodeToFrame,
207
+ id: resolvedId,
208
+ // Per-frame restore (v0.26.1, bug B19/FR21) — each frame of a sequence
209
+ // re-fits or restores independently instead of sharing one viewport.
210
+ viewport: getViewport(resolvedId, frame),
211
+ });
212
+ return { ok: true, current_frame: frame, total_frames: total };
213
+ }
@@ -0,0 +1,103 @@
1
+ // In-memory canvas state — single canvas, no persistence in v1.
2
+ // Also tracks the workspace from the most recent render() call (used by history endpoints).
3
+ //
4
+ // Unified reducer (U3, v0.26 Sprint 40): a single Presentation + cursor +
5
+ // driver model replaces the old 3-way CanvasState union (empty | single-type
6
+ // | step-frames) — "step-frames" is no longer a branch anywhere in this file.
7
+ // Every render becomes a Presentation of one or more Frames; `driver` records
8
+ // whether the cursor moves ("manual", via step()/seek()) or is fixed
9
+ // ("static", a single-frame render). `driver: "timed"` is reserved for the
10
+ // slideshow-controller distinction a later sprint introduces — today
11
+ // slideshow ticks reuse "manual" so that Prev/Next navigation during a
12
+ // slideshow keeps working exactly as it did before this refactor (F7).
13
+ /**
14
+ * Narrows a CanvasState to "a navigable multi-frame sequence is loaded" —
15
+ * the replacement for the old `canvas.type === "step-frames"` check, now
16
+ * expressed on the driver axis instead of a content-type tag.
17
+ */
18
+ export function isStepSequence(state) {
19
+ return state.presentation !== null && state.driver === "manual";
20
+ }
21
+ let canvas = { presentation: null, driver: "static" };
22
+ let lastWorkspace = "";
23
+ export function getCanvas() {
24
+ return canvas;
25
+ }
26
+ export function getLastWorkspace() {
27
+ return lastWorkspace;
28
+ }
29
+ export function setLastWorkspace(workspace) {
30
+ lastWorkspace = workspace;
31
+ }
32
+ export function setCanvas(type, payload, title, id) {
33
+ const presentation = {
34
+ cursor: 0,
35
+ frames: [{ type, payload }],
36
+ ...(title !== undefined ? { title } : {}),
37
+ ...(id !== undefined ? { id } : {}),
38
+ };
39
+ canvas = { presentation, driver: "static" };
40
+ }
41
+ export function setStepFrames(frames, frameType, rawPayload, title, nodeToFrame, id) {
42
+ const resolvedFrames = frames.map((f) => ({
43
+ type: f.type ?? frameType,
44
+ payload: f.payload,
45
+ ...(f.label !== undefined ? { label: f.label } : {}),
46
+ }));
47
+ const presentation = {
48
+ cursor: 0,
49
+ frames: resolvedFrames,
50
+ ...(title !== undefined ? { title } : {}),
51
+ ...(id !== undefined ? { id } : {}),
52
+ };
53
+ canvas = {
54
+ presentation,
55
+ driver: "manual",
56
+ rawPayload,
57
+ frameType,
58
+ ...(nodeToFrame !== undefined ? { nodeToFrame } : {}),
59
+ };
60
+ }
61
+ /**
62
+ * Seek to a specific frame index in the loaded step-frames sequence.
63
+ * Used by the slideshow expander to advance the cursor to an arbitrary frame
64
+ * without resetting the full sequence. No-op if no step-frames sequence is loaded.
65
+ */
66
+ export function seekStepFrame(index) {
67
+ if (!isStepSequence(canvas))
68
+ return;
69
+ canvas = { ...canvas, presentation: { ...canvas.presentation, cursor: index } };
70
+ }
71
+ /**
72
+ * Advance or rewind the step cursor.
73
+ * Returns the new cursor state, or null if no step-frames sequence is loaded.
74
+ */
75
+ export function stepCursor(direction) {
76
+ if (!isStepSequence(canvas))
77
+ return null;
78
+ const total = canvas.presentation.frames.length;
79
+ const next = direction === "next"
80
+ ? Math.min(canvas.presentation.cursor + 1, total - 1)
81
+ : Math.max(canvas.presentation.cursor - 1, 0);
82
+ canvas = { ...canvas, presentation: { ...canvas.presentation, cursor: next } };
83
+ return { currentFrame: next, totalFrames: total };
84
+ }
85
+ export function clearCanvas() {
86
+ canvas = { presentation: null, driver: "static" };
87
+ }
88
+ /** Returns the current source spec, or empty string if canvas is blank. */
89
+ export function exportCanvas() {
90
+ if (canvas.presentation === null)
91
+ return "";
92
+ if (canvas.rawPayload !== undefined)
93
+ return canvas.rawPayload;
94
+ return canvas.presentation.frames[0]?.payload ?? "";
95
+ }
96
+ /** Reset canvas to empty — for use in tests only. */
97
+ export function resetCanvas() {
98
+ canvas = { presentation: null, driver: "static" };
99
+ }
100
+ /** Reset lastWorkspace to empty string — for use in tests only. */
101
+ export function resetLastWorkspace() {
102
+ lastWorkspace = "";
103
+ }
@@ -0,0 +1,83 @@
1
+ // Slideshow — server-side timer that auto-advances slides on the canvas.
2
+ // Phase 2 feature (Sprint 9). Slides are single frames only (v0.26 Sprint 45)
3
+ // — a slide can no longer itself be a step-frames sequence; that expansion
4
+ // path was removed along with "step-frames" as a top-level content type.
5
+ import { broadcastReplace } from "./ws.js";
6
+ import { getCanvas, setCanvas } from "./session.js";
7
+ import { generateSnapshotId } from "./snapshot-writer.js";
8
+ import { persistContent } from "./persist.js";
9
+ function broadcastSlide(slide) {
10
+ // Fresh id per slide so the browser auto-fits (F19/C3) — without this the
11
+ // diagram never fits to view (stays at default scale/position).
12
+ const id = generateSnapshotId();
13
+ setCanvas(slide.type, slide.payload, slide.title, id);
14
+ broadcastReplace({ type: slide.type, payload: slide.payload, title: slide.title, id, cursor: 0, total: 1 });
15
+ }
16
+ let activeTimer = null;
17
+ // Set for the lifetime of a slideshow "session" — from startSlideshow() until
18
+ // the next cancelSlideshow() — even for a single-slide slideshow that never
19
+ // starts a timer. Used only to know where to persist the finalize snapshot.
20
+ let activeWorkspace;
21
+ /**
22
+ * Persists whatever is currently on the canvas as a single snapshot, once,
23
+ * when a slideshow session ends (naturally or by cancellation) — mirroring
24
+ * commit_step_frames()'s "transient until finalized" pattern (F15): individual
25
+ * ticks never touch disk, only this final state does. No-op if no slideshow
26
+ * session is active, the canvas is empty, or (F10) the write itself fails —
27
+ * a persistence failure must never block anything else.
28
+ */
29
+ function finalizeSlideshow() {
30
+ if (activeWorkspace === undefined)
31
+ return;
32
+ const canvas = getCanvas();
33
+ if (canvas.presentation === null)
34
+ return;
35
+ const { frames, title, id } = canvas.presentation;
36
+ persistContent("slideshow-end", {
37
+ frames,
38
+ title,
39
+ workspace: activeWorkspace,
40
+ id: id ?? generateSnapshotId(),
41
+ });
42
+ }
43
+ /**
44
+ * Start a slideshow. Stops after the last slide (no loop). Cancels any
45
+ * previously running slideshow.
46
+ */
47
+ export function startSlideshow(slides, delay_ms, workspace) {
48
+ cancelSlideshow(); // finalizes + ends any previous session
49
+ activeWorkspace = workspace;
50
+ broadcastSlide(slides[0]);
51
+ if (slides.length === 1)
52
+ return; // single slide — no timer needed, but the session
53
+ // stays open (unfinalized) until the next cancelSlideshow() call
54
+ let index = 1;
55
+ activeTimer = setInterval(() => {
56
+ broadcastSlide(slides[index]);
57
+ index++;
58
+ if (index >= slides.length) {
59
+ cancelSlideshow(); // natural completion
60
+ }
61
+ }, delay_ms);
62
+ }
63
+ /**
64
+ * Cancel any running slideshow timer and end the current session. Last
65
+ * rendered slide stays on screen. By default this also finalizes (persists)
66
+ * that last slide as a single snapshot — the one exception is `clear()`,
67
+ * which must never produce a snapshot (F10); its call site passes
68
+ * `{ persist: false }`.
69
+ */
70
+ export function cancelSlideshow(options = {}) {
71
+ const { persist = true } = options;
72
+ const wasActive = activeTimer !== null || activeWorkspace !== undefined;
73
+ if (wasActive && persist)
74
+ finalizeSlideshow();
75
+ if (activeTimer !== null) {
76
+ clearInterval(activeTimer);
77
+ activeTimer = null;
78
+ }
79
+ activeWorkspace = undefined;
80
+ }
81
+ export function isSlideshowRunning() {
82
+ return activeTimer !== null;
83
+ }
@@ -0,0 +1,190 @@
1
+ import { readdirSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ /** Shared "is this a valid Frame[]" predicate (F6/NF23) — the one implementation checking non-empty array + per-element shape. */
4
+ export function isFrameArray(value) {
5
+ return (Array.isArray(value) &&
6
+ value.length > 0 &&
7
+ value.every((f) => f !== null &&
8
+ typeof f === "object" &&
9
+ typeof f.type === "string" &&
10
+ typeof f.payload === "string"));
11
+ }
12
+ /**
13
+ * Display "type badge" for a snapshot's frame list (v0.26 Sprint 43 — the
14
+ * unified schema has no top-level `type` field anymore). A multi-frame
15
+ * sequence badges as "step-frames", same literal value the old schema's
16
+ * `type` field held for one; a single frame badges as its own resolved type
17
+ * — this reproduces the exact old badge values for every real case,
18
+ * including the "1-frame step-frames === plain render" policy already
19
+ * applied to the WS contract (Sprint 42) and `rawPayload` (this schema).
20
+ */
21
+ export function badgeType(frames) {
22
+ return frames.length > 1 ? "step-frames" : frames[0].type;
23
+ }
24
+ export function listSnapshots(workspace, dir) {
25
+ const workspaceDir = join(dir, workspace);
26
+ let files;
27
+ try {
28
+ files = readdirSync(workspaceDir).filter((f) => f.endsWith("_screen.json"));
29
+ }
30
+ catch {
31
+ return [];
32
+ }
33
+ const entries = [];
34
+ for (const filename of files) {
35
+ try {
36
+ const raw = readFileSync(join(workspaceDir, filename), "utf-8");
37
+ const parsed = JSON.parse(raw);
38
+ if (typeof parsed.timestamp !== "string" || !isFrameArray(parsed.frames)) {
39
+ console.error(`[agent-whiteboard] snapshot-reader: skipping malformed file: ${filename}`);
40
+ continue;
41
+ }
42
+ const entry = {
43
+ filename,
44
+ timestamp: parsed.timestamp,
45
+ type: badgeType(parsed.frames),
46
+ };
47
+ if (typeof parsed.id === "string") {
48
+ entry.id = parsed.id;
49
+ }
50
+ if (typeof parsed.title === "string" && parsed.title.length > 0) {
51
+ entry.title = parsed.title;
52
+ }
53
+ entries.push(entry);
54
+ }
55
+ catch (err) {
56
+ console.error(`[agent-whiteboard] snapshot-reader: skipping unreadable file ${filename}:`, err instanceof Error ? err.message : String(err));
57
+ }
58
+ }
59
+ // Sort newest-first by timestamp string (ISO 8601 sorts lexicographically).
60
+ entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
61
+ return entries;
62
+ }
63
+ /**
64
+ * Scan every workspace subdirectory under `dir` and return their snapshots grouped.
65
+ * Workspaces with no readable snapshots are omitted from the result.
66
+ */
67
+ export function listAllSnapshots(dir, currentWorkspace) {
68
+ let entries;
69
+ try {
70
+ entries = readdirSync(dir, { withFileTypes: true })
71
+ .filter((d) => d.isDirectory())
72
+ .map((d) => d.name);
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ const groups = [];
78
+ for (const name of entries) {
79
+ const snapshots = listSnapshots(name, dir);
80
+ if (snapshots.length === 0)
81
+ continue;
82
+ groups.push({ name, isCurrent: name === currentWorkspace, snapshots });
83
+ }
84
+ groups.sort((a, b) => a.name.localeCompare(b.name));
85
+ return groups;
86
+ }
87
+ /**
88
+ * Read the raw content of a single snapshot file.
89
+ * Returns null if the file does not exist or cannot be read.
90
+ */
91
+ export function loadSnapshotContent(workspace, dir, filename) {
92
+ try {
93
+ return readFileSync(join(dir, workspace, filename), "utf-8");
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ /** Best-effort read of a snapshot file's `id` field, for viewport-cache cleanup on delete. */
100
+ export function readSnapshotIdSafe(fullPath) {
101
+ try {
102
+ const raw = readFileSync(fullPath, "utf-8");
103
+ const parsed = JSON.parse(raw);
104
+ return typeof parsed.id === "string" ? parsed.id : undefined;
105
+ }
106
+ catch {
107
+ return undefined;
108
+ }
109
+ }
110
+ /**
111
+ * Scan all workspace subdirectories under `dir` for a snapshot whose `id` field matches.
112
+ * Returns the snapshot's original re-renderable payload string if found, or null if no
113
+ * match. `rawPayload` (the verbatim step-frames envelope) wins when present — this
114
+ * mirrors `session.ts`'s `exportCanvas()`: `rawPayload ?? frames[0].payload`.
115
+ * Old snapshots without an `id` field are silently skipped.
116
+ */
117
+ export function findSnapshotById(id, dir) {
118
+ let workspaceDirs;
119
+ try {
120
+ workspaceDirs = readdirSync(dir, { withFileTypes: true })
121
+ .filter((d) => d.isDirectory())
122
+ .map((d) => d.name);
123
+ }
124
+ catch {
125
+ return null;
126
+ }
127
+ for (const workspace of workspaceDirs) {
128
+ const workspaceDir = join(dir, workspace);
129
+ let files;
130
+ try {
131
+ files = readdirSync(workspaceDir).filter((f) => f.endsWith("_screen.json"));
132
+ }
133
+ catch {
134
+ continue;
135
+ }
136
+ for (const filename of files) {
137
+ try {
138
+ const raw = readFileSync(join(workspaceDir, filename), "utf-8");
139
+ const parsed = JSON.parse(raw);
140
+ if (parsed.id === id && isFrameArray(parsed.frames)) {
141
+ return typeof parsed.rawPayload === "string" ? parsed.rawPayload : parsed.frames[0].payload;
142
+ }
143
+ }
144
+ catch (err) {
145
+ console.error(`[agent-whiteboard] snapshot-reader: skipping unreadable file ${filename}:`, err instanceof Error ? err.message : String(err));
146
+ }
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+ /**
152
+ * Scan a single workspace directory for a snapshot whose `id` field matches.
153
+ * Returns the full parsed record (frames, timestamp, title?, nodeToFrame?)
154
+ * needed by the export pipeline, or null if no match (or the workspace
155
+ * directory is absent). Old snapshots without an `id` field are silently
156
+ * skipped.
157
+ */
158
+ export function findSnapshotByIdInWorkspace(workspace, id, dir) {
159
+ const workspaceDir = join(dir, workspace);
160
+ let files;
161
+ try {
162
+ files = readdirSync(workspaceDir).filter((f) => f.endsWith("_screen.json"));
163
+ }
164
+ catch {
165
+ return null;
166
+ }
167
+ for (const filename of files) {
168
+ try {
169
+ const raw = readFileSync(join(workspaceDir, filename), "utf-8");
170
+ const parsed = JSON.parse(raw);
171
+ if (parsed.id === id && typeof parsed.timestamp === "string" && isFrameArray(parsed.frames)) {
172
+ const record = {
173
+ frames: parsed.frames,
174
+ timestamp: parsed.timestamp,
175
+ };
176
+ if (typeof parsed.title === "string") {
177
+ record.title = parsed.title;
178
+ }
179
+ if (parsed.nodeToFrame !== null && typeof parsed.nodeToFrame === "object") {
180
+ record.nodeToFrame = parsed.nodeToFrame;
181
+ }
182
+ return record;
183
+ }
184
+ }
185
+ catch (err) {
186
+ console.error(`[agent-whiteboard] snapshot-reader: skipping unreadable file ${filename}:`, err instanceof Error ? err.message : String(err));
187
+ }
188
+ }
189
+ return null;
190
+ }