drafted 1.8.1 → 1.8.3

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/install-mcp.sh CHANGED
@@ -513,7 +513,14 @@ step "Installing skills and commands"
513
513
 
514
514
  # Resolve the plugin source: a local checkout when present (e.g. --local from this
515
515
  # repo), otherwise the installed npm package's bundled plugin/ directory.
516
- DRAFTED_PKG_DIR="$(node -e "try { console.log(require.resolve('drafted/package.json').replace('/package.json','')) } catch { process.exit(1) }" 2>/dev/null)" || true
516
+ # require.resolve() misses packages under the custom global prefix
517
+ # ($HOME/.drafted/npm-global) when run from an arbitrary cwd (curl | bash), so
518
+ # prefer the already-computed global node_modules root from the install step.
519
+ if [ -n "${NPM_ROOT:-}" ] && [ -d "$NPM_ROOT/drafted" ]; then
520
+ DRAFTED_PKG_DIR="$NPM_ROOT/drafted"
521
+ else
522
+ DRAFTED_PKG_DIR="$(node -e "try { console.log(require.resolve('drafted/package.json').replace('/package.json','')) } catch { process.exit(1) }" 2>/dev/null)" || true
523
+ fi
517
524
  if [ -d "$SCRIPT_DIR/plugin/skills" ] || [ -d "$SCRIPT_DIR/plugin/commands" ]; then
518
525
  PLUGIN_SRC="$SCRIPT_DIR/plugin"
519
526
  elif [ -n "$DRAFTED_PKG_DIR" ] && [ -d "$DRAFTED_PKG_DIR/plugin" ]; then
package/mcp/server.mjs CHANGED
@@ -1494,9 +1494,12 @@ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a
1494
1494
  width: z.number().optional().describe('Viewport width in pixels (frame default 1440, canvas default 1600).'),
1495
1495
  height: z.number().optional().describe('Viewport height in pixels (frame default 900, canvas default 1200).'),
1496
1496
  fullPage: z.boolean().optional().describe('[scope=frame] capture full page or just viewport (default true).'),
1497
+ // outputPath is local-only — omitted on remote transports (web MCP) where the host has no caller filesystem.
1498
+ ...(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
1499
  }, async (args) => {
1498
1500
  try {
1499
1501
  const { scope } = args;
1502
+ let buffer;
1500
1503
  if (scope === 'frame') {
1501
1504
  const { target, width = 1440, height = 900, fullPage = true } = args;
1502
1505
  if (!target) throw new Error('target required for scope=frame');
@@ -1514,13 +1517,8 @@ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a
1514
1517
  await ensureSession();
1515
1518
  const res = await fetch(url, { headers: getAuthHeaders() });
1516
1519
  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') {
1520
+ buffer = Buffer.from(await res.arrayBuffer());
1521
+ } else if (scope === 'canvas') {
1524
1522
  const { slug, layer = 'plans', width = 1600, height = 1200 } = args;
1525
1523
  let targetSlug = slug;
1526
1524
  if (!targetSlug) {
@@ -1533,13 +1531,21 @@ tool('screenshot', 'Render a PNG via headless browser. `scope=frame` captures a
1533
1531
  await ensureSession();
1534
1532
  const res = await fetch(url, { headers: getAuthHeaders() });
1535
1533
  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
- };
1534
+ buffer = Buffer.from(await res.arrayBuffer());
1535
+ } else {
1536
+ throw new Error(`Unknown screenshot scope: ${scope}`);
1541
1537
  }
1542
- throw new Error(`Unknown screenshot scope: ${scope}`);
1538
+
1539
+ // Persist to local disk when requested (stdio only) — returns the path
1540
+ // instead of the inline image so the PNG bytes don't burn context.
1541
+ if (!isRemote && args.outputPath) {
1542
+ const resolved = resolve(args.outputPath);
1543
+ mkdirSync(dirname(resolved), { recursive: true });
1544
+ writeFileSync(resolved, buffer);
1545
+ return ok({ ok: true, scope, savedTo: resolved, bytes: buffer.length });
1546
+ }
1547
+
1548
+ return { content: [{ type: 'image', data: buffer.toString('base64'), mimeType: 'image/png' }] };
1543
1549
  } catch (error) { return err(error); }
1544
1550
  });
1545
1551
 
@@ -2282,16 +2288,27 @@ tool('ls', 'List contents of the ACTIVE PROJECT. Use ls / after project(action="
2282
2288
  }
2283
2289
 
2284
2290
  // ChatGPT Apps SDK: canvas-overview widget renders byLayer.
2291
+ // /api/fs/ entries are { path, type: 'layer'|'lane'|'frame', id, title, size,
2292
+ // updatedAt, color, frameUrl } — there are no layer/lane/name/label fields, so
2293
+ // derive them from the path. Skip non-frame entries (lanes/layers are structural
2294
+ // and would otherwise serialize as empty objects under "unsorted").
2285
2295
  const entries = Array.isArray(result?.entries) ? result.entries : [];
2286
2296
  const byLayer = {};
2287
2297
  for (const e of entries) {
2288
- const layer = e.layer || (e.type === 'directory' ? e.name : 'unsorted');
2298
+ if (e.type && e.type !== 'frame') continue;
2299
+ const segs = String(e.path || '').replace(/^\/+/, '').split('/').filter(Boolean);
2300
+ if (!segs.length) continue;
2301
+ const layer = segs[0];
2302
+ const filename = segs[segs.length - 1];
2303
+ const lane = segs.length >= 3 ? segs.slice(1, -1).join('/') : undefined;
2289
2304
  (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,
2305
+ label: e.title || filename,
2306
+ filename,
2307
+ title: e.title,
2308
+ layer,
2309
+ lane,
2310
+ id: e.id,
2311
+ frameUrl: e.frameUrl || (e.id ? `${getServerUrl()}/f/${e.id}` : undefined),
2295
2312
  });
2296
2313
  }
2297
2314
  const projectId = getState().projectId;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drafted",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
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'],