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,117 @@
1
+ import { existsSync, mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "fs";
2
+ import { join, resolve, sep } from "path";
3
+ import { getSnapshotsRoot } from "./paths.js";
4
+ import { readSnapshotIdSafe } from "./snapshot-reader.js";
5
+ import { isValidSnapshotFilename, validateWorkspaceInput } from "./validate.js";
6
+ import { deleteViewports } from "./viewport-cache.js";
7
+ /** Generate a snapshot id up front, before the write happens — lets callers
8
+ * broadcast the id to the browser (e.g. for viewport-cache keying) without
9
+ * waiting on the (synchronous, but logically separate) disk write. */
10
+ export function generateSnapshotId() {
11
+ return crypto.randomUUID();
12
+ }
13
+ export function saveSnapshot(frames, options, rawPayload, id) {
14
+ try {
15
+ const { workspace } = options;
16
+ const root = getSnapshotsRoot();
17
+ const dir = join(root, workspace);
18
+ mkdirSync(dir, { recursive: true });
19
+ const now = new Date();
20
+ const usedId = id ?? crypto.randomUUID();
21
+ // Include the (already-unique) id so two writes in the same second never collide.
22
+ const filename = `${formatTimestamp(now)}_${usedId}_screen.json`;
23
+ const content = {
24
+ id: usedId,
25
+ timestamp: now.toISOString(),
26
+ workspace,
27
+ cursor: 0,
28
+ frames,
29
+ ...(options.title !== undefined ? { title: options.title } : {}),
30
+ ...(options.node_to_frame !== undefined ? { nodeToFrame: options.node_to_frame } : {}),
31
+ ...(rawPayload !== undefined ? { rawPayload } : {}),
32
+ };
33
+ writeFileSync(join(dir, filename), JSON.stringify(content, null, 2), "utf-8");
34
+ return usedId;
35
+ }
36
+ catch (err) {
37
+ console.error("[agent-whiteboard] snapshot write failed:", err instanceof Error ? err.message : String(err));
38
+ return undefined;
39
+ }
40
+ }
41
+ function formatTimestamp(d) {
42
+ const p = (n) => String(n).padStart(2, "0");
43
+ return (`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
44
+ `_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`);
45
+ }
46
+ /**
47
+ * Workspace validation for the delete endpoints (NF26, v0.28 Sprint 59):
48
+ * builds on the shared `validateWorkspaceInput()` (render-core.ts) and adds
49
+ * the two checks specific to deleting an existing directory — containment
50
+ * (belt-and-suspenders against a resolved path escaping the snapshots root,
51
+ * e.g. workspace ".", B6) and existence (404 if the workspace was never
52
+ * created or was already deleted).
53
+ */
54
+ export function validateWorkspaceForDelete(workspace, root) {
55
+ const validated = validateWorkspaceInput(workspace);
56
+ if (!validated.ok) {
57
+ return { ok: false, error: validated.error, status: 400 };
58
+ }
59
+ const dir = join(root, validated.workspace);
60
+ if (!resolve(dir).startsWith(resolve(root) + sep)) {
61
+ return { ok: false, error: "invalid workspace: path traversal not allowed", status: 400 };
62
+ }
63
+ if (!existsSync(dir)) {
64
+ return { ok: false, error: "workspace not found", status: 404 };
65
+ }
66
+ return { ok: true, workspace: validated.workspace };
67
+ }
68
+ /**
69
+ * Deletes the given snapshot filenames from `workspace` and cleans up their
70
+ * viewport-cache entries (C3, `02`). Missing files are silently skipped.
71
+ * Shared by POST /snapshots/delete-files (NF26, v0.28 Sprint 59).
72
+ */
73
+ export function deleteSnapshotFiles(workspace, root, filenames) {
74
+ for (const f of filenames) {
75
+ if (!isValidSnapshotFilename(f)) {
76
+ return { ok: false, error: `invalid filename: ${f}` };
77
+ }
78
+ }
79
+ const workspaceDir = join(root, workspace);
80
+ let deleted = 0;
81
+ const deletedIds = [];
82
+ for (const f of filenames) {
83
+ const fullPath = join(workspaceDir, f);
84
+ const id = readSnapshotIdSafe(fullPath);
85
+ try {
86
+ unlinkSync(fullPath);
87
+ deleted++;
88
+ if (id !== undefined)
89
+ deletedIds.push(id);
90
+ }
91
+ catch {
92
+ // Missing files are silently skipped.
93
+ }
94
+ }
95
+ deleteViewports(deletedIds);
96
+ return { ok: true, deleted };
97
+ }
98
+ /**
99
+ * Removes an entire workspace directory and cleans up viewport-cache entries
100
+ * for every snapshot that lived in it (C3, `02`). Shared by
101
+ * POST /snapshots/delete-workspace (NF26, v0.28 Sprint 59).
102
+ */
103
+ export function deleteWorkspace(workspace, root) {
104
+ const workspaceDir = join(root, workspace);
105
+ let idsToClean = [];
106
+ try {
107
+ idsToClean = readdirSync(workspaceDir)
108
+ .filter((f) => f.endsWith("_screen.json"))
109
+ .map((f) => readSnapshotIdSafe(join(workspaceDir, f)))
110
+ .filter((id) => id !== undefined);
111
+ }
112
+ catch {
113
+ // Workspace directory unreadable/absent — nothing to clean up.
114
+ }
115
+ rmSync(workspaceDir, { recursive: true, force: true });
116
+ deleteViewports(idsToClean);
117
+ }
@@ -0,0 +1,47 @@
1
+ import { mkdirSync, writeFileSync } from "fs";
2
+ import { homedir } from "os";
3
+ import { join } from "path";
4
+ /** Generate a snapshot id up front, before the write happens — lets callers
5
+ * broadcast the id to the browser (e.g. for viewport-cache keying) without
6
+ * waiting on the (synchronous, but logically separate) disk write. */
7
+ export function generateSnapshotId() {
8
+ return crypto.randomUUID();
9
+ }
10
+ export function saveSnapshot(type, payload, options, id) {
11
+ try {
12
+ const { workspace } = options;
13
+ const root = process.env.WHITEBOARD_SNAPSHOTS_DIR ?? join(homedir(), ".agent-whiteboard");
14
+ const dir = join(root, workspace);
15
+ mkdirSync(dir, { recursive: true });
16
+ const now = new Date();
17
+ const usedId = id ?? crypto.randomUUID();
18
+ // Include the (already-unique) id so two writes in the same second never collide.
19
+ const filename = `${formatTimestamp(now)}_${usedId}_screen.json`;
20
+ const content = {
21
+ id: usedId,
22
+ timestamp: now.toISOString(),
23
+ workspace,
24
+ type,
25
+ payload,
26
+ };
27
+ if (options.title !== undefined || options.node_to_frame !== undefined) {
28
+ const cleanedOptions = {};
29
+ if (options.title !== undefined)
30
+ cleanedOptions.title = options.title;
31
+ if (options.node_to_frame !== undefined)
32
+ cleanedOptions.node_to_frame = options.node_to_frame;
33
+ content.options = cleanedOptions;
34
+ }
35
+ writeFileSync(join(dir, filename), JSON.stringify(content, null, 2), "utf-8");
36
+ return usedId;
37
+ }
38
+ catch (err) {
39
+ console.error("[agent-whiteboard] snapshot write failed:", err instanceof Error ? err.message : String(err));
40
+ return undefined;
41
+ }
42
+ }
43
+ function formatTimestamp(d) {
44
+ const p = (n) => String(n).padStart(2, "0");
45
+ return (`${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
46
+ `_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`);
47
+ }
@@ -0,0 +1,70 @@
1
+ // In-memory builder map for incremental step-frames creation (v0.8).
2
+ // Agents call init → append × N → commit instead of generating one large payload.
3
+ import { randomUUID } from "crypto";
4
+ import { validateFrame } from "./validate.js";
5
+ const TTL_MS = 30 * 60 * 1000; // 30 minutes
6
+ const builders = new Map();
7
+ /** Create a new builder entry. Returns the assigned UUID. */
8
+ export function createBuilder(frame_type, workspace, title) {
9
+ const id = randomUUID();
10
+ const timer = setTimeout(() => expireBuilder(id), TTL_MS);
11
+ builders.set(id, { frame_type, workspace, title, frames: [], timer });
12
+ return id;
13
+ }
14
+ /** Append a frame to an existing builder entry. Validates payload against type ?? entry.frame_type. */
15
+ export async function appendFrame(id, payload, label, type) {
16
+ const entry = builders.get(id);
17
+ if (!entry) {
18
+ return { ok: false, error: "step-frames session not found or expired" };
19
+ }
20
+ const validationError = await validateFrame({ type: type ?? entry.frame_type, payload });
21
+ if (validationError) {
22
+ return { ok: false, error: validationError };
23
+ }
24
+ entry.frames.push({ payload, ...(label !== undefined ? { label } : {}), ...(type !== undefined ? { type } : {}) });
25
+ // Reset TTL.
26
+ clearTimeout(entry.timer);
27
+ entry.timer = setTimeout(() => expireBuilder(id), TTL_MS);
28
+ return {
29
+ ok: true,
30
+ frame_count: entry.frames.length,
31
+ frames: entry.frames,
32
+ frame_type: entry.frame_type,
33
+ title: entry.title,
34
+ };
35
+ }
36
+ /**
37
+ * Commit the builder entry — returns the assembled entry for the caller to
38
+ * render. Deletes the entry from the map.
39
+ */
40
+ export function commitBuilder(id) {
41
+ const entry = builders.get(id);
42
+ if (!entry) {
43
+ return { ok: false, error: "step-frames session not found or expired" };
44
+ }
45
+ if (entry.frames.length === 0) {
46
+ return { ok: false, error: "cannot commit empty step-frames sequence" };
47
+ }
48
+ clearTimeout(entry.timer);
49
+ builders.delete(id);
50
+ return { ok: true, entry };
51
+ }
52
+ /** Silently delete an expired builder entry (called by TTL timer). */
53
+ export function expireBuilder(id) {
54
+ const entry = builders.get(id);
55
+ if (entry) {
56
+ clearTimeout(entry.timer);
57
+ builders.delete(id);
58
+ }
59
+ }
60
+ /** Return the size of the builder map — for testing only. */
61
+ export function builderCount() {
62
+ return builders.size;
63
+ }
64
+ /** Clear all entries — for testing only. */
65
+ export function resetBuilders() {
66
+ for (const entry of builders.values()) {
67
+ clearTimeout(entry.timer);
68
+ }
69
+ builders.clear();
70
+ }
@@ -0,0 +1,140 @@
1
+ // Server-side payload validation helpers.
2
+ import mermaid from "mermaid";
3
+ import { z } from "zod";
4
+ /**
5
+ * Shared shape schemas for `node_actions` (wait_click) and `node_to_frame`
6
+ * (commit_step_frames) — the single implementation both MCP's zod
7
+ * inputSchema and REST's request-body validation parse against (F7/NF24),
8
+ * replacing REST's previously hand-written type guards.
9
+ */
10
+ export const nodeActionsSchema = z.record(z.string(), z.array(z.string()));
11
+ export const nodeToFrameSchema = z.record(z.string(), z.number());
12
+ // Initialise once — no DOM side effects in parse-only mode.
13
+ mermaid.initialize({ startOnLoad: false, securityLevel: "strict" });
14
+ /**
15
+ * Layer 1: fast prefix check.
16
+ * Returns false if the payload does not begin with a known Mermaid keyword.
17
+ */
18
+ export const MERMAID_KEYWORDS = [
19
+ "graph",
20
+ "flowchart",
21
+ "sequenceDiagram",
22
+ "classDiagram",
23
+ "erDiagram",
24
+ "gantt",
25
+ "pie",
26
+ "mindmap",
27
+ ];
28
+ export function hasMermaidKeyword(payload) {
29
+ const first = payload.trimStart().split(/\s/)[0];
30
+ return MERMAID_KEYWORDS.includes(first);
31
+ }
32
+ /**
33
+ * Layer 2: full parse via Mermaid.js.
34
+ * Throws with a human-readable message if the syntax is invalid.
35
+ *
36
+ * Some diagram types (classDiagram, gantt, pie, mindmap) call DOMPurify
37
+ * internally, which requires a DOM context unavailable in Node.js.
38
+ * Those errors are swallowed — the keyword-prefix check (Layer 1) remains
39
+ * the safety net for those types. Genuine parse errors are always re-thrown.
40
+ */
41
+ export async function parseMermaid(payload) {
42
+ try {
43
+ await mermaid.parse(payload);
44
+ }
45
+ catch (err) {
46
+ const msg = err instanceof Error ? err.message : String(err);
47
+ // Distinguish Node.js environment limitations (DOMPurify, DOM APIs missing)
48
+ // from genuine Mermaid syntax parse errors.
49
+ if (isNodeEnvLimitation(msg))
50
+ return; // can't validate in Node — skip
51
+ throw err; // real parse error — bubble up to caller
52
+ }
53
+ }
54
+ /**
55
+ * Validates a workspace name for snapshot routing.
56
+ * Accepts alphanumeric characters, dashes, underscores, dots, and spaces.
57
+ * Rejects path separators, null bytes, and the bare ".." sequence.
58
+ */
59
+ export function isValidWorkspaceName(name) {
60
+ if (!name || name === "..")
61
+ return false;
62
+ return /^[a-zA-Z0-9_\-. ]+$/.test(name);
63
+ }
64
+ /**
65
+ * Validates a snapshot filename for path safety (NF28, v0.28 Sprint 61):
66
+ * must end with `_screen.json` and contain no `/` or `..`. The one
67
+ * implementation shared by POST /snapshots/load and
68
+ * snapshot-writer.ts's delete-files logic — previously copy-pasted in both.
69
+ */
70
+ export function isValidSnapshotFilename(filename) {
71
+ return /^[^/]+_screen\.json$/.test(filename) && !filename.includes("..");
72
+ }
73
+ /**
74
+ * Validates the `workspace` field required by render(), init_step_frames(),
75
+ * list_snapshots(), and export_html() — same rule and error text everywhere
76
+ * (F14/F15/F18 in docs/03). Lives here (not render-core.ts) so both
77
+ * render-core.ts and snapshot-writer.ts can depend on it without a cycle
78
+ * (NF26, v0.28 Sprint 59) — render-core.ts re-exports it for its existing
79
+ * callers (app.ts, mcp.ts).
80
+ */
81
+ export function validateWorkspaceInput(workspace) {
82
+ if (!workspace) {
83
+ return { ok: false, error: "workspace is required" };
84
+ }
85
+ const ws = workspace;
86
+ if (!isValidWorkspaceName(ws)) {
87
+ return {
88
+ ok: false,
89
+ error: "invalid workspace: must be alphanumeric with dashes, underscores, dots, or spaces — no path separators or '..'",
90
+ };
91
+ }
92
+ return { ok: true, workspace: ws };
93
+ }
94
+ /**
95
+ * Returns true if the error originates from a missing DOM API in Node.js,
96
+ * not from invalid Mermaid syntax.
97
+ */
98
+ function isNodeEnvLimitation(msg) {
99
+ return (msg.includes("DOMPurify") ||
100
+ msg.includes("is not a function") ||
101
+ msg.includes("document is not defined") ||
102
+ msg.includes("window is not defined"));
103
+ }
104
+ /** Content types a single Frame can carry — the only content types anywhere in the MCP payload contract (v0.26 Sprint 45; "step-frames" no longer exists as a top-level type). */
105
+ export const FRAME_TYPES = ["mermaid", "svg", "html", "katex", "vega-lite"];
106
+ /**
107
+ * Validate one Frame — the single atomic-content validator (U2, v0.26).
108
+ * Every command path that accepts frame content (render(), each slide of
109
+ * slideshow(), the incremental append_frame() builder) funnels each frame
110
+ * through this same function; there is no second implementation.
111
+ * Returns null on success; an error string on failure. Async because Mermaid
112
+ * parse is async.
113
+ */
114
+ export async function validateFrame(frame) {
115
+ const { type, payload } = frame;
116
+ if (!FRAME_TYPES.includes(type)) {
117
+ return `type must be one of: ${FRAME_TYPES.join(", ")}`;
118
+ }
119
+ if (type === "mermaid") {
120
+ if (!hasMermaidKeyword(payload)) {
121
+ return ("invalid payload: mermaid source must begin with a diagram keyword " +
122
+ "(e.g. 'graph TD', 'sequenceDiagram', 'classDiagram', ...)");
123
+ }
124
+ try {
125
+ await parseMermaid(payload);
126
+ }
127
+ catch (err) {
128
+ return `invalid mermaid syntax: ${err instanceof Error ? err.message : String(err)}`;
129
+ }
130
+ }
131
+ else if (type === "vega-lite") {
132
+ try {
133
+ JSON.parse(payload);
134
+ }
135
+ catch {
136
+ return "invalid payload: vega-lite payload must be valid JSON";
137
+ }
138
+ }
139
+ return null;
140
+ }
@@ -0,0 +1,69 @@
1
+ // Global viewport-cache file — maps "<snapshot id>:<frame index>" -> user-adjusted
2
+ // Mermaid zoom/pan. Separate from the immutable snapshot JSON files (F19 / C3 in
3
+ // docs/02). Composite key (v0.26.1, bug B19 in docs/01 — FR21): each frame of a
4
+ // step-frames sequence persists its own manual viewport independently, consistent
5
+ // with each frame now getting its own auto-fit trigger (was a bare `id` key,
6
+ // shared across a whole sequence, pre-v0.26.1).
7
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
8
+ import { join } from "path";
9
+ import { getSnapshotsRoot } from "./paths.js";
10
+ function cachePath() {
11
+ return join(getSnapshotsRoot(), "viewport-cache.json");
12
+ }
13
+ function readCache() {
14
+ try {
15
+ const raw = readFileSync(cachePath(), "utf-8");
16
+ const parsed = JSON.parse(raw);
17
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
18
+ return {};
19
+ return parsed;
20
+ }
21
+ catch (err) {
22
+ // ENOENT (no cache file yet, e.g. first run) is expected and not logged.
23
+ // Anything else (corrupted JSON, permission error) silently discards all
24
+ // persisted zoom/pan state, so it's worth a warning.
25
+ if (err?.code !== "ENOENT") {
26
+ console.error("[agent-whiteboard] viewport-cache read failed, resetting cache:", err instanceof Error ? err.message : String(err));
27
+ }
28
+ return {};
29
+ }
30
+ }
31
+ function writeCache(cache) {
32
+ try {
33
+ mkdirSync(getSnapshotsRoot(), { recursive: true });
34
+ writeFileSync(cachePath(), JSON.stringify(cache, null, 2), "utf-8");
35
+ }
36
+ catch (err) {
37
+ console.error("[agent-whiteboard] viewport-cache write failed:", err instanceof Error ? err.message : String(err));
38
+ }
39
+ }
40
+ function cacheKey(id, frameIndex) {
41
+ return `${id}:${frameIndex}`;
42
+ }
43
+ export function getViewport(id, frameIndex) {
44
+ return readCache()[cacheKey(id, frameIndex)];
45
+ }
46
+ export function setViewport(id, frameIndex, viewport) {
47
+ const cache = readCache();
48
+ cache[cacheKey(id, frameIndex)] = viewport;
49
+ writeCache(cache);
50
+ }
51
+ export function deleteViewport(id) {
52
+ deleteViewports([id]);
53
+ }
54
+ /** Removes every per-frame entry for each given snapshot id (prefix match on "<id>:"). */
55
+ export function deleteViewports(ids) {
56
+ if (ids.length === 0)
57
+ return;
58
+ const cache = readCache();
59
+ const prefixes = ids.map((id) => `${id}:`);
60
+ let changed = false;
61
+ for (const key of Object.keys(cache)) {
62
+ if (prefixes.some((prefix) => key.startsWith(prefix))) {
63
+ delete cache[key];
64
+ changed = true;
65
+ }
66
+ }
67
+ if (changed)
68
+ writeCache(cache);
69
+ }
@@ -0,0 +1,71 @@
1
+ // WebSocket push to all connected browser clients.
2
+ import { getDoneArmed, setBroadcastFn } from "./interaction.js";
3
+ const clients = new Set();
4
+ // Wire up the broadcast function so interaction.ts can push state changes
5
+ // without a circular import.
6
+ setBroadcastFn((msg) => broadcast(msg));
7
+ export function addClient(ws) {
8
+ clients.add(ws);
9
+ ws.on("close", () => clients.delete(ws));
10
+ // Push current armed state immediately so a fresh browser tab shows the right
11
+ // Done button visibility without waiting for the next state change.
12
+ const payload = JSON.stringify({ action: "set_done_armed", armed: getDoneArmed() });
13
+ if (ws.readyState === 1 /* OPEN */)
14
+ ws.send(payload);
15
+ }
16
+ export function broadcast(message) {
17
+ const payload = JSON.stringify(message);
18
+ for (const client of clients) {
19
+ if (client.readyState === 1 /* OPEN */) {
20
+ client.send(payload);
21
+ }
22
+ }
23
+ }
24
+ export function broadcastReplace(msg) {
25
+ if (!("payload" in msg)) {
26
+ broadcast({
27
+ action: "replace",
28
+ type: msg.type,
29
+ frameCount: msg.frameCount,
30
+ ...(msg.title !== undefined ? { title: msg.title } : {}),
31
+ });
32
+ return;
33
+ }
34
+ broadcast({
35
+ action: "replace",
36
+ type: msg.type,
37
+ payload: msg.payload,
38
+ id: msg.id,
39
+ cursor: msg.cursor,
40
+ total: msg.total,
41
+ ...(msg.frameLabel !== undefined ? { frameLabel: msg.frameLabel } : {}),
42
+ ...(msg.title !== undefined ? { title: msg.title } : {}),
43
+ ...(msg.nodeToFrame !== undefined ? { nodeToFrame: msg.nodeToFrame } : {}),
44
+ ...(msg.viewport !== undefined ? { viewport: msg.viewport } : {}),
45
+ });
46
+ }
47
+ /**
48
+ * Broadcast a step-frames event. Used by append_frame (partial), commit_step_frames
49
+ * (final), and /step's (REST + MCP) frame-advance broadcast.
50
+ * `nodeToFrame` (v0.26.1, bug B18 in docs/01): this wrapper previously had no slot
51
+ * for it at all, so every call site silently dropped the map even when the caller
52
+ * had one — the exact drift the v0.23 broadcastReplace() unification was meant to
53
+ * prevent, reintroduced one layer up. See docs/02 C2e.
54
+ * `viewport` (v0.26.1, bug B19/FR21 in docs/01): a per-frame saved viewport to
55
+ * restore instead of auto-fitting, looked up by the caller via
56
+ * `getViewport(id, cursor)` — each frame of a sequence now re-fits or restores
57
+ * independently instead of the whole sequence sharing one viewport.
58
+ */
59
+ export function broadcastStepFrames(frames, frameType, cursor, id, title, nodeToFrame, viewport) {
60
+ broadcastReplace({
61
+ type: frames[cursor].type ?? frameType,
62
+ payload: frames[cursor].payload,
63
+ frameLabel: frames[cursor].label,
64
+ cursor,
65
+ total: frames.length,
66
+ id,
67
+ title,
68
+ nodeToFrame,
69
+ viewport,
70
+ });
71
+ }
package/package.json ADDED
@@ -0,0 +1,92 @@
1
+ {
2
+ "name": "agent-whiteboard",
3
+ "version": "1.0.0",
4
+ "description": "A domain-agnostic interactive whiteboard for AI teacher agents — render diagrams, math, charts, and HTML to a local browser tab via MCP tools.",
5
+ "type": "module",
6
+ "bin": {
7
+ "agent-whiteboard": "bin/cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "dist",
12
+ "README.md",
13
+ "LICENSE",
14
+ "CHANGELOG.md"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/bobpuley/agent-whiteboard.git"
19
+ },
20
+ "homepage": "https://github.com/bobpuley/agent-whiteboard#readme",
21
+ "bugs": {
22
+ "url": "https://github.com/bobpuley/agent-whiteboard/issues"
23
+ },
24
+ "author": "Marco Pugliese",
25
+ "license": "MIT",
26
+ "keywords": [
27
+ "mcp",
28
+ "model-context-protocol",
29
+ "claude",
30
+ "claude-code",
31
+ "whiteboard",
32
+ "ai-agent",
33
+ "teaching",
34
+ "visualization",
35
+ "mermaid"
36
+ ],
37
+ "engines": {
38
+ "node": ">=18"
39
+ },
40
+ "scripts": {
41
+ "dev": "concurrently --kill-others-on-fail --names \"server,client,browser\" \"npm run dev:server\" \"npm run dev:client\" \"npm run dev:open\"",
42
+ "dev:server": "tsx watch server/index.ts",
43
+ "dev:client": "vite --config client/vite.config.ts",
44
+ "dev:open": "wait-on http://localhost:5173 && node -e \"import('open').then(m => m.default('http://localhost:5173'))\"",
45
+ "dev:test": "concurrently --kill-others-on-fail --names \"server,client\" \"tsx server/index.ts\" \"npm run dev:client\"",
46
+ "typecheck": "svelte-check --tsconfig client/tsconfig.json",
47
+ "build": "tsc -p tsconfig.json && npm run typecheck && vite build --config client/vite.config.ts",
48
+ "test": "vitest run",
49
+ "test:e2e": "playwright test",
50
+ "lint": "eslint .",
51
+ "lint:fix": "eslint . --fix"
52
+ },
53
+ "dependencies": {
54
+ "@hono/node-server": "^1.13.7",
55
+ "@modelcontextprotocol/sdk": "^1.12.1",
56
+ "@types/dompurify": "^3.2.0",
57
+ "bootstrap": "^5.3.8",
58
+ "dompurify": "^3.4.7",
59
+ "happy-dom": "^20.10.6",
60
+ "hono": "^4.7.11",
61
+ "katex": "^0.17.0",
62
+ "mermaid": "^11.15.0",
63
+ "open": "^10.1.2",
64
+ "vega": "^6.2.0",
65
+ "vega-embed": "^7.1.0",
66
+ "vega-lite": "^6.4.3",
67
+ "ws": "^8.21.0"
68
+ },
69
+ "devDependencies": {
70
+ "@eslint/js": "^10.0.1",
71
+ "@playwright/test": "^1.60.0",
72
+ "@sveltejs/vite-plugin-svelte": "^2.4.6",
73
+ "@testing-library/jest-dom": "^6.9.1",
74
+ "@testing-library/svelte": "^4.2.3",
75
+ "@tsconfig/svelte": "^5.0.4",
76
+ "@types/node": "^20.17.57",
77
+ "@types/ws": "^8.18.1",
78
+ "concurrently": "^9.1.2",
79
+ "eslint": "^10.6.0",
80
+ "eslint-plugin-svelte": "^3.20.0",
81
+ "globals": "^17.7.0",
82
+ "svelte": "^4.2.19",
83
+ "svelte-check": "^4.7.1",
84
+ "svelte-eslint-parser": "^1.8.0",
85
+ "tsx": "^3.14.0",
86
+ "typescript": "^5.8.3",
87
+ "typescript-eslint": "^8.62.1",
88
+ "vite": "^4.5.10",
89
+ "vitest": "^0.34.6",
90
+ "wait-on": "^8.0.3"
91
+ }
92
+ }