agentic-canvas 0.0.0-stage → 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.
@@ -0,0 +1,166 @@
1
+ import {
2
+ CanvasError,
3
+ PRINCIPAL_KINDS,
4
+ SHAPE_TYPES,
5
+ principalSchema
6
+ } from "./chunk-GGQPE65T.js";
7
+
8
+ // src/version.ts
9
+ import { createRequire } from "module";
10
+ var VERSION = true ? "0.2.0" : readPackageVersion();
11
+
12
+ // src/brand.ts
13
+ var BRAND = {
14
+ command: "agentic-canvas",
15
+ description: "A shared whiteboard for diagrams, drawn by people and agents.",
16
+ version: VERSION
17
+ };
18
+
19
+ // src/actor.ts
20
+ var ActorConfigError = class extends Error {
21
+ };
22
+ function operatorActor(env = process.env) {
23
+ const raw = env.CANVAS_ACTOR;
24
+ if (!raw) throw new ActorConfigError("the operator must set CANVAS_ACTOR=<kind>:<id>");
25
+ const at = raw.indexOf(":");
26
+ const parsed = principalSchema.safeParse({ kind: raw.slice(0, at), id: raw.slice(at + 1) });
27
+ if (at < 0 || !parsed.success) {
28
+ throw new ActorConfigError(`bad CANVAS_ACTOR "${raw}": use <kind>:<id>, kind is one of ${PRINCIPAL_KINDS.join(", ")}`);
29
+ }
30
+ return parsed.data;
31
+ }
32
+
33
+ // src/png.ts
34
+ var MAX_SIDE = 16384;
35
+ var MAX_PIXELS = 16e6;
36
+ var TIMEOUT_MS = 2e4;
37
+ async function svgToPng(svg) {
38
+ const size = /^<svg [^>]*?width="(\d+)" height="(\d+)"/.exec(svg);
39
+ const width = Number(size?.[1]);
40
+ const height = Number(size?.[2]);
41
+ if (!(width > 0 && height > 0) || width > MAX_SIDE || height > MAX_SIDE || width * height > MAX_PIXELS) {
42
+ throw new CanvasError("LIMIT", `the board is too large for PNG: up to ${MAX_SIDE} px a side and ${MAX_PIXELS / 1e6} million pixels`);
43
+ }
44
+ let chromium;
45
+ try {
46
+ ({ chromium } = await import("playwright-core"));
47
+ } catch {
48
+ throw new CanvasError("UNAVAILABLE", "PNG export needs the playwright-core package. Install it, or export SVG.");
49
+ }
50
+ const browser = await chromium.launch().catch(() => {
51
+ throw new CanvasError("UNAVAILABLE", "PNG export needs Chromium. Run: npx playwright-core install chromium");
52
+ });
53
+ try {
54
+ const context = await browser.newContext({ viewport: { width, height }, javaScriptEnabled: false, serviceWorkers: "block" });
55
+ context.setDefaultTimeout(TIMEOUT_MS);
56
+ await context.route("**/*", (route) => route.abort());
57
+ const page = await context.newPage();
58
+ await page.setContent(`<!doctype html><body style="margin:0">${svg}</body>`);
59
+ return await page.screenshot({ clip: { x: 0, y: 0, width, height } });
60
+ } finally {
61
+ await browser.close();
62
+ }
63
+ }
64
+
65
+ // src/mcp.ts
66
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
67
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
68
+ import { z } from "zod";
69
+ var json = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }] });
70
+ function failure(error) {
71
+ if (!(error instanceof CanvasError)) console.error(error instanceof Error ? error.message : String(error));
72
+ const body = error instanceof CanvasError ? { code: error.code, message: error.message } : { code: "INTERNAL", message: "the board operation failed" };
73
+ return { isError: true, content: [{ type: "text", text: JSON.stringify(body) }] };
74
+ }
75
+ function tool(run) {
76
+ return async (args) => {
77
+ try {
78
+ return await run(args);
79
+ } catch (error) {
80
+ return failure(error);
81
+ }
82
+ };
83
+ }
84
+ var board = z.string().describe("Board id.");
85
+ var num = z.number();
86
+ function createMcpServer(service, actor) {
87
+ const server = new McpServer({ name: BRAND.command, version: BRAND.version });
88
+ const read = { readOnlyHint: true };
89
+ server.registerTool(
90
+ "create_board",
91
+ { description: "Make a board.", inputSchema: { id: z.string().optional(), title: z.string().optional() } },
92
+ tool(async (a) => json(await service.create(actor, a)))
93
+ );
94
+ server.registerTool(
95
+ "read_board",
96
+ { description: "Read a board as data: nodes, edges, groups, text, and who made what.", inputSchema: { board }, annotations: read },
97
+ tool(async (a) => json(await service.read(a.board)))
98
+ );
99
+ server.registerTool(
100
+ "add_shape",
101
+ {
102
+ description: "Add a shape. Image shapes need src.",
103
+ inputSchema: {
104
+ board,
105
+ id: z.string().optional(),
106
+ type: z.enum(SHAPE_TYPES),
107
+ x: num,
108
+ y: num,
109
+ w: num.optional(),
110
+ h: num.optional(),
111
+ text: z.string().optional(),
112
+ fill: z.string().optional(),
113
+ stroke: z.string().optional(),
114
+ src: z.string().optional()
115
+ }
116
+ },
117
+ tool(async ({ board: b, ...shape }) => json(await service.add(b, shape, actor)))
118
+ );
119
+ server.registerTool(
120
+ "connect",
121
+ { description: "Join two shapes with an arrow.", inputSchema: { board, from: z.string(), to: z.string(), id: z.string().optional(), text: z.string().optional() } },
122
+ tool(async (a) => json(await service.connect(a.board, { id: a.id, from: { id: a.from }, to: { id: a.to }, text: a.text }, actor)))
123
+ );
124
+ server.registerTool(
125
+ "move",
126
+ { description: "Move an element, or a whole group.", inputSchema: { board, element: z.string(), dx: num, dy: num } },
127
+ tool(async (a) => json(await service.move(a.board, a.element, a.dx, a.dy, actor)))
128
+ );
129
+ server.registerTool(
130
+ "remove",
131
+ { description: "Delete an element. A shape takes its connectors, a group its members.", inputSchema: { board, element: z.string() }, annotations: { destructiveHint: true } },
132
+ tool(async (a) => json(await service.remove(a.board, a.element)))
133
+ );
134
+ server.registerTool(
135
+ "draw_flow",
136
+ {
137
+ description: 'Draw a flowchart from text, one statement per line: "A[Box] -> B{Choice}: label". [] box, () ellipse, {} diamond.',
138
+ inputSchema: { board, text: z.string(), idPrefix: z.string().optional() }
139
+ },
140
+ tool(async (a) => json(await service.flow(a.board, a.text, actor, { idPrefix: a.idPrefix })))
141
+ );
142
+ server.registerTool(
143
+ "export_svg",
144
+ { description: "Export the board as SVG text.", inputSchema: { board }, annotations: read },
145
+ tool(async (a) => ({ content: [{ type: "text", text: await service.exportSvg(a.board) }] }))
146
+ );
147
+ server.registerTool(
148
+ "export_png",
149
+ { description: "Export the board as a PNG image. Needs the optional playwright-core and a Chromium.", inputSchema: { board }, annotations: read },
150
+ tool(async (a) => ({ content: [{ type: "image", data: Buffer.from(await svgToPng(await service.exportSvg(a.board))).toString("base64"), mimeType: "image/png" }] }))
151
+ );
152
+ return server;
153
+ }
154
+ async function runMcp(service, actor) {
155
+ await createMcpServer(service, actor).connect(new StdioServerTransport());
156
+ }
157
+
158
+ export {
159
+ VERSION,
160
+ BRAND,
161
+ ActorConfigError,
162
+ operatorActor,
163
+ svgToPng,
164
+ createMcpServer,
165
+ runMcp
166
+ };
@@ -0,0 +1,13 @@
1
+ // src/store-errors.ts
2
+ var FileStoreError = class extends Error {
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ this.name = "FileStoreError";
7
+ }
8
+ code;
9
+ };
10
+
11
+ export {
12
+ FileStoreError
13
+ };
@@ -0,0 +1,228 @@
1
+ import {
2
+ FileStoreError
3
+ } from "./chunk-NWQED42L.js";
4
+
5
+ // src/store-file.ts
6
+ import { randomUUID } from "crypto";
7
+ import { mkdirSync } from "fs";
8
+ import { link, open, readFile, rename, unlink } from "fs/promises";
9
+ import { join } from "path";
10
+ import { assertCollection, assertId, createMemoryStore, parseRegion, StoreConflictError } from "agentic-identity";
11
+ var MAX_TRANSACTION_RUNS = 50;
12
+ var clone = (value) => JSON.parse(JSON.stringify(value));
13
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
+ var codeOf = (error) => error?.code;
15
+ function processIsAlive(pid) {
16
+ try {
17
+ process.kill(pid, 0);
18
+ return true;
19
+ } catch (error) {
20
+ return codeOf(error) === "EPERM";
21
+ }
22
+ }
23
+ function lockIsStale(text, staleAfterMs) {
24
+ let held;
25
+ try {
26
+ held = JSON.parse(text);
27
+ } catch {
28
+ return false;
29
+ }
30
+ if (typeof held !== "object" || held === null) return false;
31
+ const { pid, at } = held;
32
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid < 1 || typeof at !== "number" || !Number.isFinite(at)) return false;
33
+ return !processIsAlive(pid) || Date.now() - at > staleAfterMs;
34
+ }
35
+ function createFileStore(path, options = {}) {
36
+ const region = parseRegion(options.region ?? "unknown");
37
+ const lockPath = `${path}.lock`;
38
+ const timeoutMs = options.lockTimeoutMs ?? 1e4;
39
+ const staleAfterMs = options.staleAfterMs ?? 3e4;
40
+ for (const ms of [timeoutMs, staleAfterMs]) if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) throw new TypeError("lockTimeoutMs and staleAfterMs must be finite numbers of 0 or more");
41
+ let queue = Promise.resolve();
42
+ async function load() {
43
+ let text;
44
+ try {
45
+ text = await readFile(path, "utf8");
46
+ } catch (error) {
47
+ if (codeOf(error) === "ENOENT") return /* @__PURE__ */ new Map();
48
+ throw error;
49
+ }
50
+ try {
51
+ const data = JSON.parse(text);
52
+ if (typeof data !== "object" || data === null || Array.isArray(data)) throw new Error();
53
+ return new Map(
54
+ Object.entries(data).map(([name, docs]) => {
55
+ if (typeof docs !== "object" || docs === null || Array.isArray(docs)) throw new Error();
56
+ for (const doc of Object.values(docs)) if (typeof doc !== "object" || doc === null || Array.isArray(doc)) throw new Error();
57
+ return [name, new Map(Object.entries(docs))];
58
+ })
59
+ );
60
+ } catch {
61
+ throw new FileStoreError("DAMAGED", `the database file is not valid JSON. Repair or remove it`);
62
+ }
63
+ }
64
+ async function save(snapshot) {
65
+ const text = JSON.stringify(Object.fromEntries([...snapshot].map(([name, docs]) => [name, Object.fromEntries(docs)])));
66
+ const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
67
+ try {
68
+ const handle = await open(temp, "w");
69
+ try {
70
+ await handle.writeFile(text);
71
+ await handle.sync();
72
+ } finally {
73
+ await handle.close();
74
+ }
75
+ await rename(temp, path);
76
+ } catch (error) {
77
+ await unlink(temp).catch(() => void 0);
78
+ throw error;
79
+ }
80
+ }
81
+ async function takeOverIfStale() {
82
+ let seen;
83
+ try {
84
+ seen = await readFile(lockPath, "utf8");
85
+ } catch {
86
+ return;
87
+ }
88
+ if (!lockIsStale(seen, staleAfterMs)) return;
89
+ const aside = `${lockPath}.stale.${randomUUID()}`;
90
+ try {
91
+ await rename(lockPath, aside);
92
+ } catch {
93
+ return;
94
+ }
95
+ if (await readFile(aside, "utf8").catch(() => "") !== seen) await link(aside, lockPath).catch(() => void 0);
96
+ await unlink(aside).catch(() => void 0);
97
+ }
98
+ async function acquire() {
99
+ const token = randomUUID();
100
+ const deadline = Date.now() + timeoutMs;
101
+ for (; ; ) {
102
+ try {
103
+ const handle = await open(lockPath, "wx");
104
+ try {
105
+ await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now(), token }));
106
+ } finally {
107
+ await handle.close();
108
+ }
109
+ return token;
110
+ } catch (error) {
111
+ if (codeOf(error) !== "EEXIST") throw error;
112
+ }
113
+ await takeOverIfStale();
114
+ if (Date.now() > deadline) throw new FileStoreError("LOCKED", `the database file is locked. If no agentic-canvas process runs, remove the .lock file next to it`);
115
+ await sleep(5 + Math.random() * 15);
116
+ }
117
+ }
118
+ async function release(token) {
119
+ const text = await readFile(lockPath, "utf8").catch(() => "");
120
+ if (text.includes(`"token":"${token}"`)) await unlink(lockPath).catch(() => void 0);
121
+ }
122
+ async function memoryOf(snapshot, only) {
123
+ const memory = createMemoryStore({ region });
124
+ for (const [name, docs] of snapshot) if (only === void 0 || only === name) for (const [id, doc] of docs) await memory.put(name, id, doc);
125
+ return memory;
126
+ }
127
+ const recording = (inner, log) => ({
128
+ get: (collection, id) => inner.get(collection, id),
129
+ query: (collection, query) => inner.query(collection, query),
130
+ put: async (collection, id, doc) => {
131
+ await inner.put(collection, id, doc);
132
+ log.push([collection, id, clone(doc)]);
133
+ },
134
+ delete: async (collection, id) => {
135
+ await inner.delete(collection, id);
136
+ log.push([collection, id, null]);
137
+ }
138
+ });
139
+ const locked = (body) => {
140
+ const run = queue.then(async () => {
141
+ const token = await acquire();
142
+ try {
143
+ return await body();
144
+ } finally {
145
+ await release(token);
146
+ }
147
+ });
148
+ queue = run.catch(() => void 0);
149
+ return run;
150
+ };
151
+ async function commit(snapshot, log) {
152
+ for (const [name, id, doc] of log) {
153
+ if (doc === null) snapshot.get(name)?.delete(id);
154
+ else snapshot.set(name, (snapshot.get(name) ?? /* @__PURE__ */ new Map()).set(id, doc));
155
+ }
156
+ if (log.length > 0) await save(snapshot);
157
+ }
158
+ const writeOne = (write) => locked(async () => {
159
+ const log = [];
160
+ await write(recording(createMemoryStore({ region }), log));
161
+ await commit(await load(), log);
162
+ });
163
+ const watching = (inner, seen) => ({
164
+ ...inner,
165
+ async get(collection, id) {
166
+ const doc = await inner.get(collection, id);
167
+ if (!seen.has(`${collection}/${id}`)) seen.set(`${collection}/${id}`, JSON.stringify(doc));
168
+ return doc;
169
+ },
170
+ async query(collection, query) {
171
+ const page = await inner.query(collection, query);
172
+ for (const item of page.items) if (!seen.has(`${collection}/${item.id}`)) seen.set(`${collection}/${item.id}`, JSON.stringify(item.data));
173
+ return page;
174
+ }
175
+ });
176
+ const unchanged = (snapshot, seen) => [...seen].every(([key, text]) => {
177
+ const slash = key.indexOf("/");
178
+ return JSON.stringify(snapshot.get(key.slice(0, slash))?.get(key.slice(slash + 1)) ?? null) === text;
179
+ });
180
+ return {
181
+ region,
182
+ async get(collection, id) {
183
+ assertCollection(collection);
184
+ assertId(id);
185
+ const doc = (await load()).get(collection)?.get(id);
186
+ return doc ? clone(doc) : null;
187
+ },
188
+ async query(collection, query) {
189
+ assertCollection(collection);
190
+ return (await memoryOf(await load(), collection)).query(collection, query);
191
+ },
192
+ put: (collection, id, doc) => writeOne((ops) => ops.put(collection, id, doc)),
193
+ delete: (collection, id) => writeOne((ops) => ops.delete(collection, id)),
194
+ // Optimistic, as the port requires: run on a copy of the file with no lock, then take the lock and commit
195
+ // only if every doc the run read is unchanged. Else run again. The memory adapter keeps the rule that
196
+ // every read comes before the first write.
197
+ async transaction(fn) {
198
+ for (let run = 0; run < MAX_TRANSACTION_RUNS; run++) {
199
+ const log = [];
200
+ const seen = /* @__PURE__ */ new Map();
201
+ const result = await (await memoryOf(await load())).transaction(async (tx) => {
202
+ log.length = 0;
203
+ seen.clear();
204
+ return fn(watching(recording(tx, log), seen));
205
+ });
206
+ const done = log.length === 0 ? unchanged(await load(), seen) : await locked(async () => {
207
+ const current = await load();
208
+ if (!unchanged(current, seen)) return false;
209
+ await commit(current, log);
210
+ return true;
211
+ });
212
+ if (done) return result;
213
+ }
214
+ throw new StoreConflictError();
215
+ }
216
+ };
217
+ }
218
+ function openLocalStore(dir) {
219
+ mkdirSync(dir, { recursive: true });
220
+ return createFileStore(join(dir, STORE_FILE));
221
+ }
222
+ var STORE_FILE = "canvas.json";
223
+
224
+ export {
225
+ createFileStore,
226
+ openLocalStore,
227
+ STORE_FILE
228
+ };