drafted 1.18.4 → 1.19.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/server.mjs CHANGED
@@ -291,6 +291,33 @@ export function matchesAllTerms(parts, query) {
291
291
  return terms.every((t) => hay.includes(t));
292
292
  }
293
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
+
294
321
  export function splitSkillPath(p) {
295
322
  const rest = /^\/skills\/?$/.test(p || '') ? '' : String(p || '').replace(/^\/skills\/?/, '');
296
323
  const segs = rest.split('/').filter(Boolean);
@@ -2774,7 +2801,7 @@ server.resource('info', 'drafted://info', {
2774
2801
  };
2775
2802
  });
2776
2803
 
2777
- 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.', {
2778
2805
  action: z.enum(['ls', 'read', 'write', 'edit', 'mv', 'rm', 'mkdir', 'search']).describe('Filesystem verb.'),
2779
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)'),
2780
2807
  to: z.string().optional().describe('[mv] destination path'),
@@ -2824,6 +2851,65 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
2824
2851
 
2825
2852
  // ── Root listing: / or empty → the orgs this session can address (Shape A) ──
2826
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
+ }
2827
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'));
2828
2914
  if (orgFromPath) {
2829
2915
  // ls /o/<org> → that org's three roots
@@ -3108,11 +3194,13 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3108
3194
  if (!q) return err(new Error('search requires a query — e.g. fs(search, path="/projects", query="<terms>")'));
3109
3195
  const hitProjects = rows.filter(x => matchesAllTerms([x.name, x.slug, x.description], q));
3110
3196
  let frames = [];
3197
+ let archivedCount = 0;
3111
3198
  let frameError = null;
3112
3199
  try {
3113
3200
  // Unscoped: this is the org-wide "does anything for X exist?" question.
3114
3201
  const res = await withoutProjectScope(() => api('GET', `/api/search?q=${encodeURIComponent(q)}`));
3115
3202
  frames = Array.isArray(res) ? res : (res?.results || []);
3203
+ archivedCount = Array.isArray(res) ? 0 : (res?.archivedCount || 0);
3116
3204
  } catch (e) {
3117
3205
  // Project matches still answer "does this exist?", so don't fail the whole
3118
3206
  // call — but say the frame leg broke rather than implying zero hits.
@@ -3127,18 +3215,13 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3127
3215
  ? `Projects matching "${q}":\n${formatProjectIndex(hitProjects, { boundPath })}`
3128
3216
  : `No project name or description matches "${q}".`);
3129
3217
  if (frames.length) {
3130
- const lines = frames.slice(0, 25).map(f =>
3131
- ` /o/${f.orgSlug || f.orgId}/projects/${f.projectSlug || f.projectId}/${f.layer}/${f.lane || ''}/${f.label}`.replace(/\/\//g, '/')
3132
- );
3133
- 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)}`);
3134
3219
  } else if (frameError) {
3135
3220
  out.push(`\n(frame search unavailable: ${frameError} — project matches above are complete, frame matches were not checked)`);
3136
3221
  } else {
3137
3222
  // Say the frame leg ran and found nothing. Printing only the project leg
3138
- // leaves "frames were never searched" and "no frame matched" looking the
3139
- // same, and frame search matches LABELS only so a term that lives in
3140
- // frame content is a miss, not an absence.
3141
- out.push(`\nNo frame label matches "${q}" (frame search matches labels, not frame content).`);
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)}`);
3142
3225
  }
3143
3226
  return ok(out.join('\n'));
3144
3227
  }
@@ -3346,11 +3429,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. An org is the top folder:
3346
3429
  // Express parse projectId as an ARRAY and the scope match nothing.
3347
3430
  const res = await api('GET', `/api/search?q=${encodeURIComponent(q)}`);
3348
3431
  const frames = Array.isArray(res) ? res : (res?.results || []);
3349
- if (!frames.length) return ok(`No frames matching "${q}" (frame search matches labels, not frame content).`);
3350
- const lines = frames.slice(0, 50).map(f =>
3351
- ` /o/${f.orgSlug || f.orgId}/projects/${f.projectSlug || f.projectId}/${f.layer}/${f.lane || ''}/${f.label}`.replace(/\/\//g, '/')
3352
- );
3353
- 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)}`);
3354
3435
  }
3355
3436
  default:
3356
3437
  return err(new Error(`fs ${action} not supported for /projects`));
@@ -6,7 +6,7 @@
6
6
  // description says "tool surface" and "parity" — while `query="parity"` hit it.
7
7
  // An agent reads that miss as "the project does not exist" and stops looking.
8
8
  import assert from 'node:assert/strict';
9
- import { matchesAllTerms } from './server.mjs';
9
+ import { matchesAllTerms, formatFrameHits, archivedNote, clip } from './server.mjs';
10
10
 
11
11
  const proj = [
12
12
  'Flow AI MCP Server Feature Card',
@@ -38,5 +38,42 @@ assert.equal(matchesAllTerms(proj, undefined), false);
38
38
  assert.equal(matchesAllTerms([null, undefined, 'alpha'], 'alpha'), true);
39
39
  assert.equal(matchesAllTerms([null, undefined, 'alpha'], 'null'), false);
40
40
 
41
- console.log('search term matching ok — every term must hit, across fields, never vacuously');
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');
42
79
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.18.4",
3
+ "version": "1.19.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
  "files": [