@venturewild/workspace 0.5.0 → 0.5.2
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.
- package/LICENSE +21 -21
- package/README.md +112 -112
- package/package.json +85 -85
- package/server/bin/wild-workspace.mjs +1096 -1096
- package/server/src/account.mjs +114 -114
- package/server/src/agent-login.mjs +146 -146
- package/server/src/agent-readiness.mjs +200 -200
- package/server/src/agent.mjs +468 -468
- package/server/src/bazaar/core.mjs +790 -730
- package/server/src/bazaar/index.mjs +88 -88
- package/server/src/bazaar/mcp-server.mjs +417 -417
- package/server/src/bazaar/mock-tickup.mjs +97 -97
- package/server/src/bazaar/preview-server.mjs +95 -95
- package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
- package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
- package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
- package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
- package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
- package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +40 -40
- package/server/src/canvas/core.mjs +446 -446
- package/server/src/canvas/index.mjs +42 -42
- package/server/src/canvas/mcp-server.mjs +253 -253
- package/server/src/canvas-rails.mjs +108 -108
- package/server/src/config.mjs +404 -404
- package/server/src/daemon-bin.mjs +110 -110
- package/server/src/daemon-supervisor.mjs +285 -285
- package/server/src/doctor.mjs +375 -375
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +3279 -3181
- package/server/src/listings-rails.mjs +126 -0
- package/server/src/logpaths.mjs +98 -98
- package/server/src/observability.mjs +45 -45
- package/server/src/operator.mjs +92 -92
- package/server/src/pairing.mjs +137 -137
- package/server/src/service.mjs +515 -515
- package/server/src/session-reporter.mjs +201 -201
- package/server/src/settings.mjs +145 -145
- package/server/src/share.mjs +182 -182
- package/server/src/skills.mjs +213 -213
- package/server/src/supervisor.mjs +647 -647
- package/server/src/support-consent.mjs +133 -133
- package/server/src/sync.mjs +248 -248
- package/server/src/transcript.mjs +121 -121
- package/server/src/turn-mcp.mjs +46 -46
- package/server/src/usage.mjs +405 -405
- package/server/src/workspace-registry.mjs +295 -295
- package/server/src/workspaces.mjs +145 -135
- package/web/dist/assets/index-BXq-Irj8.js +131 -0
- package/web/dist/assets/index-CzUrGoMW.css +32 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DWNJ55qg.css +0 -32
- package/web/dist/assets/index-YlSTL4Wv.js +0 -131
|
@@ -1,446 +1,446 @@
|
|
|
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
|
-
// A personKey scopes the user's canvas STATE files (layout/templates/user-theme)
|
|
211
|
-
// to one identity. It becomes a path segment, so harden it against traversal —
|
|
212
|
-
// accountId is a uuid and the role sentinels are safe, but defend in depth.
|
|
213
|
-
function sanitizePersonKey(key) {
|
|
214
|
-
const s = String(key || '')
|
|
215
|
-
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
216
|
-
.slice(0, 80);
|
|
217
|
-
return s && s !== '.' && s !== '..' ? s : 'local';
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
export function createCanvas({ baseDir, personKey } = {}) {
|
|
221
|
-
const dir = baseDir || defaultCanvasDir();
|
|
222
|
-
// Agent-made content is workspace-SHARED (the agent builds blocks / sets the
|
|
223
|
-
// theme for the workspace, not for one person), so it stays at the canvas root.
|
|
224
|
-
const blocksFile = path.join(dir, 'blocks.json');
|
|
225
|
-
const themeFile = path.join(dir, 'theme.json');
|
|
226
|
-
// The user's canvas STATE (layout, saved templates, user-theme) is PER-IDENTITY
|
|
227
|
-
// when a personKey is given: it lives under <dir>/people/<personKey>/ so two
|
|
228
|
-
// people sharing one host don't collide, and (via the rails) one person's layout
|
|
229
|
-
// follows them across hosts. Without a personKey it stays flat at <dir>/ — the
|
|
230
|
-
// legacy / no-account-install path, which also preserves the existing on-disk
|
|
231
|
-
// layout for migration. (Originally added to fix the localStorage-per-origin
|
|
232
|
-
// divergence bug — req-1 gave the user two origins → two divergent states.)
|
|
233
|
-
const stateDir = personKey ? path.join(dir, 'people', sanitizePersonKey(personKey)) : dir;
|
|
234
|
-
const layoutFile = path.join(stateDir, 'layout.json');
|
|
235
|
-
const templatesFile = path.join(stateDir, 'templates.json');
|
|
236
|
-
const userThemeFile = path.join(stateDir, 'user-theme.json');
|
|
237
|
-
|
|
238
|
-
function ensureDir() {
|
|
239
|
-
try {
|
|
240
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
241
|
-
} catch {
|
|
242
|
-
/* read-only fs — degrades to no persistence */
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
// The per-identity state dir (same as `dir` when no personKey is set).
|
|
246
|
-
function ensureStateDir() {
|
|
247
|
-
try {
|
|
248
|
-
fs.mkdirSync(stateDir, { recursive: true });
|
|
249
|
-
} catch {
|
|
250
|
-
/* read-only fs — degrades to no persistence */
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function listBlocks() {
|
|
255
|
-
const v = readJsonSafe(blocksFile, []);
|
|
256
|
-
return Array.isArray(v) ? v : [];
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function getBlock(id) {
|
|
260
|
-
return listBlocks().find((b) => b.id === id) || null;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
function save(blocks) {
|
|
264
|
-
ensureDir();
|
|
265
|
-
// Bound the store: keep the most-recent N (a user can always remove more).
|
|
266
|
-
const trimmed = blocks.slice(-CAP.blocks);
|
|
267
|
-
try {
|
|
268
|
-
writeJsonAtomic(blocksFile, trimmed);
|
|
269
|
-
} catch {
|
|
270
|
-
/* persistence best-effort */
|
|
271
|
-
}
|
|
272
|
-
return trimmed;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
function addBlock(raw) {
|
|
276
|
-
const spec = normalizeSpec(raw); // throws on bad kind
|
|
277
|
-
const block = { id: `cb-${rid()}`, ...spec, createdBy: 'agent', ts: Date.now() };
|
|
278
|
-
save([...listBlocks(), block]);
|
|
279
|
-
return block;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
// Update an existing block's content by id. Only the fields the agent supplies
|
|
283
|
-
// change; the rest (incl. kind unless re-specified) are preserved. Returns the
|
|
284
|
-
// updated block, or null if the id is unknown.
|
|
285
|
-
function updateBlock(id, raw = {}) {
|
|
286
|
-
const blocks = listBlocks();
|
|
287
|
-
const idx = blocks.findIndex((b) => b.id === id);
|
|
288
|
-
if (idx < 0) return null;
|
|
289
|
-
const prev = blocks[idx];
|
|
290
|
-
const merged = normalizeSpec({
|
|
291
|
-
kind: raw.kind || prev.kind,
|
|
292
|
-
title: raw.title ?? prev.title,
|
|
293
|
-
icon: raw.icon ?? prev.icon,
|
|
294
|
-
note: raw.note ?? prev.note,
|
|
295
|
-
// Data fields: if the agent re-specifies the data for this kind, use the new
|
|
296
|
-
// ones; otherwise re-feed the previous data so nothing is lost.
|
|
297
|
-
...flattenData(prev),
|
|
298
|
-
...raw,
|
|
299
|
-
});
|
|
300
|
-
const next = { ...prev, ...merged, updatedAt: Date.now() };
|
|
301
|
-
blocks[idx] = next;
|
|
302
|
-
save(blocks);
|
|
303
|
-
return next;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function removeBlock(id) {
|
|
307
|
-
const blocks = listBlocks();
|
|
308
|
-
const next = blocks.filter((b) => b.id !== id);
|
|
309
|
-
if (next.length === blocks.length) return false;
|
|
310
|
-
save(next);
|
|
311
|
-
return true;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
// --- theme (the agent-set look) — one file, last-write-wins ---------------
|
|
315
|
-
|
|
316
|
-
function getTheme() {
|
|
317
|
-
const v = readJsonSafe(themeFile, null);
|
|
318
|
-
return v && typeof v === 'object' ? v : null;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
function setTheme(raw) {
|
|
322
|
-
const theme = { ...normalizeTheme(raw), ts: Date.now() };
|
|
323
|
-
ensureDir();
|
|
324
|
-
try {
|
|
325
|
-
writeJsonAtomic(themeFile, theme);
|
|
326
|
-
} catch {
|
|
327
|
-
/* persistence best-effort */
|
|
328
|
-
}
|
|
329
|
-
return theme;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// --- canvas layout (which blocks exist and where) — one file per workspace ----
|
|
333
|
-
|
|
334
|
-
function getLayout() {
|
|
335
|
-
const v = readJsonSafe(layoutFile, null);
|
|
336
|
-
return v && typeof v === 'object' ? v : null;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
function saveLayout(layout) {
|
|
340
|
-
// Validate: blocks is an array, layouts has an lg array.
|
|
341
|
-
const blocks = Array.isArray(layout?.blocks) ? layout.blocks : [];
|
|
342
|
-
const lg = Array.isArray(layout?.layouts?.lg) ? layout.layouts.lg : [];
|
|
343
|
-
const data = { blocks, layouts: { lg }, ts: Date.now() };
|
|
344
|
-
ensureStateDir();
|
|
345
|
-
try { writeJsonAtomic(layoutFile, data); } catch { /* best-effort */ }
|
|
346
|
-
return data;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// --- user templates (saved layouts) — global per install ----
|
|
350
|
-
|
|
351
|
-
function getTemplates() {
|
|
352
|
-
const v = readJsonSafe(templatesFile, null);
|
|
353
|
-
return Array.isArray(v) ? v : [];
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function saveTemplates(templates) {
|
|
357
|
-
const data = Array.isArray(templates) ? templates : [];
|
|
358
|
-
ensureStateDir();
|
|
359
|
-
try { writeJsonAtomic(templatesFile, data); } catch { /* best-effort */ }
|
|
360
|
-
return data;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// --- user theme (mode + accent from the picker) — separate from the agent theme ---
|
|
364
|
-
// The agent theme (theme.json) holds tokens/wallpaper set by set_theme; the user
|
|
365
|
-
// theme holds the user's own mode+accent choice from the ThemePicker. They merge
|
|
366
|
-
// at render time (mergeAgentTheme in theme.js).
|
|
367
|
-
|
|
368
|
-
function getUserTheme() {
|
|
369
|
-
const v = readJsonSafe(userThemeFile, null);
|
|
370
|
-
return v && typeof v === 'object' ? v : null;
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function saveUserTheme(raw) {
|
|
374
|
-
// Only persist allowlisted fields — mode, accent, hot, accentManual, tokens.
|
|
375
|
-
const accent = hexOr(raw?.accent);
|
|
376
|
-
const theme = {
|
|
377
|
-
mode: raw?.mode === 'dark' ? 'dark' : 'light',
|
|
378
|
-
accent: accent || '#0891b2',
|
|
379
|
-
hot: hexOr(raw?.hot) || (accent ? darkenHex(accent) : '#0e7490'),
|
|
380
|
-
accentManual: !!raw?.accentManual,
|
|
381
|
-
tokens: {},
|
|
382
|
-
};
|
|
383
|
-
if (raw?.tokens && typeof raw.tokens === 'object') {
|
|
384
|
-
for (const key of TOKEN_KEYS) {
|
|
385
|
-
const hex = hexOr(raw.tokens[key]);
|
|
386
|
-
if (hex) theme.tokens[key] = hex;
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
ensureStateDir();
|
|
390
|
-
try { writeJsonAtomic(userThemeFile, theme); } catch { /* best-effort */ }
|
|
391
|
-
return theme;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
// Darken helper (standalone — can't import from theme.js which is client-only)
|
|
395
|
-
function darkenHex(hex, f = 0.16) {
|
|
396
|
-
const h = hex.replace('#', '');
|
|
397
|
-
const k = 1 - f;
|
|
398
|
-
const c = (i) => Math.max(0, Math.min(255, Math.round(parseInt(h.slice(i, i + 2), 16) * k)));
|
|
399
|
-
return `#${[0, 2, 4].map((i) => c(i).toString(16).padStart(2, '0')).join('')}`;
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
// --- batch get/save (used by /api/canvas/state) ---
|
|
403
|
-
|
|
404
|
-
function getState() {
|
|
405
|
-
return {
|
|
406
|
-
layout: getLayout(),
|
|
407
|
-
templates: getTemplates(),
|
|
408
|
-
userTheme: getUserTheme(),
|
|
409
|
-
};
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
function saveState(updates) {
|
|
413
|
-
const result = {};
|
|
414
|
-
if (updates.layout) result.layout = saveLayout(updates.layout);
|
|
415
|
-
if (updates.templates) result.templates = saveTemplates(updates.templates);
|
|
416
|
-
if (updates.userTheme) result.userTheme = saveUserTheme(updates.userTheme);
|
|
417
|
-
return result;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
return {
|
|
421
|
-
dir, stateDir, blocksFile, themeFile, layoutFile, templatesFile, userThemeFile,
|
|
422
|
-
listBlocks, getBlock, addBlock, updateBlock, removeBlock,
|
|
423
|
-
getTheme, setTheme,
|
|
424
|
-
getLayout, saveLayout, getTemplates, saveTemplates,
|
|
425
|
-
getUserTheme, saveUserTheme,
|
|
426
|
-
getState, saveState,
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// Re-expand a stored spec's `data` back into the flat fields normalizeSpec reads,
|
|
431
|
-
// so updateBlock can merge partial changes over the previous content.
|
|
432
|
-
function flattenData(block) {
|
|
433
|
-
const d = block?.data || {};
|
|
434
|
-
switch (block?.kind) {
|
|
435
|
-
case 'metric':
|
|
436
|
-
return { value: d.value, label: d.label, delta: d.delta, trend: d.trend, spark: d.spark };
|
|
437
|
-
case 'list':
|
|
438
|
-
return { items: d.items };
|
|
439
|
-
case 'table':
|
|
440
|
-
return { columns: d.columns, rows: d.rows };
|
|
441
|
-
case 'markdown':
|
|
442
|
-
return { markdown: d.text };
|
|
443
|
-
default:
|
|
444
|
-
return {};
|
|
445
|
-
}
|
|
446
|
-
}
|
|
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
|
+
// A personKey scopes the user's canvas STATE files (layout/templates/user-theme)
|
|
211
|
+
// to one identity. It becomes a path segment, so harden it against traversal —
|
|
212
|
+
// accountId is a uuid and the role sentinels are safe, but defend in depth.
|
|
213
|
+
function sanitizePersonKey(key) {
|
|
214
|
+
const s = String(key || '')
|
|
215
|
+
.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
216
|
+
.slice(0, 80);
|
|
217
|
+
return s && s !== '.' && s !== '..' ? s : 'local';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function createCanvas({ baseDir, personKey } = {}) {
|
|
221
|
+
const dir = baseDir || defaultCanvasDir();
|
|
222
|
+
// Agent-made content is workspace-SHARED (the agent builds blocks / sets the
|
|
223
|
+
// theme for the workspace, not for one person), so it stays at the canvas root.
|
|
224
|
+
const blocksFile = path.join(dir, 'blocks.json');
|
|
225
|
+
const themeFile = path.join(dir, 'theme.json');
|
|
226
|
+
// The user's canvas STATE (layout, saved templates, user-theme) is PER-IDENTITY
|
|
227
|
+
// when a personKey is given: it lives under <dir>/people/<personKey>/ so two
|
|
228
|
+
// people sharing one host don't collide, and (via the rails) one person's layout
|
|
229
|
+
// follows them across hosts. Without a personKey it stays flat at <dir>/ — the
|
|
230
|
+
// legacy / no-account-install path, which also preserves the existing on-disk
|
|
231
|
+
// layout for migration. (Originally added to fix the localStorage-per-origin
|
|
232
|
+
// divergence bug — req-1 gave the user two origins → two divergent states.)
|
|
233
|
+
const stateDir = personKey ? path.join(dir, 'people', sanitizePersonKey(personKey)) : dir;
|
|
234
|
+
const layoutFile = path.join(stateDir, 'layout.json');
|
|
235
|
+
const templatesFile = path.join(stateDir, 'templates.json');
|
|
236
|
+
const userThemeFile = path.join(stateDir, 'user-theme.json');
|
|
237
|
+
|
|
238
|
+
function ensureDir() {
|
|
239
|
+
try {
|
|
240
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
241
|
+
} catch {
|
|
242
|
+
/* read-only fs — degrades to no persistence */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// The per-identity state dir (same as `dir` when no personKey is set).
|
|
246
|
+
function ensureStateDir() {
|
|
247
|
+
try {
|
|
248
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
249
|
+
} catch {
|
|
250
|
+
/* read-only fs — degrades to no persistence */
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function listBlocks() {
|
|
255
|
+
const v = readJsonSafe(blocksFile, []);
|
|
256
|
+
return Array.isArray(v) ? v : [];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function getBlock(id) {
|
|
260
|
+
return listBlocks().find((b) => b.id === id) || null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function save(blocks) {
|
|
264
|
+
ensureDir();
|
|
265
|
+
// Bound the store: keep the most-recent N (a user can always remove more).
|
|
266
|
+
const trimmed = blocks.slice(-CAP.blocks);
|
|
267
|
+
try {
|
|
268
|
+
writeJsonAtomic(blocksFile, trimmed);
|
|
269
|
+
} catch {
|
|
270
|
+
/* persistence best-effort */
|
|
271
|
+
}
|
|
272
|
+
return trimmed;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function addBlock(raw) {
|
|
276
|
+
const spec = normalizeSpec(raw); // throws on bad kind
|
|
277
|
+
const block = { id: `cb-${rid()}`, ...spec, createdBy: 'agent', ts: Date.now() };
|
|
278
|
+
save([...listBlocks(), block]);
|
|
279
|
+
return block;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Update an existing block's content by id. Only the fields the agent supplies
|
|
283
|
+
// change; the rest (incl. kind unless re-specified) are preserved. Returns the
|
|
284
|
+
// updated block, or null if the id is unknown.
|
|
285
|
+
function updateBlock(id, raw = {}) {
|
|
286
|
+
const blocks = listBlocks();
|
|
287
|
+
const idx = blocks.findIndex((b) => b.id === id);
|
|
288
|
+
if (idx < 0) return null;
|
|
289
|
+
const prev = blocks[idx];
|
|
290
|
+
const merged = normalizeSpec({
|
|
291
|
+
kind: raw.kind || prev.kind,
|
|
292
|
+
title: raw.title ?? prev.title,
|
|
293
|
+
icon: raw.icon ?? prev.icon,
|
|
294
|
+
note: raw.note ?? prev.note,
|
|
295
|
+
// Data fields: if the agent re-specifies the data for this kind, use the new
|
|
296
|
+
// ones; otherwise re-feed the previous data so nothing is lost.
|
|
297
|
+
...flattenData(prev),
|
|
298
|
+
...raw,
|
|
299
|
+
});
|
|
300
|
+
const next = { ...prev, ...merged, updatedAt: Date.now() };
|
|
301
|
+
blocks[idx] = next;
|
|
302
|
+
save(blocks);
|
|
303
|
+
return next;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function removeBlock(id) {
|
|
307
|
+
const blocks = listBlocks();
|
|
308
|
+
const next = blocks.filter((b) => b.id !== id);
|
|
309
|
+
if (next.length === blocks.length) return false;
|
|
310
|
+
save(next);
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// --- theme (the agent-set look) — one file, last-write-wins ---------------
|
|
315
|
+
|
|
316
|
+
function getTheme() {
|
|
317
|
+
const v = readJsonSafe(themeFile, null);
|
|
318
|
+
return v && typeof v === 'object' ? v : null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function setTheme(raw) {
|
|
322
|
+
const theme = { ...normalizeTheme(raw), ts: Date.now() };
|
|
323
|
+
ensureDir();
|
|
324
|
+
try {
|
|
325
|
+
writeJsonAtomic(themeFile, theme);
|
|
326
|
+
} catch {
|
|
327
|
+
/* persistence best-effort */
|
|
328
|
+
}
|
|
329
|
+
return theme;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// --- canvas layout (which blocks exist and where) — one file per workspace ----
|
|
333
|
+
|
|
334
|
+
function getLayout() {
|
|
335
|
+
const v = readJsonSafe(layoutFile, null);
|
|
336
|
+
return v && typeof v === 'object' ? v : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function saveLayout(layout) {
|
|
340
|
+
// Validate: blocks is an array, layouts has an lg array.
|
|
341
|
+
const blocks = Array.isArray(layout?.blocks) ? layout.blocks : [];
|
|
342
|
+
const lg = Array.isArray(layout?.layouts?.lg) ? layout.layouts.lg : [];
|
|
343
|
+
const data = { blocks, layouts: { lg }, ts: Date.now() };
|
|
344
|
+
ensureStateDir();
|
|
345
|
+
try { writeJsonAtomic(layoutFile, data); } catch { /* best-effort */ }
|
|
346
|
+
return data;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// --- user templates (saved layouts) — global per install ----
|
|
350
|
+
|
|
351
|
+
function getTemplates() {
|
|
352
|
+
const v = readJsonSafe(templatesFile, null);
|
|
353
|
+
return Array.isArray(v) ? v : [];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function saveTemplates(templates) {
|
|
357
|
+
const data = Array.isArray(templates) ? templates : [];
|
|
358
|
+
ensureStateDir();
|
|
359
|
+
try { writeJsonAtomic(templatesFile, data); } catch { /* best-effort */ }
|
|
360
|
+
return data;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// --- user theme (mode + accent from the picker) — separate from the agent theme ---
|
|
364
|
+
// The agent theme (theme.json) holds tokens/wallpaper set by set_theme; the user
|
|
365
|
+
// theme holds the user's own mode+accent choice from the ThemePicker. They merge
|
|
366
|
+
// at render time (mergeAgentTheme in theme.js).
|
|
367
|
+
|
|
368
|
+
function getUserTheme() {
|
|
369
|
+
const v = readJsonSafe(userThemeFile, null);
|
|
370
|
+
return v && typeof v === 'object' ? v : null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function saveUserTheme(raw) {
|
|
374
|
+
// Only persist allowlisted fields — mode, accent, hot, accentManual, tokens.
|
|
375
|
+
const accent = hexOr(raw?.accent);
|
|
376
|
+
const theme = {
|
|
377
|
+
mode: raw?.mode === 'dark' ? 'dark' : 'light',
|
|
378
|
+
accent: accent || '#0891b2',
|
|
379
|
+
hot: hexOr(raw?.hot) || (accent ? darkenHex(accent) : '#0e7490'),
|
|
380
|
+
accentManual: !!raw?.accentManual,
|
|
381
|
+
tokens: {},
|
|
382
|
+
};
|
|
383
|
+
if (raw?.tokens && typeof raw.tokens === 'object') {
|
|
384
|
+
for (const key of TOKEN_KEYS) {
|
|
385
|
+
const hex = hexOr(raw.tokens[key]);
|
|
386
|
+
if (hex) theme.tokens[key] = hex;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
ensureStateDir();
|
|
390
|
+
try { writeJsonAtomic(userThemeFile, theme); } catch { /* best-effort */ }
|
|
391
|
+
return theme;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Darken helper (standalone — can't import from theme.js which is client-only)
|
|
395
|
+
function darkenHex(hex, f = 0.16) {
|
|
396
|
+
const h = hex.replace('#', '');
|
|
397
|
+
const k = 1 - f;
|
|
398
|
+
const c = (i) => Math.max(0, Math.min(255, Math.round(parseInt(h.slice(i, i + 2), 16) * k)));
|
|
399
|
+
return `#${[0, 2, 4].map((i) => c(i).toString(16).padStart(2, '0')).join('')}`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// --- batch get/save (used by /api/canvas/state) ---
|
|
403
|
+
|
|
404
|
+
function getState() {
|
|
405
|
+
return {
|
|
406
|
+
layout: getLayout(),
|
|
407
|
+
templates: getTemplates(),
|
|
408
|
+
userTheme: getUserTheme(),
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function saveState(updates) {
|
|
413
|
+
const result = {};
|
|
414
|
+
if (updates.layout) result.layout = saveLayout(updates.layout);
|
|
415
|
+
if (updates.templates) result.templates = saveTemplates(updates.templates);
|
|
416
|
+
if (updates.userTheme) result.userTheme = saveUserTheme(updates.userTheme);
|
|
417
|
+
return result;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
dir, stateDir, blocksFile, themeFile, layoutFile, templatesFile, userThemeFile,
|
|
422
|
+
listBlocks, getBlock, addBlock, updateBlock, removeBlock,
|
|
423
|
+
getTheme, setTheme,
|
|
424
|
+
getLayout, saveLayout, getTemplates, saveTemplates,
|
|
425
|
+
getUserTheme, saveUserTheme,
|
|
426
|
+
getState, saveState,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Re-expand a stored spec's `data` back into the flat fields normalizeSpec reads,
|
|
431
|
+
// so updateBlock can merge partial changes over the previous content.
|
|
432
|
+
function flattenData(block) {
|
|
433
|
+
const d = block?.data || {};
|
|
434
|
+
switch (block?.kind) {
|
|
435
|
+
case 'metric':
|
|
436
|
+
return { value: d.value, label: d.label, delta: d.delta, trend: d.trend, spark: d.spark };
|
|
437
|
+
case 'list':
|
|
438
|
+
return { items: d.items };
|
|
439
|
+
case 'table':
|
|
440
|
+
return { columns: d.columns, rows: d.rows };
|
|
441
|
+
case 'markdown':
|
|
442
|
+
return { markdown: d.text };
|
|
443
|
+
default:
|
|
444
|
+
return {};
|
|
445
|
+
}
|
|
446
|
+
}
|