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 +26 -0
- package/cli/drafted.mjs +12 -0
- package/mcp/server.mjs +368 -52
- package/package.json +1 -1
- package/src/shared/okf-log.mjs +62 -0
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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]
|
|
3341
|
-
|
|
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
|
|
3556
|
-
const entry =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3576
|
-
const updatedContent = (
|
|
3577
|
-
await api('PATCH', `/api/wiki/pages/${
|
|
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.
|
|
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
|
+
}
|