drafted 1.18.4 → 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 +95 -14
- package/mcp/test-search-terms.mjs +39 -2
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
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
|
-
|
|
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.
|
|
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": [
|