drafted 1.23.0 → 1.24.1
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/mcp/gates.mjs +21 -2
- package/mcp/server.mjs +79 -21
- package/package.json +1 -1
package/mcp/gates.mjs
CHANGED
|
@@ -111,8 +111,11 @@ export function shouldInjectLayerPrompt(layerPrompt, templateDefaultPrompt) {
|
|
|
111
111
|
|
|
112
112
|
// ── Wiki index formatting (bounded map injected with the G1 block) ────────────
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
// `empty` lets a caller say what an empty result MEANS: a zero-hit search or an
|
|
115
|
+
// empty folder is not "the wiki is empty" (that read as a contradiction when other
|
|
116
|
+
// searches of the same org had returned pages).
|
|
117
|
+
export function formatWikiIndex(pages, max = 50, empty = '(wiki is empty — no pages yet)') {
|
|
118
|
+
if (!Array.isArray(pages) || pages.length === 0) return empty;
|
|
116
119
|
const lines = pages.slice(0, max).map((p) => {
|
|
117
120
|
const path = typeof p === 'string' ? p : p.path ?? p.slug ?? '';
|
|
118
121
|
const title = typeof p === 'object' && p && p.title ? ` — ${p.title}` : '';
|
|
@@ -122,6 +125,22 @@ export function formatWikiIndex(pages, max = 50) {
|
|
|
122
125
|
return lines.join('\n') + more;
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
// A frame read is capped per page, below MAX_TOOL_RESULT_CHARS, because the
|
|
129
|
+
// content is JSON-escaped on its way out (quotes, newlines) and the blunt cap
|
|
130
|
+
// above cuts mid-token with no way back: `lines` cannot split a minified frame
|
|
131
|
+
// that is ONE line, so ~90 KB of it was simply lost. Paging by character gives
|
|
132
|
+
// every byte an address.
|
|
133
|
+
export const READ_PAGE_CHARS = 60_000;
|
|
134
|
+
|
|
135
|
+
// "start-end" (0-based, end exclusive) or "start" -> the window to return.
|
|
136
|
+
export function charWindow(spec, total) {
|
|
137
|
+
const m = /^\s*(\d+)\s*(?:-\s*(\d+))?\s*$/.exec(String(spec ?? ''));
|
|
138
|
+
if (spec != null && spec !== '' && !m) throw new Error(`chars must look like "0-60000" (got "${spec}")`);
|
|
139
|
+
const start = m ? Math.min(Number(m[1]), total) : 0;
|
|
140
|
+
const end = Math.min(m?.[2] ? Number(m[2]) : start + READ_PAGE_CHARS, start + READ_PAGE_CHARS, total);
|
|
141
|
+
return { start, end: Math.max(start, end) };
|
|
142
|
+
}
|
|
143
|
+
|
|
125
144
|
// ── Project index formatting ──────────────────────────────────────────────────
|
|
126
145
|
// `ls /projects` is an ls, not a stat: one addressable line per project. The
|
|
127
146
|
// previous shape returned the raw /api/projects rows — ~2.4KB each once the
|
package/mcp/server.mjs
CHANGED
|
@@ -20,10 +20,10 @@ import { z } from 'zod';
|
|
|
20
20
|
import { registerAppResource, RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
|
|
21
21
|
import WebSocket from 'ws';
|
|
22
22
|
import { LAYERS } from '../src/shared/constants.mjs';
|
|
23
|
-
import { liftFolderScope, normalizeFolder, rootEntries, subfolderEntries } from '../src/shared/folder-path.mjs';
|
|
23
|
+
import { liftFolderScope, normalizeFolder, rootEntries, splitFolderScope, subfolderEntries } from '../src/shared/folder-path.mjs';
|
|
24
24
|
import { emptyExcalidrawScene, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
|
|
25
25
|
import { formatOkfLogEntry, appendOkfLogEntry } from '../src/shared/okf-log.mjs';
|
|
26
|
-
import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, formatProjectIndex, projectPath, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
|
|
26
|
+
import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, formatProjectIndex, charWindow, READ_PAGE_CHARS, projectPath, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
|
|
27
27
|
import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
|
|
28
28
|
import { probeRepo, resolveAddress, addressNote } from './repo-address.mjs';
|
|
29
29
|
|
|
@@ -3093,19 +3093,18 @@ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a
|
|
|
3093
3093
|
buffer = Buffer.from(await res.arrayBuffer());
|
|
3094
3094
|
} else if (scope === 'canvas') {
|
|
3095
3095
|
const { path: canvasPath, layer = 'plans', width = 1600, height = 1200 } = args;
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
targetSlug =
|
|
3103
|
-
}
|
|
3104
|
-
if (!targetSlug) {
|
|
3096
|
+
// Resolve through the shared project resolver (path, folder path, slug, name
|
|
3097
|
+
// or UUID) and send the project ID: the server's slug lookup is scoped to the
|
|
3098
|
+
// SESSION org, so a slug from /o/<other-org>/projects/<slug> was a 404.
|
|
3099
|
+
let targetSlug;
|
|
3100
|
+
const ref = canvasPath || args.slug;
|
|
3101
|
+
if (ref) {
|
|
3102
|
+
targetSlug = (await resolveProjectArg(ref)).id;
|
|
3103
|
+
} else {
|
|
3105
3104
|
const list = await api('GET', '/api/projects');
|
|
3106
3105
|
const active = (list.projects || []).find(p => p.id === list.activeProject);
|
|
3107
3106
|
if (!active) throw new Error('No active project — pass path=/projects/<project> explicitly.');
|
|
3108
|
-
targetSlug = active.
|
|
3107
|
+
targetSlug = active.id;
|
|
3109
3108
|
}
|
|
3110
3109
|
const url = `${getServerUrl()}/api/canvas-screenshot/${encodeURIComponent(targetSlug)}?layer=${encodeURIComponent(layer)}&width=${width}&height=${height}`;
|
|
3111
3110
|
await ensureSession();
|
|
@@ -3330,10 +3329,10 @@ server.resource('info', 'drafted://info', {
|
|
|
3330
3329
|
};
|
|
3331
3330
|
});
|
|
3332
3331
|
|
|
3333
|
-
tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single container and the org is the folder at depth 0: `fs(ls, path="/")` lists the orgs you can address, `/o/<org>` is that org, and every folder level — the org included — carries the SAME four roots:\n\n- `<folder>/wiki/<path>` — knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `<folder>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside; slugs stay unique per ORG, so a skill resolves by slug from anywhere)\n- `<folder>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. `drafted:assignee:` is a REFERENCE: write a member\'s email or userId (look them up with the `members` tool), or `agent:<name>` to assign an agent. A bare name is resolved against members by unique email local-part; an unknown or ambiguous one is REJECTED with the candidate list, never stored as free text. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: todo, in_progress, in_review, done, canceled -- the Linear set minus Backlog, which this product deliberately does not have (a Backlog column only ever accumulates). The names this system used to store -- open, scheduled, needs_review, needs_decision, failed, backlog -- are still ACCEPTED and normalised (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task. `read` also projects `drafted:id:` (BEO-42), the org-scoped handle a human says out loud: READ-ONLY and server-allocated, so writing or inventing one is silently dropped rather than stored. Quote it back to the user; it resolves at /o/<org>/t/<id> and survives a rename or a move.\n- `<folder>/projects/<project>/<layer>/<lane>/<file>` — producible frames (then exactly layer → lane → file)\n\nFolders nest arbitrarily: `/o/<org>/engineering/backend/wiki/deploy`. The ROOT KEYWORD IS THE SEPARATOR — everything before `wiki`/`skills`/`tasks`/`projects` is the folder chain, everything after is the path inside that root, so `/o/<org>/wiki/engineering/foo` (the org wiki, nested page) and `/o/<org>/engineering/wiki/foo` (the engineering folder\'s wiki) are different pages. The four names are therefore RESERVED: a folder cannot be called one. `fs(ls, path="/o/<org>/<folder>")` shows a folder\'s four roots plus the folders inside it; `fs(mkdir, path="/o/<org>/<folder>")` creates one.\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org, at that org\'s root.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move — at THREE scopes, same as `rm`: a FILE path moves one frame; a LANE or LAYER path (`/<layer>/<lane>`, `/<layer>`) moves the whole set in one call, in-project or into another project, carrying frame ids, version history, connectors and assets; a PROJECT path (`/o/<org>/projects/<project>`) renames the project, with `to` as the new display name — the slug and every existing URL stay put), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/o/<org>")` fans out across wiki + skills + projects in one call. SEARCH MUST NAME ITS ORG — put it in the path, or pass org=. If you belong to more than one org an unaddressed search is REFUSED rather than silently scoped, because "no matches" from one org is indistinguishable from "nowhere" and you would stop looking. Search several orgs with one call each; every result says which org it came from), `link` / `unlink` / `links` (
|
|
3332
|
+
tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single container and the org is the folder at depth 0: `fs(ls, path="/")` lists the orgs you can address, `/o/<org>` is that org, and every folder level — the org included — carries the SAME four roots:\n\n- `<folder>/wiki/<path>` — knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `<folder>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside; slugs stay unique per ORG, so a skill resolves by slug from anywhere)\n- `<folder>/tasks/<lane?>/<file>` — work items. A task IS a frame: `read` renders its `drafted:status:`/`drafted:assignee:` as front matter and `write`/`edit` parse them back into columns, so they are never stored in the body. `drafted:assignee:` is a REFERENCE: write a member\'s email or userId (look them up with the `members` tool), or `agent:<name>` to assign an agent. A bare name is resolved against members by unique email local-part; an unknown or ambiguous one is REJECTED with the candidate list, never stored as free text. The keys are namespaced so an ordinary `status:` in your own front matter is left alone. Valid statuses: todo, in_progress, in_review, done, canceled -- the Linear set minus Backlog, which this product deliberately does not have (a Backlog column only ever accumulates). The names this system used to store -- open, scheduled, needs_review, needs_decision, failed, backlog -- are still ACCEPTED and normalised (an empty `drafted:status:` clears it). Status is a column, not a location — a task moved out of /tasks stays a task. `read` also projects `drafted:id:` (BEO-42), the org-scoped handle a human says out loud: READ-ONLY and server-allocated, so writing or inventing one is silently dropped rather than stored. Quote it back to the user; it resolves at /o/<org>/t/<id> and survives a rename or a move.\n- `<folder>/projects/<project>/<layer>/<lane>/<file>` — producible frames (then exactly layer → lane → file)\n\nFolders nest arbitrarily: `/o/<org>/engineering/backend/wiki/deploy`. The ROOT KEYWORD IS THE SEPARATOR — everything before `wiki`/`skills`/`tasks`/`projects` is the folder chain, everything after is the path inside that root, so `/o/<org>/wiki/engineering/foo` (the org wiki, nested page) and `/o/<org>/engineering/wiki/foo` (the engineering folder\'s wiki) are different pages. The four names are therefore RESERVED: a folder cannot be called one. `fs(ls, path="/o/<org>/<folder>")` shows a folder\'s four roots plus the folders inside it; `fs(mkdir, path="/o/<org>/<folder>")` creates one.\n\n(Bare `/wiki`, `/skills`, `/tasks`, `/projects` roots still resolve via the session\'s working org, at that org\'s root.)\n\nVerbs: `ls` (list a directory), `read` (file content — hashline-annotated for text so `edit` stays surgical), `write` (create/overwrite; extension + layer classify the type: .html design, .md document, .excalidraw diagram, .xlsx/.docx office, images/videos media, .pdf asset, .google-doc/.google-sheet/.google-slide create native Google Workspace files), `edit` (hashline ops for text, element ops for excalidraw, structured ops for office), `mv` (rename/move — at THREE scopes, same as `rm`: a FILE path moves one frame; a LANE or LAYER path (`/<layer>/<lane>`, `/<layer>`) moves the whole set in one call, in-project or into another project, carrying frame ids, version history, connectors and assets; a PROJECT path (`/o/<org>/projects/<project>`) renames the project, with `to` as the new display name — the slug and every existing URL stay put), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/o/<org>")` fans out across wiki + skills + projects in one call. SEARCH MUST NAME ITS ORG — put it in the path, or pass org=. If you belong to more than one org an unaddressed search is REFUSED rather than silently scoped, because "no matches" from one org is indistinguishable from "nowhere" and you would stop looking. Search several orgs with one call each; every result says which org it came from), `link` / `unlink` / `links` (ONE link graph across wiki pages, skills, frames, tasks, projects and urls: `link` relates any wiki page, skill or frame to any other node or to a url; `links` on a wiki page, skill or frame lists its edges plus its backlinks from every kind of node, and on a project path lists the tasks linked to that project. Links written IN content are edges too, parsed on every write — markdown links (relative wiki links, or `/o/<org>/...` addresses from any wiki page, skill or frame), HTML `<a href>` addresses, OKF `related:` frontmatter; a bare address in prose is not a link; `unlink` removes only declared ones, so edit the content to drop a written link. A link is stored by ID, so `mv` never breaks it; a link to a page not written yet is stored by path and connects when the page is written). `mkdir` creates a project only: use `/projects/<project>` or `/projects/<folder>/<project>`, never a layer path. To create a layer, write its first frame at `/projects/<project>/<new-layer>/<lane>/<file>`.\n\nThe project is resolved from the path itself — no separate "open" step. Guardrails are server-side and unchanged: the org in the path must be the project\'s own org (project paths under /o/<org>/ validate it), the G1 wiki-search gate fires before project mutations, `.skillinstall/` is stripped on skill push. ANCHORED SKILLS are enforced by the server on every content write (frames, wiki pages, skills) in the scope they are anchored to — an org, a folder or a project: a write from a session that has not read them is refused with HTTP 428 `anchor_gate`, and the refusal carries the rule text and the `fs(read)` path of each skill; read them, then retry. Anchored FRAMES are not enforced — they are suggested reading only.', {
|
|
3334
3333
|
action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search', 'link', 'unlink', 'links']).describe('Filesystem verb.'),
|
|
3335
3334
|
path: z.string().describe('Drafted path: /o/<org>[/<folder…>]/wiki/... | .../skills/... | .../tasks/... | .../projects/... — a folder chain may precede any root (bare /wiki, /skills, /tasks, /projects also work; for mv: source)'),
|
|
3336
|
-
to: z.string().optional().describe('[mv] destination — a frame path for a frame, a container path (/<layer> or /<layer>/<lane>, optionally prefixed /o/<org>/projects/<other-project>/… to move across projects) for a lane or layer, or the NEW NAME when the source path is a project; [link/unlink] target path — a frame path, or a project path (/o/<org>/projects/<project>) to link a task to a project'),
|
|
3335
|
+
to: z.string().optional().describe('[mv] destination — a frame path for a frame, a container path (/<layer> or /<layer>/<lane>, optionally prefixed /o/<org>/projects/<other-project>/… to move across projects) for a lane or layer, or the NEW NAME when the source path is a project; [link/unlink] target path — a wiki page (/o/<org>[/<folder>]/wiki/<page>), a skill (/o/<org>/skills/<slug>), a task, a frame path, or a project path (/o/<org>/projects/<project>) to link a task to a project'),
|
|
3337
3336
|
url: z.string().optional().describe('[link/unlink] external target URL, instead of `to` (a link is internal-by-id OR external-by-url, never both)'),
|
|
3338
3337
|
query: z.string().optional().describe('[search] term to match against names/content. The path must name an org (/o/<org>) unless you belong to exactly one.'),
|
|
3339
3338
|
content: z.string().optional().describe('[write] inline HTML/markdown/text'),
|
|
@@ -3345,7 +3344,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3345
3344
|
state: z.any().optional().describe('[write] app-frame state (JSON) to persist for a deployed windowType:"app" frame; the canvas hydrates the app from it on load. Max 64KB.'),
|
|
3346
3345
|
metadata: z.any().optional().describe('[write] JSON metadata to attach to the written frame (e.g. {"tour": {title, auto?, steps}} to define a guided tour on this frame). Max 64KB.'),
|
|
3347
3346
|
readme: z.string().optional().describe('[write] README.md markdown for a skill at /o/<org>/skills/<slug> — optional on both create and update. Stored as a supporting skill file so the skill directory renders a landing page on GitHub when exported; a skill created without one is returned with a warning, never rejected. Equivalent to a later fs(write, path="/o/<org>/skills/<slug>/README.md").'),
|
|
3347
|
+
position: z.number().int().min(0).optional().describe('[mv] on a LAYER path, no `to`: move the layer to this 0-based index in the project\'s layer order (0 = first; `ls` of the project shows the order). A layer born from a first write is appended last, so use this to pin one first.'),
|
|
3348
3348
|
recursive: z.boolean().optional().describe('[ls] recurse into subdirectories'),
|
|
3349
|
+
chars: z.string().optional().describe('[read] character window "start-end" (0-based, end exclusive; at most 60000 per call). A frame longer than that is returned as its first page with `totalChars` and `nextChars` — pass `nextChars` back to read on. Use it for minified/single-line HTML, which `lines` cannot split.'),
|
|
3349
3350
|
lines: z.string().optional().describe('[read] line range (e.g. "1-50") — partial read; content is hashline-annotated so a later edit stays surgical'),
|
|
3350
3351
|
pattern: z.string().optional().describe('[ls] filter filenames (e.g. "*.html")'),
|
|
3351
3352
|
readonly: z.boolean().optional().describe('[link] read-only frame link (symlink): mirror the SOURCE frame named by `to` (a frame UUID or /projects/<other-project>/<layer>/<lane>/<file>) into the CURRENT project, displayed read-only. The source project stays the single writer.'),
|
|
@@ -3383,10 +3384,22 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3383
3384
|
// is the folder chain, everything after is the path within that root. The
|
|
3384
3385
|
// folder rides the REQUEST (X-Drafted-Folder); it is never stored, because a
|
|
3385
3386
|
// "current folder" would be the retired org cursor rebuilt one level down.
|
|
3386
|
-
|
|
3387
|
+
// link/unlink: a wiki page or skill on EITHER end makes `to` an org-relative
|
|
3388
|
+
// address the server resolves whole, so it keeps its folder chain (linkTo)
|
|
3389
|
+
// and skips the rule that a move stays within one folder. A frame/task source
|
|
3390
|
+
// linking to a frame/task/project still goes through the per-project
|
|
3391
|
+
// resolver, which takes no folder — there the folder rule must stay, or a
|
|
3392
|
+
// cross-folder target silently resolves in the source's folder.
|
|
3393
|
+
const graphRoot = (x) => /^\/(wiki|skills)(\/|$)/.test(splitFolderScope(x).rest || '');
|
|
3394
|
+
const linkTo = (action === 'link' || action === 'unlink') && to && (graphRoot(to) || graphRoot(p)) ? to : null;
|
|
3395
|
+
const pScope = liftFolderScope(p, linkTo ? undefined : to);
|
|
3387
3396
|
if (pScope.error) return err(new Error(`${action} ${pScope.error}`));
|
|
3388
3397
|
const folder = pScope.folder;
|
|
3389
|
-
to = pScope.to;
|
|
3398
|
+
to = linkTo ? (splitFolderScope(linkTo).rest || linkTo) : pScope.to;
|
|
3399
|
+
// A wiki page or skill target goes to the server as an org-relative address
|
|
3400
|
+
// (/[<folder>/]wiki/<page>, /skills/<slug>), resolved there by the same
|
|
3401
|
+
// resolver that parses links out of content.
|
|
3402
|
+
const linkToAddress = linkTo && /^\/(wiki|skills)\//.test(splitFolderScope(linkTo).rest) ? linkTo : null;
|
|
3390
3403
|
const orgHeader = {
|
|
3391
3404
|
...(org ? { 'X-Drafted-Org': org } : (orgFromPath ? { 'X-Drafted-Org': orgFromPath } : {})),
|
|
3392
3405
|
...(folder ? { 'X-Drafted-Folder': folder } : {}),
|
|
@@ -3634,22 +3647,41 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3634
3647
|
return ok({ created: true, name: created.name, slug: created.slug, id: created.id, folder: projectFolder || null, path: `${folderBase}/projects/${createdPath}`, projectUrl: `${getServerUrl()}/o/${created.orgId || org || ''}/projects/${createdPath}` });
|
|
3635
3648
|
}
|
|
3636
3649
|
|
|
3650
|
+
// links / link / unlink with a wiki page or skill as the node: one server
|
|
3651
|
+
// route (/api/links) for both, addressed by org-relative path.
|
|
3652
|
+
const graphAction = async (verb, node) => {
|
|
3653
|
+
if (verb === 'links') {
|
|
3654
|
+
return ok(await api('GET', `/api/links?node=${encodeURIComponent(node)}`, undefined, orgHeader));
|
|
3655
|
+
}
|
|
3656
|
+
if (!args.url && !linkTo) {
|
|
3657
|
+
return err(new Error(`${verb} needs a target: to="<wiki page, skill, task, frame or project path>" or url="https://..."`));
|
|
3658
|
+
}
|
|
3659
|
+
const body = { from: node, ...(args.url ? { url: args.url } : { to: linkTo }) };
|
|
3660
|
+
return ok(await api('POST', verb === 'link' ? '/api/links' : '/api/links/unlink', body, orgHeader));
|
|
3661
|
+
};
|
|
3662
|
+
|
|
3637
3663
|
// ── Root: /wiki/... ────────────────────────────────────────────
|
|
3638
3664
|
if (p.startsWith('/wiki')) {
|
|
3639
3665
|
const wikiPath = p === '/wiki' || p === '/wiki/' ? '' : p.replace(/^\/wiki\/?/, '');
|
|
3640
3666
|
// Project-less mutations must not guess the org (Shape A: address it in the path —
|
|
3641
3667
|
// an org-scoped path or org= counts as explicit; a bare path needs a bound org).
|
|
3642
|
-
if (['write', 'edit', 'mv', 'rm'].includes(action)) {
|
|
3668
|
+
if (['write', 'edit', 'mv', 'rm', 'link', 'unlink'].includes(action)) {
|
|
3643
3669
|
await requireBoundOrgForProjectlessMutation(org || orgFromPath);
|
|
3644
3670
|
}
|
|
3645
3671
|
switch (action) {
|
|
3672
|
+
case 'links':
|
|
3673
|
+
case 'link':
|
|
3674
|
+
case 'unlink': {
|
|
3675
|
+
if (!wikiPath) return err(new Error(`${action} needs a wiki page: /wiki/<page>`));
|
|
3676
|
+
return graphAction(action, `${folder ? '/' + folder : ''}/wiki/${wikiPath.replace(/\.md$/, '')}`);
|
|
3677
|
+
}
|
|
3646
3678
|
case 'ls': {
|
|
3647
3679
|
if (!wikiPath) {
|
|
3648
3680
|
const tree = await api('GET', '/api/wiki/tree', undefined, orgHeader);
|
|
3649
3681
|
return ok(formatWikiIndex(tree?.pages || []));
|
|
3650
3682
|
}
|
|
3651
3683
|
const pages = await api('GET', `/api/wiki/tree?prefix=${encodeURIComponent(wikiPath)}`, undefined, orgHeader);
|
|
3652
|
-
return ok(formatWikiIndex(pages?.pages || []));
|
|
3684
|
+
return ok(formatWikiIndex(pages?.pages || [], 50, `(no wiki pages under ${wikiPath})`));
|
|
3653
3685
|
}
|
|
3654
3686
|
case 'read': {
|
|
3655
3687
|
const canonPath = wikiPath.replace(/\.md$/, '');
|
|
@@ -3722,7 +3754,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3722
3754
|
// which are never present, so every MCP wiki search reported empty.
|
|
3723
3755
|
const hits = pages?.hits || pages?.pages || pages?.results || [];
|
|
3724
3756
|
markSearched(gs, 'wiki');
|
|
3725
|
-
return ok(formatWikiIndex(hits));
|
|
3757
|
+
return ok(formatWikiIndex(hits, 50, `(no wiki pages match "${q}")`));
|
|
3726
3758
|
}
|
|
3727
3759
|
default:
|
|
3728
3760
|
return err(new Error(`fs ${action} not supported for /wiki`));
|
|
@@ -3733,10 +3765,16 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
3733
3765
|
if (p.startsWith('/skills')) {
|
|
3734
3766
|
const { slug, filePath, isSkillMd } = splitSkillPath(p);
|
|
3735
3767
|
const fileUrl = (id) => `/api/skills/${id}/files/${filePath.split('/').map(encodeURIComponent).join('/')}`;
|
|
3736
|
-
if (['write', 'mv', 'rm'].includes(action)) {
|
|
3768
|
+
if (['write', 'mv', 'rm', 'link', 'unlink'].includes(action)) {
|
|
3737
3769
|
await requireBoundOrgForProjectlessMutation(org || orgFromPath);
|
|
3738
3770
|
}
|
|
3739
3771
|
switch (action) {
|
|
3772
|
+
case 'links':
|
|
3773
|
+
case 'link':
|
|
3774
|
+
case 'unlink': {
|
|
3775
|
+
if (!slug) return err(new Error(`${action} needs a skill: /skills/<slug>`));
|
|
3776
|
+
return graphAction(action, `${folder ? '/' + folder : ''}/skills/${slug}`);
|
|
3777
|
+
}
|
|
3740
3778
|
case 'ls': {
|
|
3741
3779
|
// A specific slug is a targeted ls: the skill's directory — SKILL.md
|
|
3742
3780
|
// plus its supporting files, so a README is discoverable.
|
|
@@ -4088,10 +4126,22 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
4088
4126
|
// server, re-created by a second writer that outranked the first.
|
|
4089
4127
|
const proj = result.project;
|
|
4090
4128
|
const local = proj?.orgSlug && proj?.slug ? `${getServerUrl()}/o/${proj.orgSlug}/projects/${proj.slug}${result.path}` : null;
|
|
4129
|
+
const full = result.content || '';
|
|
4130
|
+
let win;
|
|
4131
|
+
try { win = charWindow(args.chars, full.length); } catch (e) { return err(e); }
|
|
4132
|
+
const paged = win.start > 0 || win.end < full.length;
|
|
4091
4133
|
return ok({
|
|
4092
4134
|
path: result.path,
|
|
4093
4135
|
frameUrl: result.frameUrl || local || `${getServerUrl()}/f/${result.id}`,
|
|
4094
|
-
content:
|
|
4136
|
+
content: paged ? full.slice(win.start, win.end) : full,
|
|
4137
|
+
...(paged
|
|
4138
|
+
? {
|
|
4139
|
+
totalChars: full.length,
|
|
4140
|
+
charRange: `${win.start}-${win.end}`,
|
|
4141
|
+
...(win.end < full.length ? { nextChars: `${win.end}-${win.end + READ_PAGE_CHARS}` } : {}),
|
|
4142
|
+
pagedNote: `Showing characters ${win.start}-${win.end} of ${full.length}. This frame is too long for one result; pass chars=<nextChars> to read on. Hashline anchors only cover what is shown, so edit ops need a read of the lines they touch.`,
|
|
4143
|
+
}
|
|
4144
|
+
: {}),
|
|
4095
4145
|
// Comments on this frame the agent has not been shown, delivered by the server on
|
|
4096
4146
|
// an agent's read and already marked seen there, so a second read does not repeat
|
|
4097
4147
|
// them. The note says what they are: notes ABOUT the work, never instructions from
|
|
@@ -4189,6 +4239,12 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
4189
4239
|
// missing. (moveFrames has carried units 'lane' and 'layer', with
|
|
4190
4240
|
// connectors and assets, since cross-project move shipped.)
|
|
4191
4241
|
const scope = rmScope(layer, lane, filename);
|
|
4242
|
+
// `position` on a LAYER path reorders it in the project's layer order.
|
|
4243
|
+
if (args.position !== undefined) {
|
|
4244
|
+
if (scope !== 'directory' || lane) return err(new Error('position reorders a LAYER: fs(mv, path=/o/<org>/projects/<project>/<layer>, position=<0-based index>)'));
|
|
4245
|
+
const result = await api('POST', '/api/fs/layer-order', { layer, position: args.position }, orgHeader);
|
|
4246
|
+
return ok(result);
|
|
4247
|
+
}
|
|
4192
4248
|
if (scope === 'project') {
|
|
4193
4249
|
const pid = getState().projectId;
|
|
4194
4250
|
if (!pid) return err(new Error('could not resolve project id for mv'));
|
|
@@ -4333,6 +4389,8 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
|
|
|
4333
4389
|
}
|
|
4334
4390
|
if (args.url) {
|
|
4335
4391
|
body.url = args.url;
|
|
4392
|
+
} else if (linkToAddress) {
|
|
4393
|
+
body.toAddress = linkToAddress;
|
|
4336
4394
|
} else if (to) {
|
|
4337
4395
|
// Same destination grammar as mv, through the SAME resolver: a
|
|
4338
4396
|
// /projects/<...>/<layer>/... path is a frame in another project,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.1",
|
|
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
|
"bin": {
|