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 +110 -14
- package/mcp/test-search-terms.mjs +79 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
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.
|
|
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": [
|