drafted 1.12.7 → 1.13.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/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
 
@@ -806,7 +807,7 @@ async function cloneSession() {
806
807
  // instead of churning a fresh session every process. (Org is per-request, so a
807
808
  // shared child session does NOT cause cross-agent org clobbering.)
808
809
  const agentKey = (process.env.DRAFTED_AGENT_NAME || '').trim() || bootstrapId;
809
- const res = await fetch(`${getServerUrl()}/auth/session/clone`, {
810
+ const res = await serverFetch(`${getServerUrl()}/auth/session/clone`, {
810
811
  method: 'POST',
811
812
  headers: { 'Content-Type': 'application/json', Cookie: `gc_session=${bootstrapId}` },
812
813
  body: JSON.stringify({ agentKey }),
@@ -838,7 +839,7 @@ async function restoreBoundOrg(clonedOrgId) {
838
839
  const want = sess.boundOrgId;
839
840
  if (!want || want === clonedOrgId) return;
840
841
  try {
841
- const res = await fetch(`${getServerUrl()}/auth/switch-org`, {
842
+ const res = await serverFetch(`${getServerUrl()}/auth/switch-org`, {
842
843
  method: 'POST',
843
844
  headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
844
845
  body: JSON.stringify({ orgId: want }),
@@ -868,6 +869,37 @@ const MIME_MAP = {
868
869
  };
869
870
  function mimeFromExt(ext) { return MIME_MAP[ext?.toLowerCase()] || 'application/octet-stream'; }
870
871
 
872
+ // TLS-trust failures from antivirus/corporate HTTPS interception (Norton,
873
+ // Zscaler, ...) surface from undici as a bare "fetch failed" — agents then tell
874
+ // users "network error / you're offline" for a day (real incident, 2026-07-02).
875
+ // Node does not use the OS certificate store, so the interceptor's re-signed
876
+ // chain is untrusted. Translate the code into the actual fix.
877
+ const TLS_TRUST_CODES = new Set([
878
+ 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'SELF_SIGNED_CERT_IN_CHAIN',
879
+ 'DEPTH_ZERO_SELF_SIGNED_CERT', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
880
+ 'UNABLE_TO_GET_ISSUER_CERT', 'CERT_UNTRUSTED',
881
+ ]);
882
+ function enrichFetchError(error, url) {
883
+ const code = String(error?.cause?.code || error?.code || '').toUpperCase();
884
+ if (!TLS_TRUST_CODES.has(code)) return error;
885
+ const e = new Error(
886
+ `TLS interception detected reaching ${url} (${code}): antivirus or a corporate proxy ` +
887
+ `(Norton, Zscaler, Netskope, ...) is re-signing HTTPS, and Node does not use the OS ` +
888
+ `certificate store, so it rejects the interceptor's certificate. This is NOT a Drafted ` +
889
+ `outage and NOT a sign-out. Fixes, best first: (1) exclude drafted.live and *.drafted.live ` +
890
+ `from the interceptor's HTTPS/SSL scanning; (2) export the interceptor's root certificate ` +
891
+ `to a PEM file and set NODE_EXTRA_CA_CERTS=<path> in this MCP server's env; (3) on Node ` +
892
+ `>= 22.15, set NODE_OPTIONS=--use-system-ca so Node trusts the OS store. NEVER set ` +
893
+ `NODE_TLS_REJECT_UNAUTHORIZED=0 (disables all TLS verification).`
894
+ );
895
+ e.cause = error;
896
+ return e;
897
+ }
898
+ async function serverFetch(url, opts) {
899
+ try { return await fetch(url, opts); }
900
+ catch (e) { throw enrichFetchError(e, url); }
901
+ }
902
+
871
903
  async function api(method, path, body, extraHeaders = {}, _retried = false) {
872
904
  await ensureSession();
873
905
  const pid = getState().projectId;
@@ -899,7 +931,7 @@ async function api(method, path, body, extraHeaders = {}, _retried = false) {
899
931
  opts.body = JSON.stringify(body);
900
932
  }
901
933
 
902
- const res = await fetch(url, opts);
934
+ const res = await serverFetch(url, opts);
903
935
  const text = await res.text();
904
936
 
905
937
  // Session expired after server restart, or a browser approval just completed
@@ -1399,7 +1431,7 @@ async function consumePendingDeviceCode() {
1399
1431
  if (!pending?.deviceCode) return false;
1400
1432
 
1401
1433
  try {
1402
- const res = await fetch(`${getServerUrl()}/auth/device/token`, {
1434
+ const res = await serverFetch(`${getServerUrl()}/auth/device/token`, {
1403
1435
  method: 'POST',
1404
1436
  headers: { 'Content-Type': 'application/json' },
1405
1437
  body: JSON.stringify({ deviceCode: pending.deviceCode }),
@@ -1435,7 +1467,7 @@ if (!isRemote) tool('auth', 'Sign in to Drafted. `action=get_link` returns a ver
1435
1467
  }, async ({ action }) => {
1436
1468
  try {
1437
1469
  if (action === 'get_link') {
1438
- const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
1470
+ const codeRes = await serverFetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
1439
1471
  if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
1440
1472
  const data = await codeRes.json();
1441
1473
  persistPendingDeviceCode(data);
@@ -1449,7 +1481,7 @@ if (!isRemote) tool('auth', 'Sign in to Drafted. `action=get_link` returns a ver
1449
1481
  const existing = getState().sessionId || getBootstrapSessionId();
1450
1482
  if (existing) {
1451
1483
  try {
1452
- const res = await fetch(`${getServerUrl()}/auth/me`, {
1484
+ const res = await serverFetch(`${getServerUrl()}/auth/me`, {
1453
1485
  headers: { Cookie: `gc_session=${existing}` },
1454
1486
  });
1455
1487
  if (res.ok) {
@@ -1467,7 +1499,7 @@ if (!isRemote) tool('auth', 'Sign in to Drafted. `action=get_link` returns a ver
1467
1499
  ({ deviceCode, verificationUrl, expiresIn } = pending);
1468
1500
  reusingPending = true;
1469
1501
  } else {
1470
- const codeRes = await fetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
1502
+ const codeRes = await serverFetch(`${getServerUrl()}/auth/device/code`, { method: 'POST' });
1471
1503
  if (!codeRes.ok) throw new Error(`Failed to start device authorization (HTTP ${codeRes.status})`);
1472
1504
  ({ deviceCode, verificationUrl, expiresIn } = await codeRes.json());
1473
1505
  }
@@ -1491,7 +1523,7 @@ if (!isRemote) tool('auth', 'Sign in to Drafted. `action=get_link` returns a ver
1491
1523
  const deadline = Date.now() + (expiresIn * 1000);
1492
1524
  while (Date.now() < deadline) {
1493
1525
  await new Promise(r => setTimeout(r, 4000));
1494
- const res = await fetch(`${getServerUrl()}/auth/device/token`, {
1526
+ const res = await serverFetch(`${getServerUrl()}/auth/device/token`, {
1495
1527
  method: 'POST',
1496
1528
  headers: { 'Content-Type': 'application/json' },
1497
1529
  body: JSON.stringify({ deviceCode }),
@@ -1543,15 +1575,18 @@ async function sessionSurfaceBlock() {
1543
1575
  const cookieSid = sessionId || getBootstrapSessionId();
1544
1576
  let me = null;
1545
1577
  let unreachable = false;
1578
+ let unreachableWhy = null;
1546
1579
  if (cookieSid) {
1547
1580
  try {
1548
- const res = await fetch(`${getServerUrl()}/auth/me`, { headers: { Cookie: `gc_session=${cookieSid}` } });
1581
+ const res = await serverFetch(`${getServerUrl()}/auth/me`, { headers: { Cookie: `gc_session=${cookieSid}` } });
1549
1582
  if (res.ok) me = await res.json();
1550
- } catch {
1583
+ } catch (e) {
1551
1584
  // Transport failure, NOT a sign-out. Conflating the two made agents tell
1552
1585
  // users "you're signed out" during a network blip and start needless
1553
- // re-logins — surface the distinction instead.
1586
+ // re-logins — surface the distinction instead. serverFetch already
1587
+ // translates TLS-interception codes into the actual fix.
1554
1588
  unreachable = true;
1589
+ if (String(e?.message || '').startsWith('TLS interception')) unreachableWhy = e.message;
1555
1590
  }
1556
1591
  }
1557
1592
  return {
@@ -1566,7 +1601,7 @@ async function sessionSurfaceBlock() {
1566
1601
  alive: false,
1567
1602
  ...(unreachable ? {
1568
1603
  serverUnreachable: true,
1569
- note: `Could not reach ${getServerUrl()} — identity UNKNOWN, not signed out. This is a network/transport failure: do not tell the user they are logged out and do not start a new login; retry when connectivity is back.`,
1604
+ note: unreachableWhy || `Could not reach ${getServerUrl()} — identity UNKNOWN, not signed out. This is a network/transport failure: do not tell the user they are logged out and do not start a new login; retry when connectivity is back.`,
1570
1605
  } : {}),
1571
1606
  };
1572
1607
  }
@@ -1585,9 +1620,9 @@ tool('whoami', 'Return THIS agent session\'s identity: its server-assigned human
1585
1620
 
1586
1621
  // ── Project management tools (direct HTTP) ────────────────────────
1587
1622
 
1588
- 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.', {
1589
- action: z.enum(['list', 'open', 'create', 'update', 'move']).describe('Operation to perform.'),
1590
- projectId: z.string().optional().describe('[open|update|move] project ID. Get IDs from action=list.'),
1623
+ 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.', {
1624
+ action: z.enum(['list', 'open', 'create', 'update', 'move', 'export', 'import']).describe('Operation to perform.'),
1625
+ 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.'),
1591
1626
  name: z.string().optional().describe('[create|update] project name'),
1592
1627
  description: z.string().nullable().optional().describe('[create|update] project description'),
1593
1628
  templateSlug: z.string().optional().describe('[create] template slug (e.g. "web-design", "mobile-app", "landing-page")'),
@@ -1596,6 +1631,16 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
1596
1631
  layers: z.array(z.object({}).passthrough()).optional().describe('[update] full layers array replacement. Use ls / to read current layers first.'),
1597
1632
  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.'),
1598
1633
  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)'),
1634
+ 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).'),
1635
+ limit: z.number().optional().describe('[export] max files per page for format="files" (default 100, max 500)'),
1636
+ offset: z.number().optional().describe('[export] pagination offset for format="files"'),
1637
+ compact: z.boolean().optional().describe('[export] with format="files": return file paths only (no content)'),
1638
+ files: z.array(z.object({
1639
+ path: z.string().describe('Bundle-relative file path, e.g. "research/default/notes.md"'),
1640
+ content: z.string().describe('File content (markdown, optional YAML frontmatter)'),
1641
+ })).optional().describe('[import] OKF bundle files inline. index.md/log.md are skipped (synthesized). Caps: 500 files, 512KB/file, 5MB total.'),
1642
+ dryRun: z.boolean().optional().describe('[import] preview the {creates, updates, skips, warnings} report without writing'),
1643
+ ...(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[]).') }),
1599
1644
  }, async (args) => {
1600
1645
  try {
1601
1646
  const { action } = args;
@@ -1740,6 +1785,80 @@ tool('project', 'START HERE for project management. Dispatch by `action`: list (
1740
1785
  if (!projectId || !targetOrgId) throw new Error('projectId and targetOrgId required for action=move');
1741
1786
  return ok(await api('POST', `/api/project/${projectId}/move`, { targetOrgId }));
1742
1787
  }
1788
+
1789
+ // ── export ──────────────────────────────────────────────────
1790
+ // The project as an OKF v0.1 bundle. Mirrors wiki export: format="files"
1791
+ // pages the bundle inline; otherwise stdio writes a local dir, remote
1792
+ // returns the authenticated tar.gz download URL.
1793
+ case 'export': {
1794
+ const projectId = args.projectId || getState().projectId;
1795
+ if (!projectId) throw new Error('projectId required for action=export (or open a project first)');
1796
+ if (args.format === 'files') {
1797
+ const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
1798
+ if (args.offset) qp.set('offset', String(args.offset));
1799
+ if (args.compact) qp.set('compact', 'true');
1800
+ return ok(await api('GET', `/api/projects/${projectId}/export?${qp.toString()}`));
1801
+ }
1802
+ if (isRemote) {
1803
+ return ok({
1804
+ downloadUrl: `${getServerUrl()}/api/projects/${projectId}/export.tar.gz`,
1805
+ 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.',
1806
+ });
1807
+ }
1808
+ // stdio: write every bundle file under a local directory.
1809
+ const meta = getCurrentProjectContext();
1810
+ const projSlug = String((meta && meta.id === projectId && (meta.slug || meta.name)) || projectId)
1811
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'project';
1812
+ const exportDir = resolve(args.dir || `./okf-project-${projSlug}`);
1813
+ let expOffset = 0;
1814
+ let written = 0;
1815
+ for (;;) {
1816
+ const batch = await api('GET', `/api/projects/${projectId}/export?limit=200&offset=${expOffset}`);
1817
+ const batchFiles = batch.files || [];
1818
+ for (const f of batchFiles) {
1819
+ const dest = resolve(exportDir, f.path);
1820
+ if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
1821
+ mkdirSync(dirname(dest), { recursive: true });
1822
+ writeFileSync(dest, f.content, 'utf8');
1823
+ written++;
1824
+ }
1825
+ expOffset += batchFiles.length;
1826
+ if (!batch.truncated || batchFiles.length === 0) break;
1827
+ }
1828
+ 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).' });
1829
+ }
1830
+
1831
+ // ── import ──────────────────────────────────────────────────
1832
+ // Ingest an OKF bundle into the project: inline files[] or (stdio) a
1833
+ // local dir walked for .md files. Concepts become markdown document
1834
+ // frames; markdown links between them become connectors.
1835
+ case 'import': {
1836
+ const projectId = args.projectId || getState().projectId;
1837
+ if (!projectId) throw new Error('projectId required for action=import (or open a project first)');
1838
+ let importFiles = args.files;
1839
+ if (!importFiles && args.dir) {
1840
+ const root = resolve(args.dir);
1841
+ if (!existsSync(root)) throw new Error(`dir not found: ${args.dir}`);
1842
+ importFiles = [];
1843
+ const walk = (d) => {
1844
+ for (const ent of readdirSync(d, { withFileTypes: true })) {
1845
+ if (ent.name.startsWith('.')) continue;
1846
+ const p = join(d, ent.name);
1847
+ if (ent.isDirectory()) walk(p);
1848
+ else if (/\.md$/i.test(ent.name)) {
1849
+ if (importFiles.length >= 500) throw new Error('import capped at 500 files — split the bundle');
1850
+ importFiles.push({ path: p.slice(root.length + 1).replace(/\\/g, '/'), content: readFileSync(p, 'utf8') });
1851
+ }
1852
+ }
1853
+ };
1854
+ walk(root);
1855
+ }
1856
+ if (!Array.isArray(importFiles) || importFiles.length === 0) {
1857
+ throw new Error('import requires files[] (or dir on stdio) with at least one .md file');
1858
+ }
1859
+ return ok(await api('POST', `/api/projects/${projectId}/import`, { files: importFiles, dryRun: !!args.dryRun }));
1860
+ }
1861
+
1743
1862
  default:
1744
1863
  throw new Error(`Unknown project action: ${action}`);
1745
1864
  }
@@ -3079,7 +3198,7 @@ function ensureSkillInstallIgnored(dir) {
3079
3198
  } catch { return false; }
3080
3199
  }
3081
3200
 
3082
- 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.', {
3201
+ 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).', {
3083
3202
  action: z.enum([
3084
3203
  'search', 'load', 'list', 'history',
3085
3204
  'add', 'update', 'remove',
@@ -3087,12 +3206,13 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3087
3206
  'attach', 'detach',
3088
3207
  'favorite', 'unfavorite',
3089
3208
  'read_file', 'update_file',
3090
- ]).describe('Operation to perform.'),
3209
+ 'export', 'import',
3210
+ ]).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).'),
3091
3211
  query: z.string().optional().describe('[search] term to match against name/description/content'),
3092
3212
  tags: z.array(z.string()).optional().describe('[search] filter by tags; [add|update] tag list'),
3093
3213
  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)'),
3094
- limit: z.number().optional().describe('[search|list] max results per page (default 25, max 100)'),
3095
- 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'),
3214
+ 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)'),
3215
+ 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)'),
3096
3216
  skill: z.string().optional().describe('[load|history] skill ID (UUID) or slug'),
3097
3217
  version: z.number().optional().describe('[history] fetch this version number\'s full snapshot (content included); omit for the reverse-chron version list'),
3098
3218
  skillId: z.string().optional().describe('[update|remove|attach|detach|favorite|unfavorite|read_file|update_file] skill ID'),
@@ -3102,13 +3222,16 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3102
3222
  content: z.string().optional().describe('[add|update] root SKILL.md content; [update_file] file content'),
3103
3223
  triggerPatterns: z.array(z.string()).optional().describe('[add|update] patterns that suggest this skill'),
3104
3224
  path: z.string().optional().describe('[read_file|update_file] relative path inside skill directory (e.g. "examples/react.md")'),
3105
- 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'),
3225
+ 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'),
3106
3226
  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.'),
3107
- 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.'),
3227
+ 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.'),
3108
3228
  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"]'),
3109
- 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'),
3110
- 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).'),
3229
+ 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.'),
3230
+ 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[]).'),
3111
3231
  deleteMissing: z.boolean().optional().describe('[push] remove stored files not present in the pushed set'),
3232
+ 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).'),
3233
+ includeGlobal: z.boolean().optional().describe('[export] include global built-in skills in the bundle (default: org skills only)'),
3234
+ dryRun: z.boolean().optional().describe('[import] report {created, updated, skips, warnings} without writing'),
3112
3235
  }, async (args) => {
3113
3236
  try {
3114
3237
  const { action } = args;
@@ -3116,7 +3239,7 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3116
3239
  // bound org (or explicit org=) when the user is multi-org. (DRAFT-36 Phase 4.)
3117
3240
  // UUID-first exception: update/remove addressed by a skillId derive their org
3118
3241
  // from the skill row server-side, so the bound-org gate is unnecessary.
3119
- if (['add', 'update', 'remove', 'push', 'fork'].includes(action)) {
3242
+ if (['add', 'update', 'remove', 'push', 'fork', 'import'].includes(action)) {
3120
3243
  const byId = (action === 'update' || action === 'remove') && /^[a-f0-9-]{36}$/.test(args.skillId || '');
3121
3244
  if (!byId) await requireBoundOrgForProjectlessMutation(args.org);
3122
3245
  }
@@ -3315,6 +3438,64 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3315
3438
  if (!skillId || !path || content == null) throw new Error('skillId, path, content required for action=update_file');
3316
3439
  return ok(await api('PUT', `/api/skills/${skillId}/files/${path}`, { content }));
3317
3440
  }
3441
+
3442
+ // ── export ──────────────────────────────────────────────────
3443
+ // The skill library as an OKF v0.1 bundle (skills/<slug>/SKILL.md +
3444
+ // supporting files + synthesized log.md/index.md). Mirrors wiki export:
3445
+ // format="files" pages the bundle inline; otherwise stdio writes a local
3446
+ // dir, remote returns the authenticated tar.gz download URL.
3447
+ case 'export': {
3448
+ const extra = args.org ? { 'X-Drafted-Org': args.org } : {};
3449
+ const inc = args.includeGlobal ? '&includeGlobal=1' : '';
3450
+ if (args.format === 'files') {
3451
+ const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
3452
+ if (args.offset) qp.set('offset', String(args.offset));
3453
+ if (args.compact) qp.set('compact', 'true');
3454
+ return ok(await api('GET', `/api/skills/export?${qp.toString()}${inc}`, undefined, extra));
3455
+ }
3456
+ if (isRemote) {
3457
+ return ok({
3458
+ downloadUrl: `${getServerUrl()}/api/skills/export.tar.gz${args.includeGlobal ? '?includeGlobal=1' : ''}`,
3459
+ 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.',
3460
+ });
3461
+ }
3462
+ // stdio: write every bundle file under a local directory.
3463
+ const skOrgCtx = await getCurrentOrgContext();
3464
+ const skOrgSlug = String(skOrgCtx?.name || skOrgCtx?.id || 'org').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'org';
3465
+ const exportDir = resolve(args.dir || `./okf-skills-${skOrgSlug}`);
3466
+ let expOffset = 0;
3467
+ let written = 0;
3468
+ for (;;) {
3469
+ const batch = await api('GET', `/api/skills/export?limit=200&offset=${expOffset}${inc}`, undefined, extra);
3470
+ const batchFiles = batch.files || [];
3471
+ for (const f of batchFiles) {
3472
+ const dest = resolve(exportDir, f.path);
3473
+ if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
3474
+ mkdirSync(dirname(dest), { recursive: true });
3475
+ writeFileSync(dest, f.content, 'utf8');
3476
+ written++;
3477
+ }
3478
+ expOffset += batchFiles.length;
3479
+ if (!batch.truncated || batchFiles.length === 0) break;
3480
+ }
3481
+ 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).' });
3482
+ }
3483
+
3484
+ // ── import ──────────────────────────────────────────────────
3485
+ // Ingest the SKILL.md-shaped concepts of an OKF bundle as org skills:
3486
+ // inline files[] or (stdio) a local dir walked with the push filters.
3487
+ // Use wiki(action="import") for a mixed knowledge bundle — it routes
3488
+ // skill concepts here and everything else into the wiki.
3489
+ case 'import': {
3490
+ const extra = args.org ? { 'X-Drafted-Org': args.org } : {};
3491
+ let importFiles = args.files;
3492
+ if (!importFiles && args.dir) importFiles = collectSkillTreeForPush(args.dir);
3493
+ if (!Array.isArray(importFiles) || importFiles.length === 0) {
3494
+ throw new Error('import requires files[] (or dir on stdio) with at least one file');
3495
+ }
3496
+ return ok(await api('POST', '/api/skills/import', { files: importFiles, dryRun: !!args.dryRun }, extra));
3497
+ }
3498
+
3318
3499
  default:
3319
3500
  throw new Error(`Unknown skill action: ${action}`);
3320
3501
  }
@@ -3325,20 +3506,32 @@ tool('skill', 'Manage the Drafted skill library. Skills are reusable prompts/gui
3325
3506
  // All 11 actions dispatch from one tool. Read-only actions skip the
3326
3507
  // skill gate; mutations require org-level wiki-maintainer skills loaded.
3327
3508
 
3328
- 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.', {
3329
- 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.'),
3330
- path: z.string().optional().describe('[ls|read|links] wiki path. For ls: default / (root). For read: required. For links: required.'),
3509
+ 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.', {
3510
+ 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).'),
3511
+ 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.'),
3331
3512
  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.'),
3332
3513
  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).'),
3333
3514
  recursive: z.boolean().optional().describe('[ls] list recursively with depth indicators'),
3334
- limit: z.number().optional().describe('[recent|search] max results (recent default 10, search default 25)'),
3515
+ limit: z.number().optional().describe('[recent|search|export] max results (recent default 10, search default 25, export files default 100)'),
3516
+ offset: z.number().optional().describe('[export] pagination offset for format="files"'),
3517
+ compact: z.boolean().optional().describe('[export] with format="files": return file paths only (no content)'),
3518
+ 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).'),
3519
+ files: z.array(z.object({
3520
+ path: z.string().describe('Bundle-relative file path, e.g. "concepts/frames.md"'),
3521
+ content: z.string().describe('File content (markdown, optional YAML frontmatter)'),
3522
+ })).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.'),
3523
+ ...(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[]).') }),
3335
3524
  query: z.string().optional().describe('[search] term to search in title, path, and content'),
3336
3525
  lines: z.string().optional().describe('[read] line range (e.g. "1-50"). Omit to read all.'),
3337
- message: z.string().optional().describe('[log] message to append to log.md page'),
3526
+ message: z.string().optional().describe('[log] message to append to the log page (OKF date-grouped format)'),
3527
+ verb: z.string().optional().describe('[log] leading bold verb for the entry: Update (default), Creation, Deprecation, or Initialization.'),
3338
3528
  title: z.string().optional().describe('[write] page title (required for write)'),
3339
3529
  content: z.string().optional().describe('[write|edit] page content (write: full content; edit: hashline content not used — use operations)'),
3340
- type: z.string().optional().describe('[write] page type (default "page")'),
3341
- frontmatter: z.any().optional().describe('[write] frontmatter object'),
3530
+ 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).'),
3531
+ 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.'),
3532
+ url: z.string().optional().describe('[cite] citation URL (required for cite)'),
3533
+ label: z.string().optional().describe('[cite] link label for the citation (defaults to the URL)'),
3534
+ 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).'),
3342
3535
  operations: z.array(z.object({
3343
3536
  type: z.enum(['replace', 'delete', 'insertAfter', 'insertBefore']).describe('Edit type'),
3344
3537
  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.'),
@@ -3346,10 +3539,10 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3346
3539
  })).optional().describe('[edit] hashline edit operations — same shape as frame.edit'),
3347
3540
  from: z.string().optional().describe('[mv] source path'),
3348
3541
  to: z.string().optional().describe('[mv] destination path'),
3349
- dryRun: z.boolean().optional().describe('[mv|rm] preview impact without applying changes'),
3542
+ dryRun: z.boolean().optional().describe('[mv|rm|import] preview impact without applying changes (import: returns the {creates, updates, skips, warnings} report without writing)'),
3350
3543
  file_path: z.string().optional().describe('[source-register] absolute path to a local file. Server hashes it and registers the source. stdio MCP only.'),
3351
- 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).'),
3352
- filename: z.string().optional().describe('[source-register] original filename for the source (informational)'),
3544
+ 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.'),
3545
+ filename: z.string().optional().describe('[source-register|cite] original filename for the source (informational)'),
3353
3546
  contentType: z.string().optional().describe('[source-register] MIME type (informational)'),
3354
3547
  size: z.number().optional().describe('[source-register] byte size (informational)'),
3355
3548
  sourceId: z.string().optional().describe('[source-get] source ID returned from source-register'),
@@ -3364,7 +3557,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3364
3557
  // Otherwise (path-addressed, multi-org, nothing bound) the gate still
3365
3558
  // refuses to guess the org so a write never silently lands in the wrong one.
3366
3559
  const orgHeader = args.org ? { 'X-Drafted-Org': args.org } : {};
3367
- if (['write', 'edit', 'mv', 'rm', 'bulk-write'].includes(action)) {
3560
+ if (['write', 'edit', 'mv', 'rm', 'bulk-write', 'cite', 'import'].includes(action)) {
3368
3561
  if (!args.pageId) await requireBoundOrgForProjectlessMutation(args.org);
3369
3562
  }
3370
3563
 
@@ -3393,7 +3586,7 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3393
3586
  // Ensure the wiki-maintainer skill is attached to this org BEFORE the
3394
3587
  // gate check, so the gate fires reliably on the very first wiki call —
3395
3588
  // not just after the org has visited /wiki in a browser. Idempotent.
3396
- const MUTATING = new Set(['write', 'edit', 'mv', 'rm', 'log', 'source-register', 'bulk-write']);
3589
+ const MUTATING = new Set(['write', 'edit', 'mv', 'rm', 'log', 'cite', 'source-register', 'bulk-write', 'import']);
3397
3590
  if (MUTATING.has(action)) {
3398
3591
  try { await api('POST', '/api/wiki/_ensure-skill'); } catch { /* non-fatal */ }
3399
3592
  const skillErr = await checkOrgSkills(orgId, action);
@@ -3476,19 +3669,21 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3476
3669
  // Returns content in hashline format (`LINE+ID|content`) so the
3477
3670
  // agent can produce hashline edit operations. Mirrors frame.read.
3478
3671
  case 'read': {
3479
- const { path: readPath, pageId: readPageId, lines: readLines } = args;
3672
+ const { path: readPath, pageId: readPageId, lines: readLines, raw: readRaw } = args;
3480
3673
  if (readLines && !/^\d+-\d+$/.test(readLines)) throw new Error(`lines must be "N-M" (e.g. "10-50"), got: ${readLines}`);
3481
3674
  let page;
3482
3675
  if (readPageId) {
3483
3676
  // UUID-first: address the page directly, org auto-derives server-side.
3484
3677
  const params = new URLSearchParams({ format: 'hashline' });
3485
3678
  if (readLines) params.set('lines', readLines);
3679
+ if (readRaw) params.set('raw', 'true');
3486
3680
  page = await api('GET', `/api/wiki/pages/${readPageId}?${params.toString()}`);
3487
3681
  } else {
3488
3682
  if (!readPath) throw new Error('path or pageId required for action=read');
3489
3683
  const normalized = normalizeWikiPath(readPath);
3490
3684
  const params = new URLSearchParams({ path: normalized, format: 'hashline' });
3491
3685
  if (readLines) params.set('lines', readLines);
3686
+ if (readRaw) params.set('raw', 'true');
3492
3687
  page = await api('GET', `/api/wiki/page?${params.toString()}`, undefined, orgHeader);
3493
3688
  }
3494
3689
  // Get backlink count via search (approximate)
@@ -3548,41 +3743,156 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3548
3743
  }
3549
3744
 
3550
3745
  // ── log ─────────────────────────────────────────────────────
3746
+ // OKF log.md format: newest-first `## YYYY-MM-DD` date headings (UTC),
3747
+ // each with `* **Verb**: message (agent, HH:MM UTC)` bullets. Legacy
3748
+ // `## <ISO datetime> ...` headings on existing log pages are left as-is.
3551
3749
  case 'log': {
3552
- const { message: logMessage } = args;
3750
+ const { message: logMessage, verb: logVerb } = args;
3553
3751
  if (!logMessage) throw new Error('message required for action=log');
3554
3752
  const agentName = process.env.DRAFTED_AGENT_NAME || 'mcp';
3555
- const dateStr = new Date().toISOString().replace('T', ' ').slice(0, 19) + 'Z';
3556
- const entry = `## ${dateStr} note by ${agentName} | ${logMessage}`;
3753
+ const now = new Date();
3754
+ const entry = formatOkfLogEntry(logVerb, logMessage, agentName, now);
3755
+ const logTitle = (orgCtx?.name ? orgCtx.name + ' ' : '') + 'Log';
3557
3756
 
3558
- // Try to read existing log page
3559
- let existingContent = '';
3560
- let existingId = null;
3757
+ // Try to read existing log page (raw: bytes-as-stored, no synthesis)
3758
+ let logPage = null;
3561
3759
  try {
3562
- const logPage = await api('GET', '/api/wiki/page?path=log', undefined, orgHeader);
3563
- existingContent = logPage.content || '';
3564
- existingId = logPage.id;
3760
+ logPage = await api('GET', '/api/wiki/page?path=log&raw=true', undefined, orgHeader);
3565
3761
  } catch {
3566
3762
  // Create new log page
3567
3763
  const created = await api('POST', '/api/wiki/pages', {
3568
3764
  path: 'log',
3569
3765
  title: 'Log',
3570
- content: entry + '\n',
3766
+ type: 'Log',
3767
+ content: appendOkfLogEntry('', entry, now, logTitle),
3571
3768
  }, orgHeader);
3572
3769
  return ok(withOrg({ appended: true, created: true, pageId: created.id, path: 'log', url: wikiBrowserUrl('log') }));
3573
3770
  }
3574
3771
 
3575
- // Append to existing log
3576
- const updatedContent = (existingContent.endsWith('\n') ? existingContent : existingContent + '\n') + entry + '\n';
3577
- await api('PATCH', `/api/wiki/pages/${existingId}`, { content: updatedContent });
3772
+ // Append under today's date heading (created at the top if missing)
3773
+ const updatedContent = appendOkfLogEntry(logPage.content || '', entry, now, logTitle);
3774
+ await api('PATCH', `/api/wiki/pages/${logPage.id}`, { content: updatedContent }, orgHeader);
3578
3775
  return ok(withOrg({ appended: true, path: 'log', url: wikiBrowserUrl('log') }));
3579
3776
  }
3580
3777
 
3778
+ // ── cite ────────────────────────────────────────────────────
3779
+ // Append a numbered citation to a page's `# Citations` section
3780
+ // (creating the section if missing), OKF style: `[n] [label](url)`.
3781
+ // Optionally registers a wiki source when contentHash is given.
3782
+ case 'cite': {
3783
+ const { path: citePath, pageId: citePageId, url: citeUrl, label: citeLabel, contentHash: citeHash, filename: citeFilename } = args;
3784
+ if (!citeUrl) throw new Error('url required for action=cite');
3785
+ let page;
3786
+ if (citePageId) {
3787
+ page = await api('GET', `/api/wiki/pages/${citePageId}?raw=true`);
3788
+ } else {
3789
+ if (!citePath) throw new Error('path or pageId required for action=cite');
3790
+ page = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalizeWikiPath(citePath))}&raw=true`, undefined, orgHeader);
3791
+ }
3792
+ const lines = (page.content || '').split('\n');
3793
+ let maxN = 0;
3794
+ for (const l of lines) {
3795
+ const m = l.match(/^\[(\d+)\]\s/);
3796
+ if (m) maxN = Math.max(maxN, parseInt(m[1], 10));
3797
+ }
3798
+ const n = maxN + 1;
3799
+ const entry = `[${n}] [${citeLabel || citeUrl}](${citeUrl})`;
3800
+ let content;
3801
+ const hIdx = lines.findIndex((l) => /^#{1,3}\s+Citations\s*$/.test(l));
3802
+ if (hIdx < 0) {
3803
+ const base = (page.content || '').replace(/\s+$/, '');
3804
+ content = (base ? base + '\n\n' : '') + '# Citations\n\n' + entry + '\n';
3805
+ } else {
3806
+ let end = hIdx + 1;
3807
+ while (end < lines.length && !/^#{1,6}\s/.test(lines[end])) end++;
3808
+ let insertAt = end;
3809
+ while (insertAt > hIdx + 1 && lines[insertAt - 1].trim() === '') insertAt--;
3810
+ lines.splice(insertAt, 0, entry);
3811
+ content = lines.join('\n');
3812
+ }
3813
+ await api('PATCH', `/api/wiki/pages/${page.id}`, { content });
3814
+ let source = null;
3815
+ if (citeHash) {
3816
+ try {
3817
+ source = await api('POST', '/api/wiki/sources', { contentHash: citeHash, filename: citeFilename }, orgHeader);
3818
+ } catch { /* source registration is best-effort */ }
3819
+ }
3820
+ return ok(withOrg({ cited: true, n, entry, path: page.path, id: page.id, sourceId: source?.id, url: wikiBrowserUrl(page.path) }));
3821
+ }
3822
+
3581
3823
  // ── health ──────────────────────────────────────────────────
3582
3824
  case 'health': {
3583
3825
  return ok(await api('GET', '/api/wiki/health', undefined, orgHeader));
3584
3826
  }
3585
3827
 
3828
+ // ── export ──────────────────────────────────────────────────
3829
+ // The whole wiki as an OKF v0.1 bundle. format="files" pages the bundle
3830
+ // inline; otherwise stdio writes a local dir, remote returns the
3831
+ // authenticated tar.gz download URL.
3832
+ case 'export': {
3833
+ if (args.format === 'files') {
3834
+ const qp = new URLSearchParams({ limit: String(Math.min(Math.max(1, args.limit || 100), 500)) });
3835
+ if (args.offset) qp.set('offset', String(args.offset));
3836
+ if (args.compact) qp.set('compact', 'true');
3837
+ return ok(withOrg(await api('GET', `/api/wiki/export?${qp.toString()}`, undefined, orgHeader)));
3838
+ }
3839
+ if (isRemote) {
3840
+ return ok(withOrg({
3841
+ downloadUrl: `${getServerUrl()}/api/wiki/export.tar.gz`,
3842
+ 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.',
3843
+ }));
3844
+ }
3845
+ // stdio: write every bundle file under a local directory.
3846
+ const orgSlug = String(orgCtx?.name || orgId || 'org').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'org';
3847
+ const exportDir = resolve(args.dir || `./okf-export-${orgSlug}`);
3848
+ let expOffset = 0;
3849
+ let written = 0;
3850
+ for (;;) {
3851
+ const batch = await api('GET', `/api/wiki/export?limit=200&offset=${expOffset}`, undefined, orgHeader);
3852
+ const batchFiles = batch.files || [];
3853
+ for (const f of batchFiles) {
3854
+ const dest = resolve(exportDir, f.path);
3855
+ if (dest !== exportDir && !dest.startsWith(exportDir + '/') && !dest.startsWith(exportDir + '\\')) continue; // traversal guard
3856
+ mkdirSync(dirname(dest), { recursive: true });
3857
+ writeFileSync(dest, f.content, 'utf8');
3858
+ written++;
3859
+ }
3860
+ expOffset += batchFiles.length;
3861
+ if (!batch.truncated || batchFiles.length === 0) break;
3862
+ }
3863
+ return ok(withOrg({ exported: written, dir: exportDir, note: 'OKF v0.1 bundle written. Pages carry synthesized frontmatter; index.md files are synthesized directory listings.' }));
3864
+ }
3865
+
3866
+ // ── import ──────────────────────────────────────────────────
3867
+ // Ingest an OKF bundle: inline files[] or (stdio) a local dir walked
3868
+ // for .md files. index.md skipped, bundle-root log.md merged into the
3869
+ // wiki log page, frontmatter lifted with unknown keys preserved.
3870
+ case 'import': {
3871
+ let importFiles = args.files;
3872
+ if (!importFiles && args.dir) {
3873
+ const root = resolve(args.dir);
3874
+ if (!existsSync(root)) throw new Error(`dir not found: ${args.dir}`);
3875
+ importFiles = [];
3876
+ const walk = (d) => {
3877
+ for (const ent of readdirSync(d, { withFileTypes: true })) {
3878
+ if (ent.name.startsWith('.')) continue;
3879
+ const p = join(d, ent.name);
3880
+ if (ent.isDirectory()) walk(p);
3881
+ else if (/\.md$/i.test(ent.name)) {
3882
+ if (importFiles.length >= 500) throw new Error('import capped at 500 files — split the bundle');
3883
+ importFiles.push({ path: p.slice(root.length + 1).replace(/\\/g, '/'), content: readFileSync(p, 'utf8') });
3884
+ }
3885
+ }
3886
+ };
3887
+ walk(root);
3888
+ }
3889
+ if (!Array.isArray(importFiles) || importFiles.length === 0) {
3890
+ throw new Error('import requires files[] (or dir on stdio) with at least one .md file');
3891
+ }
3892
+ const result = await api('POST', '/api/wiki/import', { files: importFiles, dryRun: !!args.dryRun }, orgHeader);
3893
+ return ok(withOrg(result));
3894
+ }
3895
+
3586
3896
  // ── write ───────────────────────────────────────────────────
3587
3897
  case 'write': {
3588
3898
  const { path: writePath, title: writeTitle, content: writeContent, type: writeType, frontmatter } = args;
@@ -3594,15 +3904,21 @@ tool('wiki', 'Per-org wiki. Markdown pages with paths as hierarchy. You and othe
3594
3904
  if (writeType) body.type = writeType;
3595
3905
  if (frontmatter !== undefined) body.frontmatter = frontmatter;
3596
3906
 
3907
+ // Writing the reserved log page directly is allowed (it IS an editable
3908
+ // page) but the `log` action keeps the OKF date-grouped format for you.
3909
+ const logNote = normalized === 'log'
3910
+ ? 'Note: prefer wiki(action="log") for log entries — it maintains the OKF date-grouped format (## YYYY-MM-DD headings, newest first).'
3911
+ : undefined;
3912
+
3597
3913
  // Check if page exists — if so, update; otherwise create. `orgHeader`
3598
3914
  // (the `org` arg) targets a specific org without switching the active org.
3599
3915
  try {
3600
3916
  const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(normalized)}`, undefined, orgHeader);
3601
3917
  const result = await api('PATCH', `/api/wiki/pages/${existing.id}`, body, orgHeader);
3602
- return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path) }));
3918
+ return ok(withOrg({ path: result.path, title: result.title, id: result.id, updated: true, url: wikiBrowserUrl(result.path), note: logNote }));
3603
3919
  } catch {
3604
3920
  const result = await api('POST', '/api/wiki/pages', body, orgHeader);
3605
- return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path) }));
3921
+ return ok(withOrg({ path: result.path, title: result.title, id: result.id, created: true, url: wikiBrowserUrl(result.path), note: logNote }));
3606
3922
  }
3607
3923
  }
3608
3924
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.12.7",
3
+ "version": "1.13.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
+ }