drafted 1.12.8 → 1.14.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/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # Drafted
2
+
3
+ Multi-tenant shared surface for AI-human collaboration. Drafted organizes agent-produced work into **projects** (frames on a zoomable real-time surface), **skills** (reusable operating procedures agents load), and a **wiki** (durable org knowledge) — with MCP tools for agents and a browser UI for humans.
4
+
5
+ - Product vision and positioning: [PRODUCT.md](PRODUCT.md)
6
+ - Architecture, development, and operations: [AGENTS.md](AGENTS.md)
7
+ - Live: [drafted.live](https://drafted.live)
8
+
9
+ ## OKF-native
10
+
11
+ Drafted is a native producer and consumer of the **Open Knowledge Format (OKF) v0.1** ([GoogleCloudPlatform/knowledge-catalog](https://github.com/GoogleCloudPlatform/knowledge-catalog)):
12
+
13
+ - **The org wiki is an OKF v0.1 bundle.** Every page carries conformant YAML frontmatter (required `type`, one-line `description`, `tags`, synthesized `timestamp`; unknown keys preserved). `index.md` at every level is synthesized, `log.md` keeps the date-grouped change history, links resolve with or without `.md`, and broken links are legal. The wiki UI shows a live OKF conformance badge.
14
+ - **Bundle exchange everywhere.** Export/import the wiki (`GET /api/wiki/export.tar.gz`, `POST /api/wiki/import`), the skill library (`skills/<slug>/SKILL.md` layout), and whole projects (`<layer>/<lane>/<file>.md` concepts; markdown links round-trip as connectors) — via HTTP or the MCP `wiki`, `skill`, and `project` tools, with dry-run reports.
15
+
16
+ ## Quick start
17
+
18
+ ```bash
19
+ npm install && npm run dev # Docker services + schema + server with hot reload
20
+ ```
21
+
22
+ - App: http://localhost:3477
23
+ - Email inbox (magic links): http://localhost:8025
24
+ - MinIO console: http://localhost:9001
25
+
26
+ Sign in with any email and grab the magic link from Mailpit.
package/cli/drafted.mjs CHANGED
@@ -1440,6 +1440,18 @@ function synthesizeSkillMd(skill) {
1440
1440
  lines.push('setup:');
1441
1441
  for (const s of setup) lines.push(` - ${JSON.stringify(s)}`);
1442
1442
  }
1443
+ // OKF v0.1 concept keys — byte-identical to the server authority
1444
+ // (server/lib/skill-hash.mjs synthesizeSkillMd); keep in lockstep so a
1445
+ // locally materialized bundle matches the server-side hash input.
1446
+ lines.push('type: Skill');
1447
+ lines.push(`title: ${JSON.stringify(skill.name || skill.slug || '')}`);
1448
+ const skillTags = Array.isArray(skill.tags) ? skill.tags.filter(Boolean) : [];
1449
+ if (skillTags.length) {
1450
+ lines.push('tags:');
1451
+ for (const t of skillTags) lines.push(` - ${JSON.stringify(t)}`);
1452
+ }
1453
+ const ts = skill.updatedAt ? new Date(skill.updatedAt) : null;
1454
+ if (ts && !Number.isNaN(ts.getTime())) lines.push(`timestamp: ${JSON.stringify(ts.toISOString())}`);
1443
1455
  lines.push('---', '');
1444
1456
  return lines.join('\n') + (skill.content || '');
1445
1457
  }
package/install-mcp.sh CHANGED
@@ -227,7 +227,17 @@ if [ -n "$STALE_DRAFTED_BIN" ]; then
227
227
  fi
228
228
  fi
229
229
 
230
- npm config set prefix "$NPM_GLOBAL_PREFIX" >/dev/null
230
+ # Do NOT `npm config set prefix` that repoints the user's GLOBAL npm prefix, so every
231
+ # `npm install -g <pkg>` they ever run lands in our dir AND our uninstall (`rm -rf ~/.drafted`)
232
+ # would wipe all their other globals. Install drafted with an explicit per-command --prefix
233
+ # instead (below), and HEAL any global prefix pin an older version of this installer wrote so
234
+ # the user's default is restored. Preserve every other ~/.npmrc line (auth tokens, etc.).
235
+ NPMRC="${npm_config_userconfig:-$HOME/.npmrc}"
236
+ if [ -f "$NPMRC" ] && grep -Eq '^[[:space:]]*prefix[[:space:]]*=.*/\.drafted/npm-global/?[[:space:]]*$' "$NPMRC"; then
237
+ tmp_npmrc="$(mktemp)"
238
+ grep -Ev '^[[:space:]]*prefix[[:space:]]*=.*/\.drafted/npm-global/?[[:space:]]*$' "$NPMRC" > "$tmp_npmrc" && cat "$tmp_npmrc" > "$NPMRC"
239
+ rm -f "$tmp_npmrc"
240
+ fi
231
241
  export PATH="$NPM_GLOBAL_PREFIX/bin:$PATH"
232
242
 
233
243
  # Persist the prefix's bin dir at the FRONT of PATH for future shells — without
@@ -262,7 +272,7 @@ step "Installing Drafted"
262
272
  install_drafted_pkg() {
263
273
  attempts=5; delay=4; n=1
264
274
  while :; do
265
- if npm install -g drafted@latest --force; then return 0; fi
275
+ if npm install -g drafted@latest --force --prefix "$NPM_GLOBAL_PREFIX"; then return 0; fi
266
276
  if [ "$n" -ge "$attempts" ]; then return 1; fi
267
277
  echo -e " ${YELLOW}npm install failed (attempt $n/$attempts) — retrying in ${delay}s (a new release may still be propagating to the npm CDN)...${RESET}"
268
278
  sleep "$delay"
@@ -274,7 +284,7 @@ if ! install_drafted_pkg; then
274
284
  exit 1
275
285
  fi
276
286
  hash -r 2>/dev/null || true
277
- NPM_ROOT="$(npm root -g 2>/dev/null || true)"
287
+ NPM_ROOT="$(npm root -g --prefix "$NPM_GLOBAL_PREFIX" 2>/dev/null || true)"
278
288
  MCP_SERVER_MODULE="$NPM_ROOT/drafted/mcp/server.mjs"
279
289
  if [ -n "$NPM_ROOT" ] && [ -f "$MCP_SERVER_MODULE" ]; then
280
290
  node -e "import('node:url').then(({ pathToFileURL }) => import(pathToFileURL(process.argv[1]).href)).then(() => process.exit(0), (err) => { console.error(err); process.exit(1); })" "$MCP_SERVER_MODULE"
@@ -1288,7 +1298,7 @@ echo ""
1288
1298
  echo -e " ${DIM}MCP name:${RESET} ${BOLD}$INSTALL_NAME${RESET}"
1289
1299
  echo -e " ${DIM}Server:${RESET} ${BOLD}$INSTALL_SERVER${RESET}"
1290
1300
  echo -e " ${DIM}To update production:${RESET} rerun curl -fsSL https://drafted.live/install.sh | bash"
1291
- echo -e " ${DIM}To uninstall:${RESET} npm uninstall -g drafted && rm -rf ~/.drafted"
1301
+ echo -e " ${DIM}To uninstall:${RESET} npm uninstall -g drafted --prefix ~/.drafted/npm-global && rm -rf ~/.drafted"
1292
1302
  echo ""
1293
1303
  echo -e "${YELLOW}${BOLD}"
1294
1304
  echo " ┌─────────────────────────────────────────────────────────┐"
package/mcp/server.mjs CHANGED
@@ -20,6 +20,7 @@ import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/e
20
20
  import WebSocket from 'ws';
21
21
  import { LAYERS } from '../src/shared/constants.mjs';
22
22
  import { emptyExcalidrawScene, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
23
+ import { formatOkfLogEntry, appendOkfLogEntry } from '../src/shared/okf-log.mjs';
23
24
  import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
24
25
  import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
25
26
 
@@ -1461,10 +1462,85 @@ async function consumePendingDeviceCode() {
1461
1462
  // meaningless and disruptive — it returns spurious sign-in URLs and, on login,
1462
1463
  // spawns a server-side browser-open and blocks polling until timeout. Register
1463
1464
  // it only on stdio.
1464
- if (!isRemote) tool('auth', 'Sign in to Drafted. `action=get_link` returns a verification URL immediately (use for SSH/headless/tmux where a browser may not open) and starts background polling; after the user opens the link, later Drafted tool calls also auto-consume the approved login. `action=login` opens a browser when needed and explicitly waits/polls for approval. If get_link was called first, login reuses that pending code instead of opening a new browser.', {
1465
+ // --- Local-install auth surface: the desktop app, never a device link ---
1466
+ // The stdio installer ALWAYS installs the Drafted desktop app alongside the MCP, so on a
1467
+ // local install the app IS the sign-in surface. Spawning it hands off to the always-running
1468
+ // instance (single-instance plugin), which opens the sign-in window and starts the native
1469
+ // cookie->auth.json capture that this MCP reads via getBootstrapSessionId(). Only the web MCP
1470
+ // is app-less, and that path authenticates via OAuth2 in the browser — never this tool. The
1471
+ // device-code flow below is kept ONLY as a fallback for platforms without the desktop app.
1472
+ function desktopAppBinary() {
1473
+ try {
1474
+ if (process.platform === 'darwin') {
1475
+ const p = '/Applications/Drafted.app/Contents/MacOS/drafted-desktop';
1476
+ return existsSync(p) ? p : null;
1477
+ }
1478
+ if (process.platform === 'win32') {
1479
+ const p = join(process.env.LOCALAPPDATA || '', 'Programs', 'Drafted', 'Drafted.exe');
1480
+ return existsSync(p) ? p : null;
1481
+ }
1482
+ } catch { /* fall through to no-app */ }
1483
+ return null; // e.g. Linux — no desktop app → device-code fallback
1484
+ }
1485
+
1486
+ async function launchDesktopSignin() {
1487
+ const bin = desktopAppBinary();
1488
+ if (!bin) return false;
1489
+ try {
1490
+ const { spawn } = await import('child_process');
1491
+ // If the app is already running (macOS KeepAlive normally guarantees it), the single-instance
1492
+ // handler opens the sign-in window and DRAFTED_OPEN_LOGIN is ignored. If it isn't running,
1493
+ // the fresh primary instance honors DRAFTED_OPEN_LOGIN=1 and opens sign-in on boot.
1494
+ const child = spawn(bin, [], {
1495
+ detached: true,
1496
+ stdio: 'ignore',
1497
+ env: { ...process.env, DRAFTED_OPEN_LOGIN: '1' },
1498
+ });
1499
+ child.unref();
1500
+ return true;
1501
+ } catch { return false; }
1502
+ }
1503
+
1504
+ // Poll auth.json (written by the desktop app's cookie->auth.json bridge on in-app sign-in)
1505
+ // until a valid session id lands or the deadline passes.
1506
+ async function waitForBootstrapAuth(deadline) {
1507
+ while (Date.now() < deadline) {
1508
+ const sid = getBootstrapSessionId();
1509
+ if (sid) return sid;
1510
+ await new Promise(r => setTimeout(r, 1500));
1511
+ }
1512
+ return null;
1513
+ }
1514
+
1515
+ if (!isRemote) tool('auth', 'Sign in to Drafted. On a local install the DESKTOP APP is the sign-in surface: both actions open the Drafted app to its sign-in window — no device link is shown. `action=login` opens the app and waits for the in-app sign-in to complete; `action=get_link` opens the app and returns immediately (the next Drafted tool call picks up the captured session). A device-code link is used ONLY as a fallback on platforms without the desktop app (the web MCP uses OAuth2, not this tool).', {
1465
1516
  action: z.enum(['get_link', 'login']).describe('Operation to perform.'),
1466
1517
  }, async ({ action }) => {
1467
1518
  try {
1519
+ // Local install → open the desktop app's sign-in window (no link). Falls through to the
1520
+ // device-code flow below only when no desktop app is installed on this platform.
1521
+ {
1522
+ const existing = getState().sessionId || getBootstrapSessionId();
1523
+ if (existing) {
1524
+ try {
1525
+ const meRes = await serverFetch(`${getServerUrl()}/auth/me`, { headers: { Cookie: `gc_session=${existing}` } });
1526
+ if (meRes.ok) {
1527
+ const me = await meRes.json();
1528
+ return ok({ status: 'already_authenticated', userId: me.userId, email: me.userEmail, org: me.currentOrg?.name });
1529
+ }
1530
+ } catch { /* stale session — continue to sign-in */ }
1531
+ }
1532
+ if (await launchDesktopSignin()) {
1533
+ if (action === 'get_link') {
1534
+ return ok('Opening the Drafted app to sign in — approve in the app window, then retry your request.');
1535
+ }
1536
+ const sid = await waitForBootstrapAuth(Date.now() + 180000);
1537
+ if (!sid) throw new Error('Timed out waiting for sign-in. Complete sign-in in the Drafted app window, then retry.');
1538
+ getState().sessionId = null;
1539
+ await cloneSession();
1540
+ connectAgentWs();
1541
+ return ok({ status: 'logged_in', via: 'desktop-app' });
1542
+ }
1543
+ }
1468
1544
  if (action === 'get_link') {
1469
1545
  const codeRes = await serverFetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
1470
1546
  if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
@@ -1619,9 +1695,9 @@ tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human
1619
1695
 
1620
1696
  // ── Project management tools (direct HTTP) ────────────────────────
1621
1697
 
1622
- tool('project', 'START HERE for project management. Dispatch by `action`: list (lists all projects across all orgs — always call first), open (bind this agent session to a project; required before reading/writing frames — the org derives from the project), create (new project; org= names where it is born), update (change name/folder/description/layers), move (transfer to another org). There is no org switching: for project-less work (wiki/skills) pass org=... on the call. **Skill gate:** projects with attached skills will REJECT all mutations (write, edit, mv, rm, shape, group, connector, layout, layer, asset upload) until you have loaded each attached skill via skill(action="load"). Skills tell you HOW to do the work — they\'re not optional. Open returns the attached skill list and auto-inlines content for projects with ≤3 skills.', {
1623
- action: z.enum(['list', 'open', 'create', 'update', 'move']).describe('Operation to perform.'),
1624
- projectId: z.string().optional().describe('[open|update|move] project ID. Get IDs from action=list.'),
1698
+ tool('project', 'START HERE for project management. Dispatch by `action`: list (lists all projects across all orgs — always call first), open (bind this agent session to a project; required before reading/writing frames — the org derives from the project), create (new project; org= names where it is born), update (change name/folder/description/layers), move (transfer to another org), export (the project as an OKF v0.1 bundle — <layer>/<lane>/<file>.md concepts, index.md/log.md synthesized), import (ingest an OKF bundle: concepts become markdown document frames, links between them become connectors; dryRun supported). There is no org switching: for project-less work (wiki/skills) pass org=... on the call. **Skill gate:** projects with attached skills will REJECT all mutations (write, edit, mv, rm, shape, group, connector, layout, layer, asset upload) until you have loaded each attached skill via skill(action="load"). Skills tell you HOW to do the work — they\'re not optional. Open returns the attached skill list and auto-inlines content for projects with ≤3 skills.', {
1699
+ action: z.enum(['list', 'open', 'create', 'update', 'move', 'export', 'import']).describe('Operation to perform.'),
1700
+ projectId: z.string().optional().describe('[open|update|move|export|import] project ID. Get IDs from action=list. For export/import: defaults to the bound project.'),
1625
1701
  name: z.string().optional().describe('[create|update] project name'),
1626
1702
  description: z.string().nullable().optional().describe('[create|update] project description'),
1627
1703
  templateSlug: z.string().optional().describe('[create] template slug (e.g. "web-design", "mobile-app", "landing-page")'),
@@ -1630,6 +1706,16 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
1630
1706
  layers: z.array(z.object({}).passthrough()).optional().describe('[update] full layers array replacement. Use ls / to read current layers first.'),
1631
1707
  targetOrgId: z.string().optional().describe('[move] destination organization ID. Get org IDs from action=list (each project has an orgId field) or get_org. Both source and target org must include the current user.'),
1632
1708
  skipBrowser: z.boolean().optional().describe('[open] skip opening/navigating a browser tab (use when the user already has the project open, e.g. from an invite snippet)'),
1709
+ format: z.string().optional().describe('[export] "files" returns {files:[{path,content}]} paginated via limit/offset (compact=true for paths only) instead of writing a local dir (stdio) or returning a download URL (remote).'),
1710
+ limit: z.number().optional().describe('[export] max files per page for format="files" (default 100, max 500)'),
1711
+ offset: z.number().optional().describe('[export] pagination offset for format="files"'),
1712
+ compact: z.boolean().optional().describe('[export] with format="files": return file paths only (no content)'),
1713
+ files: z.array(z.object({
1714
+ path: z.string().describe('Bundle-relative file path, e.g. "research/default/notes.md"'),
1715
+ content: z.string().describe('File content (markdown, optional YAML frontmatter)'),
1716
+ })).optional().describe('[import] OKF bundle files inline. index.md/log.md are skipped (synthesized). Caps: 500 files, 512KB/file, 5MB total.'),
1717
+ dryRun: z.boolean().optional().describe('[import] preview the {creates, updates, skips, warnings} report without writing'),
1718
+ ...(isRemote ? {} : { dir: z.string().optional().describe('[export|import] local directory. export: write the bundle files here (default ./okf-project-<slug>). import: recursively read .md files from here (alternative to files[]).') }),
1633
1719
  }, async (args) => {
1634
1720
  try {
1635
1721
  const { action } = args;
@@ -1774,6 +1860,80 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
1774
1860
  if (!projectId || !targetOrgId) throw new Error('projectId and targetOrgId required for action=move');
1775
1861
  return ok(await api('POST', `/api/project/${projectId}/move`, { targetOrgId }));
1776
1862
  }
1863
+
1864
+ // ── export ──────────────────────────────────────────────────
1865
+ // The project as an OKF v0.1 bundle. Mirrors wiki export: format="files"
1866
+ // pages the bundle inline; otherwise stdio writes a local dir, remote
1867
+ // returns the authenticated tar.gz download URL.
1868
+ case 'export': {
1869
+ const projectId = args.projectId || getState().projectId;
1870
+ if (!projectId) throw new Error('projectId required for action=export (or open a project first)');
1871
+ if (args.format === 'files') {
1872
+ const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
1873
+ if (args.offset) qp.set('offset', String(args.offset));
1874
+ if (args.compact) qp.set('compact', 'true');
1875
+ return ok(await api('GET', `/api/projects/${projectId}/export?${qp.toString()}`));
1876
+ }
1877
+ if (isRemote) {
1878
+ return ok({
1879
+ downloadUrl: `${getServerUrl()}/api/projects/${projectId}/export.tar.gz`,
1880
+ note: 'Open the URL in a signed-in browser to download the OKF v0.1 project bundle, or call export with format="files" to page the bundle contents inline.',
1881
+ });
1882
+ }
1883
+ // stdio: write every bundle file under a local directory.
1884
+ const meta = getCurrentProjectContext();
1885
+ const projSlug = String((meta && meta.id === projectId && (meta.slug || meta.name)) || projectId)
1886
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'project';
1887
+ const exportDir = resolve(args.dir || `./okf-project-${projSlug}`);
1888
+ let expOffset = 0;
1889
+ let written = 0;
1890
+ for (;;) {
1891
+ const batch = await api('GET', `/api/projects/${projectId}/export?limit=200&offset=${expOffset}`);
1892
+ const batchFiles = batch.files || [];
1893
+ for (const f of batchFiles) {
1894
+ const dest = resolve(exportDir, f.path);
1895
+ if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
1896
+ mkdirSync(dirname(dest), { recursive: true });
1897
+ writeFileSync(dest, f.content, 'utf8');
1898
+ written++;
1899
+ }
1900
+ expOffset += batchFiles.length;
1901
+ if (!batch.truncated || batchFiles.length === 0) break;
1902
+ }
1903
+ return ok({ exported: written, dir: exportDir, note: 'OKF v0.1 project bundle written (<layer>/<lane>/<file>.md concepts; index.md and log.md are synthesized).' });
1904
+ }
1905
+
1906
+ // ── import ──────────────────────────────────────────────────
1907
+ // Ingest an OKF bundle into the project: inline files[] or (stdio) a
1908
+ // local dir walked for .md files. Concepts become markdown document
1909
+ // frames; markdown links between them become connectors.
1910
+ case 'import': {
1911
+ const projectId = args.projectId || getState().projectId;
1912
+ if (!projectId) throw new Error('projectId required for action=import (or open a project first)');
1913
+ let importFiles = args.files;
1914
+ if (!importFiles && args.dir) {
1915
+ const root = resolve(args.dir);
1916
+ if (!existsSync(root)) throw new Error(`dir not found: ${args.dir}`);
1917
+ importFiles = [];
1918
+ const walk = (d) => {
1919
+ for (const ent of readdirSync(d, { withFileTypes: true })) {
1920
+ if (ent.name.startsWith('.')) continue;
1921
+ const p = join(d, ent.name);
1922
+ if (ent.isDirectory()) walk(p);
1923
+ else if (/\.md$/i.test(ent.name)) {
1924
+ if (importFiles.length >= 500) throw new Error('import capped at 500 files — split the bundle');
1925
+ importFiles.push({ path: p.slice(root.length + 1).replace(/\\/g, '/'), content: readFileSync(p, 'utf8') });
1926
+ }
1927
+ }
1928
+ };
1929
+ walk(root);
1930
+ }
1931
+ if (!Array.isArray(importFiles) || importFiles.length === 0) {
1932
+ throw new Error('import requires files[] (or dir on stdio) with at least one .md file');
1933
+ }
1934
+ return ok(await api('POST', `/api/projects/${projectId}/import`, { files: importFiles, dryRun: !!args.dryRun }));
1935
+ }
1936
+
1777
1937
  default:
1778
1938
  throw new Error(`Unknown project action: ${action}`);
1779
1939
  }
@@ -3113,7 +3273,7 @@ function ensureSkillInstallIgnored(dir) {
3113
3273
  } catch { return false; }
3114
3274
  }
3115
3275
 
3116
- tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/guidelines agents can load and follow. When you build a reusable skill, author it HERE — add for prose, push to ingest a local source tree — so its method and setup: recipe live in Drafted and any machine or agent can reuse it; keep only machine-specific build output local in .skillinstall/ (always stripped on push). Dispatch by `action`: search/load/list for discovery; history for a skill\'s version git-log; add/update/remove for org skills; fork/push for source-only skills; attach/detach for project binding; favorite/unfavorite for personal pins; read_file/update_file for supporting files inside a skill directory.', {
3276
+ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/guidelines agents can load and follow. When you build a reusable skill, author it HERE — add for prose, push to ingest a local source tree — so its method and setup: recipe live in Drafted and any machine or agent can reuse it; keep only machine-specific build output local in .skillinstall/ (always stripped on push). Dispatch by `action`: search/load/list for discovery; history for a skill\'s version git-log; add/update/remove for org skills; fork/push for source-only skills; attach/detach for project binding; favorite/unfavorite for personal pins; read_file/update_file for supporting files inside a skill directory; export/import to exchange the library as an OKF v0.1 bundle (skills/<slug>/SKILL.md layout).', {
3117
3277
  action: z.enum([
3118
3278
  'search', 'load', 'list', 'history',
3119
3279
  'add', 'update', 'remove',
@@ -3121,12 +3281,13 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3121
3281
  'attach', 'detach',
3122
3282
  'favorite', 'unfavorite',
3123
3283
  'read_file', 'update_file',
3124
- ]).describe('Operation to perform.'),
3284
+ 'export', 'import',
3285
+ ]).describe('Operation to perform. export: the skill library as an OKF v0.1 bundle (local dir on stdio, download URL on remote, or format="files" for paged inline files). import: ingest the SKILL.md-shaped concepts of an OKF bundle as org skills (files[] or local dir; dryRun supported).'),
3125
3286
  query: z.string().optional().describe('[search] term to match against name/description/content'),
3126
3287
  tags: z.array(z.string()).optional().describe('[search] filter by tags; [add|update] tag list'),
3127
3288
  scope: z.enum(['all', 'org', 'global']).optional().describe('[search|list] library scope (default: all for search; when provided to list, lists the library instead of project/org attachments)'),
3128
- limit: z.number().optional().describe('[search|list] max results per page (default 25, max 100)'),
3129
- compact: z.boolean().optional().describe('[search|list] return only {slug,name,tags} per skill instead of full summaries — for browsing large catalogs within token budget'),
3289
+ limit: z.number().optional().describe('[search|list] max results per page (default 25, max 100); [export] files per page for format="files" (default 100, max 500)'),
3290
+ compact: z.boolean().optional().describe('[search|list] return only {slug,name,tags} per skill instead of full summaries — for browsing large catalogs within token budget; [export] with format="files": file paths only (no content)'),
3130
3291
  skill: z.string().optional().describe('[load|history] skill ID (UUID) or slug'),
3131
3292
  version: z.number().optional().describe('[history] fetch this version number\'s full snapshot (content included); omit for the reverse-chron version list'),
3132
3293
  skillId: z.string().optional().describe('[update|remove|attach|detach|favorite|unfavorite|read_file|update_file] skill ID'),
@@ -3136,13 +3297,16 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3136
3297
  content: z.string().optional().describe('[add|update] root SKILL.md content; [update_file] file content'),
3137
3298
  triggerPatterns: z.array(z.string()).optional().describe('[add|update] patterns that suggest this skill'),
3138
3299
  path: z.string().optional().describe('[read_file|update_file] relative path inside skill directory (e.g. "examples/react.md")'),
3139
- offset: z.number().optional().describe('[search|list] skip N results for pagination; [read_file] start reading at this byte offset (default 0) — for large files (e.g. a >90KB app-frame bundle) read in chunks using the returned nextOffset until truncated=false'),
3300
+ offset: z.number().optional().describe('[search|list|export] skip N results for pagination; [read_file] start reading at this byte offset (default 0) — for large files (e.g. a >90KB app-frame bundle) read in chunks using the returned nextOffset until truncated=false'),
3140
3301
  maxBytes: z.number().optional().describe('[read_file] return at most this many bytes from offset (default: whole remaining file). Response reports totalSize/offset/truncated/nextOffset.'),
3141
- org: z.string().optional().describe('[add] org (id or name) the skill is born in — defaults to the open project\'s org; [list|search|load] scope to this org; [fork|push|update] resolve/fork into this org. Per-request only — nothing is switched.'),
3302
+ org: z.string().optional().describe('[add] org (id or name) the skill is born in — defaults to the open project\'s org; [list|search|load] scope to this org; [fork|push|update|export|import] resolve/fork into this org. Per-request only — nothing is switched.'),
3142
3303
  setup: z.array(z.string()).optional().describe('[add|update] setup command(s) (in order) run on materialize to build a source-only skill, e.g. ["npm ci","npm run build"]'),
3143
- files: z.array(z.object({ path: z.string(), content: z.string() })).optional().describe('[push] source files to push (path + UTF-8 content); server strips artifacts + enforces caps'),
3144
- dir: z.string().optional().describe('[push] local directory to push instead of files[]; walked locally (heavy dirs, .skillinstall/, and .skillignore pre-filtered), server re-enforces. On push the dir\'s .gitignore is auto-updated to exclude .skillinstall/ (the rebuildable bundle).'),
3304
+ files: z.array(z.object({ path: z.string(), content: z.string() })).optional().describe('[push] source files to push (path + UTF-8 content); server strips artifacts + enforces caps. [import] OKF bundle files inline — skills/<slug>/SKILL.md dirs and Skill/Playbook/SOP/Procedure-typed .md with name + description frontmatter become org skills. Caps: 500 files, 512KB/file, 5MB total.'),
3305
+ dir: z.string().optional().describe('[push|export|import] local directory. push: source tree to push instead of files[]; walked locally (heavy dirs, .skillinstall/, and .skillignore pre-filtered), server re-enforces; the dir\'s .gitignore is auto-updated to exclude .skillinstall/ (the rebuildable bundle). export: write the OKF bundle files here (default ./okf-skills-<org>). import: read the bundle from here (alternative to files[]).'),
3145
3306
  deleteMissing: z.boolean().optional().describe('[push] remove stored files not present in the pushed set'),
3307
+ format: z.string().optional().describe('[export] "files" returns {files:[{path,content}]} paginated via limit/offset (compact=true for paths only) instead of writing a local dir (stdio) or returning a download URL (remote).'),
3308
+ includeGlobal: z.boolean().optional().describe('[export] include global built-in skills in the bundle (default: org skills only)'),
3309
+ dryRun: z.boolean().optional().describe('[import] report {created, updated, skips, warnings} without writing'),
3146
3310
  }, async (args) => {
3147
3311
  try {
3148
3312
  const { action } = args;
@@ -3150,7 +3314,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3150
3314
  // bound org (or explicit org=) when the user is multi-org. (DRAFT-36 Phase 4.)
3151
3315
  // UUID-first exception: update/remove addressed by a skillId derive their org
3152
3316
  // from the skill row server-side, so the bound-org gate is unnecessary.
3153
- if (['add', 'update', 'remove', 'push', 'fork'].includes(action)) {
3317
+ if (['add', 'update', 'remove', 'push', 'fork', 'import'].includes(action)) {
3154
3318
  const byId = (action === 'update' || action === 'remove') && /^[a-f0-9-]{36}$/.test(args.skillId || '');
3155
3319
  if (!byId) await requireBoundOrgForProjectlessMutation(args.org);
3156
3320
  }
@@ -3349,6 +3513,64 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3349
3513
  if (!skillId || !path || content == null) throw new Error('skillId, path, content required for action=update_file');
3350
3514
  return ok(await api('PUT', `/api/skills/${skillId}/files/${path}`, { content }));
3351
3515
  }
3516
+
3517
+ // ── export ──────────────────────────────────────────────────
3518
+ // The skill library as an OKF v0.1 bundle (skills/<slug>/SKILL.md +
3519
+ // supporting files + synthesized log.md/index.md). Mirrors wiki export:
3520
+ // format="files" pages the bundle inline; otherwise stdio writes a local
3521
+ // dir, remote returns the authenticated tar.gz download URL.
3522
+ case 'export': {
3523
+ const extra = args.org ? { 'X-Drafted-Org': args.org } : {};
3524
+ const inc = args.includeGlobal ? '&includeGlobal=1' : '';
3525
+ if (args.format === 'files') {
3526
+ const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
3527
+ if (args.offset) qp.set('offset', String(args.offset));
3528
+ if (args.compact) qp.set('compact', 'true');
3529
+ return ok(await api('GET', `/api/skills/export?${qp.toString()}${inc}`, undefined, extra));
3530
+ }
3531
+ if (isRemote) {
3532
+ return ok({
3533
+ downloadUrl: `${getServerUrl()}/api/skills/export.tar.gz${args.includeGlobal ? '?includeGlobal=1' : ''}`,
3534
+ note: 'Open the URL in a signed-in browser to download the OKF v0.1 skill bundle, or call export with format="files" to page the bundle contents inline.',
3535
+ });
3536
+ }
3537
+ // stdio: write every bundle file under a local directory.
3538
+ const skOrgCtx = await getCurrentOrgContext();
3539
+ const skOrgSlug = String(skOrgCtx?.name || skOrgCtx?.id || 'org').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'org';
3540
+ const exportDir = resolve(args.dir || `./okf-skills-${skOrgSlug}`);
3541
+ let expOffset = 0;
3542
+ let written = 0;
3543
+ for (;;) {
3544
+ const batch = await api('GET', `/api/skills/export?limit=200&offset=${expOffset}${inc}`, undefined, extra);
3545
+ const batchFiles = batch.files || [];
3546
+ for (const f of batchFiles) {
3547
+ const dest = resolve(exportDir, f.path);
3548
+ if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
3549
+ mkdirSync(dirname(dest), { recursive: true });
3550
+ writeFileSync(dest, f.content, 'utf8');
3551
+ written++;
3552
+ }
3553
+ expOffset += batchFiles.length;
3554
+ if (!batch.truncated || batchFiles.length === 0) break;
3555
+ }
3556
+ return ok({ exported: written, dir: exportDir, note: 'OKF v0.1 skill bundle written (skills/<slug>/SKILL.md layout; index.md and log.md are synthesized).' });
3557
+ }
3558
+
3559
+ // ── import ──────────────────────────────────────────────────
3560
+ // Ingest the SKILL.md-shaped concepts of an OKF bundle as org skills:
3561
+ // inline files[] or (stdio) a local dir walked with the push filters.
3562
+ // Use wiki(action="import") for a mixed knowledge bundle — it routes
3563
+ // skill concepts here and everything else into the wiki.
3564
+ case 'import': {
3565
+ const extra = args.org ? { 'X-Drafted-Org': args.org } : {};
3566
+ let importFiles = args.files;
3567
+ if (!importFiles && args.dir) importFiles = collectSkillTreeForPush(args.dir);
3568
+ if (!Array.isArray(importFiles) || importFiles.length === 0) {
3569
+ throw new Error('import requires files[] (or dir on stdio) with at least one file');
3570
+ }
3571
+ return ok(await api('POST', '/api/skills/import', { files: importFiles, dryRun: !!args.dryRun }, extra));
3572
+ }
3573
+
3352
3574
  default:
3353
3575
  throw new Error(`Unknown skill action: ${action}`);
3354
3576
  }
@@ -3359,20 +3581,32 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3359
3581
  // All 11 actions dispatch from one tool. Read-only actions skip the
3360
3582
  // skill gate; mutations require org-level wiki-maintainer skills loaded.
3361
3583
 
3362
- 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\n**Addressing:** a `pageId` (UUID) self-derives its org — no org arg needed. Path-based and listing actions scope to the open project\'s org by default; pass `org=...` to target another org (there is no org switching). `search` spans ALL your orgs by default — don\'t assume "no hits" means the content doesn\'t exist.\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.', {
3363
- action: z.enum(['ls', 'recent', 'read', 'search', 'links', 'log', 'health', 'write', 'edit', 'mv', 'rm', 'source-register', 'source-list', 'source-get', 'bulk-write']).describe('Operation to perform.'),
3364
- path: z.string().optional().describe('[ls|read|links] wiki path. For ls: default / (root). For read: required. For links: required.'),
3584
+ 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\n**Addressing:** a `pageId` (UUID) self-derives its org — no org arg needed. Path-based and listing actions scope to the open project\'s org by default; pass `org=...` to target another org (there is no org switching). `search` spans ALL your orgs by default — don\'t assume "no hits" means the content doesn\'t exist.\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. Reference sources with `cite` (appends a numbered entry to the page\'s `# Citations` section).\n\nThe wiki is an OKF v0.1 bundle (Open Knowledge Format): every page carries frontmatter with a `type` (default "Page"), a one-line `description` is recommended, links may use `/a/b.md` or extensionless `a/b` form, and `index.md` at any level is synthesized — read it for a directory listing, never write it. Exchange whole bundles with `export` (conformant tar.gz / local dir / paged files) and `import` (files[] or local dir, dryRun supported).\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.', {
3585
+ action: z.enum(['ls', 'recent', 'read', 'search', 'links', 'log', 'health', 'write', 'edit', 'mv', 'rm', 'cite', 'source-register', 'source-list', 'source-get', 'bulk-write', 'export', 'import']).describe('Operation to perform. export: the whole wiki as an OKF v0.1 bundle (local dir on stdio, download URL on remote, or format="files" for paged inline files). import: ingest an OKF bundle (files[] or local dir; dryRun supported).'),
3586
+ path: z.string().optional().describe('[ls|read|links|cite] wiki path. For ls: default / (root). For read: required. For links/cite: required unless pageId given. Reading `index.md` (any level) returns the SYNTHESIZED OKF directory listing.'),
3365
3587
  pageId: z.string().optional().describe('[read|edit|mv|rm|links] page UUID (from read/search). UUID-first: addresses the page directly, org auto-derives — no org needed and no path lookup. Preferred over path for an existing page.'),
3366
3588
  org: z.string().optional().describe('Org slug or id to scope this call to (per-request only — nothing is switched). [write] the org the page is created in — required when you belong to several orgs and no project is open. [search] restrict to this org (default: ALL your orgs). [ls|recent|read|links|log|health|edit|mv|rm|bulk-write] target this org\'s wiki instead of the open project\'s org. Ignored when a pageId is given (the page self-derives its org).'),
3367
3589
  recursive: z.boolean().optional().describe('[ls] list recursively with depth indicators'),
3368
- limit: z.number().optional().describe('[recent|search] max results (recent default 10, search default 25)'),
3590
+ limit: z.number().optional().describe('[recent|search|export] max results (recent default 10, search default 25, export files default 100)'),
3591
+ offset: z.number().optional().describe('[export] pagination offset for format="files"'),
3592
+ compact: z.boolean().optional().describe('[export] with format="files": return file paths only (no content)'),
3593
+ format: z.string().optional().describe('[export] "files" returns {files:[{path,content}]} paginated via limit/offset (compact=true for paths only) instead of writing a local dir (stdio) or returning a download URL (remote).'),
3594
+ files: z.array(z.object({
3595
+ path: z.string().describe('Bundle-relative file path, e.g. "concepts/frames.md"'),
3596
+ content: z.string().describe('File content (markdown, optional YAML frontmatter)'),
3597
+ })).optional().describe('[import] OKF bundle files inline. index.md files are skipped (synthesized), bundle-root log.md merges into the wiki log page. Caps: 500 files, 512KB/file, 5MB total.'),
3598
+ ...(isRemote ? {} : { dir: z.string().optional().describe('[export|import] local directory. export: write the bundle files here (default ./okf-export-<org>). import: recursively read .md files from here (alternative to files[]).') }),
3369
3599
  query: z.string().optional().describe('[search] term to search in title, path, and content'),
3370
3600
  lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
3371
- message: z.string().optional().describe('[log] message to append to log.md page'),
3601
+ message: z.string().optional().describe('[log] message to append to the log page (OKF date-grouped format)'),
3602
+ verb: z.string().optional().describe('[log] leading bold verb for the entry: Update (default), Creation, Deprecation, or Initialization.'),
3372
3603
  title: z.string().optional().describe('[write] page title (required for write)'),
3373
3604
  content: z.string().optional().describe('[write|edit] page content (write: full content; edit: hashline content not used — use operations)'),
3374
- type: z.string().optional().describe('[write] page type (default "page")'),
3375
- frontmatter: z.any().optional().describe('[write] frontmatter object'),
3605
+ type: z.string().optional().describe('[write] OKF concept type — free-form string, e.g. "Page", "Reference", "Playbook", "Decision", "Metric". Defaults to "Page" on create; must stay non-empty on update (OKF v0.1 requires a type on every page).'),
3606
+ raw: z.boolean().optional().describe('[read] return content bytes-as-stored (skip the synthesized OKF frontmatter block). Do NOT build edit operations from a raw read — hashline anchors for edit must come from a normal (non-raw) read.'),
3607
+ url: z.string().optional().describe('[cite] citation URL (required for cite)'),
3608
+ label: z.string().optional().describe('[cite] link label for the citation (defaults to the URL)'),
3609
+ frontmatter: z.any().optional().describe('[write] frontmatter object. Recommended OKF keys: a one-line "description" (index listings and consumers read it), "tags" (list), "resource" (canonical URI of the underlying asset). Unknown keys are preserved; never set "timestamp" (synthesized from the last update).'),
3376
3610
  operations: z.array(z.object({
3377
3611
  type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
3378
3612
  lineHash: z.string().describe('The full line anchor copied verbatim from read output — line number + 3-char hash, e.g. "182vix" (the token left of the "|"). NOT the bare hash.'),
@@ -3380,10 +3614,10 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3380
3614
  })).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
3381
3615
  from: z.string().optional().describe('[mv] source path'),
3382
3616
  to: z.string().optional().describe('[mv] destination path'),
3383
- dryRun: z.boolean().optional().describe('[mv|rm] preview impact without applying changes'),
3617
+ dryRun: z.boolean().optional().describe('[mv|rm|import] preview impact without applying changes (import: returns the {creates, updates, skips, warnings} report without writing)'),
3384
3618
  file_path: z.string().optional().describe('[source-register] absolute path to a local file. Server hashes it and registers the source. stdio MCP only.'),
3385
- contentHash: z.string().optional().describe('[source-register|source-list] hex-encoded SHA-256 (64 chars). Use when the client already hashed the bytes (HTTP MCP).'),
3386
- filename: z.string().optional().describe('[source-register] original filename for the source (informational)'),
3619
+ contentHash: z.string().optional().describe('[source-register|source-list|cite] hex-encoded SHA-256 (64 chars). Use when the client already hashed the bytes (HTTP MCP). For cite: also registers the cited source.'),
3620
+ filename: z.string().optional().describe('[source-register|cite] original filename for the source (informational)'),
3387
3621
  contentType: z.string().optional().describe('[source-register] MIME type (informational)'),
3388
3622
  size: z.number().optional().describe('[source-register] byte size (informational)'),
3389
3623
  sourceId: z.string().optional().describe('[source-get] source ID returned from source-register'),
@@ -3398,7 +3632,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3398
3632
  // Otherwise (path-addressed, multi-org, nothing bound) the gate still
3399
3633
  // refuses to guess the org so a write never silently lands in the wrong one.
3400
3634
  const orgHeader = args.org ? { 'X-Drafted-Org': args.org } : {};
3401
- if (['write', 'edit', 'mv', 'rm', 'bulk-write'].includes(action)) {
3635
+ if (['write', 'edit', 'mv', 'rm', 'bulk-write', 'cite', 'import'].includes(action)) {
3402
3636
  if (!args.pageId) await requireBoundOrgForProjectlessMutation(args.org);
3403
3637
  }
3404
3638
 
@@ -3427,7 +3661,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3427
3661
  // Ensure the wiki-maintainer skill is attached to this org BEFORE the
3428
3662
  // gate check, so the gate fires reliably on the very first wiki call —
3429
3663
  // not just after the org has visited /wiki in a browser. Idempotent.
3430
- const MUTATING = new Set(['write', 'edit', 'mv', 'rm', 'log', 'source-register', 'bulk-write']);
3664
+ const MUTATING = new Set(['write', 'edit', 'mv', 'rm', 'log', 'cite', 'source-register', 'bulk-write', 'import']);
3431
3665
  if (MUTATING.has(action)) {
3432
3666
  try { await api('POST', '/api/wiki/_ensure-skill'); } catch { /* non-fatal */ }
3433
3667
  const skillErr = await checkOrgSkills(orgId, action);
@@ -3510,19 +3744,21 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3510
3744
  // Returns content in hashline format (`LINE+ID|content`) so the
3511
3745
  // agent can produce hashline edit operations. Mirrors frame.read.
3512
3746
  case 'read': {
3513
- const { path: readPath, pageId: readPageId, lines: readLines } = args;
3747
+ const { path: readPath, pageId: readPageId, lines: readLines, raw: readRaw } = args;
3514
3748
  if (readLines && !/^\d+-\d+$/.test(readLines)) throw new Error(`lines must be "N-M" (e.g. "10-50"), got: ${readLines}`);
3515
3749
  let page;
3516
3750
  if (readPageId) {
3517
3751
  // UUID-first: address the page directly, org auto-derives server-side.
3518
3752
  const params = new URLSearchParams({ format: 'hashline' });
3519
3753
  if (readLines) params.set('lines', readLines);
3754
+ if (readRaw) params.set('raw', 'true');
3520
3755
  page = await api('GET', `/api/wiki/pages/${readPageId}?${params.toString()}`);
3521
3756
  } else {
3522
3757
  if (!readPath) throw new Error('path or pageId required for action=read');
3523
3758
  const normalized = normalizeWikiPath(readPath);
3524
3759
  const params = new URLSearchParams({ path: normalized, format: 'hashline' });
3525
3760
  if (readLines) params.set('lines', readLines);
3761
+ if (readRaw) params.set('raw', 'true');
3526
3762
  page = await api('GET', `/api/wiki/page?${params.toString()}`, undefined, orgHeader);
3527
3763
  }
3528
3764
  // Get backlink count via search (approximate)
@@ -3582,41 +3818,156 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3582
3818
  }
3583
3819
 
3584
3820
  // ── log ─────────────────────────────────────────────────────
3821
+ // OKF log.md format: newest-first `## YYYY-MM-DD` date headings (UTC),
3822
+ // each with `* **Verb**: message (agent, HH:MM UTC)` bullets. Legacy
3823
+ // `## <ISO datetime> ...` headings on existing log pages are left as-is.
3585
3824
  case 'log': {
3586
- const { message: logMessage } = args;
3825
+ const { message: logMessage, verb: logVerb } = args;
3587
3826
  if (!logMessage) throw new Error('message required for action=log');
3588
3827
  const agentName = process.env.DRAFTED_AGENT_NAME || 'mcp';
3589
- const dateStr = new Date().toISOString().replace('T', ' ').slice(0, 19) + 'Z';
3590
- const entry = `## ${dateStr} note by ${agentName} | ${logMessage}`;
3828
+ const now = new Date();
3829
+ const entry = formatOkfLogEntry(logVerb, logMessage, agentName, now);
3830
+ const logTitle = (orgCtx?.name ? orgCtx.name + ' ' : '') + 'Log';
3591
3831
 
3592
- // Try to read existing log page
3593
- let existingContent = '';
3594
- let existingId = null;
3832
+ // Try to read existing log page (raw: bytes-as-stored, no synthesis)
3833
+ let logPage = null;
3595
3834
  try {
3596
- const logPage = await api('GET', '/api/wiki/page?path=log', undefined, orgHeader);
3597
- existingContent = logPage.content || '';
3598
- existingId = logPage.id;
3835
+ logPage = await api('GET', '/api/wiki/page?path=log&raw=true', undefined, orgHeader);
3599
3836
  } catch {
3600
3837
  // Create new log page
3601
3838
  const created = await api('POST', '/api/wiki/pages', {
3602
3839
  path: 'log',
3603
3840
  title: 'Log',
3604
- content: entry + '\n',
3841
+ type: 'Log',
3842
+ content: appendOkfLogEntry('', entry, now, logTitle),
3605
3843
  }, orgHeader);
3606
3844
  return ok(withOrg({ appended: true, created: true, pageId: created.id, path: 'log', url: wikiBrowserUrl('log') }));
3607
3845
  }
3608
3846
 
3609
- // Append to existing log
3610
- const updatedContent = (existingContent.endsWith('\n') ? existingContent : existingContent + '\n') + entry + '\n';
3611
- await api('PATCH', `/api/wiki/pages/${existingId}`, { content: updatedContent });
3847
+ // Append under today's date heading (created at the top if missing)
3848
+ const updatedContent = appendOkfLogEntry(logPage.content || '', entry, now, logTitle);
3849
+ await api('PATCH', `/api/wiki/pages/${logPage.id}`, { content: updatedContent }, orgHeader);
3612
3850
  return ok(withOrg({ appended: true, path: 'log', url: wikiBrowserUrl('log') }));
3613
3851
  }
3614
3852
 
3853
+ // ── cite ────────────────────────────────────────────────────
3854
+ // Append a numbered citation to a page's `# Citations` section
3855
+ // (creating the section if missing), OKF style: `[n] [label](url)`.
3856
+ // Optionally registers a wiki source when contentHash is given.
3857
+ case 'cite': {
3858
+ const { path: citePath, pageId: citePageId, url: citeUrl, label: citeLabel, contentHash: citeHash, filename: citeFilename } = args;
3859
+ if (!citeUrl) throw new Error('url required for action=cite');
3860
+ let page;
3861
+ if (citePageId) {
3862
+ page = await api('GET', `/api/wiki/pages/${citePageId}?raw=true`);
3863
+ } else {
3864
+ if (!citePath) throw new Error('path or pageId required for action=cite');
3865
+ page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalizeWikiPath(citePath))}&raw=true`, undefined, orgHeader);
3866
+ }
3867
+ const lines = (page.content || '').split('\n');
3868
+ let maxN = 0;
3869
+ for (const l of lines) {
3870
+ const m = l.match(/^\[(\d+)\]\s/);
3871
+ if (m) maxN = Math.max(maxN, parseInt(m[1], 10));
3872
+ }
3873
+ const n = maxN + 1;
3874
+ const entry = `[${n}] [${citeLabel || citeUrl}](${citeUrl})`;
3875
+ let content;
3876
+ const hIdx = lines.findIndex((l) => /^#{1,3}\s+Citations\s*$/.test(l));
3877
+ if (hIdx < 0) {
3878
+ const base = (page.content || '').replace(/\s+$/, '');
3879
+ content = (base ? base + '\n\n' : '') + '# Citations\n\n' + entry + '\n';
3880
+ } else {
3881
+ let end = hIdx + 1;
3882
+ while (end < lines.length && !/^#{1,6}\s/.test(lines[end])) end++;
3883
+ let insertAt = end;
3884
+ while (insertAt > hIdx + 1 && lines[insertAt - 1].trim() === '') insertAt--;
3885
+ lines.splice(insertAt, 0, entry);
3886
+ content = lines.join('\n');
3887
+ }
3888
+ await api('PATCH', `/api/wiki/pages/${page.id}`, { content });
3889
+ let source = null;
3890
+ if (citeHash) {
3891
+ try {
3892
+ source = await api('POST', '/api/wiki/sources', { contentHash: citeHash, filename: citeFilename }, orgHeader);
3893
+ } catch { /* source registration is best-effort */ }
3894
+ }
3895
+ return ok(withOrg({ cited: true, n, entry, path: page.path, id: page.id, sourceId: source?.id, url: wikiBrowserUrl(page.path) }));
3896
+ }
3897
+
3615
3898
  // ── health ──────────────────────────────────────────────────
3616
3899
  case 'health': {
3617
3900
  return ok(await api('GET', '/api/wiki/health', undefined, orgHeader));
3618
3901
  }
3619
3902
 
3903
+ // ── export ──────────────────────────────────────────────────
3904
+ // The whole wiki as an OKF v0.1 bundle. format="files" pages the bundle
3905
+ // inline; otherwise stdio writes a local dir, remote returns the
3906
+ // authenticated tar.gz download URL.
3907
+ case 'export': {
3908
+ if (args.format === 'files') {
3909
+ const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
3910
+ if (args.offset) qp.set('offset', String(args.offset));
3911
+ if (args.compact) qp.set('compact', 'true');
3912
+ return ok(withOrg(await api('GET', `/api/wiki/export?${qp.toString()}`, undefined, orgHeader)));
3913
+ }
3914
+ if (isRemote) {
3915
+ return ok(withOrg({
3916
+ downloadUrl: `${getServerUrl()}/api/wiki/export.tar.gz`,
3917
+ note: 'Open the URL in a signed-in browser to download the OKF v0.1 bundle, or call export with format="files" to page the bundle contents inline.',
3918
+ }));
3919
+ }
3920
+ // stdio: write every bundle file under a local directory.
3921
+ const orgSlug = String(orgCtx?.name || orgId || 'org').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'org';
3922
+ const exportDir = resolve(args.dir || `./okf-export-${orgSlug}`);
3923
+ let expOffset = 0;
3924
+ let written = 0;
3925
+ for (;;) {
3926
+ const batch = await api('GET', `/api/wiki/export?limit=200&offset=${expOffset}`, undefined, orgHeader);
3927
+ const batchFiles = batch.files || [];
3928
+ for (const f of batchFiles) {
3929
+ const dest = resolve(exportDir, f.path);
3930
+ if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
3931
+ mkdirSync(dirname(dest), { recursive: true });
3932
+ writeFileSync(dest, f.content, 'utf8');
3933
+ written++;
3934
+ }
3935
+ expOffset += batchFiles.length;
3936
+ if (!batch.truncated || batchFiles.length === 0) break;
3937
+ }
3938
+ return ok(withOrg({ exported: written, dir: exportDir, note: 'OKF v0.1 bundle written. Pages carry synthesized frontmatter; index.md files are synthesized directory listings.' }));
3939
+ }
3940
+
3941
+ // ── import ──────────────────────────────────────────────────
3942
+ // Ingest an OKF bundle: inline files[] or (stdio) a local dir walked
3943
+ // for .md files. index.md skipped, bundle-root log.md merged into the
3944
+ // wiki log page, frontmatter lifted with unknown keys preserved.
3945
+ case 'import': {
3946
+ let importFiles = args.files;
3947
+ if (!importFiles && args.dir) {
3948
+ const root = resolve(args.dir);
3949
+ if (!existsSync(root)) throw new Error(`dir not found: ${args.dir}`);
3950
+ importFiles = [];
3951
+ const walk = (d) => {
3952
+ for (const ent of readdirSync(d, { withFileTypes: true })) {
3953
+ if (ent.name.startsWith('.')) continue;
3954
+ const p = join(d, ent.name);
3955
+ if (ent.isDirectory()) walk(p);
3956
+ else if (/\.md$/i.test(ent.name)) {
3957
+ if (importFiles.length >= 500) throw new Error('import capped at 500 files — split the bundle');
3958
+ importFiles.push({ path: p.slice(root.length + 1).replace(/\\/g, '/'), content: readFileSync(p, 'utf8') });
3959
+ }
3960
+ }
3961
+ };
3962
+ walk(root);
3963
+ }
3964
+ if (!Array.isArray(importFiles) || importFiles.length === 0) {
3965
+ throw new Error('import requires files[] (or dir on stdio) with at least one .md file');
3966
+ }
3967
+ const result = await api('POST', '/api/wiki/import', { files: importFiles, dryRun: !!args.dryRun }, orgHeader);
3968
+ return ok(withOrg(result));
3969
+ }
3970
+
3620
3971
  // ── write ───────────────────────────────────────────────────
3621
3972
  case 'write': {
3622
3973
  const { path: writePath, title: writeTitle, content: writeContent, type: writeType, frontmatter } = args;
@@ -3628,15 +3979,21 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3628
3979
  if (writeType) body.type = writeType;
3629
3980
  if (frontmatter !== undefined) body.frontmatter = frontmatter;
3630
3981
 
3982
+ // Writing the reserved log page directly is allowed (it IS an editable
3983
+ // page) but the `log` action keeps the OKF date-grouped format for you.
3984
+ const logNote = normalized === 'log'
3985
+ ? 'Note: prefer wiki(action="log") for log entries — it maintains the OKF date-grouped format (## YYYY-MM-DD headings, newest first).'
3986
+ : undefined;
3987
+
3631
3988
  // Check if page exists — if so, update; otherwise create. `orgHeader`
3632
3989
  // (the `org` arg) targets a specific org without switching the active org.
3633
3990
  try {
3634
3991
  const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`, undefined, orgHeader);
3635
3992
  const result = await api('PATCH', `/api/wiki/pages/${existing.id}`, body, orgHeader);
3636
- return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path) }));
3993
+ return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path), note: logNote }));
3637
3994
  } catch {
3638
3995
  const result = await api('POST', '/api/wiki/pages', body, orgHeader);
3639
- return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path) }));
3996
+ return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path), note: logNote }));
3640
3997
  }
3641
3998
  }
3642
3999
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.12.8",
3
+ "version": "1.14.0",
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": [
@@ -0,0 +1,62 @@
1
+ /**
2
+ * OKF v0.1 log.md formatting (knowledge-catalog okf/SPEC.md, reserved files):
3
+ * a `# ... Log` title, then newest-first `## YYYY-MM-DD` date headings (ISO
4
+ * date ONLY), each with `* **Verb**: message` bullets.
5
+ *
6
+ * Lives in src/shared (shipped in the npm package) because both the server
7
+ * (server/lib/okf.mjs) and the stdio MCP wiki `log` action need it —
8
+ * server/lib is NOT shipped to npm installs.
9
+ */
10
+
11
+ const OKF_LOG_VERBS = ['Update', 'Creation', 'Deprecation', 'Initialization'];
12
+
13
+ /** Normalize a verb: capitalized, defaulting to 'Update'. Unknown verbs pass
14
+ * through capitalized — the leading bold word is an OKF convention, not an
15
+ * enforced enum. */
16
+ export function okfLogVerb(verb) {
17
+ const v = typeof verb === 'string' ? verb.trim() : '';
18
+ if (!v) return 'Update';
19
+ return v[0].toUpperCase() + v.slice(1);
20
+ }
21
+
22
+ /** One OKF log bullet: `* **Verb**: message (agent, HH:MM UTC)` */
23
+ export function formatOkfLogEntry(verb, message, agent, when = new Date()) {
24
+ const hh = String(when.getUTCHours()).padStart(2, '0');
25
+ const mm = String(when.getUTCMinutes()).padStart(2, '0');
26
+ return `* **${okfLogVerb(verb)}**: ${message} (${agent}, ${hh}:${mm} UTC)`;
27
+ }
28
+
29
+ /**
30
+ * Insert `entryLine` under the `## YYYY-MM-DD` heading for `when` (UTC),
31
+ * creating the heading (newest-first, after the `# ... Log` title when one
32
+ * exists) if missing. Legacy `## <ISO datetime> ...` headings are left
33
+ * untouched — permissive, no destructive rewrite.
34
+ */
35
+ export function appendOkfLogEntry(content, entryLine, when = new Date(), title = 'Log') {
36
+ const dateHeading = '## ' + when.toISOString().slice(0, 10);
37
+ const text = content || '';
38
+ if (!text.trim()) {
39
+ return ['# ' + title, '', dateHeading, '', entryLine, ''].join('\n');
40
+ }
41
+ const lines = text.split('\n');
42
+ const idx = lines.findIndex((l) => l.trim() === dateHeading);
43
+ if (idx >= 0) {
44
+ // Append at the end of today's section (before the next heading),
45
+ // skipping past trailing blank lines inside the section.
46
+ let end = idx + 1;
47
+ while (end < lines.length && !/^#{1,6}\s/.test(lines[end])) end++;
48
+ let insertAt = end;
49
+ while (insertAt > idx + 1 && lines[insertAt - 1].trim() === '') insertAt--;
50
+ lines.splice(insertAt, 0, entryLine);
51
+ return lines.join('\n');
52
+ }
53
+ // New date section goes at the top (newest first), after a leading
54
+ // `# ...` document title when present.
55
+ let at = 0;
56
+ if (/^#\s/.test(lines[0])) {
57
+ at = 1;
58
+ while (at < lines.length && lines[at].trim() === '') at++;
59
+ }
60
+ lines.splice(at, 0, dateHeading, '', entryLine, '');
61
+ return lines.join('\n');
62
+ }