drafted 1.24.0 → 1.24.2

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/gates.mjs CHANGED
@@ -111,8 +111,11 @@ export function shouldInjectLayerPrompt(layerPrompt, templateDefaultPrompt) {
111
111
 
112
112
  // ── Wiki index formatting (bounded map injected with the G1 block) ────────────
113
113
 
114
- export function formatWikiIndex(pages, max = 50) {
115
- if (!Array.isArray(pages) || pages.length === 0) return '(wiki is empty — no pages yet)';
114
+ // `empty` lets a caller say what an empty result MEANS: a zero-hit search or an
115
+ // empty folder is not "the wiki is empty" (that read as a contradiction when other
116
+ // searches of the same org had returned pages).
117
+ export function formatWikiIndex(pages, max = 50, empty = '(wiki is empty — no pages yet)') {
118
+ if (!Array.isArray(pages) || pages.length === 0) return empty;
116
119
  const lines = pages.slice(0, max).map((p) => {
117
120
  const path = typeof p === 'string' ? p : p.path ?? p.slug ?? '';
118
121
  const title = typeof p === 'object' && p && p.title ? ` — ${p.title}` : '';
@@ -122,6 +125,22 @@ export function formatWikiIndex(pages, max = 50) {
122
125
  return lines.join('\n') + more;
123
126
  }
124
127
 
128
+ // A frame read is capped per page, below MAX_TOOL_RESULT_CHARS, because the
129
+ // content is JSON-escaped on its way out (quotes, newlines) and the blunt cap
130
+ // above cuts mid-token with no way back: `lines` cannot split a minified frame
131
+ // that is ONE line, so ~90 KB of it was simply lost. Paging by character gives
132
+ // every byte an address.
133
+ export const READ_PAGE_CHARS = 60_000;
134
+
135
+ // "start-end" (0-based, end exclusive) or "start" -> the window to return.
136
+ export function charWindow(spec, total) {
137
+ const m = /^\s*(\d+)\s*(?:-\s*(\d+))?\s*$/.exec(String(spec ?? ''));
138
+ if (spec != null && spec !== '' && !m) throw new Error(`chars must look like "0-60000" (got "${spec}")`);
139
+ const start = m ? Math.min(Number(m[1]), total) : 0;
140
+ const end = Math.min(m?.[2] ? Number(m[2]) : start + READ_PAGE_CHARS, start + READ_PAGE_CHARS, total);
141
+ return { start, end: Math.max(start, end) };
142
+ }
143
+
125
144
  // ── Project index formatting ──────────────────────────────────────────────────
126
145
  // `ls /projects` is an ls, not a stat: one addressable line per project. The
127
146
  // previous shape returned the raw /api/projects rows — ~2.4KB each once the
package/mcp/server.mjs CHANGED
@@ -23,7 +23,7 @@ import { LAYERS } from '../src/shared/constants.mjs';
23
23
  import { liftFolderScope, normalizeFolder, rootEntries, splitFolderScope, subfolderEntries } from '../src/shared/folder-path.mjs';
24
24
  import { emptyExcalidrawScene, stringifyExcalidrawScene } from '../src/shared/excalidraw.mjs';
25
25
  import { formatOkfLogEntry, appendOkfLogEntry } from '../src/shared/okf-log.mjs';
26
- import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, formatProjectIndex, projectPath, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
26
+ import { createGateState, markSearched, g1Block, g2Block, g3Block, selectWithinBudget, wouldExceedBudget, budgetError, formatWikiIndex, formatProjectIndex, charWindow, READ_PAGE_CHARS, projectPath, PROJECT_CONTEXT_BUDGET_CHARS } from './gates.mjs';
27
27
  import { loadPersistedProject, savePersistedProject } from './active-project-store.mjs';
28
28
  import { probeRepo, resolveAddress, addressNote } from './repo-address.mjs';
29
29
 
@@ -1616,21 +1616,47 @@ async function pathOrgMatches(orgSegment, meta) {
1616
1616
  return !!hit && meta?.orgId === hit.id;
1617
1617
  }
1618
1618
 
1619
- async function resolveProjectRef(ref) {
1619
+ /**
1620
+ * Fetch /api/projects with NO org scope.
1621
+ *
1622
+ * api() otherwise stamps the session's working org on every request, so a
1623
+ * listing meant to answer "which projects exist in the org this PATH names"
1624
+ * came back scoped to the org the session happened to be working in, and the
1625
+ * org filter then removed all of it. An empty header is how the server is told
1626
+ * "unaddressed" (req.get('x-drafted-org') is falsy) without a server change.
1627
+ */
1628
+ const NO_ORG_SCOPE = { 'X-Drafted-Org': '' };
1629
+
1630
+ async function resolveProjectRef(ref, orgHint = null) {
1620
1631
  if (!ref) return null;
1621
- const hit = projectRefCache.get(ref);
1632
+ const cacheKey = orgHint ? `${orgHint}|${ref}` : ref;
1633
+ const hit = projectRefCache.get(cacheKey) || (!orgHint && projectRefCache.get(ref));
1622
1634
  if (hit) return hit;
1623
- const data = await api('GET', '/api/projects');
1624
- const list = Array.isArray(data?.projects) ? data.projects : [];
1635
+ // Scope the lookup to the org the path named. Without a hint the session's
1636
+ // org applies — which is right for `fs ls /projects` but WRONG for a path
1637
+ // that addressed another org, and that mismatch is what made a project
1638
+ // created in org B unresolvable from a session working in org A.
1639
+ const scoped = orgHint ? await api('GET', '/api/projects', undefined, { 'X-Drafted-Org': orgHint }) : await api('GET', '/api/projects');
1640
+ let list = Array.isArray(scoped?.projects) ? scoped.projects : [];
1625
1641
  const want = String(ref).toLowerCase();
1626
- const p =
1627
- list.find(x => x.id === ref) ||
1628
- list.find(x => String(x.slug || '').toLowerCase() === want) ||
1629
- list.find(x => String(x.name || '').toLowerCase() === want);
1642
+ const pick = (rows) =>
1643
+ rows.find(x => x.id === ref) ||
1644
+ rows.find(x => String(x.slug || '').toLowerCase() === want) ||
1645
+ rows.find(x => String(x.name || '').toLowerCase() === want);
1646
+ let p = pick(list);
1647
+ // A miss is worth one unscoped look before "not found": the hint may be stale
1648
+ // (a project that moved org), and an id passed as projectId= carries no org at
1649
+ // all — only a UUID can name a project in an org the session is not inside.
1650
+ if (!p && (orgHint || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(ref)))) {
1651
+ const all = await api('GET', '/api/projects', undefined, NO_ORG_SCOPE);
1652
+ list = Array.isArray(all?.projects) ? all.projects : [];
1653
+ p = pick(list);
1654
+ }
1630
1655
  if (!p) return null;
1631
1656
  // `folder` rides along so a caller can rebuild the project's printed address
1632
1657
  // (/o/<org>/projects/<folder>/<slug>) without a second lookup.
1633
1658
  const meta = { id: p.id, slug: p.slug || null, name: p.name || null, orgId: p.orgId || null, orgSlug: p.orgSlug || null, folder: p.folder || '' };
1659
+ projectRefCache.set(cacheKey, meta);
1634
1660
  projectRefCache.set(ref, meta);
1635
1661
  projectRefCache.set(p.id, meta);
1636
1662
  return meta;
@@ -1645,13 +1671,15 @@ async function resolveProjectRef(ref) {
1645
1671
  *
1646
1672
  * One /api/projects fetch for the whole scan, not one per candidate.
1647
1673
  */
1648
- async function resolveFolderQualifiedProject(parts) {
1674
+ async function resolveFolderQualifiedProject(parts, orgHint = null) {
1649
1675
  // NOT `.catch(() => null)`. Swallowing the fetch turns "the session is gated"
1650
1676
  // or "the server is down" into "project not found", which is a wrong DIAGNOSIS,
1651
1677
  // not just a lost detail — it sends you hunting for a project instead of
1652
1678
  // reading the gate. Observed once for real: an unnamed session after an MCP
1653
1679
  // restart reported `project not found: Marketing/beoflow-marketing`.
1654
- const data = await api('GET', '/api/projects');
1680
+ // Same org rule as resolveProjectRef: a folder-qualified address names an org,
1681
+ // so the lookup has to be made in it rather than in the working org.
1682
+ const data = await api('GET', '/api/projects', undefined, orgHint ? { 'X-Drafted-Org': orgHint } : NO_ORG_SCOPE);
1655
1683
  const list = Array.isArray(data?.projects) ? data.projects : [];
1656
1684
  const norm = (s) => String(s || '').toLowerCase();
1657
1685
  for (let i = 1; i < parts.length; i++) {
@@ -3093,19 +3121,18 @@ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a
3093
3121
  buffer = Buffer.from(await res.arrayBuffer());
3094
3122
  } else if (scope === 'canvas') {
3095
3123
  const { path: canvasPath, layer = 'plans', width = 1600, height = 1200 } = args;
3096
- let targetSlug = args.slug;
3097
- if (!targetSlug && canvasPath) {
3098
- // /o/<org>/projects/<project> or /projects/<project> or /projects/<folder>/<project> → project slug
3099
- const scoped = splitOrgScope(canvasPath);
3100
- if (scoped.error) throw new Error(scoped.error);
3101
- const parts = scoped.path.replace(/^\/projects\/?/, '').split('/').filter(Boolean);
3102
- targetSlug = parts[parts.length - 1];
3103
- }
3104
- if (!targetSlug) {
3124
+ // Resolve through the shared project resolver (path, folder path, slug, name
3125
+ // or UUID) and send the project ID: the server's slug lookup is scoped to the
3126
+ // SESSION org, so a slug from /o/<other-org>/projects/<slug> was a 404.
3127
+ let targetSlug;
3128
+ const ref = canvasPath || args.slug;
3129
+ if (ref) {
3130
+ targetSlug = (await resolveProjectArg(ref)).id;
3131
+ } else {
3105
3132
  const list = await api('GET', '/api/projects');
3106
3133
  const active = (list.projects || []).find(p => p.id === list.activeProject);
3107
3134
  if (!active) throw new Error('No active project — pass path=/projects/<project> explicitly.');
3108
- targetSlug = active.slug;
3135
+ targetSlug = active.id;
3109
3136
  }
3110
3137
  const url = `${getServerUrl()}/api/canvas-screenshot/${encodeURIComponent(targetSlug)}?layer=${encodeURIComponent(layer)}&width=${width}&height=${height}`;
3111
3138
  await ensureSession();
@@ -3343,16 +3370,19 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3343
3370
  title: z.string().optional().describe('[write + googleType] title for a new native file'),
3344
3371
  ops: z.array(z.any()).optional().describe('[edit] hashline ops for text frames. Apply ONLY against a fresh fs(read) of the frame: each op = {type, lineHash, newContent} where type is one of replace | delete | insertAfter | insertBefore, and lineHash is the FULL anchor from the read output (line number + hash, e.g. the token left of the |, like 42srt) — NOT a bare hash. delete/replace consume the target line; at most one replace/delete per line per edit (combine into one replace). If an op targets a line that changed since read it is rejected — re-read and retry. Element ops for excalidraw ({id,x,y,...}), or structured ops for office'),
3345
3372
  state: z.any().optional().describe('[write] app-frame state (JSON) to persist for a deployed windowType:"app" frame; the canvas hydrates the app from it on load. Max 64KB.'),
3373
+ expectedUpdatedAt: z.string().optional().describe('[write] optimistic concurrency: the frame\'s updatedAt as you last read it. If somebody else wrote since, the write is refused with code "conflict" and the current version, instead of silently overwriting their change. Omit for a blind write.'),
3346
3374
  metadata: z.any().optional().describe('[write] JSON metadata to attach to the written frame (e.g. {"tour": {title, auto?, steps}} to define a guided tour on this frame). Max 64KB.'),
3347
3375
  readme: z.string().optional().describe('[write] README.md markdown for a skill at /o/<org>/skills/<slug> — optional on both create and update. Stored as a supporting skill file so the skill directory renders a landing page on GitHub when exported; a skill created without one is returned with a warning, never rejected. Equivalent to a later fs(write, path="/o/<org>/skills/<slug>/README.md").'),
3376
+ position: z.number().int().min(0).optional().describe('[mv] on a LAYER path, no `to`: move the layer to this 0-based index in the project\'s layer order (0 = first; `ls` of the project shows the order). A layer born from a first write is appended last, so use this to pin one first.'),
3348
3377
  recursive: z.boolean().optional().describe('[ls] recurse into subdirectories'),
3378
+ chars: z.string().optional().describe('[read] character window "start-end" (0-based, end exclusive; at most 60000 per call). A frame longer than that is returned as its first page with `totalChars` and `nextChars` — pass `nextChars` back to read on. Use it for minified/single-line HTML, which `lines` cannot split.'),
3349
3379
  lines: z.string().optional().describe('[read] line range (e.g. "1-50") — partial read; content is hashline-annotated so a later edit stays surgical'),
3350
3380
  pattern: z.string().optional().describe('[ls] filter filenames (e.g. "*.html")'),
3351
3381
  readonly: z.boolean().optional().describe('[link] read-only frame link (symlink): mirror the SOURCE frame named by `to` (a frame UUID or /projects/<other-project>/<layer>/<lane>/<file>) into the CURRENT project, displayed read-only. The source project stays the single writer.'),
3352
3382
  org: z.string().optional().describe('Org (id or name) for bare /wiki and /skills paths (org-scoped /o/<org>/... paths don\'t need it). Per-request only; project paths self-derive org from the project row.'),
3353
3383
  projectId: PROJECT_OVERRIDE_PARAM,
3354
3384
  }, async (args) => {
3355
- const { action, path: rawPath, to: rawTo, query, content, file_path, base64, googleType, title, ops, state, metadata, readme, recursive, pattern, lines, org } = args;
3385
+ const { action, path: rawPath, to: rawTo, query, content, file_path, base64, googleType, title, ops, state, metadata, expectedUpdatedAt, readme, recursive, pattern, lines, org } = args;
3356
3386
  // Org-scoped path (Shape A): /o/<org>/<root>/... — strip the org segment here,
3357
3387
  // carry it as the per-request org scope (orgHeader), validate per-root below.
3358
3388
  // The PATH is the address; the org= param is legacy for bare roots only, and a
@@ -3630,7 +3660,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3630
3660
  // (/o/<org>/projects/eng/x). The chain wins when both are given.
3631
3661
  const projectFolder = folder || (parts.length === 2 ? parts[0] : null);
3632
3662
  const projectRef = parts[parts.length - 1];
3633
- const existing = await resolveProjectRef(projectRef).catch(() => null);
3663
+ const existing = await resolveProjectRef(projectRef, orgFromPath || org || null).catch(() => null);
3634
3664
  if (existing) return err(new Error(`project already exists: ${projectRef}`));
3635
3665
  const projectName = projectRef.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
3636
3666
  const created = await api('POST', '/api/projects', { name: projectName, ...(projectFolder ? { folder: projectFolder } : {}) }, orgHeader);
@@ -3680,7 +3710,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3680
3710
  return ok(formatWikiIndex(tree?.pages || []));
3681
3711
  }
3682
3712
  const pages = await api('GET', `/api/wiki/tree?prefix=${encodeURIComponent(wikiPath)}`, undefined, orgHeader);
3683
- return ok(formatWikiIndex(pages?.pages || []));
3713
+ return ok(formatWikiIndex(pages?.pages || [], 50, `(no wiki pages under ${wikiPath})`));
3684
3714
  }
3685
3715
  case 'read': {
3686
3716
  const canonPath = wikiPath.replace(/\.md$/, '');
@@ -3753,7 +3783,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3753
3783
  // which are never present, so every MCP wiki search reported empty.
3754
3784
  const hits = pages?.hits || pages?.pages || pages?.results || [];
3755
3785
  markSearched(gs, 'wiki');
3756
- return ok(formatWikiIndex(hits));
3786
+ return ok(formatWikiIndex(hits, 50, `(no wiki pages match "${q}")`));
3757
3787
  }
3758
3788
  default:
3759
3789
  return err(new Error(`fs ${action} not supported for /wiki`));
@@ -3924,7 +3954,14 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3924
3954
  // Archived projects live in the Archive bin and are hidden from the agent's
3925
3955
  // active list — mirroring the web UI. An org-scoped root lists ONLY that
3926
3956
  // org's projects (Shape A).
3927
- const listing = await api('GET', '/api/projects');
3957
+ //
3958
+ // Fetch in the org the PATH names, not the one this session happens to be
3959
+ // working in. api() stamps the working org on every request, so the
3960
+ // unfiltered call came back as the working org's projects and the org
3961
+ // filter below then removed every one of them: `ls /o/<other-org>/projects`
3962
+ // answered "no projects" for an org with 265. Unscoped when the path names
3963
+ // no org, which is what makes `/projects` the whole inventory again.
3964
+ const listing = await api('GET', '/api/projects', undefined, orgFromPath ? { 'X-Drafted-Org': orgFromPath } : NO_ORG_SCOPE);
3928
3965
  let rows = (Array.isArray(listing?.projects) ? listing.projects : []).filter(x => !isTrashedRow(x));
3929
3966
  if (orgFromPath) {
3930
3967
  const kept = [];
@@ -4013,9 +4050,11 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4013
4050
  // every failure as "project not found", so a gated session or an unreachable
4014
4051
  // server reads as a missing project.
4015
4052
  let lookupError = null;
4016
- let pathProject = await resolveProjectRef(projectRef).catch((e) => { lookupError = e; return null; });
4053
+ // The org from the path rides the lookup: without it a project in another
4054
+ // org was invisible and every write to it answered "project not found".
4055
+ let pathProject = await resolveProjectRef(projectRef, orgFromPath || org || null).catch((e) => { lookupError = e; return null; });
4017
4056
  if (!pathProject?.id && parts.length > 1) {
4018
- const found = await resolveFolderQualifiedProject(parts);
4057
+ const found = await resolveFolderQualifiedProject(parts, orgFromPath || org || null);
4019
4058
  if (found) { projectRef = parts[found.index]; tail = parts.slice(found.index + 1); pathProject = found.meta; }
4020
4059
  }
4021
4060
  if (!pathProject?.id && lookupError) return err(lookupError);
@@ -4056,9 +4095,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4056
4095
  const segs = clean.replace(/^projects\/?/, '').split('/').filter(Boolean);
4057
4096
  if (!segs.length) throw new Error('destination needs a project: /o/<org>/projects/<project>/...');
4058
4097
  let idx = 0;
4059
- let meta = await resolveProjectRef(segs[0]).catch(() => null);
4098
+ let meta = await resolveProjectRef(segs[0], orgFromPath || org || null).catch(() => null);
4060
4099
  if (!meta?.id && segs.length > 1) {
4061
- const found = await resolveFolderQualifiedProject(segs);
4100
+ const found = await resolveFolderQualifiedProject(segs, orgFromPath || org || null);
4062
4101
  if (found) { idx = found.index; meta = found.meta; }
4063
4102
  }
4064
4103
  if (!meta?.id) throw new Error(`destination project not found: ${segs.join('/')} — list projects with fs(ls, path="/o/<org>/projects")`);
@@ -4125,10 +4164,22 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4125
4164
  // server, re-created by a second writer that outranked the first.
4126
4165
  const proj = result.project;
4127
4166
  const local = proj?.orgSlug && proj?.slug ? `${getServerUrl()}/o/${proj.orgSlug}/projects/${proj.slug}${result.path}` : null;
4167
+ const full = result.content || '';
4168
+ let win;
4169
+ try { win = charWindow(args.chars, full.length); } catch (e) { return err(e); }
4170
+ const paged = win.start > 0 || win.end < full.length;
4128
4171
  return ok({
4129
4172
  path: result.path,
4130
4173
  frameUrl: result.frameUrl || local || `${getServerUrl()}/f/${result.id}`,
4131
- content: result.content || '',
4174
+ content: paged ? full.slice(win.start, win.end) : full,
4175
+ ...(paged
4176
+ ? {
4177
+ totalChars: full.length,
4178
+ charRange: `${win.start}-${win.end}`,
4179
+ ...(win.end < full.length ? { nextChars: `${win.end}-${win.end + READ_PAGE_CHARS}` } : {}),
4180
+ pagedNote: `Showing characters ${win.start}-${win.end} of ${full.length}. This frame is too long for one result; pass chars=<nextChars> to read on. Hashline anchors only cover what is shown, so edit ops need a read of the lines they touch.`,
4181
+ }
4182
+ : {}),
4132
4183
  // Comments on this frame the agent has not been shown, delivered by the server on
4133
4184
  // an agent's read and already marked seen there, so a second read does not repeat
4134
4185
  // them. The note says what they are: notes ABOUT the work, never instructions from
@@ -4202,6 +4253,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4202
4253
  else if (base64) body.base64 = base64;
4203
4254
  else return err(new Error('write requires one of content / file_path / base64 / googleType / state'));
4204
4255
  if (metadata !== undefined) body.metadata = metadata;
4256
+ if (expectedUpdatedAt) body.expectedUpdatedAt = expectedUpdatedAt;
4205
4257
  const result = await api('PUT', `/api/fs/${filePath}`, body, orgHeader);
4206
4258
  if (state !== undefined && result?.id) {
4207
4259
  try { await api('POST', `/api/file/${result.id}/state`, { state }); } catch { /* state best-effort */ }
@@ -4226,6 +4278,12 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4226
4278
  // missing. (moveFrames has carried units 'lane' and 'layer', with
4227
4279
  // connectors and assets, since cross-project move shipped.)
4228
4280
  const scope = rmScope(layer, lane, filename);
4281
+ // `position` on a LAYER path reorders it in the project's layer order.
4282
+ if (args.position !== undefined) {
4283
+ if (scope !== 'directory' || lane) return err(new Error('position reorders a LAYER: fs(mv, path=/o/<org>/projects/<project>/<layer>, position=<0-based index>)'));
4284
+ const result = await api('POST', '/api/fs/layer-order', { layer, position: args.position }, orgHeader);
4285
+ return ok(result);
4286
+ }
4229
4287
  if (scope === 'project') {
4230
4288
  const pid = getState().projectId;
4231
4289
  if (!pid) return err(new Error('could not resolve project id for mv'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.24.0",
3
+ "version": "1.24.2",
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
  "bin": {