@venturewild/workspace 0.1.14 → 0.2.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.
- package/LICENSE +21 -21
- package/README.md +112 -112
- package/package.json +83 -76
- package/server/bin/wild-workspace.mjs +825 -763
- package/server/src/agent.mjs +453 -386
- package/server/src/bazaar/core.mjs +579 -0
- package/server/src/bazaar/index.mjs +75 -0
- package/server/src/bazaar/mcp-server.mjs +328 -0
- package/server/src/bazaar/mock-tickup.mjs +97 -0
- package/server/src/bazaar/preview-server.mjs +95 -0
- package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -0
- package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -0
- package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -0
- package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -0
- package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -0
- package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -0
- package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -0
- package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -0
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -0
- package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +32 -0
- package/server/src/canvas/core.mjs +324 -0
- package/server/src/canvas/index.mjs +42 -0
- package/server/src/canvas/mcp-server.mjs +253 -0
- package/server/src/config.mjs +365 -365
- package/server/src/daemon-supervisor.mjs +216 -216
- package/server/src/inbox.mjs +86 -86
- package/server/src/index.mjs +1948 -1721
- package/server/src/logpaths.mjs +98 -98
- package/server/src/service.mjs +419 -419
- package/server/src/share.mjs +182 -148
- package/server/src/sync.mjs +248 -248
- package/server/src/turn-mcp.mjs +46 -0
- package/web/dist/assets/index-DVWgeTl_.js +91 -0
- package/web/dist/assets/index-Dl0VT5e6.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-Bj-mdLGj.css +0 -1
- package/web/dist/assets/index-Dc6jo84c.js +0 -89
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Sam's receipt-sorter — how to build it
|
|
2
|
+
|
|
3
|
+
> Absorb this and build the user a receipt-sorting tool in one shot. Single
|
|
4
|
+
> self-contained `index.html` (inline CSS + JS), no build step, runs offline.
|
|
5
|
+
|
|
6
|
+
## Build steps
|
|
7
|
+
|
|
8
|
+
1. Create `receipt-sorter/index.html`.
|
|
9
|
+
2. The page:
|
|
10
|
+
- A **textarea** to paste receipts, one per line, in a loose form like
|
|
11
|
+
`2026-03-04 Uber 18.50` or `Adobe subscription 52.99`. Prefill with ~6
|
|
12
|
+
realistic example lines so it's usable immediately.
|
|
13
|
+
- A **"Sort receipts"** button.
|
|
14
|
+
- A **results table**: columns Date · Vendor · Category · Amount, best-effort
|
|
15
|
+
parsed from each line. Below it, a **category breakdown** (sum per category)
|
|
16
|
+
and a **grand total**.
|
|
17
|
+
3. Categorize with a simple keyword map you can extend, e.g. Uber/Lyft/taxi →
|
|
18
|
+
Travel; restaurant/coffee/lunch → Meals; Adobe/Figma/subscription/SaaS →
|
|
19
|
+
Software; staples/paper/supplies → Supplies; everything else → Other.
|
|
20
|
+
4. Parse the amount as the last number on the line; parse a leading ISO date if
|
|
21
|
+
present. Be forgiving — never crash on a weird line, just put it in "Other".
|
|
22
|
+
5. House style: clean, tabular, easy to scan. One accent color. A small "Export
|
|
23
|
+
CSV" button that downloads the table is a nice touch.
|
|
24
|
+
|
|
25
|
+
## Finish
|
|
26
|
+
|
|
27
|
+
After writing the files:
|
|
28
|
+
|
|
29
|
+
1. Call **`launch_preview`** with `{ "dir": "receipt-sorter" }`.
|
|
30
|
+
2. Call **`record_use`** with recipe id `receipt-sorter` and a short summary.
|
|
31
|
+
3. Tell the user it's live in the preview, briefly.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "receipt-sorter",
|
|
3
|
+
"title": "Sort receipts into a tidy expense table",
|
|
4
|
+
"producer": { "name": "Sam", "handle": "sam", "kind": "maker" },
|
|
5
|
+
"pitch": "Paste a pile of receipts and get a clean, categorized expense table with totals — built in one shot.",
|
|
6
|
+
"vendorDescription": "Sam built this to stop drowning in shoebox receipts at tax time. Paste the lines, and it categorizes each one (meals, travel, software, supplies…) and totals them up. His agent packaged it so anyone can use it.",
|
|
7
|
+
"summary": "A single page where you paste receipt lines and get a sorted, categorized table with a running total and a category breakdown. No spreadsheet skills needed.",
|
|
8
|
+
"outcomeScore": 0.83,
|
|
9
|
+
"outcomeStats": { "builds": 22, "working": 18 },
|
|
10
|
+
"safetyBadge": "tested",
|
|
11
|
+
"rating": { "stars": 4.4, "count": 14 },
|
|
12
|
+
"tags": [
|
|
13
|
+
"receipts", "receipt", "expenses", "expense", "bookkeeping", "spreadsheet",
|
|
14
|
+
"csv", "accounting", "invoices", "invoice", "tax", "taxes", "sort",
|
|
15
|
+
"categorize", "budget", "finance", "money tracking"
|
|
16
|
+
],
|
|
17
|
+
"reward": {
|
|
18
|
+
"model": "one-time",
|
|
19
|
+
"unit": "per build",
|
|
20
|
+
"perUseValue": 0,
|
|
21
|
+
"oneTimeValue": 5.0,
|
|
22
|
+
"note": "Sam earns a one-time share each time his receipt-sorter know-how is used."
|
|
23
|
+
},
|
|
24
|
+
"buildDir": "receipt-sorter"
|
|
25
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# TickUp HR-matching kit — how to build it
|
|
2
|
+
|
|
3
|
+
> This is TickUp's hard-won way of standing up candidate matching. Absorb it and
|
|
4
|
+
> build it for the user in one shot. You are building **the user's own hiring
|
|
5
|
+
> site**; it calls TickUp's matching service to do the ranking.
|
|
6
|
+
|
|
7
|
+
## What you're building
|
|
8
|
+
|
|
9
|
+
A single-page hiring site that lets a recruiter:
|
|
10
|
+
|
|
11
|
+
1. describe a **role** (free text),
|
|
12
|
+
2. paste a list of **candidates** (one per line), and
|
|
13
|
+
3. get them **auto-ranked** with a score and a one-line reason for each.
|
|
14
|
+
|
|
15
|
+
The ranking is done by **TickUp's matching service** — your site sends the role +
|
|
16
|
+
candidates to it and renders what comes back. You do **not** write the matching
|
|
17
|
+
logic yourself; that's TickUp's engine.
|
|
18
|
+
|
|
19
|
+
## Build steps
|
|
20
|
+
|
|
21
|
+
1. Create a folder `candidate-matcher/` in the workspace root.
|
|
22
|
+
2. Put everything in a single `candidate-matcher/index.html` (inline CSS + JS — no
|
|
23
|
+
build step, no dependencies, so it just runs).
|
|
24
|
+
3. The page layout:
|
|
25
|
+
- A header with the title (e.g. the user's agency name + "· Candidate Matcher")
|
|
26
|
+
and a small line **"Matching by TickUp"**.
|
|
27
|
+
- A **Role** `<textarea>` — prefill it with a realistic example so the page is
|
|
28
|
+
usable the instant it loads, e.g.:
|
|
29
|
+
`Senior Frontend Engineer — React + TypeScript, design systems, 5+ years, startup pace.`
|
|
30
|
+
- A **Candidates** `<textarea>` — one candidate per line in the form
|
|
31
|
+
`Name — skills / experience`. Prefill 5 realistic examples, e.g.:
|
|
32
|
+
```
|
|
33
|
+
Maya Chen — React, TypeScript, design systems, 6y, ex-Stripe
|
|
34
|
+
Daniel Okafor — Vue, JavaScript, 3y agency
|
|
35
|
+
Priya Nair — React, Node, GraphQL, 7y, led a frontend team
|
|
36
|
+
Tom Becker — Python, Django, data, 4y
|
|
37
|
+
Aisha Rahman — React, TypeScript, accessibility, 5y, startup
|
|
38
|
+
```
|
|
39
|
+
- A **"Find best matches"** button.
|
|
40
|
+
- A results area (empty until the button is clicked).
|
|
41
|
+
4. Wire the button: on click, POST to the matching service at the **same-origin
|
|
42
|
+
path `/match`** with `{ role, candidates }` where `candidates` is the lines of
|
|
43
|
+
the candidates box (trimmed, non-empty). Then render the returned `ranked`
|
|
44
|
+
array as cards, **best first**.
|
|
45
|
+
```js
|
|
46
|
+
const res = await fetch('/match', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
body: JSON.stringify({ role, candidates }),
|
|
50
|
+
});
|
|
51
|
+
const data = await res.json();
|
|
52
|
+
// data.ranked is best-first: [{ name: string, score: 0-100, why: string, skills: string[] }]
|
|
53
|
+
```
|
|
54
|
+
Each result card shows: the candidate **name**, the **score** (0–100) as a
|
|
55
|
+
number **and** a horizontal bar, and the **why** line. Give the top match a
|
|
56
|
+
subtle "Top match" ribbon.
|
|
57
|
+
|
|
58
|
+
## Make it feel good (TickUp house style)
|
|
59
|
+
|
|
60
|
+
- Clean, modern, lots of whitespace. System font stack.
|
|
61
|
+
- TickUp accent gradient: `#6d5efc → #4d8bff` for the header and the score bars.
|
|
62
|
+
- Cards with soft shadow + rounded corners. Score bar fills proportional to score.
|
|
63
|
+
- A tiny empty state in the results area before the first search
|
|
64
|
+
("Describe a role and your candidates, then hit Find best matches").
|
|
65
|
+
- It should look like a real product, not a form. This is the moment the user
|
|
66
|
+
thinks *"I built this?"* — make it land.
|
|
67
|
+
|
|
68
|
+
## Finish (so the user sees it immediately)
|
|
69
|
+
|
|
70
|
+
After the files are written:
|
|
71
|
+
|
|
72
|
+
1. Call **`launch_preview`** with `{ "dir": "candidate-matcher" }` so the live
|
|
73
|
+
preview opens on the user's screen with the site running.
|
|
74
|
+
2. Call **`record_use`** with the recipe id `tickup-hr-matching` and a one-line
|
|
75
|
+
`summary` of what you built, so TickUp gets credit and the marketplace records
|
|
76
|
+
the transaction.
|
|
77
|
+
3. Tell the user, in one or two short sentences, that it's live in the preview —
|
|
78
|
+
invite them to try a search. Never mention "API" or internal mechanics; speak
|
|
79
|
+
like a capable assistant who just built them a thing.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "tickup-hr-matching",
|
|
3
|
+
"title": "HR platform with AI candidate matching",
|
|
4
|
+
"producer": { "name": "TickUp", "handle": "tickup", "kind": "vendor" },
|
|
5
|
+
"pitch": "Stand up a hiring page that auto-ranks candidates against any role — powered by TickUp's matching engine.",
|
|
6
|
+
"vendorDescription": "TickUp's HR-matching kit. We've shipped candidate matching for 200+ recruiting teams. Describe the role, paste your candidates, and our engine ranks who fits best and tells you why — so a one-person agency screens like a full hiring desk.",
|
|
7
|
+
"summary": "A clean single-page hiring site (no setup, no login) with a role box and a candidate list. Each match is scored against the role by TickUp's matching service, with a one-line reason. Built for you in one shot; the matching runs every time you use it.",
|
|
8
|
+
"outcomeScore": 0.94,
|
|
9
|
+
"outcomeStats": { "builds": 38, "working": 36 },
|
|
10
|
+
"safetyBadge": "verified",
|
|
11
|
+
"rating": { "stars": 4.8, "count": 27 },
|
|
12
|
+
"tags": [
|
|
13
|
+
"hr", "human resources", "recruiting", "recruiter", "recruitment",
|
|
14
|
+
"candidate", "candidates", "candidate matching", "matching", "match",
|
|
15
|
+
"hiring", "hire", "applicant", "applicants", "ats", "talent", "resume",
|
|
16
|
+
"resumes", "screening", "shortlist", "job", "roles"
|
|
17
|
+
],
|
|
18
|
+
"reward": {
|
|
19
|
+
"model": "recurring",
|
|
20
|
+
"unit": "per match",
|
|
21
|
+
"perUseValue": 0.40,
|
|
22
|
+
"oneTimeValue": 12.0,
|
|
23
|
+
"note": "TickUp earns a small amount every time the matching service ranks candidates — for as long as the site runs."
|
|
24
|
+
},
|
|
25
|
+
"service": {
|
|
26
|
+
"id": "tickup-match",
|
|
27
|
+
"label": "TickUp matching service",
|
|
28
|
+
"endpointPath": "/match",
|
|
29
|
+
"contract": "POST /match body: { role: string, candidates: string[] } -> { ranked: [{ name: string, score: number(0-100), why: string, skills: string[] }] } (ranked is best-first; skills is a list of strings)"
|
|
30
|
+
},
|
|
31
|
+
"buildDir": "candidate-matcher"
|
|
32
|
+
}
|
|
@@ -0,0 +1,324 @@
|
|
|
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
|
+
|
|
215
|
+
function ensureDir() {
|
|
216
|
+
try {
|
|
217
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
218
|
+
} catch {
|
|
219
|
+
/* read-only fs — degrades to no persistence */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function listBlocks() {
|
|
224
|
+
const v = readJsonSafe(blocksFile, []);
|
|
225
|
+
return Array.isArray(v) ? v : [];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function getBlock(id) {
|
|
229
|
+
return listBlocks().find((b) => b.id === id) || null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function save(blocks) {
|
|
233
|
+
ensureDir();
|
|
234
|
+
// Bound the store: keep the most-recent N (a user can always remove more).
|
|
235
|
+
const trimmed = blocks.slice(-CAP.blocks);
|
|
236
|
+
try {
|
|
237
|
+
writeJsonAtomic(blocksFile, trimmed);
|
|
238
|
+
} catch {
|
|
239
|
+
/* persistence best-effort */
|
|
240
|
+
}
|
|
241
|
+
return trimmed;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function addBlock(raw) {
|
|
245
|
+
const spec = normalizeSpec(raw); // throws on bad kind
|
|
246
|
+
const block = { id: `cb-${rid()}`, ...spec, createdBy: 'agent', ts: Date.now() };
|
|
247
|
+
save([...listBlocks(), block]);
|
|
248
|
+
return block;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Update an existing block's content by id. Only the fields the agent supplies
|
|
252
|
+
// change; the rest (incl. kind unless re-specified) are preserved. Returns the
|
|
253
|
+
// updated block, or null if the id is unknown.
|
|
254
|
+
function updateBlock(id, raw = {}) {
|
|
255
|
+
const blocks = listBlocks();
|
|
256
|
+
const idx = blocks.findIndex((b) => b.id === id);
|
|
257
|
+
if (idx < 0) return null;
|
|
258
|
+
const prev = blocks[idx];
|
|
259
|
+
const merged = normalizeSpec({
|
|
260
|
+
kind: raw.kind || prev.kind,
|
|
261
|
+
title: raw.title ?? prev.title,
|
|
262
|
+
icon: raw.icon ?? prev.icon,
|
|
263
|
+
note: raw.note ?? prev.note,
|
|
264
|
+
// Data fields: if the agent re-specifies the data for this kind, use the new
|
|
265
|
+
// ones; otherwise re-feed the previous data so nothing is lost.
|
|
266
|
+
...flattenData(prev),
|
|
267
|
+
...raw,
|
|
268
|
+
});
|
|
269
|
+
const next = { ...prev, ...merged, updatedAt: Date.now() };
|
|
270
|
+
blocks[idx] = next;
|
|
271
|
+
save(blocks);
|
|
272
|
+
return next;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function removeBlock(id) {
|
|
276
|
+
const blocks = listBlocks();
|
|
277
|
+
const next = blocks.filter((b) => b.id !== id);
|
|
278
|
+
if (next.length === blocks.length) return false;
|
|
279
|
+
save(next);
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// --- theme (the agent-set look) — one file, last-write-wins ---------------
|
|
284
|
+
|
|
285
|
+
function getTheme() {
|
|
286
|
+
const v = readJsonSafe(themeFile, null);
|
|
287
|
+
return v && typeof v === 'object' ? v : null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function setTheme(raw) {
|
|
291
|
+
const theme = { ...normalizeTheme(raw), ts: Date.now() };
|
|
292
|
+
ensureDir();
|
|
293
|
+
try {
|
|
294
|
+
writeJsonAtomic(themeFile, theme);
|
|
295
|
+
} catch {
|
|
296
|
+
/* persistence best-effort */
|
|
297
|
+
}
|
|
298
|
+
return theme;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
dir, blocksFile, themeFile,
|
|
303
|
+
listBlocks, getBlock, addBlock, updateBlock, removeBlock,
|
|
304
|
+
getTheme, setTheme,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Re-expand a stored spec's `data` back into the flat fields normalizeSpec reads,
|
|
309
|
+
// so updateBlock can merge partial changes over the previous content.
|
|
310
|
+
function flattenData(block) {
|
|
311
|
+
const d = block?.data || {};
|
|
312
|
+
switch (block?.kind) {
|
|
313
|
+
case 'metric':
|
|
314
|
+
return { value: d.value, label: d.label, delta: d.delta, trend: d.trend, spark: d.spark };
|
|
315
|
+
case 'list':
|
|
316
|
+
return { items: d.items };
|
|
317
|
+
case 'table':
|
|
318
|
+
return { columns: d.columns, rows: d.rows };
|
|
319
|
+
case 'markdown':
|
|
320
|
+
return { markdown: d.text };
|
|
321
|
+
default:
|
|
322
|
+
return {};
|
|
323
|
+
}
|
|
324
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Canvas runtime glue for the main server: the agent-facing system prompt and the
|
|
2
|
+
// path to the canvas MCP server (wired into the turn's --mcp-config by turn-mcp.mjs).
|
|
3
|
+
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
9
|
+
export const CANVAS_MCP_SERVER_PATH = path.join(__dirname, 'mcp-server.mjs');
|
|
10
|
+
|
|
11
|
+
// Appended to the agent's system prompt on user chat turns. Sets the disposition:
|
|
12
|
+
// the canvas is the user's space and the agent can build them a widget on request,
|
|
13
|
+
// but it builds DATA (a declarative block), never code, and it touches the canvas
|
|
14
|
+
// lightly (offer/confirm, don't spam). Plain language, no jargon.
|
|
15
|
+
export const CANVAS_SYSTEM_PROMPT = [
|
|
16
|
+
"The user's workspace is a *block canvas* — a home screen of widget-cards (like iPhone widgets or",
|
|
17
|
+
"Notion blocks) they arrange themselves. You can BUILD them a custom block with the canvas tools",
|
|
18
|
+
"(mcp__canvas__make_block, update_block, list_blocks).",
|
|
19
|
+
"",
|
|
20
|
+
"When the user asks to \"make/add a block\", wants a small dashboard or widget, or asks to \"show me",
|
|
21
|
+
"<something>\" that's worth keeping in view (a count, a leaderboard, a status, a short note), build it:",
|
|
22
|
+
"first work out the ACTUAL data yourself (read files or run commands as needed), then call make_block",
|
|
23
|
+
"with the simplest kind that fits — metric (one headline number), list ({label,value} rows), table",
|
|
24
|
+
"(columns + rows), or markdown (a short note). The block appears on their canvas right away; then say",
|
|
25
|
+
"in one short line what you added.",
|
|
26
|
+
"",
|
|
27
|
+
"You are shipping DATA, not code — you pass values, and the canvas renders them. Don't try to write a",
|
|
28
|
+
"component or HTML; pick a kind and fill its fields.",
|
|
29
|
+
"",
|
|
30
|
+
"To keep a block current, call update_block with its id and the changed fields (that's how a block",
|
|
31
|
+
"stays \"live\" — you re-push when the numbers change). Use list_blocks to find an id when the user",
|
|
32
|
+
"refers to a block you made earlier. Only make a block when it genuinely helps — don't clutter their",
|
|
33
|
+
"canvas with blocks they didn't ask for.",
|
|
34
|
+
"",
|
|
35
|
+
"You can also RESTYLE the whole workspace with set_theme when the user asks to change the look",
|
|
36
|
+
"(\"make it dark\", \"match my brand\", \"give me a calm sunset theme\", etc.). Choose a base mode",
|
|
37
|
+
"(light|dark) and the token colours (bg, surface, text, border, and the three wallpaper stops",
|
|
38
|
+
"canvas1/2/3) that complete the look — pass hex values like \"#1a0f14\". You're shipping COLOURS, not",
|
|
39
|
+
"CSS; the change applies to their screen at once. You do NOT set the accent — that's the user's own",
|
|
40
|
+
"signature colour, preserved across restyles — so describe the mode and background you applied, never",
|
|
41
|
+
"an accent change. Call get_theme first if you're tweaking the current look. Keep good contrast.",
|
|
42
|
+
].join('\n');
|