carouselbot 0.3.2 → 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 +22 -4
- package/guidance/design.md +6 -1
- package/package.json +1 -1
- package/skill/carouselbot/SKILL.md +14 -6
- package/src/daemon.mjs +12 -24
- package/src/mcp-server.mjs +37 -12
package/README.md
CHANGED
|
@@ -28,13 +28,29 @@ The same validated tool surface is available through the package CLI when a runn
|
|
|
28
28
|
npx -y carouselbot@latest call get_design_guidance
|
|
29
29
|
npx -y carouselbot@latest call list_editors
|
|
30
30
|
npx -y carouselbot@latest call begin_edit_session --json '{"editorId":"EDITOR_ID","purpose":"Build my deck"}'
|
|
31
|
-
npx -y carouselbot@latest call create_project --json '{"editSessionId":"SESSION_ID","name":"My presentation","folderPath":"/campaigns"}'
|
|
31
|
+
npx -y carouselbot@latest call create_project --json '{"editSessionId":"SESSION_ID","name":"My presentation","aspectRatio":"3:4","folderPath":"/campaigns"}'
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Every MCP tool name and JSON argument shape works with `call`. The CLI-only `list_tools` helper lists names compactly or returns selected schemas. `render_slide` writes image output to a temporary `previewPath` instead of dumping base64 into the terminal.
|
|
35
35
|
|
|
36
36
|
Folders use exact canonical slash paths such as `/campaigns` and are derived from project membership. Pass `folderPath` to `create_project` to create a project in a folder. Use `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 are part of the virtual path and do not create a nested UI hierarchy. Because folders are implicit, moving the last project out removes the empty folder card automatically. `inspect_editor` returns each project's current folder path.
|
|
37
37
|
|
|
38
|
+
## Canvas formats and backgrounds
|
|
39
|
+
|
|
40
|
+
Choose `aspectRatio` when creating a project to set the default for new slides. The editor offers `9:16`, `2:3`, `3:4`, `4:5`, `1:1`, `4:3`, and `16:9`; MCP callers may also use another positive `W:H` ratio that produces a canvas between 180 and 3840 pixels high at the fixed 1080-pixel export width. Pass `aspectRatio` to `add_slide` when one slide should differ from the project default, or to `update_slide` to change only that slide. Existing text and image layers keep their proportions and relative centers when a slide ratio changes.
|
|
41
|
+
|
|
42
|
+
An image-backed `add_slide` automatically adopts the source image's exact reduced ratio when `backgroundPath` is supplied without `aspectRatio`. The image therefore scales to the 1080-pixel canvas width without adding workspace padding to the output; exports contain only the image-shaped slide canvas and its layers. Pass `aspectRatio` alongside `backgroundPath` only for an intentional crop-to-format slide. Native browser uploads use the same source-ratio behavior.
|
|
43
|
+
|
|
44
|
+
For example, a `3:4` project can contain an inherited `3:4` opener, a square slide, and a custom ultra-wide slide:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx -y carouselbot@latest call add_slide --json '{"editSessionId":"SESSION_ID","projectId":"PROJECT_ID","backgroundColor":"#F4EFE6"}'
|
|
48
|
+
npx -y carouselbot@latest call add_slide --json '{"editSessionId":"SESSION_ID","projectId":"PROJECT_ID","aspectRatio":"1:1","backgroundColor":"#111111"}'
|
|
49
|
+
npx -y carouselbot@latest call add_slide --json '{"editSessionId":"SESSION_ID","projectId":"PROJECT_ID","aspectRatio":"18:5","backgroundColor":"#FFFFFF"}'
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Create a flat slide directly with `add_slide({ backgroundColor: "#F4EFE6" })`, or replace a slide background with `update_slide`. A color is stored as project data and rendered natively, so no white or colored placeholder file is needed. `backgroundColor` and `backgroundPath` are mutually exclusive.
|
|
53
|
+
|
|
38
54
|
Always use `list_editors` to check the browser connection. Do not open CarouselBot or click **Connect AI** through a sandboxed, remote, or agent-controlled browser: that is a different browser session and may not reach the local companion.
|
|
39
55
|
|
|
40
56
|
Browser and MCP-process reconnection are automatic, including after a compatible daemon upgrade. The MCP checks the daemon's advertised internal actions rather than trusting the browser protocol number alone. Retry transient disconnects; use `restart` only when automatic recovery or `doctor` reports a failed daemon health check. An already-running host may still need to refresh its native tool catalog when a release adds entirely new tool names; the CLI fallback works immediately without waiting for that refresh.
|
|
@@ -53,7 +69,7 @@ import_font({ editSessionId, projectId, localFontId })
|
|
|
53
69
|
add_text({ editSessionId, projectId, slideId, text, fontId })
|
|
54
70
|
```
|
|
55
71
|
|
|
56
|
-
`list_project_fonts` reports faces already embedded in a project. `add_text` and `update_text` accept the returned `fontId`, plus optional `fontWeight
|
|
72
|
+
`list_project_fonts` reports faces already embedded in a project. `add_text` and `update_text` accept the returned `fontId`, plus optional `fontWeight` and `fontStyle`. The `wght` variable-font axis has editor/export parity; other axes are preserved for compatibility but should not be newly applied until the canvas renderer supports them exactly. The companion returns opaque local font IDs and never exposes font paths. A selected face is transferred over the authenticated loopback connection, persisted only in the browser's local IndexedDB project, and loaded before fitting or rendering. If the exact bytes are missing or invalid, rendering reports `FONT_UNAVAILABLE` instead of silently using a fallback. Ordinary typography remains an editable text layer; agents must not substitute a text-only image to imitate a font.
|
|
57
73
|
|
|
58
74
|
Any MCP client can launch it with:
|
|
59
75
|
|
|
@@ -77,8 +93,10 @@ The new companion accepts both `https://carousel.bot` and the legacy `https://sl
|
|
|
77
93
|
|
|
78
94
|
## Parallel agents and browser tabs
|
|
79
95
|
|
|
80
|
-
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.
|
|
81
97
|
|
|
82
|
-
|
|
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.
|
|
83
99
|
|
|
84
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/guidance/design.md
CHANGED
|
@@ -4,7 +4,9 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
|
|
|
4
4
|
|
|
5
5
|
## Defaults that usually look good
|
|
6
6
|
|
|
7
|
-
-
|
|
7
|
+
- Choose a project aspect ratio as the default for new slides. `9:16` is the default; presets also include `2:3`, `3:4`, `4:5`, `1:1`, `4:3`, and `16:9`. Pass `aspectRatio` to `add_slide` or `update_slide` when an individual slide should differ, and use another validated positive `W:H` ratio when the reference demands it. Deliberate format changes can add rhythm and uniqueness; accidental inconsistency cannot. Keep one clear idea per slide.
|
|
8
|
+
- When `add_slide` uses `backgroundPath` without an explicit `aspectRatio`, the slide adopts the image's exact reduced ratio. Use this for source-faithful image slides: the image scales to the fixed 1080-pixel canvas width, workspace space above or below stays outside the slide, and export contains only the visible slide canvas. Pass `aspectRatio` only when deliberate crop-to-format behavior is wanted.
|
|
9
|
+
- For a flat background, call `add_slide` with `backgroundColor` (or `update_slide` with `backgroundColor`). Do not create, upload, or import a bitmap merely to get a solid color.
|
|
8
10
|
- Prefer `boxed` text with `backgroundShape: "lines"` for highlighted copy. Treat per-line boxes as the default; use `full` only for a deliberate card or label.
|
|
9
11
|
- `add_text` and `update_text` automatically preserve width and fit height around all wrapped lines with safety padding. Do not render just to discover clipping or call `fit_text_boxes` after ordinary copy edits.
|
|
10
12
|
- Use `fit_text_boxes` with `mode: "both"` only when you intentionally want width to shrink as well. If automatic fitting rejects copy that cannot fit on one slide, shorten it or split it across slides.
|
|
@@ -21,6 +23,8 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
|
|
|
21
23
|
- Use rotation sparingly. Small intentional angles can add energy; arbitrary angles make carousels feel inconsistent.
|
|
22
24
|
- Reuse a small palette and consistent type scale across the project.
|
|
23
25
|
- For an installed Mac font, select an exact face with `list_local_fonts`, import its opaque ID with `import_font`, and apply only the returned project `fontId`. Reuse project faces through `list_project_fonts`; never guess family strings.
|
|
26
|
+
- Use `fontWeight` (or the matching `wght` axis) for variable weight. Do not newly apply `wdth`, `opsz`, `slnt`, or custom axes until exported-canvas parity is available.
|
|
27
|
+
- Keep ordinary typography as editable text layers, including typography that uses an imported font. Never generate or import a text-only PNG/SVG and place it with `add_image` just to imitate a font. If the requested face cannot be imported, keep the copy editable in an available face and report the substitution instead of baking the words into pixels.
|
|
24
28
|
|
|
25
29
|
## Working method
|
|
26
30
|
|
|
@@ -47,4 +51,5 @@ Read this before creating or editing slides. Use it as a compact quality bar, th
|
|
|
47
51
|
- Keep tool responses and progress messages concise.
|
|
48
52
|
- Prefer IDs returned by tools over guessed names or array positions.
|
|
49
53
|
- Do not claim a slide looks good until you have inspected a rendered image.
|
|
54
|
+
- Use `add_text` or `update_text` for all ordinary words. Reserve image assets for photographs, illustrations, logos, screenshots, and deliberate artwork that cannot be represented by editable CarouselBot layers.
|
|
50
55
|
- Do not call `open_project` merely to edit or render another project. It intentionally changes the user's browser view; use it only when the user asks to see that project.
|
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
|
|
|
@@ -57,7 +57,15 @@ When the user asks for a font installed on their Mac, use the deterministic two-
|
|
|
57
57
|
|
|
58
58
|
If listing returns `FONT_PERMISSION_REQUIRED`, ask the user to open the real CarouselBot tab and choose **Allow local fonts** from the text font control, then retry. Do not bypass this with browser automation. Font paths and bytes are intentionally unavailable to agents. If rendering reports `FONT_UNAVAILABLE`, preserve the editable text, report the missing face, and ask the user to replace or re-import it rather than accepting fallback pixels.
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
Use `fontWeight` or `fontVariationSettings.wght` when varying an imported face. Preserve existing non-weight variable settings, but do not newly apply `wdth`, `opsz`, `slnt`, or custom axes until CarouselBot advertises exact exported-canvas parity for them.
|
|
61
|
+
|
|
62
|
+
All ordinary words must remain CarouselBot text layers, including words styled with a local font. Never generate or import a text-only PNG/SVG and place it as an image merely to imitate a font. If an exact face cannot be listed, imported, or rendered, keep the copy editable in an available face and tell the user what was substituted. Use image layers only for photographs, illustrations, logos, screenshots, and deliberate artwork that CarouselBot cannot represent as editable layers.
|
|
63
|
+
|
|
64
|
+
Choose a project's default canvas when creating it with the optional `aspectRatio`: `9:16` (default), `2:3`, `3:4`, `4:5`, `1:1`, `4:3`, or `16:9`. MCP callers may also use another validated positive `W:H` ratio when the reference requires it. Pass `aspectRatio` to `add_slide` when a new slide should differ from the project default, or to `update_slide` to change one existing slide while preserving its layers' proportions and relative centers. Mixed ratios should be a deliberate creative choice, not accidental inconsistency. For a flat slide, pass `backgroundColor` directly to `add_slide` or `update_slide`; never create or import a white or colored image just to fill the background. `backgroundColor` and `backgroundPath` are alternatives and must not be supplied together.
|
|
65
|
+
|
|
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
|
+
|
|
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`.
|
|
61
69
|
|
|
62
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.
|
|
63
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,8 +8,23 @@ 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
|
+
const ASPECT_RATIO_INPUT_MAX_LENGTH = 80;
|
|
14
|
+
const aspectRatio = z.string().max(ASPECT_RATIO_INPUT_MAX_LENGTH)
|
|
15
|
+
.regex(/^\d{1,39}:\d{1,39}$/, "Use a positive width:height ratio such as 4:5 or 16:9.")
|
|
16
|
+
.refine((value) => {
|
|
17
|
+
try {
|
|
18
|
+
if (value.length > ASPECT_RATIO_INPUT_MAX_LENGTH) return false;
|
|
19
|
+
const [width, height] = value.split(":").map((part) => BigInt(part));
|
|
20
|
+
if (width <= 0n || height <= 0n) return false;
|
|
21
|
+
const canvasHeight = Number((1080n * height + width / 2n) / width);
|
|
22
|
+
return canvasHeight >= 180 && canvasHeight <= 3840;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}, "The ratio must produce a canvas height between 180 and 3840 pixels at 1080 pixels wide.")
|
|
27
|
+
.describe("Canvas ratio. Presets: 9:16, 2:3, 3:4, 4:5, 1:1, 4:3, 16:9. Other positive W:H ratios are reduced to canonical form and must yield a 180–3840 px canvas height at 1080 px wide. A project ratio is the default for new slides; an add_slide or update_slide ratio applies to that slide only.");
|
|
13
28
|
const unit = z.number().min(-0.5).max(1.5);
|
|
14
29
|
const positiveUnit = z.number().min(0.01).max(2.4);
|
|
15
30
|
const expectedRevision = z.number().int().min(0).optional().describe("Optional optimistic-concurrency guard from inspect_editor.");
|
|
@@ -21,12 +36,15 @@ const textFields = {
|
|
|
21
36
|
text: z.string().max(4000).optional(), x: unit.optional(), y: unit.optional(), width: positiveUnit.optional(), height: positiveUnit.optional(),
|
|
22
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."),
|
|
23
38
|
size: z.number().min(20).max(180).optional(), style: z.enum(["plain", "outline", "boxed"]).optional(),
|
|
24
|
-
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(),
|
|
25
42
|
backgroundShape: z.enum(["lines", "full"]).optional(), align: z.enum(["left", "center", "right"]).optional(),
|
|
26
43
|
fontId,
|
|
27
44
|
fontWeight: z.number().int().min(1).max(1000).optional(),
|
|
28
45
|
fontStyle: z.enum(["normal", "italic"]).optional(),
|
|
29
|
-
fontVariationSettings: z.record(z.string().regex(/^[A-Za-z0-9]{4}$/), z.number()).optional()
|
|
46
|
+
fontVariationSettings: z.record(z.string().regex(/^[A-Za-z0-9]{4}$/), z.number()).optional()
|
|
47
|
+
.describe("Variable-font axis settings. Only wght currently has guaranteed DOM, fitting, and exported-canvas parity; preserve but do not newly apply other axes."),
|
|
30
48
|
rotation: z.number().min(-720).max(720).optional(), z: z.number().optional(),
|
|
31
49
|
};
|
|
32
50
|
const imageFields = {
|
|
@@ -36,6 +54,13 @@ const imageFields = {
|
|
|
36
54
|
cropW: z.number().min(0.05).max(1).optional(), cropH: z.number().min(0.05).max(1).optional(),
|
|
37
55
|
};
|
|
38
56
|
|
|
57
|
+
function backgroundSourceSchema(shape) {
|
|
58
|
+
return z.object(shape).strict().refine(
|
|
59
|
+
({ backgroundColor, backgroundPath }) => !(backgroundColor && backgroundPath),
|
|
60
|
+
{ message: "backgroundColor and backgroundPath are mutually exclusive; choose one background source." },
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
39
64
|
const definitions = new Map();
|
|
40
65
|
|
|
41
66
|
function textResult(value, summary = value) {
|
|
@@ -43,7 +68,7 @@ function textResult(value, summary = value) {
|
|
|
43
68
|
}
|
|
44
69
|
|
|
45
70
|
function compactMutation(value) {
|
|
46
|
-
const keys = ["id", "editSessionId", "editorId", "projectId", "folderPath", "slideId", "revision", "leaseExpiresAt", "purpose", "released", "opened", "createdSlideId", "createdTextId", "fittedTextBox", "createdImageId", "createdLayers", "assetId", "fontId", "localFontId", "existing", "repaired", "deletedAssetId", "deletedProjectId", "deletedSlideId", "deletedLayerIds", "updatedTextIds", "fittedTextBoxes", "updatedImageIds", "applied", "path", "bytes"];
|
|
71
|
+
const keys = ["id", "editSessionId", "editorId", "projectId", "aspectRatio", "canvasWidth", "canvasHeight", "folderPath", "slideId", "revision", "leaseExpiresAt", "purpose", "released", "opened", "createdSlideId", "createdTextId", "fittedTextBox", "createdImageId", "createdLayers", "assetId", "fontId", "localFontId", "existing", "repaired", "deletedAssetId", "deletedProjectId", "deletedSlideId", "deletedLayerIds", "updatedTextIds", "fittedTextBoxes", "updatedImageIds", "applied", "path", "bytes"];
|
|
47
72
|
return Object.fromEntries(keys.flatMap((key) => {
|
|
48
73
|
if (key === "folderPath" && Object.hasOwn(value || {}, key)) return [[key, value[key] ?? null]];
|
|
49
74
|
return value?.[key] == null ? [] : [[key, value[key]]];
|
|
@@ -115,7 +140,7 @@ export async function createCarouselBotMcpServer(companion) {
|
|
|
115
140
|
let guidanceRead = false;
|
|
116
141
|
let identifiedAs = null;
|
|
117
142
|
const server = new McpServer({ name: PACKAGE_NAME, version: PACKAGE_VERSION }, {
|
|
118
|
-
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.`,
|
|
119
144
|
capabilities: { tools: {}, resources: {} },
|
|
120
145
|
});
|
|
121
146
|
|
|
@@ -162,23 +187,23 @@ export async function createCarouselBotMcpServer(companion) {
|
|
|
162
187
|
|
|
163
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 });
|
|
164
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 });
|
|
165
|
-
register("begin_edit_session", "
|
|
166
|
-
register("end_edit_session", "
|
|
167
|
-
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 });
|
|
168
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 });
|
|
169
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 });
|
|
170
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 });
|
|
171
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 });
|
|
172
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 });
|
|
173
198
|
|
|
174
|
-
register("create_project", "Create an empty project without changing the user's current browser view. 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 });
|
|
175
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 });
|
|
176
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 });
|
|
177
|
-
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 });
|
|
178
203
|
register("delete_project", "Delete a project from browser storage.", z.object({ ...targetProject, projectId: id }).strict(), (args) => browserOperation(companion, "delete_project", args), { destructiveHint: true });
|
|
179
204
|
|
|
180
|
-
register("add_slide", "Add a slide using a solid
|
|
181
|
-
register("update_slide", "Rename a slide, replace its background, or change background pan/zoom. The browser follows it only when that project is already visible.",
|
|
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 });
|
|
206
|
+
register("update_slide", "Rename a slide, change only that slide's optional aspectRatio, replace its background with either a solid backgroundColor or local backgroundPath, or change background pan/zoom. Ratio changes preserve layer proportions and centers. The two background sources are mutually exclusive. The browser follows it only when that project is already visible.", backgroundSourceSchema({ ...targetSlide, name: z.string().max(160).optional(), aspectRatio: aspectRatio.optional(), backgroundColor: color.optional(), backgroundPath: z.string().min(1).optional(), imageScale: z.number().min(1).max(3).optional(), imageX: unit.optional(), imageY: unit.optional() }), (args) => browserOperation(companion, "update_slide", args), { destructiveHint: true });
|
|
182
207
|
register("duplicate_slide", "Duplicate a slide with all layers. The browser follows the copy only when that project is already visible.", z.object({ ...targetSlide, name: z.string().max(160).optional() }).strict(), (args) => browserOperation(companion, "duplicate_slide", args), { destructiveHint: false });
|
|
183
208
|
register("reorder_slides", "Set the complete slide order using every slide ID exactly once.", z.object({ ...targetProject, slideIds: z.array(id).min(1) }).strict(), (args) => browserOperation(companion, "reorder_slides", args), { destructiveHint: true });
|
|
184
209
|
register("delete_slide", "Delete one slide.", z.object({ ...targetSlide, slideId: id }).strict(), (args) => browserOperation(companion, "delete_slide", args), { destructiveHint: true });
|