drafted 1.18.3 → 1.19.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/mcp/server.mjs CHANGED
@@ -279,6 +279,45 @@ export function stripUrlOrigin(p) {
279
279
  // segment 0, so `write /skills/<slug>/README.md` silently rewrote SKILL.md — and
280
280
  // the create-time README gate then looked unsatisfiable from this tool.
281
281
  // Pure + exported (asserted in mcp/test-skill-paths.mjs).
282
+ // Search matches every TERM in the query, not the raw phrase. A whole-string
283
+ // substring test means a natural multi-word search ("tool surface parity MCP")
284
+ // never hits a project whose name/slug/description contains each of those words
285
+ // — while the single word "parity" does. That reads as "no such project" for a
286
+ // project that is right there. Pure + exported (asserted in mcp/test-search-terms.mjs).
287
+ export function matchesAllTerms(parts, query) {
288
+ const terms = String(query || '').toLowerCase().split(/\s+/).filter(Boolean);
289
+ if (!terms.length) return false;
290
+ const hay = parts.map((v) => String(v || '')).join(' ').toLowerCase();
291
+ return terms.every((t) => hay.includes(t));
292
+ }
293
+
294
+ // One frame hit: its addressable path, and the line that matched underneath it.
295
+ // A hit with no snippet matched on the LABEL, not on content — say which, so the
296
+ // agent knows whether the frame's text is evidence or just its filename.
297
+ // Pure + exported (asserted in mcp/test-search-terms.mjs).
298
+ export function formatFrameHits(frames, { limit = 25, snippetChars = 200 } = {}) {
299
+ return frames.slice(0, limit).map((f) => {
300
+ const path = `/o/${f.orgSlug || f.orgId}/projects/${f.projectSlug || f.projectId}/${f.layer}/${f.lane || ''}/${f.label}`.replace(/\/\//g, '/');
301
+ const snip = clip(String(f.snippet || '').replace(/\s+/g, ' ').trim(), snippetChars);
302
+ return snip ? ` ${path}\n ${snip}` : ` ${path} (label match)`;
303
+ }).join('\n');
304
+ }
305
+
306
+ // A search result is a POINTER. Long fields (a skill's full description, a
307
+ // paragraph-length snippet) turn a 25-hit answer into a payload the client spills
308
+ // to a file — the failure that made the old /projects listing unusable in-band.
309
+ export function clip(s, n) {
310
+ const t = String(s || '').trim();
311
+ return t.length > n ? t.slice(0, n - 1).trimEnd() + '…' : t;
312
+ }
313
+
314
+ // Archived projects are excluded from search results, mirroring the web UI. Saying
315
+ // how many were withheld is the difference between a scoped answer and a silent
316
+ // omission — the failure that prompted docs/plans/frame-content-search.md.
317
+ export function archivedNote(n) {
318
+ return n > 0 ? `\n(${n} more in archived projects — not listed; move the project out of Archive to surface them)` : '';
319
+ }
320
+
282
321
  export function splitSkillPath(p) {
283
322
  const rest = /^\/skills\/?$/.test(p || '') ? '' : String(p || '').replace(/^\/skills\/?/, '');
284
323
  const segs = rest.split('/').filter(Boolean);
@@ -2762,7 +2801,7 @@ server.resource('info', 'drafted://info', {
2762
2801
  };
2763
2802
  });
2764
2803
 
2765
- tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder: `fs(ls, path="/")` lists the orgs you can address, then `/o/<org>/<root>/...` addresses one of them — the org is part of the path, there is no org switching:\n\n- `/o/<org>/wiki/<path>` — org knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `/o/<org>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside)\n- `/o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file>` — producible frames (folder optional; then exactly layer → lane → file)\n\n(Bare `/wiki`, `/skills`, `/projects` roots still resolve via the session\'s working org.)\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, cross-project), `rm` (delete), `search` (across wiki + skills + projects). `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, attached-skill gates fire on mutations, anchored frames must be read before editing a layer, `.skillinstall/` is stripped on skill push.', {
2804
+ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder: `fs(ls, path="/")` lists the orgs you can address, then `/o/<org>/<root>/...` addresses one of them — the org is part of the path, there is no org switching:\n\n- `/o/<org>/wiki/<path>` — org knowledge pages (markdown, OKF; free nesting; `index.md` at any level is synthesized and read-only)\n- `/o/<org>/skills/<slug>` — reusable procedures (flat: one dir per skill slug, `SKILL.md` + supporting files inside)\n- `/o/<org>/projects/<folder?>/<project>/<layer>/<lane>/<file>` — producible frames (folder optional; then exactly layer → lane → file)\n\n(Bare `/wiki`, `/skills`, `/projects` roots still resolve via the session\'s working org.)\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, cross-project), `rm` (delete), `search` (frames are searched by label AND content, with the matching line returned as a snippet; `fs(search, path="/")` or `path="/o/<org>"` fans out across wiki + skills + projects in one call). `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, attached-skill gates fire on mutations, anchored frames must be read before editing a layer, `.skillinstall/` is stripped on skill push.', {
2766
2805
  action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search']).describe('Filesystem verb.'),
2767
2806
  path: z.string().describe('Drafted path: /o/<org>/wiki/... | /o/<org>/skills/... | /o/<org>/projects/... (bare /wiki, /skills, /projects also work; for mv: source)'),
2768
2807
  to: z.string().optional().describe('[mv] destination path'),
@@ -2812,6 +2851,65 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2812
2851
 
2813
2852
  // ── Root listing: / or empty → the orgs this session can address (Shape A) ──
2814
2853
  if (!p || p === '/' || p === '') {
2854
+ // search at the root fans out across all three roots, so the tool description's
2855
+ // "search across wiki + skills + projects" is literally one call. Each leg is
2856
+ // capped and labelled: the point is to find WHERE something lives, not to dump
2857
+ // three inventories. A leg that fails says so instead of reading as zero hits.
2858
+ if (action === 'search') {
2859
+ const q = String(query || '').trim();
2860
+ if (!q) return err(new Error('search requires a query — e.g. fs(search, path="/", query="<terms>")'));
2861
+ const scope = orgFromPath ? `/o/${orgFromPath}` : '';
2862
+ const leg = async (fn) => { try { return { v: await fn() }; } catch (e) { return { e: e?.message || String(e) }; } };
2863
+ const [wiki, skills, projects, frames] = await Promise.all([
2864
+ leg(() => api('GET', `/api/wiki/search?q=${encodeURIComponent(q)}&limit=10`, undefined, orgHeader)),
2865
+ leg(() => api('GET', `/api/skills/search?q=${encodeURIComponent(q)}`, undefined, orgHeader)),
2866
+ leg(() => api('GET', '/api/projects', undefined, orgHeader)),
2867
+ leg(() => withoutProjectScope(() => api('GET', `/api/search?q=${encodeURIComponent(q)}&limit=15`))),
2868
+ ]);
2869
+ // A root search IS the prior-art search both gates ask for — it read the wiki
2870
+ // and the skill library. Not crediting it would send the agent back to run
2871
+ // the same two queries again.
2872
+ markSearched(gs, 'wiki');
2873
+ markSearched(gs, 'skill');
2874
+
2875
+ const out = [];
2876
+ const section = (title, res, render) => {
2877
+ if (res.e) { out.push(`${title}: unavailable (${res.e})`); return; }
2878
+ const body = render(res.v);
2879
+ out.push(body ? `${title}:\n${body}` : `${title}: no matches`);
2880
+ };
2881
+
2882
+ section('Wiki', wiki, (v) => {
2883
+ const hits = (v?.hits || v?.pages || v?.results || []).slice(0, 10);
2884
+ return hits.length ? hits.map(h => ` ${scope}/wiki/${h.path}${h.title ? ` — ${clip(h.title, 80)}` : ''}`).join('\n') : '';
2885
+ });
2886
+ section('Skills', skills, (v) => {
2887
+ // Fewer than the other legs on purpose: skills search is fuzzy and its tail
2888
+ // is weak matches, which is noise in a fan-out whose job is "where does this
2889
+ // live?". fs(search, path="/skills") is the place to go deeper.
2890
+ const list = (Array.isArray(v) ? v : (v?.skills || [])).slice(0, 5);
2891
+ return list.length ? list.map(s => ` ${scope}/skills/${s.slug} — ${clip(s.description || s.name, 120)}`.trimEnd()).join('\n') : '';
2892
+ });
2893
+
2894
+ let allowed = null;
2895
+ section('Projects', projects, (v) => {
2896
+ let rows = (Array.isArray(v?.projects) ? v.projects : []).filter(x => x.folder !== '__archived');
2897
+ if (orgFromPath) rows = rows.filter(x => x.orgSlug === orgFromPath || x.orgId === orgFromPath);
2898
+ allowed = new Set(rows.map(x => x.id));
2899
+ const hits = rows.filter(x => matchesAllTerms([x.name, x.slug, x.description], q)).slice(0, 10);
2900
+ return hits.length ? hits.map(x => ` /o/${x.orgSlug || x.orgId}/projects/${x.slug || x.id}`).join('\n') : '';
2901
+ });
2902
+ section('Frames', frames, (v) => {
2903
+ let hits = Array.isArray(v) ? v : (v?.results || []);
2904
+ // Only gate on org when the projects leg actually resolved; an empty set
2905
+ // from a FAILED projects call would silently zero out every frame hit.
2906
+ if (orgFromPath && allowed) hits = hits.filter(f => allowed.has(f.projectId));
2907
+ const note = archivedNote(Array.isArray(v) ? 0 : (v?.archivedCount || 0));
2908
+ return hits.length ? formatFrameHits(hits, { limit: 15 }) + note : (note ? note.trimStart() : '');
2909
+ });
2910
+
2911
+ return ok(`Search "${q}"${orgFromPath ? ` in /o/${orgFromPath}` : ''}\n\n${out.join('\n\n')}`);
2912
+ }
2815
2913
  if (action !== 'ls') return err(new Error('read/write/edit/mv/rm require a path under /o/<org>/wiki, /o/<org>/skills, or /o/<org>/projects'));
2816
2914
  if (orgFromPath) {
2817
2915
  // ls /o/<org> → that org's three roots
@@ -3094,16 +3192,15 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3094
3192
  // addressable paths instead of the whole inventory.
3095
3193
  const q = String(query || '').trim();
3096
3194
  if (!q) return err(new Error('search requires a query — e.g. fs(search, path="/projects", query="<terms>")'));
3097
- const needle = q.toLowerCase();
3098
- const hitProjects = rows.filter(x =>
3099
- [x.name, x.slug, x.description].some(v => String(v || '').toLowerCase().includes(needle))
3100
- );
3195
+ const hitProjects = rows.filter(x => matchesAllTerms([x.name, x.slug, x.description], q));
3101
3196
  let frames = [];
3197
+ let archivedCount = 0;
3102
3198
  let frameError = null;
3103
3199
  try {
3104
3200
  // Unscoped: this is the org-wide "does anything for X exist?" question.
3105
3201
  const res = await withoutProjectScope(() => api('GET', `/api/search?q=${encodeURIComponent(q)}`));
3106
3202
  frames = Array.isArray(res) ? res : (res?.results || []);
3203
+ archivedCount = Array.isArray(res) ? 0 : (res?.archivedCount || 0);
3107
3204
  } catch (e) {
3108
3205
  // Project matches still answer "does this exist?", so don't fail the whole
3109
3206
  // call — but say the frame leg broke rather than implying zero hits.
@@ -3118,12 +3215,13 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3118
3215
  ? `Projects matching "${q}":\n${formatProjectIndex(hitProjects, { boundPath })}`
3119
3216
  : `No project name or description matches "${q}".`);
3120
3217
  if (frames.length) {
3121
- const lines = frames.slice(0, 25).map(f =>
3122
- ` /o/${f.orgSlug || f.orgId}/projects/${f.projectSlug || f.projectId}/${f.layer}/${f.lane || ''}/${f.label}`.replace(/\/\//g, '/')
3123
- );
3124
- out.push(`\nFrames matching "${q}" (${frames.length}${frames.length > 25 ? ', first 25' : ''}):\n${lines.join('\n')}`);
3218
+ out.push(`\nFrames matching "${q}" (${frames.length}${frames.length > 25 ? ', first 25' : ''}):\n${formatFrameHits(frames)}${archivedNote(archivedCount)}`);
3125
3219
  } else if (frameError) {
3126
3220
  out.push(`\n(frame search unavailable: ${frameError} — project matches above are complete, frame matches were not checked)`);
3221
+ } else {
3222
+ // Say the frame leg ran and found nothing. Printing only the project leg
3223
+ // leaves "frames were never searched" and "no frame matched" looking the same.
3224
+ out.push(`\nNo frame matches "${q}" (searched frame labels and content).${archivedNote(archivedCount)}`);
3127
3225
  }
3128
3226
  return ok(out.join('\n'));
3129
3227
  }
@@ -3331,11 +3429,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3331
3429
  // Express parse projectId as an ARRAY and the scope match nothing.
3332
3430
  const res = await api('GET', `/api/search?q=${encodeURIComponent(q)}`);
3333
3431
  const frames = Array.isArray(res) ? res : (res?.results || []);
3334
- if (!frames.length) return ok(`No frames matching "${q}".`);
3335
- const lines = frames.slice(0, 50).map(f =>
3336
- ` /o/${f.orgSlug || f.orgId}/projects/${f.projectSlug || f.projectId}/${f.layer}/${f.lane || ''}/${f.label}`.replace(/\/\//g, '/')
3337
- );
3338
- return ok(`${frames.length} frame${frames.length === 1 ? '' : 's'} matching "${q}":\n${lines.join('\n')}`);
3432
+ const archivedCount = Array.isArray(res) ? 0 : (res?.archivedCount || 0);
3433
+ if (!frames.length) return ok(`No frames matching "${q}" (searched frame labels and content).${archivedNote(archivedCount)}`);
3434
+ return ok(`${frames.length} frame${frames.length === 1 ? '' : 's'} matching "${q}":\n${formatFrameHits(frames, { limit: 50 })}${archivedNote(archivedCount)}`);
3339
3435
  }
3340
3436
  default:
3341
3437
  return err(new Error(`fs ${action} not supported for /projects`));
@@ -0,0 +1,79 @@
1
+ // Regression for fs(search) term matching on /projects.
2
+ // Run: `node mcp/test-search-terms.mjs`. No framework — asserts the pure core.
3
+ //
4
+ // Why this exists: search used to substring-test the WHOLE query against each
5
+ // field, so `query="tool surface parity MCP Flow"` missed a project whose
6
+ // description says "tool surface" and "parity" — while `query="parity"` hit it.
7
+ // An agent reads that miss as "the project does not exist" and stops looking.
8
+ import assert from 'node:assert/strict';
9
+ import { matchesAllTerms, formatFrameHits, archivedNote, clip } from './server.mjs';
10
+
11
+ const proj = [
12
+ 'Flow AI MCP Server Feature Card',
13
+ 'flow-ai-mcp-server-feature-card',
14
+ 'Covers the BEO-2527 parity guard for the agent tool surface.',
15
+ ];
16
+
17
+ // The reported failure: a natural multi-word query spanning name AND description.
18
+ assert.equal(matchesAllTerms(proj, 'tool surface parity MCP Flow'), true);
19
+ // The single word that did work must keep working.
20
+ assert.equal(matchesAllTerms(proj, 'parity'), true);
21
+ // Terms match across fields and across a hyphenated slug.
22
+ assert.equal(matchesAllTerms(proj, 'flow feature card'), true);
23
+ assert.equal(matchesAllTerms(proj, 'BEO-2527'), true);
24
+ // Case and surrounding whitespace are irrelevant.
25
+ assert.equal(matchesAllTerms(proj, ' PARITY Guard '), true);
26
+
27
+ // AND, not OR: one unmatched term is a miss. An OR would return most of the org.
28
+ assert.equal(matchesAllTerms(proj, 'parity kubernetes'), false);
29
+ assert.equal(matchesAllTerms(proj, 'kubernetes'), false);
30
+
31
+ // An empty query never matches — the caller rejects it before this, and
32
+ // "every term" over zero terms would otherwise be vacuously true for every row.
33
+ assert.equal(matchesAllTerms(proj, ''), false);
34
+ assert.equal(matchesAllTerms(proj, ' '), false);
35
+ assert.equal(matchesAllTerms(proj, undefined), false);
36
+
37
+ // Null/undefined fields don't throw or match a stray "null"/"undefined" term.
38
+ assert.equal(matchesAllTerms([null, undefined, 'alpha'], 'alpha'), true);
39
+ assert.equal(matchesAllTerms([null, undefined, 'alpha'], 'null'), false);
40
+
41
+ // ── Frame hit rendering (docs/plans/frame-content-search.md, Phase 2) ────────
42
+ const hit = {
43
+ orgSlug: 'beoflow', projectSlug: 'flow-ai-mcp-server-feature-card',
44
+ layer: 'plans', lane: 'phase-0', label: 'feature-plan.md',
45
+ snippet: 'the BEO-2527 «parity guard» runs\n before every diff',
46
+ };
47
+ const rendered = formatFrameHits([hit]);
48
+ assert.match(rendered, /\/o\/beoflow\/projects\/flow-ai-mcp-server-feature-card\/plans\/phase-0\/feature-plan\.md/);
49
+ // The snippet is the evidence — it must survive, with its newlines flattened so
50
+ // one hit stays one block instead of wrapping into the next path.
51
+ assert.match(rendered, /«parity guard» runs before every diff/);
52
+ assert.equal(rendered.split('\n').length, 2);
53
+
54
+ // A hit with no snippet matched the LABEL, not content. Say so rather than
55
+ // rendering a bare path that looks identical to a content match.
56
+ assert.match(formatFrameHits([{ ...hit, snippet: null }]), /\(label match\)$/);
57
+ assert.match(formatFrameHits([{ ...hit, snippet: ' ' }]), /\(label match\)$/);
58
+
59
+ // A lane-less frame must not render a doubled slash — that path is not addressable.
60
+ assert.ok(!formatFrameHits([{ ...hit, lane: null }]).includes('//'));
61
+
62
+ // The cap is a cap; nothing renders beyond it.
63
+ assert.equal(formatFrameHits(Array(40).fill({ ...hit, snippet: null }), { limit: 3 }).split('\n').length, 3);
64
+
65
+ // Archived matches are counted, never silently dropped — and zero says nothing.
66
+ assert.equal(archivedNote(0), '');
67
+ assert.match(archivedNote(2), /2 more in archived projects/);
68
+
69
+ // A result is a pointer, not the document: long fields are clipped so 25 hits
70
+ // stay readable in-band instead of spilling to a file.
71
+ assert.equal(clip('short', 20), 'short');
72
+ assert.equal(clip('x'.repeat(50), 20).length, 20);
73
+ assert.match(clip('x'.repeat(50), 20), /…$/);
74
+ assert.equal(clip(null, 20), '');
75
+ const long = formatFrameHits([{ ...hit, snippet: 'w '.repeat(400) }], { snippetChars: 60 });
76
+ assert.ok(long.split('\n')[1].length <= 60 + 6, 'snippet line stays within its cap + indent');
77
+
78
+ console.log('search term matching + frame hit rendering ok');
79
+ process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.18.3",
3
+ "version": "1.19.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": [