carouselbot 0.3.3 → 0.3.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/README.md CHANGED
@@ -93,8 +93,10 @@ The new companion accepts both `https://carousel.bot` and the legacy `https://sl
93
93
 
94
94
  ## Parallel agents and browser tabs
95
95
 
96
- Call `begin_edit_session` before editing and pass its `editSessionId` to all operations. A session atomically reserves one browser tab and one project, follows that tab instead of global focus, and expires after inactivity. Always call `end_edit_session` when finished.
96
+ Call `begin_edit_session` before editing and pass its `editSessionId` to all operations. A session tracks one browser tab and one project, follows that target instead of global focus, and expires after inactivity. It does not lock either resource. Always call `end_edit_session` when finished.
97
97
 
98
- For parallel editing, reserve a different connected editor for each worker. The daemon rejects conflicting claims with `EDITOR_BUSY` or `PROJECT_BUSY` and records a sanitized local audit through `list_recent_operations`; it never records slide text, prompts, file paths, or image bytes.
98
+ Any number of agents can share one connected browser tab, including editing the same project. Commands execute sequentially in that tab while notifications and project updates remain live. Use explicit project and slide IDs or a project-bound session to keep targets stable. The daemon records a sanitized local audit through `list_recent_operations`; it never records slide text, prompts, file paths, or image bytes.
99
99
 
100
100
  Browser writes use revision-checked IndexedDB transactions and cross-tab synchronization. A stale tab cannot replace a newer project snapshot; it reloads the canonical copy and returns `STALE_PROJECT` so the agent can inspect and retry.
101
+
102
+ Paths support exactly two folder levels: `/Client/Account` appears as Home → Client → Account. Use this when the user asks to organize multiple accounts for a client; do not reorganize projects without being asked. Create or move projects with `folderPath: "/Client/Account"`; the parent and subfolder appear automatically. `/Client/Account/Campaign` is rejected. `inspect_editor` includes parent folders and `parentPath`; folder project counts include descendants. Renaming a parent in the UI preserves its subfolders.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "carouselbot",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Local-first MCP companion for the hosted CarouselBot editor",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -33,18 +33,18 @@ Every tool accepts the same JSON arguments as MCP. Use `list_tools` without argu
33
33
 
34
34
  Before the first mutation in a task, call `get_design_guidance`. The server intentionally rejects mutations until this guidance has been read.
35
35
 
36
- Before editing, reserve a target with `begin_edit_session`. Pass its `editSessionId` to every mutating tool, relevant reads, notifications, and `apply_operations`. Release it with `end_edit_session` as soon as the work finishes or fails. Reservations expire after inactivity, but explicit release is the normal cleanup path.
36
+ Before editing, track a target with `begin_edit_session`. Pass its `editSessionId` to every mutating tool, relevant reads, notifications, and `apply_operations`. Release it with `end_edit_session` as soon as the work finishes or fails. Sessions expire after inactivity, but explicit release is the normal cleanup path.
37
37
 
38
38
  For parallel work, the parent agent owns orchestration:
39
39
 
40
40
  1. Call `list_editors` and `list_edit_sessions` before spawning editing workers.
41
- 2. Reserve one distinct editor per editing worker with `begin_edit_session`, and assign one project per session. Never launch more editing workers than available unassigned editors. Research-only workers do not need a session.
42
- 3. Give each worker its exact `editorId`, `editSessionId`, and `projectId` (once known). A worker must never select or use another worker's editor.
41
+ 2. Create one session per editing worker with `begin_edit_session`, and assign one project per session. Any number of workers can share the same editor and project; sessions do not lock them. Research-only workers do not need a session.
42
+ 3. Give each worker its exact `editorId`, `editSessionId`, and `projectId` (once known). Keep project and slide targets explicit; commands in a shared editor execute sequentially.
43
43
  4. If a worker creates a project, the daemon binds that new project to its session automatically. Report the returned `projectId` to the parent.
44
- 5. Treat `EDITOR_BUSY`, `PROJECT_BUSY`, and `SESSION_PROJECT_MISMATCH` as coordination signals. Do not retry against a different target silently. Re-plan, wait, or use another unassigned editor.
44
+ 5. Treat `SESSION_PROJECT_MISMATCH` as a targeting error. Begin a session for the intended project instead of silently changing targets.
45
45
  6. End every worker session in cleanup, including after errors. Use `list_recent_operations` to investigate routing, conflicts, or failed edits without exposing prompts, text, paths, or image bytes.
46
46
 
47
- With only one agent and one editor, the server can create an implicit session for compatibility. Explicit sessions are still preferred because they make routing deterministic. Never rely on which tab is focused once multiple editors exist.
47
+ The server can create an implicit session for each agent for compatibility, including when agents share an editor. Explicit sessions are still preferred because they make routing deterministic. Never rely on which tab is focused once multiple editors exist.
48
48
 
49
49
  Inspect the assigned editor before editing and keep the returned IDs and revision. Work on the assigned project and slide. Pass `expectedRevision` for sensitive mutations. If `STALE_PROJECT` is returned, the browser has reloaded the canonical IndexedDB copy; inspect again and retry with current IDs. Prefer `apply_operations` for compact related changes while preserving logical order.
50
50
 
@@ -65,7 +65,7 @@ Choose a project's default canvas when creating it with the optional `aspectRati
65
65
 
66
66
  When `add_slide` uses `backgroundPath` without `aspectRatio`, CarouselBot derives the exact reduced ratio from the source image. Use that default for source-faithful image slides: the image scales to the 1080-pixel canvas width, any workspace space above or below is outside the slide, and export contains only the image-shaped slide canvas plus its editable layers. Supply `aspectRatio` with `backgroundPath` only when the user intentionally wants crop-to-format behavior. Native browser uploads follow the same source-ratio default, and the user can select another ratio afterward.
67
67
 
68
- Dashboard folders are implicit and use exact canonical slash paths such as `/campaigns`. Set `folderPath` when calling `create_project` to create the project inside a folder. Call `move_project` with another slash path to move it between folders, or with `folderPath: null` to move it back to the dashboard root. Embedded slashes remain part of one virtual path rather than creating a nested UI hierarchy. Moving the final project out of a folder removes that empty folder automatically. Use `inspect_editor` to read each project's current `folderPath`.
68
+ Dashboard folders are implicit and use exact canonical slash paths such as `/campaigns`. Set `folderPath` when calling `create_project` to create the project inside a folder. Call `move_project` with another slash path to move it between folders, or with `folderPath: null` to move it back to the dashboard root. Paths support exactly two folder levels: `/Client/Account` appears as Home Client → Account. Use this when the user asks to organize multiple accounts for a client; do not reorganize projects without being asked. Create or move projects with `folderPath: "/Client/Account"`; the parent and subfolder appear automatically. `/Client/Account/Campaign` is rejected. `inspect_editor` includes parent folders and `parentPath`; folder project counts include descendants. Renaming a parent in the UI preserves its subfolders. Moving the final project out of a folder removes that empty folder automatically. Use `inspect_editor` to read each project's current `folderPath`.
69
69
 
70
70
  Use readable role-based type ranges: title `92–124`, subtitle `68–84`, body `54–68`, caption `44–52`. Do not solve dense copy by dropping below the body range; shorten it or split it across slides. `add_text` and `update_text` automatically preserve width and fit height around every wrapped line with safe padding. For highlighted text, prefer `style: "boxed"` with `backgroundShape: "lines"`. Use `backgroundShape: "full"` only for a deliberate card. Call `fit_text_boxes` with `mode: "both"` only when you intentionally want the width to shrink too.
71
71
 
package/src/daemon.mjs CHANGED
@@ -147,10 +147,6 @@ function sessionForEditor(editorId) {
147
147
  return activeEditSessions().find((session) => session.editorId === editorId) || null;
148
148
  }
149
149
 
150
- function sessionForProject(projectId) {
151
- return projectId ? activeEditSessions().find((session) => session.projectId === projectId) || null : null;
152
- }
153
-
154
150
  function requireEditSession(sessionId) {
155
151
  const session = editSessions.get(sessionId);
156
152
  if (!session || session.lastSeen < Date.now() - EDIT_SESSION_TTL_MS) {
@@ -167,11 +163,6 @@ function requireEditSession(sessionId) {
167
163
 
168
164
  function claimProject(session, projectId) {
169
165
  if (!projectId) return;
170
- const conflict = sessionForProject(projectId);
171
- if (conflict && conflict.id !== session.id) {
172
- recordAudit({ action: "edit_session.conflict", status: "blocked", session, projectId, message: `Project held by ${conflict.owner.name}` });
173
- throw codedError("PROJECT_BUSY", `Project ${projectId} is being edited by ${conflict.owner.name} (${conflict.purpose}). Use a different project or wait for edit session ${conflict.id} to end.`, { session: publicSession(conflict) });
174
- }
175
166
  if (session.projectId && session.projectId !== projectId) {
176
167
  if (session.implicit) {
177
168
  session.projectId = projectId;
@@ -187,16 +178,7 @@ function beginEditSession(client, { editorId, projectId, purpose }) {
187
178
  if (!connected.length) throw codedError("NO_EDITOR", "No CarouselBot editor is connected. Open the editor in the user's normal browser and click Connect AI.");
188
179
  let editor = editorId ? connected.find((item) => item.id === editorId) : null;
189
180
  if (editorId && !editor) throw codedError("EDITOR_DISCONNECTED", `Editor is not connected: ${editorId}`);
190
- if (!editor) {
191
- const selected = client.selectedEditorId && connected.find((item) => item.id === client.selectedEditorId);
192
- const available = connected.filter((item) => !sessionForEditor(item.id));
193
- editor = selected && !sessionForEditor(selected.id) ? selected : available.length === 1 ? available[0] : null;
194
- if (!editor) throw codedError("EDITOR_SELECTION_REQUIRED", "Multiple browser tabs are available. Call list_editors, choose an unassigned editor, then begin_edit_session with editorId.");
195
- }
196
- const editorConflict = sessionForEditor(editor.id);
197
- if (editorConflict) throw codedError("EDITOR_BUSY", `Editor ${editor.id} is assigned to ${editorConflict.owner.name} (${editorConflict.purpose}) until ${new Date(editorConflict.lastSeen + EDIT_SESSION_TTL_MS).toISOString()}.`, { session: publicSession(editorConflict) });
198
- const projectConflict = sessionForProject(projectId);
199
- if (projectConflict) throw codedError("PROJECT_BUSY", `Project ${projectId} is being edited by ${projectConflict.owner.name} (${projectConflict.purpose}).`, { session: publicSession(projectConflict) });
181
+ if (!editor) editor = selectEditor(client.id);
200
182
  const now = Date.now();
201
183
  const session = {
202
184
  id: randomUUID(), editorId: editor.id, projectId: projectId || null,
@@ -285,8 +267,11 @@ function requireEditor(request, response, editorId, cors) {
285
267
  }
286
268
 
287
269
  function queueEditorEvent(editor, event) {
270
+ // Keep only the latest roster snapshot, but never drop commands or notifications.
271
+ if (event.kind === "system" && ["agents.changed", "edit-sessions.changed"].includes(event.type)) {
272
+ editor.queue = editor.queue.filter((queued) => queued.kind !== "system" || queued.type !== event.type);
273
+ }
288
274
  editor.queue.push(event);
289
- if (editor.queue.length > 100) editor.queue.splice(0, editor.queue.length - 100);
290
275
  deliverNext(editor);
291
276
  }
292
277
 
@@ -377,11 +362,9 @@ function resolveBrowserTarget(clientId, { editSessionId, mutating, projectId })
377
362
  return { client, editor: editors.get(session.editorId), session };
378
363
  }
379
364
  const editor = selectEditor(clientId);
380
- const conflict = sessionForEditor(editor.id);
381
- if (conflict) throw codedError("EDITOR_BUSY", `Editor ${editor.id} is assigned to ${conflict.owner.name} (${conflict.purpose}). Begin an edit session on another editor.`, { session: publicSession(conflict) });
382
365
  const now = Date.now();
383
366
  session = {
384
- id: randomUUID(), editorId: editor.id, projectId: null, purpose: "Implicit single-agent edit",
367
+ id: randomUUID(), editorId: editor.id, projectId: null, purpose: "Implicit edit",
385
368
  owner: publicClient(client), creatorClientId: client.id, lastClientId: client.id,
386
369
  implicit: true, createdAt: now, lastSeen: now,
387
370
  };
@@ -394,6 +377,10 @@ function resolveBrowserTarget(clientId, { editSessionId, mutating, projectId })
394
377
  }
395
378
 
396
379
  function callBrowser(clientId, toolName, operation, label, { editSessionId = null, mutating = false } = {}) {
380
+ const boundSession = editSessionId ? requireEditSession(editSessionId) : null;
381
+ if (!operation?.projectId && boundSession?.projectId && operation?.type !== "project.create") {
382
+ operation = { ...operation, projectId: boundSession.projectId };
383
+ }
397
384
  const projectId = operation?.projectId || null;
398
385
  const { client, editor, session } = resolveBrowserTarget(clientId, { editSessionId, mutating, projectId });
399
386
  if (mutating && session && !session.implicit && !session.projectId && toolName !== "create_project") {
@@ -404,6 +391,7 @@ function callBrowser(clientId, toolName, operation, label, { editSessionId = nul
404
391
  return new Promise((resolve, reject) => {
405
392
  const timer = setTimeout(() => {
406
393
  inflight.delete(requestId);
394
+ editor.queue = editor.queue.filter((event) => event.requestId !== requestId);
407
395
  if (operation?.fontMediaId) fontMedia.delete(operation.fontMediaId);
408
396
  recordAudit({ action: "tool.result", client, session, editorId: editor.id, projectId, toolName, status: "error", message: "Browser timeout" });
409
397
  reject(codedError("BROWSER_TIMEOUT", "The browser did not answer within 90 seconds."));
@@ -518,7 +506,7 @@ async function handleInternalCall(body) {
518
506
  selectedEditorId,
519
507
  editors: connected.map((editor) => {
520
508
  const assigned = sessionForEditor(editor.id);
521
- return { id: editor.id, selected: editor.id === selectedEditorId, focused: editor.id === focusedEditorId, pageUrl: editor.pageUrl, state: editor.state, editSession: assigned ? publicSession(assigned) : null };
509
+ return { id: editor.id, selected: editor.id === selectedEditorId, focused: editor.id === focusedEditorId, pageUrl: editor.pageUrl, state: editor.state, editSession: assigned ? publicSession(assigned) : null, editSessions: activeEditSessions().filter((session) => session.editorId === editor.id).map(publicSession) };
522
510
  }),
523
511
  editSessions: activeEditSessions().map(publicSession),
524
512
  };
@@ -8,7 +8,7 @@ const id = z.string().min(1).max(160);
8
8
  const optionalId = id.optional();
9
9
  const folderPath = z.string().min(2).max(160)
10
10
  .regex(/^\/(?!\/)\S(?:[\s\S]*\S)?$/, "Use a canonical folder path with one leading slash and no surrounding whitespace, for example /my-folder.")
11
- .refine((value) => value !== "/." && value !== "/..", "Folder paths cannot use the reserved names /. or /..");
11
+ .refine((value) => value.slice(1).split("/").length <= 2 && value.slice(1).split("/").every((part) => part && part === part.trim() && part !== "." && part !== ".."), "Use at most two folder levels: /Client/Account; no empty, dot, or double-dot names.");
12
12
  const color = z.string().regex(/^#?[0-9a-f]{3}(?:[0-9a-f]{3})?$/i, "Use a 3- or 6-digit hex color.");
13
13
  const ASPECT_RATIO_INPUT_MAX_LENGTH = 80;
14
14
  const aspectRatio = z.string().max(ASPECT_RATIO_INPUT_MAX_LENGTH)
@@ -36,7 +36,9 @@ const textFields = {
36
36
  text: z.string().max(4000).optional(), x: unit.optional(), y: unit.optional(), width: positiveUnit.optional(), height: positiveUnit.optional(),
37
37
  role: z.enum(["title", "subtitle", "body", "caption"]).optional().describe("Semantic size role. Recommended ranges: title 92-124, subtitle 68-84, body 54-68, caption 44-52."),
38
38
  size: z.number().min(20).max(180).optional(), style: z.enum(["plain", "outline", "boxed"]).optional(),
39
- outlineWidth: z.number().min(0).max(40).optional(), color: color.optional(), background: z.enum(["white", "black"]).optional(),
39
+ outlineWidth: z.number().min(0).max(40).optional()
40
+ .describe("Relative outline thickness. The default 12 renders at 14.4% of the font size; all values scale proportionally with the font."),
41
+ color: color.optional(), background: z.enum(["white", "black"]).optional(),
40
42
  backgroundShape: z.enum(["lines", "full"]).optional(), align: z.enum(["left", "center", "right"]).optional(),
41
43
  fontId,
42
44
  fontWeight: z.number().int().min(1).max(1000).optional(),
@@ -138,7 +140,7 @@ export async function createCarouselBotMcpServer(companion) {
138
140
  let guidanceRead = false;
139
141
  let identifiedAs = null;
140
142
  const server = new McpServer({ name: PACKAGE_NAME, version: PACKAGE_VERSION }, {
141
- instructions: `First call list_editors and use the registered local browser tab. Never open or connect CarouselBot through a sandboxed agent browser. If no editor is listed, retry briefly because browser reconnection is automatic, then ask the user to open ${EDITOR_URL} in their normal browser and click Connect AI. Companion compatibility and reconnects are automatic. Do not parallel-retry an action that reports an unsupported internal action; retry once after list_editors so automatic recovery can finish. Never restart a healthy companion for a transient editor disconnect. Before edits call get_design_guidance, then begin_edit_session; pass editSessionId to every edit and end it in cleanup. Parallel editing workers require distinct editor sessions. Use render_slide to inspect actual pixels.`,
143
+ instructions: `First call list_editors and use the registered local browser tab. Never open or connect CarouselBot through a sandboxed agent browser. If no editor is listed, retry briefly because browser reconnection is automatic, then ask the user to open ${EDITOR_URL} in their normal browser and click Connect AI. Companion compatibility and reconnects are automatic. Do not parallel-retry an action that reports an unsupported internal action; retry once after list_editors so automatic recovery can finish. Never restart a healthy companion for a transient editor disconnect. Before edits call get_design_guidance, then begin_edit_session; pass editSessionId to every edit and end it in cleanup. Parallel workers can share the same browser tab and project; sessions track targets without exclusive locks. Use render_slide to inspect actual pixels.`,
142
144
  capabilities: { tools: {}, resources: {} },
143
145
  });
144
146
 
@@ -185,19 +187,19 @@ export async function createCarouselBotMcpServer(companion) {
185
187
 
186
188
  register("list_editors", "Check the user's real local browser connection and show which registered CarouselBot tab this session targets. Call this instead of opening a sandboxed browser.", z.object({}).strict(), () => companion.call("list_editors"), { readOnlyHint: true });
187
189
  register("select_editor", "Select a connected browser tab for this MCP session.", z.object({ editorId: id }).strict(), ({ editorId }) => companion.call("select_editor", { editorId }), { destructiveHint: false, idempotentHint: true });
188
- register("begin_edit_session", "Atomically reserve one browser tab and optionally one project for an editing agent. Use one session per parallel editing worker and pass editSessionId to every edit.", z.object({ editorId: optionalId, projectId: optionalId, purpose: z.string().min(1).max(160).optional() }).strict(), (args) => companion.call("begin_edit_session", args), { destructiveHint: false });
189
- register("end_edit_session", "Release a browser-tab/project reservation as soon as an editing task finishes or fails.", z.object({ editSessionId: id }).strict(), (args) => companion.call("end_edit_session", args), { destructiveHint: false, idempotentHint: true });
190
- register("list_edit_sessions", "List active edit reservations, their owners, projects, and lease expirations.", z.object({}).strict(), () => companion.call("list_edit_sessions"), { readOnlyHint: true });
190
+ register("begin_edit_session", "Track one browser tab and optionally one project for an editing agent without locking either. Any number of workers may share a tab or project. Pass editSessionId to every edit.", z.object({ editorId: optionalId, projectId: optionalId, purpose: z.string().min(1).max(160).optional() }).strict(), (args) => companion.call("begin_edit_session", args), { destructiveHint: false });
191
+ register("end_edit_session", "End an editing session as soon as an editing task finishes or fails.", z.object({ editSessionId: id }).strict(), (args) => companion.call("end_edit_session", args), { destructiveHint: false, idempotentHint: true });
192
+ register("list_edit_sessions", "List active editing sessions, their owners, projects, and lease expirations.", z.object({}).strict(), () => companion.call("list_edit_sessions"), { readOnlyHint: true });
191
193
  register("list_recent_operations", "Read the local sanitized operation audit. Text, prompts, paths, and image bytes are never logged.", z.object({ limit: z.number().int().min(1).max(200).default(50), projectId: optionalId, status: z.enum(["started", "ok", "error", "blocked"]).optional() }).strict(), (args) => companion.call("list_recent_operations", args), { readOnlyHint: true });
192
194
  register("inspect_editor", "Inspect projects, slides, assets, and every text/image layer without returning image bytes.", z.object({ ...targetSlide, includeAllProjects: z.boolean().default(true) }).strict(), (args) => browserOperation(companion, "inspect_editor", args), { readOnlyHint: true });
193
195
  register("list_local_fonts", "Search fonts installed on this computer. Returns opaque local font IDs and never filesystem paths. The user must enable local fonts in CarouselBot first.", z.object({ editSessionId, query: z.string().max(200).optional(), limit: z.number().int().min(1).max(200).default(80), cursor: z.string().max(2048).optional(), sort: z.enum(["recent_then_alphabetical", "alphabetical"]).default("recent_then_alphabetical") }).strict(), (args) => companion.call("list_local_fonts", args), { readOnlyHint: true });
194
196
  register("list_project_fonts", "List fonts already imported into one project, including whether each face is currently available.", z.object({ ...targetProject, projectId: id }).strict(), (args) => browserOperation(companion, "list_project_fonts", args), { readOnlyHint: true });
195
197
  register("show_notification", "Show a short visual notification in a connected editor for status or marketing demos.", z.object({ editSessionId, message: z.string().min(1).max(240), tone: z.enum(["agent", "success", "info", "error"]).default("agent") }).strict(), ({ editSessionId, ...args }) => companion.call("notify", { ...args, editSessionId }), { destructiveHint: false, idempotentHint: false });
196
198
 
197
- register("create_project", "Create an empty project without changing the user's current browser view. Choose an optional aspect ratio: use a documented preset or a positive integer W:H value; legacy/default projects use 9:16. Pass a canonical folderPath such as /my-folder to create it inside that folder; omit it or use null for the dashboard root.", z.object({ editSessionId, name: z.string().min(1).max(160), aspectRatio: aspectRatio.optional(), folderPath: folderPath.nullable().optional() }).strict(), (args) => browserOperation(companion, "create_project", args), { destructiveHint: false });
199
+ register("create_project", "Create an empty project without changing the user's current browser view. Choose an optional aspect ratio: use a documented preset or a positive integer W:H value; legacy/default projects use 9:16. Pass a canonical folderPath such as /Client or /Client/Account to create it inside that folder (maximum two levels); omit it or use null for the dashboard root.", z.object({ editSessionId, name: z.string().min(1).max(160), aspectRatio: aspectRatio.optional(), folderPath: folderPath.nullable().optional() }).strict(), (args) => browserOperation(companion, "create_project", args), { destructiveHint: false });
198
200
  register("open_project", "Explicitly navigate the browser to a project and optionally a specific slide without changing content. Use only when the user asks to show it.", z.object({ editSessionId, projectId: id, slideId: optionalId }).strict(), (args) => browserOperation(companion, "open_project", args), { destructiveHint: false, idempotentHint: true });
199
201
  register("update_project", "Rename a project.", z.object({ ...targetProject, name: z.string().min(1).max(160) }).strict(), (args) => browserOperation(companion, "update_project", args), { destructiveHint: true });
200
- register("move_project", "Move a project into a folder by canonical slash path, move it between folders, or move it back to the dashboard root with folderPath=null. Folder cards are derived from project membership, so empty folders disappear.", z.object({ ...targetProject, projectId: id, folderPath: folderPath.nullable() }).strict(), (args) => browserOperation(companion, "move_project", args), { destructiveHint: true });
202
+ register("move_project", "Move a project into a folder by canonical slash path (/Client or /Client/Account, maximum two levels), move it between folders, or move it back to the dashboard root with folderPath=null. Folder cards are derived from project membership, so empty folders disappear.", z.object({ ...targetProject, projectId: id, folderPath: folderPath.nullable() }).strict(), (args) => browserOperation(companion, "move_project", args), { destructiveHint: true });
201
203
  register("delete_project", "Delete a project from browser storage.", z.object({ ...targetProject, projectId: id }).strict(), (args) => browserOperation(companion, "delete_project", args), { destructiveHint: true });
202
204
 
203
205
  register("add_slide", "Add a slide using a solid backgroundColor or local backgroundPath. A backgroundPath with no aspectRatio adopts the source image's exact reduced ratio, scales it to the 1080-pixel canvas width, and exports only that canvas. A solid slide with no aspectRatio uses the project's default; omit both background sources to use #EEEDE7. Pass aspectRatio to override either default. The browser follows it only when that project is already visible.", backgroundSourceSchema({ ...targetProject, name: z.string().max(160).optional(), index: z.number().int().min(0).optional(), aspectRatio: aspectRatio.optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional() }), (args) => browserOperation(companion, "add_slide", args), { destructiveHint: false });