drafted 1.17.13 → 1.17.15

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/mcp/server.mjs CHANGED
@@ -307,6 +307,9 @@ const TOOL_ANNOTATIONS = {
307
307
  // name describing the work before any other tool call succeeds.
308
308
  session: { title: 'Session', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Name THIS agent session (and rename it later). The name is what the user sees on your surface tab — pick a short 2-3 word description of the work (e.g. "beoflow backend", "drafted fs work"). The name persists across reconnects and restarts; you only set it once unless the work changes. Dispatch by `action`: `name` (set/rename with the `name` param).' },
309
309
 
310
+ // Comments — the review loop agents could previously only reach over raw HTTP
311
+ comment: { title: 'Comments', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Read and write review comments on frames. Comments are notes ABOUT THE WORK: they attach to a frame, optionally to one element inside it, and anyone who can see the frame sees them. Dispatch by `action`: list (paginated, compact mode), add (with an optional element anchor or a reply), resolve, reopen, delete. Use this to leave findings a human can action in place, and to read the feedback they left you.' },
312
+
310
313
  // Canvas / view
311
314
  focus: { title: 'Focus on target', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Pan the canvas viewport for connected clients to a frame, lane, or layer.' },
312
315
  tour: { title: 'Guided tours', readOnlyHint: false, destructiveHint: false, openWorldHint: false, description: 'Play or stop an agent-authored guided tour (driver.js) on the project surface for all connected clients. Tours live on a frame\'s metadata.tour — author them with fs(write, metadata={tour: {title, steps}}). Dispatch by `action`: play (frameId) starts the walkthrough, stop (projectId) ends it. Use for presentations and collaborative walkthroughs.' },
@@ -2321,6 +2324,101 @@ tool('tour', {
2321
2324
  } catch (error) { return err(error); }
2322
2325
  });
2323
2326
 
2327
+ tool('comment', {
2328
+ action: z.enum(['list', 'add', 'resolve', 'reopen', 'delete']).describe('list: read comments (paginated). add: post a comment or a reply. resolve/reopen: flip a comment\'s state. delete: remove one.'),
2329
+ target: z.string().optional().describe('[list, add] The frame: a path (/projects/<project>/<layer>/<lane>/<file>), a frame URL, or a frame UUID. Required for add. For list, omit it to read the whole project.'),
2330
+ projectId: z.string().optional().describe('[list] Project to read when no target is given — UUID, slug, or /projects/<name>. Defaults to the session\'s bound project.'),
2331
+ body: z.string().optional().describe('[add] The comment text. Be specific and actionable: quote what you mean and say what should change. Prefix with [MUST] / [NICE] / [Q] so a reviewer can triage.'),
2332
+ replyTo: z.string().optional().describe('[add] Comment UUID this replies to. A reply cannot carry its own anchor — it inherits the thread\'s.'),
2333
+ anchorRef: z.string().optional().describe('[add] CSS selector for the element this comment is about, e.g. "h1" or ".email-body > p:nth-of-type(3)". The frame must load /vendor/frame-guide.js (markdown and HTML content frames get it automatically). Without it the comment lands on the whole frame.'),
2334
+ anchorText: z.string().optional().describe('[add] The text of the anchored element, stored as a snapshot. Supply it whenever you pass anchorRef: a selector is an nth-of-type path that breaks when the frame is re-authored, and this snapshot is the only way to re-find what you meant.'),
2335
+ commentId: z.string().optional().describe('[resolve, reopen, delete] The comment UUID.'),
2336
+ status: z.enum(['open', 'resolved', 'all']).optional().describe('[list] Filter. Defaults to open — the comments that still need action.'),
2337
+ limit: z.number().optional().describe('[list] Max comments to return (default 25).'),
2338
+ offset: z.number().optional().describe('[list] Skip this many before returning (default 0).'),
2339
+ compact: z.boolean().optional().describe('[list] Return only id, frame, author, status and a truncated body — enough to pick one to act on, without the full text of every thread.'),
2340
+ }, async ({ action, target, projectId, body, replyTo, anchorRef, anchorText, commentId, status, limit, offset, compact }) => {
2341
+ try {
2342
+ if (action === 'add') {
2343
+ if (!target) throw new Error('target is required for comment add');
2344
+ if (!body || !String(body).trim()) throw new Error('body is required for comment add');
2345
+ const { frameId } = await resolveFsFramePath(target);
2346
+ // A reply inherits its parent's anchor; sending both would silently drop one.
2347
+ const anchor = (!replyTo && anchorRef)
2348
+ ? { type: 'selector', ref: anchorRef, ...(anchorText ? { text: anchorText } : {}) }
2349
+ : null;
2350
+ const result = await api('POST', `/api/frames/${frameId}/comments`, {
2351
+ body: String(body),
2352
+ parentId: replyTo || null,
2353
+ anchor,
2354
+ });
2355
+ return ok(result);
2356
+ }
2357
+ if (action === 'resolve' || action === 'reopen') {
2358
+ if (!commentId) throw new Error(`commentId is required for comment ${action}`);
2359
+ const result = await api('PATCH', `/api/comments/${commentId}`, { resolved: action === 'resolve' });
2360
+ return ok(result);
2361
+ }
2362
+ if (action === 'delete') {
2363
+ if (!commentId) throw new Error('commentId is required for comment delete');
2364
+ // DELETE answers 204 with an empty body, so there is nothing to echo back.
2365
+ await api('DELETE', `/api/comments/${commentId}`);
2366
+ return ok({ deleted: commentId });
2367
+ }
2368
+ if (action === 'list') {
2369
+ let rows;
2370
+ let scope;
2371
+ if (target) {
2372
+ // /api/frames/:id/comments does not join designs, so its rows carry no frameLabel.
2373
+ // Name the frame once in the scope instead of emitting `frame: null` on every row.
2374
+ const { frameId } = await resolveFsFramePath(target);
2375
+ rows = (await api('GET', `/api/frames/${frameId}/comments`)).comments || [];
2376
+ scope = { frameId, frame: target };
2377
+ } else {
2378
+ const meta = await resolveProjectArg(projectId || getState().projectId, { required: true });
2379
+ rows = (await api('GET', `/api/projects/${meta.id}/comments`)).comments || [];
2380
+ scope = { projectId: meta.id, project: meta.name };
2381
+ }
2382
+ const want = status || 'open';
2383
+ const filtered = rows.filter((c) => (want === 'all' ? true : want === 'resolved' ? !!c.resolvedAt : !c.resolvedAt));
2384
+ const total = filtered.length;
2385
+ const start = Math.max(0, Number(offset) || 0);
2386
+ const size = Math.max(1, Math.min(Number(limit) || 25, 200));
2387
+ const page = filtered.slice(start, start + size);
2388
+ const shape = (c) => compact
2389
+ ? {
2390
+ id: c.id,
2391
+ ...(c.frameLabel ? { frame: c.frameLabel } : {}),
2392
+ author: c.authorName || c.authorEmail || null,
2393
+ status: c.resolvedAt ? 'resolved' : 'open',
2394
+ replyTo: c.parentId || null,
2395
+ body: String(c.body || '').slice(0, 120),
2396
+ }
2397
+ : {
2398
+ id: c.id,
2399
+ frameId: c.frameId,
2400
+ ...(c.frameLabel ? { frame: c.frameLabel, layer: c.layer || null, lane: c.lane || null } : {}),
2401
+ author: c.authorName || c.authorEmail || null,
2402
+ status: c.resolvedAt ? 'resolved' : 'open',
2403
+ replyTo: c.parentId || null,
2404
+ anchor: c.anchor || null,
2405
+ body: c.body,
2406
+ createdAt: c.createdAt,
2407
+ };
2408
+ return ok({
2409
+ ...scope,
2410
+ status: want,
2411
+ comments: page.map(shape),
2412
+ totalAvailable: total,
2413
+ returned: page.length,
2414
+ truncated: start + page.length < total,
2415
+ ...(start + page.length < total ? { nextOffset: start + page.length } : {}),
2416
+ });
2417
+ }
2418
+ throw new Error(`Unknown comment action: ${action}`);
2419
+ } catch (error) { return err(error); }
2420
+ });
2421
+
2324
2422
  tool('focus', {
2325
2423
  target: z.string().describe('What to pan the canvas to: a frame path (/projects/<project>/<layer>/<lane>/<file>), a frame URL (any URL containing /f/{uuid} or /o/<org>/projects/...), or a frame ID (UUID). When a user shares a Drafted link, pass it directly here.'),
2326
2424
  }, async ({ target }) => {
@@ -2714,7 +2812,12 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2714
2812
  // OKF boundary rule: /a/b.md ≡ a/b — strip a trailing .md so both spellings work.
2715
2813
  const canonPath = wikiPath.replace(/\.md$/, '');
2716
2814
  const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, undefined, orgHeader).catch(() => null);
2717
- const body = { path: canonPath, title: canonPath.split('/').pop(), content, type: existing?.type || 'Page' };
2815
+ // Send ONLY { path, content }. An explicit `type`/`title` outranks the
2816
+ // content preamble server-side (PATCH semantics), so defaulting them
2817
+ // here silently forced every MCP-created page to type "Page" and a
2818
+ // path-segment title, discarding the author's frontmatter. The server
2819
+ // lifts both from the preamble and falls back to the path segment.
2820
+ const body = { path: canonPath, content };
2718
2821
  const result = existing
2719
2822
  ? await api('PUT', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, { content }, orgHeader)
2720
2823
  : await api('POST', '/api/wiki/pages', body, orgHeader);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.17.13",
3
+ "version": "1.17.15",
4
4
  "description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
5
5
  "type": "module",
6
6
  "files": [
@@ -64,6 +64,7 @@
64
64
  "mdast-util-from-markdown": "^2.0.3",
65
65
  "multer": "^2.1.1",
66
66
  "nodemailer": "^8.0.2",
67
+ "parse5": "^8.0.1",
67
68
  "pg": "^8.20.0",
68
69
  "playwright": "^1.58.2",
69
70
  "puppeteer": "^24.37.5",
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Conformance of org-defined record types.
3
+ *
4
+ * Drafted deliberately has NO built-in schema for "Process" or anything else —
5
+ * an org's agents invent their own vocabulary by writing typed wiki pages, and
6
+ * `wiki_pages.type` accepts any string. The cost of that freedom is drift: an
7
+ * agent declares a Process needs `steps`, then six months of writes quietly
8
+ * omit it, and structural queries return partial answers that LOOK complete.
9
+ *
10
+ * This is the drift report. It rejects nothing — a type definition is usually
11
+ * wrong on its first draft, so gating writes on one would be worse than the
12
+ * drift. Enforcement is opt-in per type (`enforced: true`), read by callers
13
+ * that choose to act on it; this module only observes.
14
+ *
15
+ * ONE reserved word: a page typed `RecordType` defines a record type. That is
16
+ * the single fixed point the org does not get to invent — you cannot discover
17
+ * definitions without agreeing on how a definition announces itself. Everything
18
+ * else (the type names, the field names, what they mean) belongs to the org.
19
+ */
20
+
21
+ export const RECORD_TYPE = 'RecordType';
22
+
23
+ /** How many same-typed pages before an undefined type looks like a record type
24
+ * someone forgot to declare. Low enough to catch an ingest early, high enough
25
+ * that a handful of Notes or Concepts never trips it. */
26
+ export const UNDECLARED_CLUSTER_MIN = 5;
27
+
28
+ function isBlank(v) {
29
+ if (v === null || v === undefined) return true;
30
+ if (typeof v === 'string') return v.trim() === '';
31
+ if (Array.isArray(v)) return v.length === 0;
32
+ if (typeof v === 'object') return Object.keys(v).length === 0;
33
+ return false;
34
+ }
35
+
36
+ /**
37
+ * Normalize a `fields` declaration. Both forms are accepted because agents
38
+ * write both:
39
+ * fields: [owner, steps] -> known, not required
40
+ * fields: [{ name: owner, required: true }] -> required
41
+ * A bare string is deliberately NOT treated as required: shorthand should be
42
+ * the cheap way to sketch a type, not a way to accidentally mass-flag pages.
43
+ */
44
+ export function normalizeFields(fields) {
45
+ if (!Array.isArray(fields)) return [];
46
+ const out = [];
47
+ for (const f of fields) {
48
+ if (typeof f === 'string' && f.trim()) {
49
+ out.push({ name: f.trim(), required: false });
50
+ } else if (f && typeof f === 'object' && typeof f.name === 'string' && f.name.trim()) {
51
+ out.push({ name: f.name.trim(), required: f.required === true });
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /** The type name a definition page defines: explicit `defines`, else its title. */
58
+ export function definedTypeName(page) {
59
+ const fm = (page && page.frontmatter) || {};
60
+ const explicit = typeof fm.defines === 'string' ? fm.defines.trim() : '';
61
+ if (explicit) return explicit;
62
+ const title = typeof page?.title === 'string' ? page.title.trim() : '';
63
+ return title || null;
64
+ }
65
+
66
+ /**
67
+ * @param pages - every wiki page for the org ({ path, title, type, frontmatter })
68
+ * @returns {{ types: Array, summary: object }}
69
+ * types[]: { type, definedAt, enforced, fields, instances, violations[] }
70
+ * violations[]: { path, missing: string[] }
71
+ *
72
+ * Only types that HAVE a definition are checked. Untyped and ad-hoc pages are
73
+ * none of this report's business — flagging them would bury the real drift.
74
+ */
75
+ export function checkRecordConformance(pages) {
76
+ const all = Array.isArray(pages) ? pages : [];
77
+ const definitions = all.filter((p) => String(p?.type || '').trim() === RECORD_TYPE);
78
+
79
+ const types = [];
80
+ const seen = new Set();
81
+ for (const def of definitions) {
82
+ const typeName = definedTypeName(def);
83
+ if (!typeName) {
84
+ types.push({
85
+ type: null,
86
+ definedAt: def.path,
87
+ enforced: false,
88
+ fields: [],
89
+ instances: 0,
90
+ violations: [],
91
+ error: 'definition declares no type name (set frontmatter `defines`, or give the page a title)',
92
+ });
93
+ continue;
94
+ }
95
+ // Two pages defining the same type is itself drift worth surfacing —
96
+ // otherwise whichever sorts first silently wins.
97
+ if (seen.has(typeName)) {
98
+ types.push({
99
+ type: typeName,
100
+ definedAt: def.path,
101
+ enforced: false,
102
+ fields: [],
103
+ instances: 0,
104
+ violations: [],
105
+ error: 'duplicate definition for this type',
106
+ });
107
+ continue;
108
+ }
109
+ seen.add(typeName);
110
+
111
+ const fm = def.frontmatter || {};
112
+ const fields = normalizeFields(fm.fields);
113
+ const required = fields.filter((f) => f.required).map((f) => f.name);
114
+ const instances = all.filter((p) => String(p?.type || '').trim() === typeName);
115
+
116
+ const violations = [];
117
+ for (const inst of instances) {
118
+ const ifm = inst.frontmatter || {};
119
+ const missing = required.filter((name) => isBlank(ifm[name]));
120
+ if (missing.length) violations.push({ path: inst.path, missing });
121
+ }
122
+
123
+ types.push({
124
+ type: typeName,
125
+ definedAt: def.path,
126
+ enforced: fm.enforced === true,
127
+ fields,
128
+ instances: instances.length,
129
+ violations,
130
+ });
131
+ }
132
+
133
+ types.sort((a, b) => String(a.type || '').localeCompare(String(b.type || '')));
134
+
135
+ // The blind spot this closes: a report that only checks DEFINED types is
136
+ // silent in the most likely failure — an agent asked to "ingest all our X"
137
+ // writes 200 prose pages typed `Page`, defines nothing, and every number
138
+ // below reads zero. Health looks perfect precisely when nothing is
139
+ // queryable. So surface the clusters that LOOK like undeclared record types.
140
+ const defined = new Set(types.map((t) => t.type).filter(Boolean));
141
+ const counts = new Map();
142
+ for (const p of all) {
143
+ const t = String(p?.type || '').trim();
144
+ if (!t || t === RECORD_TYPE || defined.has(t)) continue;
145
+ counts.set(t, (counts.get(t) || 0) + 1);
146
+ }
147
+ const undeclared = [...counts.entries()]
148
+ .filter(([, n]) => n >= UNDECLARED_CLUSTER_MIN)
149
+ .sort((a, b) => b[1] - a[1])
150
+ .map(([type, pages]) => ({
151
+ type,
152
+ pages,
153
+ hint: `${pages} pages share type "${type}" with no RecordType definition — if these get compared or audited, define one so the fields become queryable`,
154
+ }));
155
+
156
+ return {
157
+ types,
158
+ undeclared,
159
+ summary: {
160
+ typesDefined: types.length,
161
+ instances: types.reduce((n, t) => n + t.instances, 0),
162
+ violations: types.reduce((n, t) => n + t.violations.length, 0),
163
+ definitionErrors: types.filter((t) => t.error).length,
164
+ undeclaredClusters: undeclared.length,
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Excalidraw diagrams embedded in wiki page markdown.
3
+ *
4
+ * A diagram is a fenced block whose language is `excalidraw` and whose body is
5
+ * an Excalidraw scene. Keeping it inside the page (rather than as a separate
6
+ * frame referenced by link) means the process model and its diagram version,
7
+ * export and move together, and cannot drift apart.
8
+ *
9
+ * The fence pattern here MUST stay identical to the one in renderMarkdown()
10
+ * (server/lib/markdown.mjs) — what we find has to be exactly what renders, or
11
+ * an edit writes back to a block the reader never saw.
12
+ *
13
+ * Addressing: the diagram id lives INSIDE the scene JSON as `draftedDiagramId`,
14
+ * not in the fence info string. The shared renderer's fence regex captures the
15
+ * language as `\w*`, so `\`\`\`excalidraw id=d1` would not match as a fence at
16
+ * all and the block would stop rendering everywhere. Ordinal position was the
17
+ * other option and was rejected: inserting a paragraph above a diagram would
18
+ * silently repoint every id after it.
19
+ */
20
+
21
+ const FENCE_RE = /```(\w*)\n([\s\S]*?)```/g;
22
+ export const DIAGRAM_ID_KEY = 'draftedDiagramId';
23
+
24
+ /** Stable-ish id for a new diagram. Callers pass an existing set to avoid collisions. */
25
+ export function nextDiagramId(taken = new Set()) {
26
+ for (let n = 1; n < 10000; n++) {
27
+ const id = 'd' + n;
28
+ if (!taken.has(id)) return id;
29
+ }
30
+ throw new Error('Too many diagrams on one page');
31
+ }
32
+
33
+ /**
34
+ * Every excalidraw fence in the document, in order.
35
+ * Returns `[{ id, scene, raw, start, end }]`. A block whose body is not valid
36
+ * JSON is still reported (scene null) so a caller can surface it rather than
37
+ * silently skipping a block the reader can see.
38
+ */
39
+ export function findExcalidrawBlocks(md) {
40
+ const text = String(md || '');
41
+ const out = [];
42
+ FENCE_RE.lastIndex = 0;
43
+ let m;
44
+ while ((m = FENCE_RE.exec(text)) !== null) {
45
+ if (m[1] !== 'excalidraw') continue;
46
+ const body = m[2];
47
+ let scene = null;
48
+ try { scene = JSON.parse(body); } catch { /* malformed — reported with scene null */ }
49
+ const id = scene && typeof scene[DIAGRAM_ID_KEY] === 'string' ? scene[DIAGRAM_ID_KEY] : null;
50
+ out.push({ id, scene, raw: body, start: m.index, end: m.index + m[0].length });
51
+ }
52
+ return out;
53
+ }
54
+
55
+ export function getExcalidrawBlock(md, diagramId) {
56
+ return findExcalidrawBlocks(md).find((b) => b.id === diagramId) || null;
57
+ }
58
+
59
+ /** Ids already used on this page — pass to nextDiagramId when inserting. */
60
+ export function usedDiagramIds(md) {
61
+ return new Set(findExcalidrawBlocks(md).map((b) => b.id).filter(Boolean));
62
+ }
63
+
64
+ function serialize(scene, diagramId) {
65
+ // Id first so it survives a human hand-editing the block and is visible at a glance.
66
+ const { [DIAGRAM_ID_KEY]: _drop, ...rest } = scene || {};
67
+ return JSON.stringify({ [DIAGRAM_ID_KEY]: diagramId, ...rest }, null, 2);
68
+ }
69
+
70
+ /**
71
+ * Replace one diagram's scene, leaving every other byte of the page untouched.
72
+ * Throws when the id is absent — a save must never silently no-op or append a
73
+ * duplicate block the user did not ask for.
74
+ */
75
+ export function replaceExcalidrawBlock(md, diagramId, scene) {
76
+ const text = String(md || '');
77
+ const block = getExcalidrawBlock(text, diagramId);
78
+ if (!block) throw new Error(`No excalidraw block with id "${diagramId}" on this page`);
79
+ const fence = '```excalidraw\n' + serialize(scene, diagramId) + '\n```';
80
+ return text.slice(0, block.start) + fence + text.slice(block.end);
81
+ }
82
+
83
+ /** Append a new diagram block and return `{ content, id }`. */
84
+ export function appendExcalidrawBlock(md, scene) {
85
+ const text = String(md || '');
86
+ const id = nextDiagramId(usedDiagramIds(text));
87
+ const fence = '```excalidraw\n' + serialize(scene, id) + '\n```';
88
+ const sep = text && !text.endsWith('\n\n') ? (text.endsWith('\n') ? '\n' : '\n\n') : '';
89
+ return { content: text + sep + fence + '\n', id };
90
+ }