@venturewild/workspace 0.3.6 → 0.3.7

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 (52) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +83 -83
  4. package/server/bin/wild-workspace.mjs +995 -995
  5. package/server/src/account.mjs +114 -114
  6. package/server/src/agent-login.mjs +146 -146
  7. package/server/src/agent-readiness.mjs +200 -200
  8. package/server/src/agent.mjs +468 -453
  9. package/server/src/bazaar/core.mjs +579 -579
  10. package/server/src/bazaar/index.mjs +75 -75
  11. package/server/src/bazaar/mcp-server.mjs +328 -328
  12. package/server/src/bazaar/mock-tickup.mjs +97 -97
  13. package/server/src/bazaar/preview-server.mjs +95 -95
  14. package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
  15. package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
  16. package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
  17. package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
  18. package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
  19. package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
  20. package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
  21. package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
  22. package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
  23. package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -32
  24. package/server/src/canvas/core.mjs +421 -421
  25. package/server/src/canvas/index.mjs +42 -42
  26. package/server/src/canvas/mcp-server.mjs +253 -253
  27. package/server/src/config.mjs +404 -404
  28. package/server/src/daemon-bin.mjs +110 -110
  29. package/server/src/daemon-supervisor.mjs +285 -285
  30. package/server/src/doctor.mjs +375 -375
  31. package/server/src/inbox.mjs +86 -86
  32. package/server/src/index.mjs +2475 -2365
  33. package/server/src/logpaths.mjs +98 -98
  34. package/server/src/observability.mjs +45 -45
  35. package/server/src/operator.mjs +92 -92
  36. package/server/src/pairing.mjs +137 -137
  37. package/server/src/service.mjs +515 -515
  38. package/server/src/session-reporter.mjs +201 -201
  39. package/server/src/settings.mjs +145 -0
  40. package/server/src/share.mjs +182 -182
  41. package/server/src/skills.mjs +213 -0
  42. package/server/src/supervisor.mjs +647 -647
  43. package/server/src/support-consent.mjs +133 -133
  44. package/server/src/sync.mjs +248 -248
  45. package/server/src/transcript.mjs +121 -121
  46. package/server/src/turn-mcp.mjs +46 -46
  47. package/server/src/usage.mjs +405 -0
  48. package/web/dist/assets/index-BxRx8EsD.js +91 -0
  49. package/web/dist/assets/index-DoOPBr3s.css +1 -0
  50. package/web/dist/index.html +2 -2
  51. package/web/dist/assets/index-B7cOsWLt.js +0 -91
  52. package/web/dist/assets/index-Dl0VT5e6.css +0 -1
@@ -1,421 +1,421 @@
1
- // Canvas core — the store + validator for AGENT-MADE custom blocks.
2
- //
3
- // The block canvas (UX §3.3) lets the agent build the user a widget on request
4
- // ("make me a block that shows today's signups"). The differentiator: the canvas
5
- // stops being a fixed menu of widgets and becomes "anything the agent can build".
6
- //
7
- // SECURITY POSTURE — declarative, not code. The agent does NOT ship a React
8
- // component or any code to eval. It computes the data itself (Read/Bash in its own
9
- // turn) and emits a *spec* drawn from a small, fixed vocabulary of presentation
10
- // primitives (metric · list · table · markdown). The browser renders that spec with
11
- // trusted primitives (React-escaped text; markdown via react-markdown with NO raw
12
- // HTML). So an agent-made block adds NO new execution surface and cannot escalate a
13
- // read-only share-link viewer's privileges. "Live" = the agent re-pushes via
14
- // update_block (principle #4: no idle polling), not a server-run refresh command.
15
- // A shell-bound live refresh is a deliberate, owner-gated follow-up (see NOTES).
16
- //
17
- // State lives ENTIRELY under ~/.wild-workspace/canvas/ (absolute, OUTSIDE the
18
- // user's repo — CLAUDE.md rule #1). One module, imported by BOTH the main server
19
- // (to serve /api/canvas/blocks) and the spawned MCP server (to make/update blocks),
20
- // so there is a single file-backed source of truth and no port handshake:
21
- // - blocks.json the array of custom block specs. MCP writes, main reads.
22
-
23
- import fs from 'node:fs';
24
- import path from 'node:path';
25
- import os from 'node:os';
26
- import crypto from 'node:crypto';
27
-
28
- // The presentation primitives the agent may choose from. Anything else is rejected
29
- // at the boundary — the renderer only knows these.
30
- export const KINDS = ['metric', 'list', 'table', 'markdown'];
31
-
32
- // The named theme tokens an agent (or a Bazaar theme) may override — the EXTENSION
33
- // surface for "make my terminal look how I want". Each is ONLY ever a hex color
34
- // (validated below), so a theme is DATA, never CSS: it cannot inject url()/
35
- // expression()/selector-escapes. Keep in lockstep with TOKEN_VARS in web/src/theme.js.
36
- export const TOKEN_KEYS = [
37
- 'bg', 'bgElev', 'surface', 'border', 'text', 'textMuted', 'canvas1', 'canvas2', 'canvas3',
38
- ];
39
-
40
- const HEX = /^#[0-9a-f]{6}$/i;
41
- export function isHex(v) {
42
- return typeof v === 'string' && HEX.test(v.trim());
43
- }
44
- function hexOr(v, fallback = null) {
45
- return isHex(v) ? v.trim().toLowerCase() : fallback;
46
- }
47
-
48
- const DEFAULT_ICON = { metric: '📈', list: '📋', table: '🗂️', markdown: '📝' };
49
- const TRENDS = ['up', 'down', 'flat'];
50
-
51
- // Caps — bound every field so a runaway tool call can't bloat the store or the UI.
52
- const CAP = {
53
- blocks: 60, // keep the most-recent N specs
54
- title: 80,
55
- icon: 8,
56
- note: 240,
57
- value: 48,
58
- label: 48,
59
- delta: 24,
60
- spark: 60, // sparkline points
61
- listItems: 50,
62
- itemLabel: 80,
63
- itemValue: 40,
64
- columns: 12,
65
- colName: 40,
66
- rows: 50,
67
- cell: 80,
68
- markdown: 6000,
69
- };
70
-
71
- // ~/.wild-workspace/canvas — mirrors logpaths.globalDir() but kept dependency-free
72
- // here so the MCP child can import this module standalone (same trick as bazaar).
73
- export function defaultCanvasDir(env = process.env) {
74
- const base = env.WILD_WORKSPACE_GLOBAL_DIR || path.join(os.homedir(), '.wild-workspace');
75
- return path.join(base, 'canvas');
76
- }
77
-
78
- function rid() {
79
- return crypto.randomUUID().slice(0, 12);
80
- }
81
-
82
- function readJsonSafe(file, fallback) {
83
- try {
84
- return JSON.parse(fs.readFileSync(file, 'utf8'));
85
- } catch {
86
- return fallback;
87
- }
88
- }
89
-
90
- function writeJsonAtomic(file, value) {
91
- const tmp = `${file}.${process.pid}.tmp`;
92
- fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
93
- fs.renameSync(tmp, file); // Node rename replaces the destination on all platforms
94
- }
95
-
96
- // --- spec normalization (the security boundary) ---------------------------
97
-
98
- function str(v, max) {
99
- if (v === null || v === undefined) return '';
100
- const s = String(typeof v === 'object' ? JSON.stringify(v) : v);
101
- return s.slice(0, max);
102
- }
103
-
104
- function nonEmptyStr(v, max, fallback = '') {
105
- const s = str(v, max).trim();
106
- return s || fallback;
107
- }
108
-
109
- function numArray(v, max) {
110
- if (!Array.isArray(v)) return [];
111
- return v
112
- .map((n) => Number(n))
113
- .filter((n) => Number.isFinite(n))
114
- .slice(0, max);
115
- }
116
-
117
- function normalizeData(kind, raw = {}) {
118
- switch (kind) {
119
- case 'metric': {
120
- const data = { value: nonEmptyStr(raw.value, CAP.value, '—') };
121
- const label = nonEmptyStr(raw.label, CAP.label);
122
- if (label) data.label = label;
123
- const delta = nonEmptyStr(raw.delta, CAP.delta);
124
- if (delta) data.delta = delta;
125
- if (TRENDS.includes(raw.trend)) data.trend = raw.trend;
126
- const spark = numArray(raw.spark, CAP.spark);
127
- if (spark.length) data.spark = spark;
128
- return data;
129
- }
130
- case 'list': {
131
- const src = Array.isArray(raw.items) ? raw.items : [];
132
- const items = src.slice(0, CAP.listItems).map((it) => {
133
- // Accept {label,value} objects OR bare strings (the agent's convenience).
134
- if (it && typeof it === 'object') {
135
- const item = { label: nonEmptyStr(it.label ?? it.name ?? it.key, CAP.itemLabel, '—') };
136
- const value = nonEmptyStr(it.value ?? it.amount ?? it.count, CAP.itemValue);
137
- if (value) item.value = value;
138
- return item;
139
- }
140
- return { label: nonEmptyStr(it, CAP.itemLabel, '—') };
141
- });
142
- return { items };
143
- }
144
- case 'table': {
145
- const columns = (Array.isArray(raw.columns) ? raw.columns : [])
146
- .slice(0, CAP.columns)
147
- .map((c) => nonEmptyStr(c, CAP.colName, ''));
148
- const width = columns.length || CAP.columns;
149
- const rows = (Array.isArray(raw.rows) ? raw.rows : [])
150
- .slice(0, CAP.rows)
151
- .map((r) => (Array.isArray(r) ? r : [r]).slice(0, width).map((cell) => str(cell, CAP.cell)));
152
- return { columns, rows };
153
- }
154
- case 'markdown':
155
- default:
156
- return { text: nonEmptyStr(raw.markdown ?? raw.text ?? raw.body, CAP.markdown) };
157
- }
158
- }
159
-
160
- /**
161
- * Validate + normalize a raw spec (from the agent's flat tool input) into the
162
- * stored shape. Throws on an unsupported `kind` so the MCP tool returns an error.
163
- * Everything else degrades gracefully (capped strings, dropped junk) — the agent
164
- * gets a usable block even if it over- or mis-specifies.
165
- */
166
- export function normalizeSpec(raw = {}) {
167
- const kind = String(raw.kind || '').toLowerCase();
168
- if (!KINDS.includes(kind)) {
169
- throw new Error(`unsupported kind "${raw.kind}". Use one of: ${KINDS.join(', ')}`);
170
- }
171
- const spec = {
172
- title: nonEmptyStr(raw.title, CAP.title, 'Untitled'),
173
- icon: nonEmptyStr(raw.icon, CAP.icon, DEFAULT_ICON[kind]),
174
- kind,
175
- data: normalizeData(kind, raw),
176
- };
177
- const note = nonEmptyStr(raw.note, CAP.note);
178
- if (note) spec.note = note;
179
- return spec;
180
- }
181
-
182
- // --- theme normalization (the same security boundary, for the look) --------
183
-
184
- /**
185
- * Validate + normalize a raw theme (from the agent's flat set_theme input) into the
186
- * stored shape: { mode, accent, hot?, name?, tokens: {…hex} }. Hex-only, allowlisted
187
- * tokens — anything else is dropped. Never throws; a junk theme degrades to the
188
- * mode default (the picker/stylesheet still produces a usable look).
189
- */
190
- export function normalizeTheme(raw = {}) {
191
- const theme = {
192
- mode: raw.mode === 'dark' ? 'dark' : 'light',
193
- accent: hexOr(raw.accent, '#0891b2'),
194
- };
195
- const hot = hexOr(raw.hot);
196
- if (hot) theme.hot = hot;
197
- const name = nonEmptyStr(raw.name, 60);
198
- if (name) theme.name = name;
199
- const tokens = {};
200
- for (const key of TOKEN_KEYS) {
201
- const hex = hexOr(raw[key] ?? raw.tokens?.[key]);
202
- if (hex) tokens[key] = hex;
203
- }
204
- theme.tokens = tokens;
205
- return theme;
206
- }
207
-
208
- // --- store ----------------------------------------------------------------
209
-
210
- export function createCanvas({ baseDir } = {}) {
211
- const dir = baseDir || defaultCanvasDir();
212
- const blocksFile = path.join(dir, 'blocks.json');
213
- const themeFile = path.join(dir, 'theme.json');
214
- // Server-side persistence for canvas state (layout, templates, user-theme).
215
- // Fixes the localStorage-per-origin divergence bug (req-1 gave the user two
216
- // origins → two divergent canvas states for the same workspace).
217
- const layoutFile = path.join(dir, 'layout.json');
218
- const templatesFile = path.join(dir, 'templates.json');
219
- const userThemeFile = path.join(dir, 'user-theme.json');
220
-
221
- function ensureDir() {
222
- try {
223
- fs.mkdirSync(dir, { recursive: true });
224
- } catch {
225
- /* read-only fs — degrades to no persistence */
226
- }
227
- }
228
-
229
- function listBlocks() {
230
- const v = readJsonSafe(blocksFile, []);
231
- return Array.isArray(v) ? v : [];
232
- }
233
-
234
- function getBlock(id) {
235
- return listBlocks().find((b) => b.id === id) || null;
236
- }
237
-
238
- function save(blocks) {
239
- ensureDir();
240
- // Bound the store: keep the most-recent N (a user can always remove more).
241
- const trimmed = blocks.slice(-CAP.blocks);
242
- try {
243
- writeJsonAtomic(blocksFile, trimmed);
244
- } catch {
245
- /* persistence best-effort */
246
- }
247
- return trimmed;
248
- }
249
-
250
- function addBlock(raw) {
251
- const spec = normalizeSpec(raw); // throws on bad kind
252
- const block = { id: `cb-${rid()}`, ...spec, createdBy: 'agent', ts: Date.now() };
253
- save([...listBlocks(), block]);
254
- return block;
255
- }
256
-
257
- // Update an existing block's content by id. Only the fields the agent supplies
258
- // change; the rest (incl. kind unless re-specified) are preserved. Returns the
259
- // updated block, or null if the id is unknown.
260
- function updateBlock(id, raw = {}) {
261
- const blocks = listBlocks();
262
- const idx = blocks.findIndex((b) => b.id === id);
263
- if (idx < 0) return null;
264
- const prev = blocks[idx];
265
- const merged = normalizeSpec({
266
- kind: raw.kind || prev.kind,
267
- title: raw.title ?? prev.title,
268
- icon: raw.icon ?? prev.icon,
269
- note: raw.note ?? prev.note,
270
- // Data fields: if the agent re-specifies the data for this kind, use the new
271
- // ones; otherwise re-feed the previous data so nothing is lost.
272
- ...flattenData(prev),
273
- ...raw,
274
- });
275
- const next = { ...prev, ...merged, updatedAt: Date.now() };
276
- blocks[idx] = next;
277
- save(blocks);
278
- return next;
279
- }
280
-
281
- function removeBlock(id) {
282
- const blocks = listBlocks();
283
- const next = blocks.filter((b) => b.id !== id);
284
- if (next.length === blocks.length) return false;
285
- save(next);
286
- return true;
287
- }
288
-
289
- // --- theme (the agent-set look) — one file, last-write-wins ---------------
290
-
291
- function getTheme() {
292
- const v = readJsonSafe(themeFile, null);
293
- return v && typeof v === 'object' ? v : null;
294
- }
295
-
296
- function setTheme(raw) {
297
- const theme = { ...normalizeTheme(raw), ts: Date.now() };
298
- ensureDir();
299
- try {
300
- writeJsonAtomic(themeFile, theme);
301
- } catch {
302
- /* persistence best-effort */
303
- }
304
- return theme;
305
- }
306
-
307
- // --- canvas layout (which blocks exist and where) — one file per workspace ----
308
-
309
- function getLayout() {
310
- const v = readJsonSafe(layoutFile, null);
311
- return v && typeof v === 'object' ? v : null;
312
- }
313
-
314
- function saveLayout(layout) {
315
- // Validate: blocks is an array, layouts has an lg array.
316
- const blocks = Array.isArray(layout?.blocks) ? layout.blocks : [];
317
- const lg = Array.isArray(layout?.layouts?.lg) ? layout.layouts.lg : [];
318
- const data = { blocks, layouts: { lg }, ts: Date.now() };
319
- ensureDir();
320
- try { writeJsonAtomic(layoutFile, data); } catch { /* best-effort */ }
321
- return data;
322
- }
323
-
324
- // --- user templates (saved layouts) — global per install ----
325
-
326
- function getTemplates() {
327
- const v = readJsonSafe(templatesFile, null);
328
- return Array.isArray(v) ? v : [];
329
- }
330
-
331
- function saveTemplates(templates) {
332
- const data = Array.isArray(templates) ? templates : [];
333
- ensureDir();
334
- try { writeJsonAtomic(templatesFile, data); } catch { /* best-effort */ }
335
- return data;
336
- }
337
-
338
- // --- user theme (mode + accent from the picker) — separate from the agent theme ---
339
- // The agent theme (theme.json) holds tokens/wallpaper set by set_theme; the user
340
- // theme holds the user's own mode+accent choice from the ThemePicker. They merge
341
- // at render time (mergeAgentTheme in theme.js).
342
-
343
- function getUserTheme() {
344
- const v = readJsonSafe(userThemeFile, null);
345
- return v && typeof v === 'object' ? v : null;
346
- }
347
-
348
- function saveUserTheme(raw) {
349
- // Only persist allowlisted fields — mode, accent, hot, accentManual, tokens.
350
- const accent = hexOr(raw?.accent);
351
- const theme = {
352
- mode: raw?.mode === 'dark' ? 'dark' : 'light',
353
- accent: accent || '#0891b2',
354
- hot: hexOr(raw?.hot) || (accent ? darkenHex(accent) : '#0e7490'),
355
- accentManual: !!raw?.accentManual,
356
- tokens: {},
357
- };
358
- if (raw?.tokens && typeof raw.tokens === 'object') {
359
- for (const key of TOKEN_KEYS) {
360
- const hex = hexOr(raw.tokens[key]);
361
- if (hex) theme.tokens[key] = hex;
362
- }
363
- }
364
- ensureDir();
365
- try { writeJsonAtomic(userThemeFile, theme); } catch { /* best-effort */ }
366
- return theme;
367
- }
368
-
369
- // Darken helper (standalone — can't import from theme.js which is client-only)
370
- function darkenHex(hex, f = 0.16) {
371
- const h = hex.replace('#', '');
372
- const k = 1 - f;
373
- const c = (i) => Math.max(0, Math.min(255, Math.round(parseInt(h.slice(i, i + 2), 16) * k)));
374
- return `#${[0, 2, 4].map((i) => c(i).toString(16).padStart(2, '0')).join('')}`;
375
- }
376
-
377
- // --- batch get/save (used by /api/canvas/state) ---
378
-
379
- function getState() {
380
- return {
381
- layout: getLayout(),
382
- templates: getTemplates(),
383
- userTheme: getUserTheme(),
384
- };
385
- }
386
-
387
- function saveState(updates) {
388
- const result = {};
389
- if (updates.layout) result.layout = saveLayout(updates.layout);
390
- if (updates.templates) result.templates = saveTemplates(updates.templates);
391
- if (updates.userTheme) result.userTheme = saveUserTheme(updates.userTheme);
392
- return result;
393
- }
394
-
395
- return {
396
- dir, blocksFile, themeFile, layoutFile, templatesFile, userThemeFile,
397
- listBlocks, getBlock, addBlock, updateBlock, removeBlock,
398
- getTheme, setTheme,
399
- getLayout, saveLayout, getTemplates, saveTemplates,
400
- getUserTheme, saveUserTheme,
401
- getState, saveState,
402
- };
403
- }
404
-
405
- // Re-expand a stored spec's `data` back into the flat fields normalizeSpec reads,
406
- // so updateBlock can merge partial changes over the previous content.
407
- function flattenData(block) {
408
- const d = block?.data || {};
409
- switch (block?.kind) {
410
- case 'metric':
411
- return { value: d.value, label: d.label, delta: d.delta, trend: d.trend, spark: d.spark };
412
- case 'list':
413
- return { items: d.items };
414
- case 'table':
415
- return { columns: d.columns, rows: d.rows };
416
- case 'markdown':
417
- return { markdown: d.text };
418
- default:
419
- return {};
420
- }
421
- }
1
+ // Canvas core — the store + validator for AGENT-MADE custom blocks.
2
+ //
3
+ // The block canvas (UX §3.3) lets the agent build the user a widget on request
4
+ // ("make me a block that shows today's signups"). The differentiator: the canvas
5
+ // stops being a fixed menu of widgets and becomes "anything the agent can build".
6
+ //
7
+ // SECURITY POSTURE — declarative, not code. The agent does NOT ship a React
8
+ // component or any code to eval. It computes the data itself (Read/Bash in its own
9
+ // turn) and emits a *spec* drawn from a small, fixed vocabulary of presentation
10
+ // primitives (metric · list · table · markdown). The browser renders that spec with
11
+ // trusted primitives (React-escaped text; markdown via react-markdown with NO raw
12
+ // HTML). So an agent-made block adds NO new execution surface and cannot escalate a
13
+ // read-only share-link viewer's privileges. "Live" = the agent re-pushes via
14
+ // update_block (principle #4: no idle polling), not a server-run refresh command.
15
+ // A shell-bound live refresh is a deliberate, owner-gated follow-up (see NOTES).
16
+ //
17
+ // State lives ENTIRELY under ~/.wild-workspace/canvas/ (absolute, OUTSIDE the
18
+ // user's repo — CLAUDE.md rule #1). One module, imported by BOTH the main server
19
+ // (to serve /api/canvas/blocks) and the spawned MCP server (to make/update blocks),
20
+ // so there is a single file-backed source of truth and no port handshake:
21
+ // - blocks.json the array of custom block specs. MCP writes, main reads.
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import os from 'node:os';
26
+ import crypto from 'node:crypto';
27
+
28
+ // The presentation primitives the agent may choose from. Anything else is rejected
29
+ // at the boundary — the renderer only knows these.
30
+ export const KINDS = ['metric', 'list', 'table', 'markdown'];
31
+
32
+ // The named theme tokens an agent (or a Bazaar theme) may override — the EXTENSION
33
+ // surface for "make my terminal look how I want". Each is ONLY ever a hex color
34
+ // (validated below), so a theme is DATA, never CSS: it cannot inject url()/
35
+ // expression()/selector-escapes. Keep in lockstep with TOKEN_VARS in web/src/theme.js.
36
+ export const TOKEN_KEYS = [
37
+ 'bg', 'bgElev', 'surface', 'border', 'text', 'textMuted', 'canvas1', 'canvas2', 'canvas3',
38
+ ];
39
+
40
+ const HEX = /^#[0-9a-f]{6}$/i;
41
+ export function isHex(v) {
42
+ return typeof v === 'string' && HEX.test(v.trim());
43
+ }
44
+ function hexOr(v, fallback = null) {
45
+ return isHex(v) ? v.trim().toLowerCase() : fallback;
46
+ }
47
+
48
+ const DEFAULT_ICON = { metric: '📈', list: '📋', table: '🗂️', markdown: '📝' };
49
+ const TRENDS = ['up', 'down', 'flat'];
50
+
51
+ // Caps — bound every field so a runaway tool call can't bloat the store or the UI.
52
+ const CAP = {
53
+ blocks: 60, // keep the most-recent N specs
54
+ title: 80,
55
+ icon: 8,
56
+ note: 240,
57
+ value: 48,
58
+ label: 48,
59
+ delta: 24,
60
+ spark: 60, // sparkline points
61
+ listItems: 50,
62
+ itemLabel: 80,
63
+ itemValue: 40,
64
+ columns: 12,
65
+ colName: 40,
66
+ rows: 50,
67
+ cell: 80,
68
+ markdown: 6000,
69
+ };
70
+
71
+ // ~/.wild-workspace/canvas — mirrors logpaths.globalDir() but kept dependency-free
72
+ // here so the MCP child can import this module standalone (same trick as bazaar).
73
+ export function defaultCanvasDir(env = process.env) {
74
+ const base = env.WILD_WORKSPACE_GLOBAL_DIR || path.join(os.homedir(), '.wild-workspace');
75
+ return path.join(base, 'canvas');
76
+ }
77
+
78
+ function rid() {
79
+ return crypto.randomUUID().slice(0, 12);
80
+ }
81
+
82
+ function readJsonSafe(file, fallback) {
83
+ try {
84
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
85
+ } catch {
86
+ return fallback;
87
+ }
88
+ }
89
+
90
+ function writeJsonAtomic(file, value) {
91
+ const tmp = `${file}.${process.pid}.tmp`;
92
+ fs.writeFileSync(tmp, JSON.stringify(value, null, 2));
93
+ fs.renameSync(tmp, file); // Node rename replaces the destination on all platforms
94
+ }
95
+
96
+ // --- spec normalization (the security boundary) ---------------------------
97
+
98
+ function str(v, max) {
99
+ if (v === null || v === undefined) return '';
100
+ const s = String(typeof v === 'object' ? JSON.stringify(v) : v);
101
+ return s.slice(0, max);
102
+ }
103
+
104
+ function nonEmptyStr(v, max, fallback = '') {
105
+ const s = str(v, max).trim();
106
+ return s || fallback;
107
+ }
108
+
109
+ function numArray(v, max) {
110
+ if (!Array.isArray(v)) return [];
111
+ return v
112
+ .map((n) => Number(n))
113
+ .filter((n) => Number.isFinite(n))
114
+ .slice(0, max);
115
+ }
116
+
117
+ function normalizeData(kind, raw = {}) {
118
+ switch (kind) {
119
+ case 'metric': {
120
+ const data = { value: nonEmptyStr(raw.value, CAP.value, '—') };
121
+ const label = nonEmptyStr(raw.label, CAP.label);
122
+ if (label) data.label = label;
123
+ const delta = nonEmptyStr(raw.delta, CAP.delta);
124
+ if (delta) data.delta = delta;
125
+ if (TRENDS.includes(raw.trend)) data.trend = raw.trend;
126
+ const spark = numArray(raw.spark, CAP.spark);
127
+ if (spark.length) data.spark = spark;
128
+ return data;
129
+ }
130
+ case 'list': {
131
+ const src = Array.isArray(raw.items) ? raw.items : [];
132
+ const items = src.slice(0, CAP.listItems).map((it) => {
133
+ // Accept {label,value} objects OR bare strings (the agent's convenience).
134
+ if (it && typeof it === 'object') {
135
+ const item = { label: nonEmptyStr(it.label ?? it.name ?? it.key, CAP.itemLabel, '—') };
136
+ const value = nonEmptyStr(it.value ?? it.amount ?? it.count, CAP.itemValue);
137
+ if (value) item.value = value;
138
+ return item;
139
+ }
140
+ return { label: nonEmptyStr(it, CAP.itemLabel, '—') };
141
+ });
142
+ return { items };
143
+ }
144
+ case 'table': {
145
+ const columns = (Array.isArray(raw.columns) ? raw.columns : [])
146
+ .slice(0, CAP.columns)
147
+ .map((c) => nonEmptyStr(c, CAP.colName, ''));
148
+ const width = columns.length || CAP.columns;
149
+ const rows = (Array.isArray(raw.rows) ? raw.rows : [])
150
+ .slice(0, CAP.rows)
151
+ .map((r) => (Array.isArray(r) ? r : [r]).slice(0, width).map((cell) => str(cell, CAP.cell)));
152
+ return { columns, rows };
153
+ }
154
+ case 'markdown':
155
+ default:
156
+ return { text: nonEmptyStr(raw.markdown ?? raw.text ?? raw.body, CAP.markdown) };
157
+ }
158
+ }
159
+
160
+ /**
161
+ * Validate + normalize a raw spec (from the agent's flat tool input) into the
162
+ * stored shape. Throws on an unsupported `kind` so the MCP tool returns an error.
163
+ * Everything else degrades gracefully (capped strings, dropped junk) — the agent
164
+ * gets a usable block even if it over- or mis-specifies.
165
+ */
166
+ export function normalizeSpec(raw = {}) {
167
+ const kind = String(raw.kind || '').toLowerCase();
168
+ if (!KINDS.includes(kind)) {
169
+ throw new Error(`unsupported kind "${raw.kind}". Use one of: ${KINDS.join(', ')}`);
170
+ }
171
+ const spec = {
172
+ title: nonEmptyStr(raw.title, CAP.title, 'Untitled'),
173
+ icon: nonEmptyStr(raw.icon, CAP.icon, DEFAULT_ICON[kind]),
174
+ kind,
175
+ data: normalizeData(kind, raw),
176
+ };
177
+ const note = nonEmptyStr(raw.note, CAP.note);
178
+ if (note) spec.note = note;
179
+ return spec;
180
+ }
181
+
182
+ // --- theme normalization (the same security boundary, for the look) --------
183
+
184
+ /**
185
+ * Validate + normalize a raw theme (from the agent's flat set_theme input) into the
186
+ * stored shape: { mode, accent, hot?, name?, tokens: {…hex} }. Hex-only, allowlisted
187
+ * tokens — anything else is dropped. Never throws; a junk theme degrades to the
188
+ * mode default (the picker/stylesheet still produces a usable look).
189
+ */
190
+ export function normalizeTheme(raw = {}) {
191
+ const theme = {
192
+ mode: raw.mode === 'dark' ? 'dark' : 'light',
193
+ accent: hexOr(raw.accent, '#0891b2'),
194
+ };
195
+ const hot = hexOr(raw.hot);
196
+ if (hot) theme.hot = hot;
197
+ const name = nonEmptyStr(raw.name, 60);
198
+ if (name) theme.name = name;
199
+ const tokens = {};
200
+ for (const key of TOKEN_KEYS) {
201
+ const hex = hexOr(raw[key] ?? raw.tokens?.[key]);
202
+ if (hex) tokens[key] = hex;
203
+ }
204
+ theme.tokens = tokens;
205
+ return theme;
206
+ }
207
+
208
+ // --- store ----------------------------------------------------------------
209
+
210
+ export function createCanvas({ baseDir } = {}) {
211
+ const dir = baseDir || defaultCanvasDir();
212
+ const blocksFile = path.join(dir, 'blocks.json');
213
+ const themeFile = path.join(dir, 'theme.json');
214
+ // Server-side persistence for canvas state (layout, templates, user-theme).
215
+ // Fixes the localStorage-per-origin divergence bug (req-1 gave the user two
216
+ // origins → two divergent canvas states for the same workspace).
217
+ const layoutFile = path.join(dir, 'layout.json');
218
+ const templatesFile = path.join(dir, 'templates.json');
219
+ const userThemeFile = path.join(dir, 'user-theme.json');
220
+
221
+ function ensureDir() {
222
+ try {
223
+ fs.mkdirSync(dir, { recursive: true });
224
+ } catch {
225
+ /* read-only fs — degrades to no persistence */
226
+ }
227
+ }
228
+
229
+ function listBlocks() {
230
+ const v = readJsonSafe(blocksFile, []);
231
+ return Array.isArray(v) ? v : [];
232
+ }
233
+
234
+ function getBlock(id) {
235
+ return listBlocks().find((b) => b.id === id) || null;
236
+ }
237
+
238
+ function save(blocks) {
239
+ ensureDir();
240
+ // Bound the store: keep the most-recent N (a user can always remove more).
241
+ const trimmed = blocks.slice(-CAP.blocks);
242
+ try {
243
+ writeJsonAtomic(blocksFile, trimmed);
244
+ } catch {
245
+ /* persistence best-effort */
246
+ }
247
+ return trimmed;
248
+ }
249
+
250
+ function addBlock(raw) {
251
+ const spec = normalizeSpec(raw); // throws on bad kind
252
+ const block = { id: `cb-${rid()}`, ...spec, createdBy: 'agent', ts: Date.now() };
253
+ save([...listBlocks(), block]);
254
+ return block;
255
+ }
256
+
257
+ // Update an existing block's content by id. Only the fields the agent supplies
258
+ // change; the rest (incl. kind unless re-specified) are preserved. Returns the
259
+ // updated block, or null if the id is unknown.
260
+ function updateBlock(id, raw = {}) {
261
+ const blocks = listBlocks();
262
+ const idx = blocks.findIndex((b) => b.id === id);
263
+ if (idx < 0) return null;
264
+ const prev = blocks[idx];
265
+ const merged = normalizeSpec({
266
+ kind: raw.kind || prev.kind,
267
+ title: raw.title ?? prev.title,
268
+ icon: raw.icon ?? prev.icon,
269
+ note: raw.note ?? prev.note,
270
+ // Data fields: if the agent re-specifies the data for this kind, use the new
271
+ // ones; otherwise re-feed the previous data so nothing is lost.
272
+ ...flattenData(prev),
273
+ ...raw,
274
+ });
275
+ const next = { ...prev, ...merged, updatedAt: Date.now() };
276
+ blocks[idx] = next;
277
+ save(blocks);
278
+ return next;
279
+ }
280
+
281
+ function removeBlock(id) {
282
+ const blocks = listBlocks();
283
+ const next = blocks.filter((b) => b.id !== id);
284
+ if (next.length === blocks.length) return false;
285
+ save(next);
286
+ return true;
287
+ }
288
+
289
+ // --- theme (the agent-set look) — one file, last-write-wins ---------------
290
+
291
+ function getTheme() {
292
+ const v = readJsonSafe(themeFile, null);
293
+ return v && typeof v === 'object' ? v : null;
294
+ }
295
+
296
+ function setTheme(raw) {
297
+ const theme = { ...normalizeTheme(raw), ts: Date.now() };
298
+ ensureDir();
299
+ try {
300
+ writeJsonAtomic(themeFile, theme);
301
+ } catch {
302
+ /* persistence best-effort */
303
+ }
304
+ return theme;
305
+ }
306
+
307
+ // --- canvas layout (which blocks exist and where) — one file per workspace ----
308
+
309
+ function getLayout() {
310
+ const v = readJsonSafe(layoutFile, null);
311
+ return v && typeof v === 'object' ? v : null;
312
+ }
313
+
314
+ function saveLayout(layout) {
315
+ // Validate: blocks is an array, layouts has an lg array.
316
+ const blocks = Array.isArray(layout?.blocks) ? layout.blocks : [];
317
+ const lg = Array.isArray(layout?.layouts?.lg) ? layout.layouts.lg : [];
318
+ const data = { blocks, layouts: { lg }, ts: Date.now() };
319
+ ensureDir();
320
+ try { writeJsonAtomic(layoutFile, data); } catch { /* best-effort */ }
321
+ return data;
322
+ }
323
+
324
+ // --- user templates (saved layouts) — global per install ----
325
+
326
+ function getTemplates() {
327
+ const v = readJsonSafe(templatesFile, null);
328
+ return Array.isArray(v) ? v : [];
329
+ }
330
+
331
+ function saveTemplates(templates) {
332
+ const data = Array.isArray(templates) ? templates : [];
333
+ ensureDir();
334
+ try { writeJsonAtomic(templatesFile, data); } catch { /* best-effort */ }
335
+ return data;
336
+ }
337
+
338
+ // --- user theme (mode + accent from the picker) — separate from the agent theme ---
339
+ // The agent theme (theme.json) holds tokens/wallpaper set by set_theme; the user
340
+ // theme holds the user's own mode+accent choice from the ThemePicker. They merge
341
+ // at render time (mergeAgentTheme in theme.js).
342
+
343
+ function getUserTheme() {
344
+ const v = readJsonSafe(userThemeFile, null);
345
+ return v && typeof v === 'object' ? v : null;
346
+ }
347
+
348
+ function saveUserTheme(raw) {
349
+ // Only persist allowlisted fields — mode, accent, hot, accentManual, tokens.
350
+ const accent = hexOr(raw?.accent);
351
+ const theme = {
352
+ mode: raw?.mode === 'dark' ? 'dark' : 'light',
353
+ accent: accent || '#0891b2',
354
+ hot: hexOr(raw?.hot) || (accent ? darkenHex(accent) : '#0e7490'),
355
+ accentManual: !!raw?.accentManual,
356
+ tokens: {},
357
+ };
358
+ if (raw?.tokens && typeof raw.tokens === 'object') {
359
+ for (const key of TOKEN_KEYS) {
360
+ const hex = hexOr(raw.tokens[key]);
361
+ if (hex) theme.tokens[key] = hex;
362
+ }
363
+ }
364
+ ensureDir();
365
+ try { writeJsonAtomic(userThemeFile, theme); } catch { /* best-effort */ }
366
+ return theme;
367
+ }
368
+
369
+ // Darken helper (standalone — can't import from theme.js which is client-only)
370
+ function darkenHex(hex, f = 0.16) {
371
+ const h = hex.replace('#', '');
372
+ const k = 1 - f;
373
+ const c = (i) => Math.max(0, Math.min(255, Math.round(parseInt(h.slice(i, i + 2), 16) * k)));
374
+ return `#${[0, 2, 4].map((i) => c(i).toString(16).padStart(2, '0')).join('')}`;
375
+ }
376
+
377
+ // --- batch get/save (used by /api/canvas/state) ---
378
+
379
+ function getState() {
380
+ return {
381
+ layout: getLayout(),
382
+ templates: getTemplates(),
383
+ userTheme: getUserTheme(),
384
+ };
385
+ }
386
+
387
+ function saveState(updates) {
388
+ const result = {};
389
+ if (updates.layout) result.layout = saveLayout(updates.layout);
390
+ if (updates.templates) result.templates = saveTemplates(updates.templates);
391
+ if (updates.userTheme) result.userTheme = saveUserTheme(updates.userTheme);
392
+ return result;
393
+ }
394
+
395
+ return {
396
+ dir, blocksFile, themeFile, layoutFile, templatesFile, userThemeFile,
397
+ listBlocks, getBlock, addBlock, updateBlock, removeBlock,
398
+ getTheme, setTheme,
399
+ getLayout, saveLayout, getTemplates, saveTemplates,
400
+ getUserTheme, saveUserTheme,
401
+ getState, saveState,
402
+ };
403
+ }
404
+
405
+ // Re-expand a stored spec's `data` back into the flat fields normalizeSpec reads,
406
+ // so updateBlock can merge partial changes over the previous content.
407
+ function flattenData(block) {
408
+ const d = block?.data || {};
409
+ switch (block?.kind) {
410
+ case 'metric':
411
+ return { value: d.value, label: d.label, delta: d.delta, trend: d.trend, spark: d.spark };
412
+ case 'list':
413
+ return { items: d.items };
414
+ case 'table':
415
+ return { columns: d.columns, rows: d.rows };
416
+ case 'markdown':
417
+ return { markdown: d.text };
418
+ default:
419
+ return {};
420
+ }
421
+ }