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 +4 -2
- package/package.json +1 -1
- package/skill/carouselbot/SKILL.md +6 -6
- package/src/daemon.mjs +12 -24
- package/src/mcp-server.mjs +10 -8
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
|
|
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
|
-
|
|
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
|
@@ -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,
|
|
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.
|
|
42
|
-
3. Give each worker its exact `editorId`, `editSessionId`, and `projectId` (once known).
|
|
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 `
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
};
|
package/src/mcp-server.mjs
CHANGED
|
@@ -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 !== "
|
|
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()
|
|
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
|
|
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", "
|
|
189
|
-
register("end_edit_session", "
|
|
190
|
-
register("list_edit_sessions", "List active
|
|
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 /
|
|
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 });
|