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