drafted 1.8.2 → 1.8.4

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 CHANGED
@@ -755,7 +755,16 @@ async function api(method, path, body, _retried) {
755
755
  }
756
756
 
757
757
  if (!res.ok) {
758
- const msg = data.error || `HTTP ${res.status}`;
758
+ let msg = data.error || `HTTP ${res.status}`;
759
+ // Hashline edits (frame.edit, wiki edit) report a per-op `errors` array.
760
+ // Surface each one so the agent can fix the specific operation instead of
761
+ // seeing only the generic "Edit could not be applied".
762
+ if (Array.isArray(data.errors) && data.errors.length) {
763
+ const detail = data.errors
764
+ .map(e => ` • ${Number.isInteger(e.index) ? `op ${e.index + 1}: ` : ''}${e.error}`)
765
+ .join('\n');
766
+ msg += `\n${detail}`;
767
+ }
759
768
  // The active project no longer resolves in the current org context.
760
769
  // This is the silent-drift bug: project.open set a project, then
761
770
  // something switched the active org (parallel agent, browser tab, or
@@ -1494,9 +1503,12 @@ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a
1494
1503
  width: z.number().optional().describe('Viewport width in pixels (frame default 1440, canvas default 1600).'),
1495
1504
  height: z.number().optional().describe('Viewport height in pixels (frame default 900, canvas default 1200).'),
1496
1505
  fullPage: z.boolean().optional().describe('[scope=frame] capture full page or just viewport (default true).'),
1506
+ // outputPath is local-only — omitted on remote transports (web MCP) where the host has no caller filesystem.
1507
+ ...(isRemote ? {} : { outputPath: z.string().optional().describe('[stdio only] Absolute local path to write the rendered PNG to. When set, the PNG is saved to disk and the tool returns the path + byte count instead of returning the image inline — use this to get rendered pixels OUT of Drafted (attach to a message, upload, post-process). Parent directories are created if missing.') }),
1497
1508
  }, async (args) => {
1498
1509
  try {
1499
1510
  const { scope } = args;
1511
+ let buffer;
1500
1512
  if (scope === 'frame') {
1501
1513
  const { target, width = 1440, height = 900, fullPage = true } = args;
1502
1514
  if (!target) throw new Error('target required for scope=frame');
@@ -1514,13 +1526,8 @@ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a
1514
1526
  await ensureSession();
1515
1527
  const res = await fetch(url, { headers: getAuthHeaders() });
1516
1528
  if (!res.ok) throw new Error(`Screenshot failed: ${res.status}`);
1517
- const buffer = await res.arrayBuffer();
1518
- const base64 = Buffer.from(buffer).toString('base64');
1519
- return {
1520
- content: [{ type: 'image', data: base64, mimeType: 'image/png' }],
1521
- };
1522
- }
1523
- if (scope === 'canvas') {
1529
+ buffer = Buffer.from(await res.arrayBuffer());
1530
+ } else if (scope === 'canvas') {
1524
1531
  const { slug, layer = 'plans', width = 1600, height = 1200 } = args;
1525
1532
  let targetSlug = slug;
1526
1533
  if (!targetSlug) {
@@ -1533,13 +1540,21 @@ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a
1533
1540
  await ensureSession();
1534
1541
  const res = await fetch(url, { headers: getAuthHeaders() });
1535
1542
  if (!res.ok) throw new Error(`Canvas screenshot failed: ${res.status} ${await res.text()}`);
1536
- const buffer = await res.arrayBuffer();
1537
- const base64 = Buffer.from(buffer).toString('base64');
1538
- return {
1539
- content: [{ type: 'image', data: base64, mimeType: 'image/png' }],
1540
- };
1543
+ buffer = Buffer.from(await res.arrayBuffer());
1544
+ } else {
1545
+ throw new Error(`Unknown screenshot scope: ${scope}`);
1541
1546
  }
1542
- throw new Error(`Unknown screenshot scope: ${scope}`);
1547
+
1548
+ // Persist to local disk when requested (stdio only) — returns the path
1549
+ // instead of the inline image so the PNG bytes don't burn context.
1550
+ if (!isRemote && args.outputPath) {
1551
+ const resolved = resolve(args.outputPath);
1552
+ mkdirSync(dirname(resolved), { recursive: true });
1553
+ writeFileSync(resolved, buffer);
1554
+ return ok({ ok: true, scope, savedTo: resolved, bytes: buffer.length });
1555
+ }
1556
+
1557
+ return { content: [{ type: 'image', data: buffer.toString('base64'), mimeType: 'image/png' }] };
1543
1558
  } catch (error) { return err(error); }
1544
1559
  });
1545
1560
 
@@ -2282,16 +2297,27 @@ tool('ls', 'List contents of the ACTIVE PROJECT. Use ls / after project(action="
2282
2297
  }
2283
2298
 
2284
2299
  // ChatGPT Apps SDK: canvas-overview widget renders byLayer.
2300
+ // /api/fs/ entries are { path, type: 'layer'|'lane'|'frame', id, title, size,
2301
+ // updatedAt, color, frameUrl } — there are no layer/lane/name/label fields, so
2302
+ // derive them from the path. Skip non-frame entries (lanes/layers are structural
2303
+ // and would otherwise serialize as empty objects under "unsorted").
2285
2304
  const entries = Array.isArray(result?.entries) ? result.entries : [];
2286
2305
  const byLayer = {};
2287
2306
  for (const e of entries) {
2288
- const layer = e.layer || (e.type === 'directory' ? e.name : 'unsorted');
2307
+ if (e.type && e.type !== 'frame') continue;
2308
+ const segs = String(e.path || '').replace(/^\/+/, '').split('/').filter(Boolean);
2309
+ if (!segs.length) continue;
2310
+ const layer = segs[0];
2311
+ const filename = segs[segs.length - 1];
2312
+ const lane = segs.length >= 3 ? segs.slice(1, -1).join('/') : undefined;
2289
2313
  (byLayer[layer] ??= []).push({
2290
- label: e.label || e.name,
2291
- filename: e.filename || e.name,
2292
- layer: e.layer,
2293
- lane: e.lane,
2294
- frameUrl: e.id ? `${getServerUrl()}/f/${e.id}` : undefined,
2314
+ label: e.title || filename,
2315
+ filename,
2316
+ title: e.title,
2317
+ layer,
2318
+ lane,
2319
+ id: e.id,
2320
+ frameUrl: e.frameUrl || (e.id ? `${getServerUrl()}/f/${e.id}` : undefined),
2295
2321
  });
2296
2322
  }
2297
2323
  const projectId = getState().projectId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.8.2",
3
+ "version": "1.8.4",
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": [
@@ -129,11 +129,17 @@ function getRequestUrl(req) {
129
129
  export function trackUmamiEvent(name, data = {}, req = null) {
130
130
  const umami = getUmamiConfig();
131
131
  if (!umami.enabled || !name || name.length > 50) return;
132
+ // Account de-dupe: when the event carries a userId, send it as Umami's distinct id
133
+ // (`payload.id`). Umami then derives sessionId = uuid(websiteId, userId) with no IP/UA/date
134
+ // salt, so server-side events land in the SAME canonical session as the browser's
135
+ // umami.identify(userId) call — collapsing each account to one session across devices/time.
136
+ const distinctId = (typeof data.userId === 'string' && data.userId.length <= 64) ? data.userId : undefined;
132
137
  const payload = {
133
138
  type: 'event',
134
139
  payload: {
135
140
  website: umami.websiteId,
136
141
  name,
142
+ ...(distinctId ? { id: distinctId } : {}),
137
143
  url: getRequestUrl(req) || process.env.BASE_URL || 'https://drafted.live',
138
144
  hostname: req?.hostname || 'drafted.live',
139
145
  language: req?.headers?.['accept-language'],