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 +21 -2
- package/mcp/server.mjs +89 -31
- package/package.json +1 -1
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
|
1632
|
+
const cacheKey = orgHint ? `${orgHint}|${ref}` : ref;
|
|
1633
|
+
const hit = projectRefCache.get(cacheKey) || (!orgHint && projectRefCache.get(ref));
|
|
1622
1634
|
if (hit) return hit;
|
|
1623
|
-
|
|
1624
|
-
|
|
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
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
targetSlug =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
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": {
|