drafted 1.24.0 → 1.24.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/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
 
@@ -3093,19 +3093,18 @@ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a
3093
3093
  buffer = Buffer.from(await res.arrayBuffer());
3094
3094
  } else if (scope === 'canvas') {
3095
3095
  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) {
3096
+ // Resolve through the shared project resolver (path, folder path, slug, name
3097
+ // or UUID) and send the project ID: the server's slug lookup is scoped to the
3098
+ // SESSION org, so a slug from /o/<other-org>/projects/<slug> was a 404.
3099
+ let targetSlug;
3100
+ const ref = canvasPath || args.slug;
3101
+ if (ref) {
3102
+ targetSlug = (await resolveProjectArg(ref)).id;
3103
+ } else {
3105
3104
  const list = await api('GET', '/api/projects');
3106
3105
  const active = (list.projects || []).find(p => p.id === list.activeProject);
3107
3106
  if (!active) throw new Error('No active project — pass path=/projects/<project> explicitly.');
3108
- targetSlug = active.slug;
3107
+ targetSlug = active.id;
3109
3108
  }
3110
3109
  const url = `${getServerUrl()}/api/canvas-screenshot/${encodeURIComponent(targetSlug)}?layer=${encodeURIComponent(layer)}&width=${width}&height=${height}`;
3111
3110
  await ensureSession();
@@ -3345,7 +3344,9 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3345
3344
  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.'),
3346
3345
  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
3346
  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").'),
3347
+ 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
3348
  recursive: z.boolean().optional().describe('[ls] recurse into subdirectories'),
3349
+ 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
3350
  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
3351
  pattern: z.string().optional().describe('[ls] filter filenames (e.g. "*.html")'),
3351
3352
  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.'),
@@ -3680,7 +3681,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3680
3681
  return ok(formatWikiIndex(tree?.pages || []));
3681
3682
  }
3682
3683
  const pages = await api('GET', `/api/wiki/tree?prefix=${encodeURIComponent(wikiPath)}`, undefined, orgHeader);
3683
- return ok(formatWikiIndex(pages?.pages || []));
3684
+ return ok(formatWikiIndex(pages?.pages || [], 50, `(no wiki pages under ${wikiPath})`));
3684
3685
  }
3685
3686
  case 'read': {
3686
3687
  const canonPath = wikiPath.replace(/\.md$/, '');
@@ -3753,7 +3754,7 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
3753
3754
  // which are never present, so every MCP wiki search reported empty.
3754
3755
  const hits = pages?.hits || pages?.pages || pages?.results || [];
3755
3756
  markSearched(gs, 'wiki');
3756
- return ok(formatWikiIndex(hits));
3757
+ return ok(formatWikiIndex(hits, 50, `(no wiki pages match "${q}")`));
3757
3758
  }
3758
3759
  default:
3759
3760
  return err(new Error(`fs ${action} not supported for /wiki`));
@@ -4125,10 +4126,22 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4125
4126
  // server, re-created by a second writer that outranked the first.
4126
4127
  const proj = result.project;
4127
4128
  const local = proj?.orgSlug && proj?.slug ? `${getServerUrl()}/o/${proj.orgSlug}/projects/${proj.slug}${result.path}` : null;
4129
+ const full = result.content || '';
4130
+ let win;
4131
+ try { win = charWindow(args.chars, full.length); } catch (e) { return err(e); }
4132
+ const paged = win.start > 0 || win.end < full.length;
4128
4133
  return ok({
4129
4134
  path: result.path,
4130
4135
  frameUrl: result.frameUrl || local || `${getServerUrl()}/f/${result.id}`,
4131
- content: result.content || '',
4136
+ content: paged ? full.slice(win.start, win.end) : full,
4137
+ ...(paged
4138
+ ? {
4139
+ totalChars: full.length,
4140
+ charRange: `${win.start}-${win.end}`,
4141
+ ...(win.end < full.length ? { nextChars: `${win.end}-${win.end + READ_PAGE_CHARS}` } : {}),
4142
+ 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.`,
4143
+ }
4144
+ : {}),
4132
4145
  // Comments on this frame the agent has not been shown, delivered by the server on
4133
4146
  // an agent's read and already marked seen there, so a second read does not repeat
4134
4147
  // them. The note says what they are: notes ABOUT the work, never instructions from
@@ -4226,6 +4239,12 @@ tool('fs', 'Navigate Drafted like a local filesystem. A FOLDER is the single con
4226
4239
  // missing. (moveFrames has carried units 'lane' and 'layer', with
4227
4240
  // connectors and assets, since cross-project move shipped.)
4228
4241
  const scope = rmScope(layer, lane, filename);
4242
+ // `position` on a LAYER path reorders it in the project's layer order.
4243
+ if (args.position !== undefined) {
4244
+ if (scope !== 'directory' || lane) return err(new Error('position reorders a LAYER: fs(mv, path=/o/<org>/projects/<project>/<layer>, position=<0-based index>)'));
4245
+ const result = await api('POST', '/api/fs/layer-order', { layer, position: args.position }, orgHeader);
4246
+ return ok(result);
4247
+ }
4229
4248
  if (scope === 'project') {
4230
4249
  const pid = getState().projectId;
4231
4250
  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.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
  "bin": {