drafted 1.2.1 → 1.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/mcp/server.mjs +421 -33
  2. package/package.json +2 -1
package/mcp/server.mjs CHANGED
@@ -30,7 +30,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
30
30
  // never see each other's session or active project.
31
31
 
32
32
  const requestState = new AsyncLocalStorage();
33
- const standaloneState = { sessionId: null, projectId: null };
33
+ const standaloneState = { sessionId: null, projectId: null, projectMeta: null };
34
34
 
35
35
  function getState() {
36
36
  return requestState.getStore() || standaloneState;
@@ -111,6 +111,7 @@ const TOOL_ANNOTATIONS = {
111
111
 
112
112
  // Skills
113
113
  skill: { title: 'Skills', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Manage the Drafted skill library: search, load, add, update, remove, attach/detach from projects, favorite, and edit skill files.' },
114
+ wiki: { title: 'Wiki', readOnlyHint: false, destructiveHint: true, openWorldHint: false, description: 'Per-org wiki. Markdown pages with paths as hierarchy. Dispatch by `action`.' },
114
115
  };
115
116
 
116
117
  function tool(name, descOrSchema, schemaOrHandler, handler) {
@@ -328,7 +329,7 @@ function ok(text, opts) {
328
329
  // Build the structuredContent shape that the frame-preview widget reads.
329
330
  // Tools that produce or return a frame (read/write/edit) call this so the
330
331
  // model and widget see the same metadata view.
331
- function frameStructuredContent(result) {
332
+ function frameStructuredContent(result, project = null) {
332
333
  return {
333
334
  id: result.id,
334
335
  path: result.path || (result.layer && result.lane && result.label
@@ -341,6 +342,7 @@ function frameStructuredContent(result) {
341
342
  width: result.width,
342
343
  height: result.height,
343
344
  frameUrl: result.id ? `${getServerUrl()}/f/${result.id}` : undefined,
345
+ project, // {id, slug, name, orgId} of the active project — visible in client UI
344
346
  };
345
347
  }
346
348
 
@@ -371,8 +373,19 @@ function withProjectBreadcrumb(result) {
371
373
  let agentWs = null;
372
374
  let agentWsReconnectTimer = null;
373
375
 
374
- function setMcpActiveProject(projectId) {
375
- getState().projectId = projectId;
376
+ function setMcpActiveProject(projectId, meta = null) {
377
+ const s = getState();
378
+ s.projectId = projectId;
379
+ s.projectMeta = meta; // { id, slug, name, orgId } — for echo on mutation responses
380
+ }
381
+
382
+ // Returns { id, slug, name, orgId } for the project this MCP session most
383
+ // recently opened — what frame mutations will actually target. Echoed on
384
+ // every mutation so silent cross-project drift is visible.
385
+ function getCurrentProjectContext() {
386
+ const s = getState();
387
+ if (!s.projectId) return null;
388
+ return s.projectMeta || { id: s.projectId, slug: null, name: null, orgId: null };
376
389
  }
377
390
 
378
391
  async function connectAgentWs() {
@@ -502,19 +515,108 @@ function invalidateProjectSkillsCache(projectId) {
502
515
  if (projectId) projectSkillsCache.delete(projectId);
503
516
  }
504
517
 
505
- async function checkProjectSkills(projectId) {
518
+ async function checkProjectSkills(projectId, operation) {
506
519
  if (!projectId) return null;
507
520
  const skills = await getProjectSkills(projectId);
508
521
  if (skills.length === 0) return null;
509
522
  const unloaded = skills.filter(s => !loadedSkillIds.has(s.id));
510
523
  if (unloaded.length === 0) return null;
511
524
 
525
+ // Fire-and-forget: tell the server so any open browser shows a toast.
526
+ api('POST', `/api/projects/${projectId}/skill-gate-events`, {
527
+ unloadedSkills: unloaded.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
528
+ operation: operation || 'mutation',
529
+ agent: process.env.DRAFTED_AGENT_NAME || null,
530
+ }).catch(() => { /* non-fatal */ });
531
+
512
532
  const lines = unloaded.map(s => ` skill(action="load", skill="${s.slug || s.id}") -- ${s.name}`);
513
533
  return `This project has ${skills.length} attached skill(s) that must be loaded before making changes. ` +
514
534
  `Unloaded skills:\n${lines.join('\n')}\n\n` +
515
535
  `Load all attached skills first, then retry your operation. Skills tell you HOW to do the work — they're not optional.`;
516
536
  }
517
537
 
538
+ // ── Org skill enforcement ────────────────────────────────────────
539
+ // Same shape as project skill enforcement but for org-attached skills.
540
+ // Wiki mutations require org skills to be loaded first.
541
+
542
+ let cachedOrgId = null;
543
+ let cachedOrgIdTime = 0;
544
+
545
+ async function getCurrentOrgId() {
546
+ const ctx = await getCurrentOrgContext();
547
+ return ctx?.id || null;
548
+ }
549
+
550
+ // Returns { id, name } for the org bound to THIS MCP session. Each session is
551
+ // independent on purpose — parallel agents can run on different orgs.
552
+ async function getCurrentOrgContext() {
553
+ if (cachedOrgId && Date.now() - cachedOrgIdTime < 30000) return cachedOrgId;
554
+ try {
555
+ const data = await api('GET', '/auth/me');
556
+ if (!data?.orgId) return null;
557
+ cachedOrgId = { id: data.orgId, name: data.currentOrg?.name || null };
558
+ cachedOrgIdTime = Date.now();
559
+ return cachedOrgId;
560
+ } catch { return null; }
561
+ }
562
+
563
+ async function getOrgSkills(orgId) {
564
+ if (!orgId) return [];
565
+ // No cache: gate freshness > the ~5ms HTTP roundtrip. Otherwise an attach
566
+ // performed mid-session won't take effect until the cached [] expires.
567
+ try {
568
+ const result = await api('GET', `/api/orgs/${orgId}/skills`);
569
+ return Array.isArray(result?.skills) ? result.skills : [];
570
+ } catch { return []; }
571
+ }
572
+
573
+ async function checkOrgSkills(orgId, operation) {
574
+ if (!orgId) return null;
575
+ const skills = await getOrgSkills(orgId);
576
+ if (skills.length === 0) return null;
577
+ const unloaded = skills.filter(s => !loadedSkillIds.has(s.id));
578
+ if (unloaded.length === 0) return null;
579
+ const lines = unloaded.map(s => ` skill(action="load", skill="${s.slug || s.id}") -- ${s.name}`);
580
+ return `This org has ${skills.length} attached skill(s) that must be loaded before making changes. ` +
581
+ `Unloaded skills:\n${lines.join('\n')}\n\n` +
582
+ `Load all attached skills first, then retry your operation. Skills tell you HOW to do the work — they're not optional.`;
583
+ }
584
+
585
+ // ── Wiki helpers ─────────────────────────────────────────────────
586
+
587
+ function normalizeWikiPath(input) {
588
+ if (!input) return input;
589
+ return input.replace(/^\/+/, '').replace(/\.md$/, '');
590
+ }
591
+
592
+ function applyHashlineOps(content, operations) {
593
+ let lines = content.split('\n');
594
+ const lineToHash = {};
595
+ for (let i = 0; i < lines.length; i++) {
596
+ lineToHash[i] = createHash('sha256').update(lines[i] || '').digest('hex').slice(0, 4);
597
+ }
598
+ const sorted = [...operations].reverse();
599
+ for (const op of sorted) {
600
+ const matches = Object.entries(lineToHash).filter(([, h]) => h === op.lineHash).map(([idx]) => parseInt(idx));
601
+ if (matches.length === 0) throw new Error(`Line with hash "${op.lineHash}" not found`);
602
+ if (matches.length > 1) throw new Error(`Ambiguous hash "${op.lineHash}" matches ${matches.length} lines`);
603
+ const idx = matches[0];
604
+ if (op.type === 'replace') { lines[idx] = op.newContent; }
605
+ else if (op.type === 'delete') { lines.splice(idx, 1); }
606
+ else if (op.type === 'insertAfter') { lines.splice(idx + 1, 0, op.newContent); }
607
+ else if (op.type === 'insertBefore') { lines.splice(idx, 0, op.newContent); }
608
+ }
609
+ return lines.join('\n');
610
+ }
611
+
612
+ async function getTreeAsMap() {
613
+ const tree = await api('GET', '/api/wiki/tree');
614
+ const pages = tree.pages || [];
615
+ const pathToPage = {};
616
+ for (const p of pages) { pathToPage[p.path] = p; }
617
+ return { pages, pathToPage };
618
+ }
619
+
518
620
  // ── CLI passthrough (for login/start/stop only) ───────────────────
519
621
 
520
622
  function runCLI(command, args = [], options = {}) {
@@ -686,15 +788,19 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
686
788
  const { projectId, skipBrowser } = args;
687
789
  if (!projectId) throw new Error('projectId required for action=open');
688
790
  const result = await api('POST', '/api/project/switch', { projectId });
689
- setMcpActiveProject(projectId);
690
791
  joinAgentWsRoom(projectId);
691
792
  const base = getServerUrl();
692
793
  let projectSlug = projectId;
794
+ let projectMeta = { id: projectId, slug: null, name: null, orgId: null };
693
795
  try {
694
796
  const data = await api('GET', '/api/projects');
695
797
  const proj = (data.projects || []).find(p => p.id === projectId);
696
- if (proj?.slug) projectSlug = proj.slug;
798
+ if (proj) {
799
+ projectSlug = proj.slug || projectId;
800
+ projectMeta = { id: proj.id, slug: proj.slug || null, name: proj.name || null, orgId: proj.orgId || null };
801
+ }
697
802
  } catch { /* fall back to projectId */ }
803
+ setMcpActiveProject(projectId, projectMeta);
698
804
  const url = `${base}/project/${projectSlug}`;
699
805
  let navigated = 0;
700
806
  if (!skipBrowser) {
@@ -1000,28 +1106,28 @@ tool('layer', 'Manage layers in a project. Dispatch by `action`: add/update/remo
1000
1106
 
1001
1107
  tool('get_org', {}, async () => {
1002
1108
  try {
1109
+ // Source of truth = THIS MCP session's bound org (what mutations will actually
1110
+ // hit). Each session is independent on purpose — multiple agents can run in
1111
+ // parallel against different orgs. /auth/me reads sessions.org_id directly.
1112
+ const me = await api('GET', '/auth/me');
1113
+ const sessionOrgId = me?.orgId || null;
1114
+
1003
1115
  const data = await api('GET', '/api/orgs');
1004
- const orgs = data.orgs || data || [];
1005
- // Get members for the active org
1006
- const auth = JSON.parse(readFileSync(AUTH_FILE, 'utf8'));
1116
+ const orgs = (data.orgs || data || []).map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name }));
1117
+ const activeOrg = sessionOrgId ? (orgs.find(o => o.id === sessionOrgId) || null) : null;
1118
+
1007
1119
  let members = [];
1008
- let activeOrg = null;
1009
- if (auth.sessionId) {
1010
- // Find the active org from the session
1011
- const projectData = await api('GET', '/api/projects');
1012
- const orgId = projectData.projects?.[0]?.orgId;
1013
- if (orgId) {
1014
- activeOrg = orgs.find(o => (o.orgId || o.id) === orgId);
1015
- try {
1016
- const memberData = await api('GET', `/api/orgs/${orgId}/members`);
1017
- members = memberData.members || memberData || [];
1018
- } catch { /* no members */ }
1019
- }
1120
+ if (sessionOrgId) {
1121
+ try {
1122
+ const memberData = await api('GET', `/api/orgs/${sessionOrgId}/members`);
1123
+ members = memberData.members || memberData || [];
1124
+ } catch { /* no members */ }
1020
1125
  }
1021
1126
  return ok({
1022
- activeOrg: activeOrg ? { id: activeOrg.orgId || activeOrg.id, name: activeOrg.orgName || activeOrg.name } : null,
1023
- orgs: orgs.map(o => ({ id: o.orgId || o.id, name: o.orgName || o.name })),
1127
+ activeOrg,
1128
+ orgs,
1024
1129
  members: members.map(m => ({ id: m.userId, name: m.username, email: m.email, role: m.role })),
1130
+ note: "activeOrg is the org bound to THIS MCP session — what mutations will actually target. Browser tabs and other MCP sessions for the same user can be on different orgs.",
1025
1131
  });
1026
1132
  } catch (error) { return err(error); }
1027
1133
  });
@@ -1052,6 +1158,10 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1052
1158
  }, async (args) => {
1053
1159
  try {
1054
1160
  const { action } = args;
1161
+ // Echo project context on every mutation so the agent sees where the
1162
+ // write landed before it can develop a wrong assumption.
1163
+ const projectCtx = getCurrentProjectContext();
1164
+ const withProject = (result) => ({ ...result, project: projectCtx });
1055
1165
  switch (action) {
1056
1166
  case 'read': {
1057
1167
  const { path, lines } = args;
@@ -1075,7 +1185,7 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1075
1185
  // structuredContent over text when both are present and structured looks
1076
1186
  // "complete" — so include content in BOTH places to ensure the agent
1077
1187
  // can always see the frame's actual content, not just metadata.
1078
- const structured = frameStructuredContent(result);
1188
+ const structured = frameStructuredContent(result, projectCtx);
1079
1189
  structured.content = result.content ?? '';
1080
1190
  structured.size = result.size;
1081
1191
  structured.totalLines = result.totalLines;
@@ -1124,8 +1234,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1124
1234
  if (height) body.height = height;
1125
1235
  if (color) body.color = color;
1126
1236
  const result = await api('PUT', `/api/fs/${parts[0]}/${parts[1]}/${parts[2]}`, body);
1127
- return ok(withFrameBreadcrumb(result, { hint: true }), {
1128
- structuredContent: frameStructuredContent(result),
1237
+ return ok(withProject(withFrameBreadcrumb(result, { hint: true })), {
1238
+ structuredContent: frameStructuredContent(result, projectCtx),
1129
1239
  _meta: body.content ? { frameHtml: body.content } : undefined,
1130
1240
  });
1131
1241
  }
@@ -1138,8 +1248,8 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1138
1248
  const anchorErr = await checkAnchors(parseLayer(path));
1139
1249
  if (anchorErr) return err(new Error(anchorErr));
1140
1250
  const result = await api('POST', '/api/fs/edit', { path, operations });
1141
- return ok(result, {
1142
- structuredContent: frameStructuredContent(result),
1251
+ return ok(withProject(result), {
1252
+ structuredContent: frameStructuredContent(result, projectCtx),
1143
1253
  _meta: result.content ? { frameHtml: result.content } : undefined,
1144
1254
  });
1145
1255
  }
@@ -1152,7 +1262,7 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1152
1262
  if (fromErr) return err(new Error(fromErr));
1153
1263
  const toErr = await checkAnchors(parseLayer(to));
1154
1264
  if (toErr) return err(new Error(toErr));
1155
- return ok(await api('POST', '/api/fs/mv', { from, to }));
1265
+ return ok(withProject(await api('POST', '/api/fs/mv', { from, to })));
1156
1266
  }
1157
1267
  case 'anchor': {
1158
1268
  const { path, anchored } = args;
@@ -1160,7 +1270,7 @@ tool('frame', 'Frame CRUD in the ACTIVE PROJECT. Dispatch by `action`: read (by
1160
1270
  if (typeof anchored !== 'boolean') throw new Error('anchored (boolean) required for action=anchor');
1161
1271
  const skillErr = await checkProjectSkills(getState().projectId);
1162
1272
  if (skillErr) return err(new Error(skillErr));
1163
- return ok(await api('POST', '/api/fs/anchor', { path, anchored }));
1273
+ return ok(withProject(await api('POST', '/api/fs/anchor', { path, anchored })));
1164
1274
  }
1165
1275
  case 'search': {
1166
1276
  const { query, projectId, limit = 50 } = args;
@@ -1248,7 +1358,7 @@ tool('ls', 'List contents of the ACTIVE PROJECT. Use ls / after project(action="
1248
1358
  } catch (error) { return err(error); }
1249
1359
  });
1250
1360
 
1251
- tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response includes "project" field.', {
1361
+ tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response includes "project" field so you see where the deletion landed.', {
1252
1362
  path: z.string().describe('Path to delete: /{layer}/{lane}/{filename} or /{layer}/{lane} (deletes entire lane).'),
1253
1363
  }, async ({ path }) => {
1254
1364
  try {
@@ -1258,7 +1368,7 @@ tool('rm', 'Delete a frame or lane from the ACTIVE PROJECT. Response includes "p
1258
1368
  if (anchorErr) return err(new Error(anchorErr));
1259
1369
  const clean = path.replace(/^\/+|\/+$/g, '');
1260
1370
  const result = await api('DELETE', `/api/fs/${clean}`);
1261
- return ok(result);
1371
+ return ok({ ...result, project: getCurrentProjectContext() });
1262
1372
  } catch (error) { return err(error); }
1263
1373
  });
1264
1374
 
@@ -1657,6 +1767,284 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
1657
1767
  } catch (error) { return err(error); }
1658
1768
  });
1659
1769
 
1770
+ // ── Wiki tool (org-scoped, no project needed) ─────────────────────
1771
+ // All 11 actions dispatch from one tool. Read-only actions skip the
1772
+ // skill gate; mutations require org-level wiki-maintainer skills loaded.
1773
+
1774
+ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and other agents/humans share maintenance — every edit broadcasts live, and edits from others appear in `recent` and on `read`.\n\nBefore mutating: check `recent` and `search` for relevant existing pages. Before mv/rm: check `links` (or pass `dryRun=true`). After completing a logical session of work, append a `log` entry.\n\nThe tool handles bookkeeping you\'d otherwise forget: `mv` rewrites inbound references via the link index, `read` shows who edited last and when. Use `health` to find unlinked pages and broken links.\n\n**Skill gate:** the org may attach a `wiki-maintainer` skill that you MUST load before mutations. If you get a skill-gate error, run skill(action="load", skill="wiki-maintainer") then retry.', {
1775
+ action: z.enum(['ls', 'recent', 'read', 'search', 'links', 'log', 'health', 'write', 'edit', 'mv', 'rm']).describe('Operation to perform.'),
1776
+ path: z.string().optional().describe('[ls|read|links] wiki path. For ls: default / (root). For read: required. For links: required.'),
1777
+ recursive: z.boolean().optional().describe('[ls] list recursively with depth indicators'),
1778
+ limit: z.number().optional().describe('[recent|search] max results (recent default 10, search default 25)'),
1779
+ query: z.string().optional().describe('[search] term to search in title, path, and content'),
1780
+ lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
1781
+ message: z.string().optional().describe('[log] message to append to log.md page'),
1782
+ title: z.string().optional().describe('[write] page title (required for write)'),
1783
+ content: z.string().optional().describe('[write|edit] page content (write: full content; edit: hashline content not used — use operations)'),
1784
+ type: z.string().optional().describe('[write] page type (default "page")'),
1785
+ frontmatter: z.any().optional().describe('[write] frontmatter object'),
1786
+ operations: z.array(z.object({
1787
+ type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
1788
+ lineHash: z.string().describe('4-char hash of the target line (from read output)'),
1789
+ newContent: z.string().optional().describe('New content (for replace, insertAfter, insertBefore)'),
1790
+ })).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
1791
+ from: z.string().optional().describe('[mv] source path'),
1792
+ to: z.string().optional().describe('[mv] destination path'),
1793
+ dryRun: z.boolean().optional().describe('[mv|rm] preview impact without applying changes'),
1794
+ }, async (args) => {
1795
+ try {
1796
+ const { action } = args;
1797
+
1798
+ // Resolve org context once for the wiki tool. Each MCP session is bound
1799
+ // to one org (parallel agents on different orgs is supported by design).
1800
+ const orgCtx = await getCurrentOrgContext();
1801
+ const orgId = orgCtx?.id || null;
1802
+ // Mutation responses include `org` so the agent always sees where the
1803
+ // write landed — eliminates silent cross-org confusion.
1804
+ const withOrg = (result) => ({ ...result, org: orgCtx });
1805
+
1806
+ // ── Skill gate: all mutation actions ──────────────────────────
1807
+ const MUTATING = new Set(['write', 'edit', 'mv', 'rm', 'log']);
1808
+ if (MUTATING.has(action)) {
1809
+ const skillErr = await checkOrgSkills(orgId, action);
1810
+ if (skillErr) return err(new Error(skillErr));
1811
+ }
1812
+
1813
+ switch (action) {
1814
+
1815
+ // ── ls ──────────────────────────────────────────────────────
1816
+ case 'ls': {
1817
+ const { path: lsPath = '/', recursive: lsRecursive = false } = args;
1818
+ const { pages, pathToPage } = await getTreeAsMap();
1819
+ const parent = normalizeWikiPath(lsPath);
1820
+
1821
+ if (lsRecursive) {
1822
+ // Full tree under path with depth
1823
+ const prefix = parent ? parent + '/' : '';
1824
+ const filtered = !parent ? pages : pages.filter(p => p.path === parent || p.path.startsWith(prefix));
1825
+ const tree = filtered.map(p => ({
1826
+ depth: !parent ? p.path.split('/').length - 1 : p.path.split('/').length - parent.split('/').length - (p.path === parent ? 1 : 0),
1827
+ path: p.path,
1828
+ title: p.title,
1829
+ type: p.type,
1830
+ id: p.id,
1831
+ }));
1832
+ return ok({ tree });
1833
+ }
1834
+
1835
+ // Non-recursive: children of parent path
1836
+ if (!parent) {
1837
+ // Root: show top-level directories + root pages
1838
+ const dirs = new Set();
1839
+ const rootPages = [];
1840
+ for (const p of pages) {
1841
+ if (!p.path.includes('/')) { rootPages.push(p); }
1842
+ else { dirs.add(p.path.split('/')[0]); }
1843
+ }
1844
+ return ok({
1845
+ tree: [
1846
+ ...Array.from(dirs).sort().map(d => ({ type: 'directory', name: d })),
1847
+ ...rootPages.map(p => ({ type: 'page', path: p.path, title: p.title, id: p.id })),
1848
+ ],
1849
+ });
1850
+ }
1851
+
1852
+ // Has parent: show immediate children
1853
+ const children = [];
1854
+ const seenDirs = new Set();
1855
+ const prefix = parent + '/';
1856
+ for (const p of pages) {
1857
+ if (p.path === parent) {
1858
+ children.push({ type: 'page', path: p.path, title: p.title, id: p.id });
1859
+ } else if (p.path.startsWith(prefix)) {
1860
+ const rest = p.path.slice(prefix.length);
1861
+ if (rest.includes('/')) {
1862
+ const sub = rest.split('/')[0];
1863
+ if (!seenDirs.has(sub)) { seenDirs.add(sub); children.push({ type: 'directory', name: parent + '/' + sub }); }
1864
+ } else {
1865
+ children.push({ type: 'page', path: p.path, title: p.title, id: p.id });
1866
+ }
1867
+ }
1868
+ }
1869
+ return ok({ tree: children });
1870
+ }
1871
+
1872
+ // ── recent ──────────────────────────────────────────────────
1873
+ case 'recent': {
1874
+ const { limit: recentLimit = 10 } = args;
1875
+ const tree = await api('GET', '/api/wiki/tree');
1876
+ const recent = (tree.pages || [])
1877
+ .filter(p => p.updatedAt)
1878
+ .sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt))
1879
+ .slice(0, Math.max(1, recentLimit))
1880
+ .map(p => ({ path: p.path, title: p.title, updatedAt: p.updatedAt }));
1881
+ return ok({ pages: recent });
1882
+ }
1883
+
1884
+ // ── read ────────────────────────────────────────────────────
1885
+ case 'read': {
1886
+ const { path: readPath, lines: readLines } = args;
1887
+ if (!readPath) throw new Error('path required for action=read');
1888
+ const normalized = normalizeWikiPath(readPath);
1889
+ const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
1890
+ // Client-side line slice — server returns full content; we trim if `lines` was passed.
1891
+ let outContent = page.content;
1892
+ if (readLines && typeof outContent === 'string') {
1893
+ const m = readLines.match(/^(\d+)-(\d+)$/);
1894
+ if (!m) throw new Error(`lines must be "N-M" (e.g. "10-50"), got: ${readLines}`);
1895
+ const [start, end] = [parseInt(m[1]), parseInt(m[2])];
1896
+ const allLines = outContent.split('\n');
1897
+ outContent = allLines.slice(Math.max(0, start - 1), end).join('\n');
1898
+ }
1899
+ // Get backlink count via search (approximate)
1900
+ let backlinkCount = 0;
1901
+ try {
1902
+ const searchRes = await api('GET', `/api/wiki/search?q=${encodeURIComponent(normalized)}`);
1903
+ backlinkCount = (searchRes.hits || []).length;
1904
+ } catch { /* best-effort */ }
1905
+ return ok({
1906
+ path: page.path,
1907
+ title: page.title,
1908
+ type: page.type,
1909
+ frontmatter: page.frontmatter,
1910
+ content: outContent,
1911
+ lastEditedBy: page.updatedBy,
1912
+ lastEditedAt: page.updatedAt,
1913
+ backlinkCount,
1914
+ });
1915
+ }
1916
+
1917
+ // ── search ──────────────────────────────────────────────────
1918
+ case 'search': {
1919
+ const { query: searchQuery, limit: searchLimit = 25 } = args;
1920
+ if (!searchQuery) throw new Error('query required for action=search');
1921
+ const result = await api('GET', `/api/wiki/search?q=${encodeURIComponent(searchQuery)}&limit=${searchLimit}`);
1922
+ const hits = (result.hits || []).map(h => ({ path: h.path, title: h.title }));
1923
+ return ok({ hits });
1924
+ }
1925
+
1926
+ // ── links ───────────────────────────────────────────────────
1927
+ case 'links': {
1928
+ const { path: linksPath } = args;
1929
+ if (!linksPath) throw new Error('path required for action=links');
1930
+ const normalized = normalizeWikiPath(linksPath);
1931
+ const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
1932
+ return ok(await api('GET', `/api/wiki/pages/${page.id}/links`));
1933
+ }
1934
+
1935
+ // ── log ─────────────────────────────────────────────────────
1936
+ case 'log': {
1937
+ const { message: logMessage } = args;
1938
+ if (!logMessage) throw new Error('message required for action=log');
1939
+ const agentName = process.env.DRAFTED_AGENT_NAME || 'mcp';
1940
+ const dateStr = new Date().toISOString().replace('T', ' ').slice(0, 19) + 'Z';
1941
+ const entry = `## ${dateStr} note by ${agentName} | ${logMessage}`;
1942
+
1943
+ // Try to read existing log page
1944
+ let existingContent = '';
1945
+ let existingId = null;
1946
+ try {
1947
+ const logPage = await api('GET', '/api/wiki/page?path=log');
1948
+ existingContent = logPage.content || '';
1949
+ existingId = logPage.id;
1950
+ } catch {
1951
+ // Create new log page
1952
+ const created = await api('POST', '/api/wiki/pages', {
1953
+ path: 'log',
1954
+ title: 'Log',
1955
+ content: entry + '\n',
1956
+ });
1957
+ return ok(withOrg({ appended: true, created: true, pageId: created.id }));
1958
+ }
1959
+
1960
+ // Append to existing log
1961
+ const updatedContent = (existingContent.endsWith('\n') ? existingContent : existingContent + '\n') + entry + '\n';
1962
+ await api('PATCH', `/api/wiki/pages/${existingId}`, { content: updatedContent });
1963
+ return ok(withOrg({ appended: true }));
1964
+ }
1965
+
1966
+ // ── health ──────────────────────────────────────────────────
1967
+ case 'health': {
1968
+ return ok(await api('GET', '/api/wiki/health'));
1969
+ }
1970
+
1971
+ // ── write ───────────────────────────────────────────────────
1972
+ case 'write': {
1973
+ const { path: writePath, title: writeTitle, content: writeContent, type: writeType, frontmatter } = args;
1974
+ if (!writePath) throw new Error('path required for action=write');
1975
+ if (!writeTitle) throw new Error('title required for action=write');
1976
+ const normalized = normalizeWikiPath(writePath);
1977
+ const body = { path: normalized, title: writeTitle };
1978
+ if (writeContent !== undefined) body.content = writeContent;
1979
+ if (writeType) body.type = writeType;
1980
+ if (frontmatter !== undefined) body.frontmatter = frontmatter;
1981
+
1982
+ // Check if page exists — if so, update; otherwise create
1983
+ try {
1984
+ const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
1985
+ const result = await api('PATCH', `/api/wiki/pages/${existing.id}`, body);
1986
+ return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true }));
1987
+ } catch {
1988
+ const result = await api('POST', '/api/wiki/pages', body);
1989
+ return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true }));
1990
+ }
1991
+ }
1992
+
1993
+ // ── edit ────────────────────────────────────────────────────
1994
+ case 'edit': {
1995
+ const { path: editPath, operations: editOps } = args;
1996
+ if (!editPath) throw new Error('path required for action=edit');
1997
+ if (!Array.isArray(editOps) || editOps.length === 0) throw new Error('operations (array) required for action=edit');
1998
+ const normalized = normalizeWikiPath(editPath);
1999
+ const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
2000
+ const newContent = applyHashlineOps(page.content || '', editOps);
2001
+ const result = await api('PATCH', `/api/wiki/pages/${page.id}`, { content: newContent });
2002
+ return ok(withOrg({ path: result.path, id: result.id, updated: true }));
2003
+ }
2004
+
2005
+ // ── mv ──────────────────────────────────────────────────────
2006
+ case 'mv': {
2007
+ const { from: mvFrom, to: mvTo, dryRun: mvDryRun = false } = args;
2008
+ if (!mvFrom) throw new Error('from (source path) required for action=mv');
2009
+ if (!mvTo) throw new Error('to (destination path) required for action=mv');
2010
+ const fromPath = normalizeWikiPath(mvFrom);
2011
+ const toPath = normalizeWikiPath(mvTo);
2012
+ if (fromPath === toPath) throw new Error('source and destination are the same');
2013
+
2014
+ const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(fromPath)}`);
2015
+
2016
+ if (mvDryRun) {
2017
+ const { referrers } = await api('GET', `/api/wiki/pages/${page.id}/referrers`);
2018
+ return ok(withOrg({ impacted: referrers.map(r => ({ path: r.path, title: r.title })) }));
2019
+ }
2020
+
2021
+ // Server-side cascade: /move rewrites referrers in one transaction
2022
+ const moved = await api('PATCH', `/api/wiki/pages/${page.id}/move`, { path: toPath });
2023
+ return ok(withOrg({ path: moved.path, title: moved.title, id: moved.id, referrersUpdated: moved.referrersUpdated ?? 0 }));
2024
+ }
2025
+
2026
+ // ── rm ──────────────────────────────────────────────────────
2027
+ case 'rm': {
2028
+ const { path: rmPath, dryRun: rmDryRun = false } = args;
2029
+ if (!rmPath) throw new Error('path required for action=rm');
2030
+ const normalized = normalizeWikiPath(rmPath);
2031
+ const page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`);
2032
+ const { referrers } = await api('GET', `/api/wiki/pages/${page.id}/referrers`);
2033
+ const broken = referrers.map(r => ({ path: r.path, title: r.title }));
2034
+
2035
+ if (rmDryRun) {
2036
+ return ok(withOrg({ brokenAfterDelete: broken }));
2037
+ }
2038
+ await api('DELETE', `/api/wiki/pages/${page.id}`);
2039
+ return ok(withOrg({ deleted: true, path: normalized, id: page.id, brokenReferences: broken }));
2040
+ }
2041
+
2042
+ default:
2043
+ throw new Error(`Unknown wiki action: ${action}`);
2044
+ }
2045
+ } catch (error) { return err(error); }
2046
+ });
2047
+
1660
2048
  // ── Resource: canvas info ─────────────────────────────────────────
1661
2049
 
1662
2050
  server.resource('info', 'drafted://info', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "Drafted CLI - Design preview server for Claude agents",
5
5
  "type": "module",
6
6
  "files": [
@@ -40,6 +40,7 @@
40
40
  "dotenv": "^17.3.1",
41
41
  "drizzle-orm": "^0.45.1",
42
42
  "express": "^4.18.2",
43
+ "mdast-util-from-markdown": "^2.0.3",
43
44
  "multer": "^2.1.1",
44
45
  "nodemailer": "^8.0.2",
45
46
  "pg": "^8.20.0",