drafted 1.17.16 → 1.17.18

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/server.mjs CHANGED
@@ -129,6 +129,37 @@ export function runWithRequestState(initial, fn) {
129
129
  // Stripped from the advertised schema on remote transports.
130
130
  const LOCAL_ONLY_PARAMS = ['file_path'];
131
131
 
132
+ /**
133
+ * Read a local file as UTF-8 text for a `file_path` write to /wiki or /skills.
134
+ *
135
+ * Those roots are markdown — they take `content`, not bytes — so this is a
136
+ * different seam from the /projects upload path, which base64s the file and
137
+ * sends a contentType. Without this, writing a page or a SKILL.md that already
138
+ * exists on disk means pasting the whole file through the caller's context,
139
+ * which for anything sizeable is both slow and lossy.
140
+ *
141
+ * stdio only: `file_path` is stripped from the advertised schema on remote
142
+ * transports (LOCAL_ONLY_PARAMS), because the server has no access to the
143
+ * caller's filesystem.
144
+ *
145
+ * Exported for `mcp/test-file-path-text.mjs`.
146
+ */
147
+ export function textFromLocalFile(filePath) {
148
+ let resolved = filePath;
149
+ try { resolved = resolve(filePath); } catch { /* keep as-is */ }
150
+ if (!existsSync(resolved)) throw new Error(`file not found: ${filePath}`);
151
+ if (statSync(resolved).isDirectory()) {
152
+ throw new Error(`${filePath} is a directory — pass a file`);
153
+ }
154
+ const buf = readFileSync(resolved);
155
+ // A NUL byte means this is not text. Writing it would store mojibake that
156
+ // only shows up much later, when someone reads the page back.
157
+ if (buf.includes(0)) {
158
+ throw new Error(`${filePath} looks binary — /wiki and /skills take text (markdown)`);
159
+ }
160
+ return buf.toString('utf8');
161
+ }
162
+
132
163
  // Per-tool params whose value is arbitrary / deeply-nested JSON. Zod renders
133
164
  // these (z.any(), z.array(z.any()), z.object({}).passthrough()) as an UNTYPED
134
165
  // schema — an empty `{}` or an array whose `items` has no `type`. ChatGPT's
@@ -2697,7 +2728,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2697
2728
  to: z.string().optional().describe('[mv] destination path'),
2698
2729
  query: z.string().optional().describe('[search] term to match against names/content'),
2699
2730
  content: z.string().optional().describe('[write] inline HTML/markdown/text'),
2700
- file_path: z.string().optional().describe('[write] absolute path to a local file to upload (stdio only)'),
2731
+ file_path: z.string().optional().describe('[write] absolute path to a local file to upload (stdio only). Under /projects it uploads bytes (images, PDFs, office files); under /wiki and /skills it reads the file as UTF-8 text, so a markdown file on disk can be written straight to a page or a SKILL.md without pasting it.'),
2701
2732
  base64: z.string().optional().describe('[write] base64-encoded binary content'),
2702
2733
  googleType: z.enum(['google-doc', 'google-sheet', 'google-slide']).optional().describe('[write] explicit Google Workspace type (also derived from .google-* filename extension)'),
2703
2734
  title: z.string().optional().describe('[write + googleType] title for a new native file'),
@@ -2808,7 +2839,11 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2808
2839
  return ok(page.content);
2809
2840
  }
2810
2841
  case 'write': {
2811
- if (!content) return err(new Error('write to /wiki/... requires content'));
2842
+ let text = content;
2843
+ if (text === undefined && file_path) {
2844
+ try { text = textFromLocalFile(file_path); } catch (e) { return err(e); }
2845
+ }
2846
+ if (!text) return err(new Error('write to /wiki/... requires content or file_path'));
2812
2847
  // OKF boundary rule: /a/b.md ≡ a/b — strip a trailing .md so both spellings work.
2813
2848
  const canonPath = wikiPath.replace(/\.md$/, '');
2814
2849
  const existing = await api('GET', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, undefined, orgHeader).catch(() => null);
@@ -2817,9 +2852,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2817
2852
  // here silently forced every MCP-created page to type "Page" and a
2818
2853
  // path-segment title, discarding the author's frontmatter. The server
2819
2854
  // lifts both from the preamble and falls back to the path segment.
2820
- const body = { path: canonPath, content };
2855
+ const body = { path: canonPath, content: text };
2821
2856
  const result = existing
2822
- ? await api('PUT', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, { content }, orgHeader)
2857
+ ? await api('PUT', `/api/wiki/page?path=${encodeURIComponent(canonPath)}`, { content: text }, orgHeader)
2823
2858
  : await api('POST', '/api/wiki/pages', body, orgHeader);
2824
2859
  return ok(result || { path: canonPath, written: true });
2825
2860
  }
@@ -2894,7 +2929,12 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2894
2929
  return ok(s?.content || '');
2895
2930
  }
2896
2931
  case 'write': {
2897
- if (!slug || !content) return err(new Error('write /skills/<slug> requires content'));
2932
+ if (!slug) return err(new Error('write /skills/<slug> requires a slug'));
2933
+ let text = content;
2934
+ if (text === undefined && file_path) {
2935
+ try { text = textFromLocalFile(file_path); } catch (e) { return err(e); }
2936
+ }
2937
+ if (!text) return err(new Error('write /skills/<slug> requires content or file_path'));
2898
2938
  const existing = await api('GET', `/api/skills/slug/${slug}`, undefined, orgHeader).catch(() => null);
2899
2939
  if (!existing) {
2900
2940
  const g2 = g2Block(gs);
@@ -2905,8 +2945,8 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2905
2945
  // (the API requires a non-empty description on create).
2906
2946
  const description = args.description || existing?.description || `Reusable procedure: ${name.toLowerCase()}`;
2907
2947
  const result = existing
2908
- ? await api('PUT', `/api/skills/${existing.id}`, { content }, orgHeader)
2909
- : await api('POST', '/api/skills', { slug, name, content, description }, orgHeader);
2948
+ ? await api('PUT', `/api/skills/${existing.id}`, { content: text }, orgHeader)
2949
+ : await api('POST', '/api/skills', { slug, name, content: text, description }, orgHeader);
2910
2950
  // Writing an archived skill restores it (update flow un-archives).
2911
2951
  if (result?.id && existing?.archived) {
2912
2952
  try { await api('POST', `/api/skills/${result.id}/restore`, undefined, orgHeader); } catch { /* best-effort */ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Regression for textFromLocalFile() — the `file_path` text seam used by
3
+ * fs(write) on /wiki and /skills.
4
+ *
5
+ * node mcp/test-file-path-text.mjs
6
+ *
7
+ * Why this exists: /projects uploads bytes (base64 + contentType), /wiki and
8
+ * /skills take markdown text. Routing a file down the wrong seam stores
9
+ * mojibake that only surfaces when someone reads the page back, so the binary
10
+ * guard below is the load-bearing assertion, not a nicety.
11
+ */
12
+ import assert from 'node:assert/strict';
13
+ import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { textFromLocalFile } from './server.mjs';
17
+
18
+ const dir = mkdtempSync(join(tmpdir(), 'drafted-filepath-'));
19
+
20
+ // Plain markdown round-trips byte-for-byte.
21
+ const md = join(dir, 'page.md');
22
+ const body = '# Title\n\nBody with a UTF-8 em dash — and an accent é.\n';
23
+ writeFileSync(md, body, 'utf8');
24
+ assert.equal(textFromLocalFile(md), body);
25
+
26
+ // Multi-byte characters survive — a latin1 read would mangle these.
27
+ assert.ok(textFromLocalFile(md).includes('—'));
28
+ assert.ok(textFromLocalFile(md).includes('é'));
29
+
30
+ // Empty file returns '' rather than throwing. The caller decides: both write
31
+ // sites treat falsy text as "no content supplied" and error with guidance.
32
+ const empty = join(dir, 'empty.md');
33
+ writeFileSync(empty, '');
34
+ assert.equal(textFromLocalFile(empty), '');
35
+
36
+ // Binary is refused. A PNG header contains NUL bytes.
37
+ const png = join(dir, 'shot.png');
38
+ writeFileSync(png, Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]));
39
+ assert.throws(() => textFromLocalFile(png), /looks binary/);
40
+
41
+ // Directories are refused with a clear message, not an EISDIR stack trace.
42
+ const sub = join(dir, 'nested');
43
+ mkdirSync(sub);
44
+ assert.throws(() => textFromLocalFile(sub), /is a directory/);
45
+
46
+ // Missing files name the path the caller passed, not the resolved one.
47
+ assert.throws(() => textFromLocalFile(join(dir, 'nope.md')), /file not found/);
48
+
49
+ // Relative paths resolve against cwd rather than being read blindly.
50
+ assert.throws(() => textFromLocalFile('definitely-not-here-xyz.md'), /file not found/);
51
+
52
+ console.log('file_path text seam ok — utf8 preserved, binary and dirs refused');
53
+
54
+ // Importing server.mjs opens the MCP WebSocket at module scope, which pins the
55
+ // event loop open forever. mcp/test-org-guards.mjs only exits because it
56
+ // finishes in ~176ms — before the socket connects — so it passes on a race
57
+ // rather than by design. Exit explicitly instead of inheriting that luck.
58
+ process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.17.16",
3
+ "version": "1.17.18",
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": [
@@ -72,7 +72,7 @@
72
72
  "react": "^18.3.1",
73
73
  "react-dom": "^18.3.1",
74
74
  "sharp": "^0.34.5",
75
- "ws": "^8.16.0",
75
+ "ws": "^8.21.3",
76
76
  "yaml": "^2.8.3",
77
77
  "zod": "^4.3.6"
78
78
  },
@@ -8,13 +8,13 @@ Spin up a project on the surface, or generalize one into a reusable template. Go
8
8
  First ask: **a new project, or a reusable template?** (This command does both.)
9
9
 
10
10
  Search before creating (also satisfies the gates):
11
- 1. `fs(search, path/o/<org>/wiki", query="<terms>")` for relevant knowledge. [G1]
12
- 2. `fs(search, path/o/<org>/skills", query="<topic>")` for procedures that should be attached. [G2/G3]
11
+ 1. `fs(search, path="/o/<org>/wiki", query="<terms>")` for relevant knowledge. [G1]
12
+ 2. `fs(search, path="/o/<org>/skills", query="<topic>")` for procedures that should be attached. [G2/G3]
13
13
 
14
14
  Then:
15
- - Create the project — a project is a folder: `fs(mkdir, path/o/<org>/projects/<name>")`. It starts with **no layers**; you define them by writing into them.
16
- - `fs(write, path/o/<org>/projects/<name>/<layer>/<lane>/brief.md", content=...)` a real brief at the earliest layer (goal, audience, constraints — 6-12 lines, not a placeholder). The layer + lane auto-create.
15
+ - Create the project — a project is a folder: `fs(mkdir, path="/o/<org>/projects/<name>")`. It starts with **no layers**; you define them by writing into them.
16
+ - `fs(write, path="/o/<org>/projects/<name>/<layer>/<lane>/brief.md", content=...)` a real brief at the earliest layer (goal, audience, constraints — 6-12 lines, not a placeholder). The layer + lane auto-create.
17
17
  - Attach the relevant skills you found via the skill gates.
18
- - `fs(ls, path/o/<org>/projects/<name>")` to confirm the structure, and hand the user the `projectUrl` from the response.
18
+ - `fs(ls, path="/o/<org>/projects/<name>")` to confirm the structure, and hand the user the `projectUrl` from the response.
19
19
 
20
20
  For a **template**: build the layer structure + anchored guidance, then note how to fork it next time. Don't speculatively fill downstream frames — wait for direction.
@@ -6,8 +6,8 @@ argument-hint: <what the procedure is for>
6
6
  Turn a repeatable way of working into a Drafted skill so every future agent in the org follows it. Procedure: $ARGUMENTS
7
7
 
8
8
  Do the searches first (they also satisfy the gates):
9
- 1. `fs(search, path/o/<org>/wiki", query="<terms>")` for relevant org knowledge the procedure should reference. [G1]
10
- 2. `fs(search, path/o/<org>/skills", query="<topic>")` for prior art — an existing skill to improve instead of duplicating. [G2 — also enforced on writing a new skill.] If a close match exists, prefer `/drafted:improve-skill`.
9
+ 1. `fs(search, path="/o/<org>/wiki", query="<terms>")` for relevant org knowledge the procedure should reference. [G1]
10
+ 2. `fs(search, path="/o/<org>/skills", query="<topic>")` for prior art — an existing skill to improve instead of duplicating. [G2 — also enforced on writing a new skill.] If a close match exists, prefer `/drafted:improve-skill`.
11
11
 
12
12
  Then define a PROPER procedure, not a vague note:
13
13
  - a clear trigger ("when to use this"),
@@ -15,4 +15,4 @@ Then define a PROPER procedure, not a vague note:
15
15
  - success criteria / what "done right" looks like,
16
16
  - written in the second person so any agent can follow it directly. Keep it sharp (~40 lines).
17
17
 
18
- Show the draft to the user with a proposed `name` (Title Case), one-line `description`, `tags` (3-5), and `triggerPatterns`. After approval, write it with `fs(write, path/o/<org>/skills/<slug>", content=...)` — the slug is derived from the name. Confirm with the slug and note it will auto-surface on matching tasks.
18
+ Show the draft to the user with a proposed `name` (Title Case), one-line `description`, `tags` (3-5), and `triggerPatterns`. After approval, write it with `fs(write, path="/o/<org>/skills/<slug>", content=...)` — the slug is derived from the name. Confirm with the slug and note it will auto-surface on matching tasks.
@@ -7,7 +7,7 @@ Session-end deposit. Harvest what's durable from this conversation back into the
7
7
 
8
8
  Review the session, then **present the user options for which store(s) to deposit into** — don't auto-decide. Offer any that apply:
9
9
 
10
- - **Knowledge → wiki** — durable facts, decisions, or findings worth keeping. Search first (`fs(search, path/o/<org>/wiki", ...)`) to avoid fragmenting, then `fs(write, path/o/<org>/wiki/<path>", ...)`.
10
+ - **Knowledge → wiki** — durable facts, decisions, or findings worth keeping. Search first (`fs(search, path="/o/<org>/wiki", ...)`) to avoid fragmenting, then `fs(write, path="/o/<org>/wiki/<path>", ...)`.
11
11
  - **Procedure → skill** — a repeatable way of working that emerged. Follow `/drafted:create-skill`.
12
12
  - **Template → surface** — a reusable project structure that emerged. Build it as a project via `fs(mkdir, ...)` + frames.
13
13
 
@@ -7,8 +7,8 @@ When the user had to correct the work in this project, codify the correction so
7
7
 
8
8
  1. Identify the recurring correction(s) or standing rule from this session.
9
9
  2. Choose the right Drafted-side gate per correction (each counts toward the project's context budget):
10
- - **Project anchor** — a brief, constraint, or style guide that must be in context project-wide: `fs(write, path/o/<org>/projects/<project>/<layer>/<lane>/<file>", content=...)` it, then anchor it so it surfaces on open. [G5]
11
- - **Attached skill** — a procedure that must be loaded before work: `/drafted:create-skill` (or `fs(search, path/o/<org>/skills", ...)` for an existing one), then attach it to the project. [G4]
10
+ - **Project anchor** — a brief, constraint, or style guide that must be in context project-wide: `fs(write, path="/o/<org>/projects/<project>/<layer>/<lane>/<file>", content=...)` it, then anchor it so it surfaces on open. [G5]
11
+ - **Attached skill** — a procedure that must be loaded before work: `/drafted:create-skill` (or `fs(search, path="/o/<org>/skills", ...)` for an existing one), then attach it to the project. [G4]
12
12
  - **Layer rule** — a standing instruction for one stage: set that layer's rules so work in it is gated. [G6]
13
13
  3. Propose which mechanism for each correction, confirm with the user, then apply.
14
14
  4. Confirm what is now enforced — the next session in this project will be gated on it automatically.
@@ -5,10 +5,10 @@ argument-hint: <which skill, and what's off>
5
5
 
6
6
  Improve an existing org skill when it underperformed in practice. Skill / issue: $ARGUMENTS
7
7
 
8
- 1. `fs(search, path/o/<org>/skills", query="<slug>")` then `fs(read, path/o/<org>/skills/<slug>")` the skill in question — read it fully.
8
+ 1. `fs(search, path="/o/<org>/skills", query="<slug>")` then `fs(read, path="/o/<org>/skills/<slug>")` the skill in question — read it fully.
9
9
  2. Pinpoint the inefficiency: a missing step, a wrong instruction, an ambiguous trigger, or a step that wastes effort.
10
10
  3. Propose the specific edit to the user — show before/after of the changed steps.
11
- 4. After approval, `fs(write, path/o/<org>/skills/<slug>", content=<updated>)` — writing an existing skill updates it (the version bumps automatically). If it was archived, writing restores it.
11
+ 4. After approval, `fs(write, path="/o/<org>/skills/<slug>", content=<updated>)` — writing an existing skill updates it (the version bumps automatically). If it was archived, writing restores it.
12
12
  5. Confirm what changed so the next agent benefits.
13
13
 
14
14
  Skills are the org's stable processes — every fix compounds across everyone who uses them.
@@ -5,10 +5,10 @@ argument-hint: <what's wrong, or the topic to clean up>
5
5
 
6
6
  Improve the org wiki when knowledge has drifted. Issue/topic: $ARGUMENTS
7
7
 
8
- 1. `fs(search, path/o/<org>/wiki", query="<terms>")` (3-5 paraphrased queries) and `fs(read, path/o/<org>/wiki/<path>")` the affected pages — don't trust titles, open them.
8
+ 1. `fs(search, path="/o/<org>/wiki", query="<terms>")` (3-5 paraphrased queries) and `fs(read, path="/o/<org>/wiki/<path>")` the affected pages — don't trust titles, open them.
9
9
  2. Diagnose: duplicate pages, a stale fact, a contradiction, or knowledge fragmented across pages.
10
10
  3. Propose the fix to the user: consolidate duplicates, correct the fact, reconcile the contradiction, or re-link fragments.
11
- 4. Apply with `fs(write, path/o/<org>/wiki/<path>", content=<updated>)` (hashline `edit` for surgical changes) or `fs(mv, path/o/<org>/wiki/<from>", to/o/<org>/wiki/<to>")` (which rewrites inbound links). Check what links to a page before moving or archiving it.
12
- 5. Never hard-delete — `fs(rm, path/o/<org>/wiki/<path>")` moves a page to the archive folder.
11
+ 4. Apply with `fs(write, path="/o/<org>/wiki/<path>", content=<updated>)` (hashline `edit` for surgical changes) or `fs(mv, path="/o/<org>/wiki/<from>", to="/o/<org>/wiki/<to>")` (which rewrites inbound links). Check what links to a page before moving or archiving it.
12
+ 5. Never hard-delete — `fs(rm, path="/o/<org>/wiki/<path>")` moves a page to the archive folder.
13
13
 
14
14
  Leave the wiki more coherent than you found it — fewer, sharper, better-linked pages.
@@ -12,9 +12,9 @@ First decide WHAT to ingest. If it's obvious from the conversation (a research r
12
12
  3. **Interrogate the user** (grill-me) — when the knowledge is in their head. Interview relentlessly, walking ONE branch of the decision tree at a time and proposing your recommended answer at each step, until you reach shared understanding. Then structure what you captured.
13
13
 
14
14
  Then deposit:
15
- - `fs(search, path/o/<org>/wiki", query="<terms>")` first (3-5 paraphrased queries) so you don't fragment existing pages.
15
+ - `fs(search, path="/o/<org>/wiki", query="<terms>")` first (3-5 paraphrased queries) so you don't fragment existing pages.
16
16
  - Propose the page set to the user before writing.
17
- - Write with `fs(write, path/o/<org>/wiki/<path>", content=...)` per page. Cross-link related pages.
17
+ - Write with `fs(write, path="/o/<org>/wiki/<path>", content=...)` per page. Cross-link related pages.
18
18
  - Confirm what landed where, with the page links.
19
19
 
20
20
  The wiki is the org's durable knowledge — write for the next agent and teammate, not just this session.