slidemind 0.1.6 → 0.1.7
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/.claude-plugin/plugin.json +1 -7
- package/CLAUDE.md +18 -14
- package/README.md +20 -10
- package/bin/slidemind.mjs +820 -35
- package/package.json +2 -5
- package/skills/create-slides/SKILL.md +81 -57
- package/skills/search/SKILL.md +27 -27
- package/src/bridge/build-and-push.mjs +166 -55
- package/src/bridge/bundle.mjs +49 -7
- package/src/bridge/capture.mjs +2 -2
- package/src/bridge/client.mjs +374 -44
- package/src/bridge/engine-download.mjs +89 -0
- package/src/{mcp → bridge}/recolor.mjs +10 -9
- package/src/builders/build.mjs +209 -13
- package/src/builders/lint.mjs +2 -2
- package/src/builders/review-overlay.js +22 -7
- package/src/builders/review.mjs +8 -15
- package/src/installers/claude.mjs +10 -17
- package/src/installers/codex.mjs +14 -77
- package/src/installers/opencode.mjs +10 -18
- package/src/lib/reused-slides.mjs +2 -4
- package/src/mcp/server.mjs +0 -958
package/src/mcp/server.mjs
DELETED
|
@@ -1,958 +0,0 @@
|
|
|
1
|
-
// Slidemind MCP server (stdio).
|
|
2
|
-
//
|
|
3
|
-
// Exposes the deck-authoring + push-to-PowerPoint surface to a local agent
|
|
4
|
-
// (Claude Code, Codex, opencode) as native MCP tools so the agent can
|
|
5
|
-
// enumerate open PowerPoint windows and push a freshly built deck without
|
|
6
|
-
// leaving an on-disk .pptx behind.
|
|
7
|
-
//
|
|
8
|
-
// Tools:
|
|
9
|
-
// slidemind_list_open_presentations
|
|
10
|
-
// Lists PowerPoint windows registered with the local bridge, including
|
|
11
|
-
// retained entries whose task-pane socket is temporarily disconnected.
|
|
12
|
-
//
|
|
13
|
-
// slidemind_read_presentation
|
|
14
|
-
// Pulls the current content of an open PowerPoint window — a per-slide
|
|
15
|
-
// outline (summary) or each slide's full plain text (raw) — so the agent can
|
|
16
|
-
// inspect the live deck before editing it.
|
|
17
|
-
//
|
|
18
|
-
// slidemind_push_deck
|
|
19
|
-
// Renders a deck server-side via the add-in and pushes its slides into one
|
|
20
|
-
// open PowerPoint window.
|
|
21
|
-
//
|
|
22
|
-
// slidemind_view_draft
|
|
23
|
-
// Renders the deck's authored slides to per-slide preview images locally and
|
|
24
|
-
// returns them WITHOUT pushing — the agent's "look at my own draft" check
|
|
25
|
-
// before push / the human review gate.
|
|
26
|
-
//
|
|
27
|
-
// slidemind_list_engines
|
|
28
|
-
// Lists the live engine catalog (image + slide) for the active corporate
|
|
29
|
-
// design, filterable by type/subtype.
|
|
30
|
-
//
|
|
31
|
-
// slidemind_search_engine
|
|
32
|
-
// Searches the active corporate design's engine (image/icon OR slide,
|
|
33
|
-
// content/visual intent) over the bridge.
|
|
34
|
-
//
|
|
35
|
-
// slidemind_view_slide
|
|
36
|
-
// Returns a preview image of a chosen slide search result.
|
|
37
|
-
//
|
|
38
|
-
// slidemind_insert_slide
|
|
39
|
-
// Inserts chosen slide search results live into an open PowerPoint window.
|
|
40
|
-
//
|
|
41
|
-
// slidemind_add_slide_to_deck
|
|
42
|
-
// Folds chosen slide search results into a deck as native one-slide .pptx
|
|
43
|
-
// under slides/ (survive re-render, spliced into play order).
|
|
44
|
-
//
|
|
45
|
-
// slidemind_download_result
|
|
46
|
-
// Fetches a chosen image search result's bytes and writes them into a deck's
|
|
47
|
-
// assets/ folder for the slide JSX to reference.
|
|
48
|
-
//
|
|
49
|
-
// Everything routes through the add-in's cloud relay at
|
|
50
|
-
// `<backendURL>/api/cli/*` with the Bearer session token established by
|
|
51
|
-
// `slidemind pair`. See src/bridge/client.mjs.
|
|
52
|
-
|
|
53
|
-
import fs from "node:fs";
|
|
54
|
-
import path from "node:path";
|
|
55
|
-
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
56
|
-
|
|
57
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
58
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
59
|
-
import { z } from "zod";
|
|
60
|
-
|
|
61
|
-
import { describeRecolor } from "./recolor.mjs";
|
|
62
|
-
|
|
63
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
64
|
-
const __dirname = path.dirname(__filename);
|
|
65
|
-
const PACKAGE_ROOT = path.resolve(__dirname, "..", "..");
|
|
66
|
-
const SRC_DIR = path.join(PACKAGE_ROOT, "src");
|
|
67
|
-
|
|
68
|
-
async function loadBridgeClient() {
|
|
69
|
-
return import(pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
async function loadBuildAndPush() {
|
|
73
|
-
return import(
|
|
74
|
-
pathToFileURL(path.join(SRC_DIR, "bridge", "build-and-push.mjs")).href
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
async function loadBundle() {
|
|
79
|
-
return import(pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
async function loadReusedSlides() {
|
|
83
|
-
return import(pathToFileURL(path.join(SRC_DIR, "lib", "reused-slides.mjs")).href);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function textResult(value) {
|
|
87
|
-
const text =
|
|
88
|
-
typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
89
|
-
return { content: [{ type: "text", text }] };
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function errorResult(err) {
|
|
93
|
-
let text = err && err.message ? err.message : String(err);
|
|
94
|
-
const code = err && err.code;
|
|
95
|
-
// Known transient states are recoverable without rebuilding anything — tell
|
|
96
|
-
// the agent exactly how to resume if the bridge timed out waiting for a true
|
|
97
|
-
// task-pane reconnect. Normal backgrounded PowerPoint should not reach this.
|
|
98
|
-
if (code === "pane_suspended") {
|
|
99
|
-
text +=
|
|
100
|
-
"\n\nRecoverable: the CLI already waited ~2 minutes for the task pane to " +
|
|
101
|
-
"reconnect. The render itself never needs the pane (it runs server-side) — " +
|
|
102
|
-
"only pushing/engine ops do. Ask the user to click the slidemind task pane " +
|
|
103
|
-
"once, then retry this same call; do NOT ask them to keep PowerPoint " +
|
|
104
|
-
"foregrounded beyond that.";
|
|
105
|
-
} else if (code === "engine_auth_expired") {
|
|
106
|
-
text +=
|
|
107
|
-
"\n\nRecoverable: ask the user to click the slidemind task pane for ~1s so " +
|
|
108
|
-
"it refreshes its sign-in, then retry this call.";
|
|
109
|
-
}
|
|
110
|
-
return { content: [{ type: "text", text }], isError: true };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Parse a `data:<mime>;base64,<data>` URI into an MCP image content block so a
|
|
114
|
-
// vision-capable agent can "see" the result grid, exactly as the in-app design
|
|
115
|
-
// agent does.
|
|
116
|
-
function imageContentFromDataUri(dataUri) {
|
|
117
|
-
const m = /^data:([^;]+);base64,(.+)$/.exec(dataUri || "");
|
|
118
|
-
if (!m) return null;
|
|
119
|
-
return { type: "image", data: m[2], mimeType: m[1] };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const MIME_EXT = {
|
|
123
|
-
"image/svg+xml": "svg",
|
|
124
|
-
"image/png": "png",
|
|
125
|
-
"image/jpeg": "jpg",
|
|
126
|
-
"image/jpg": "jpg",
|
|
127
|
-
"image/webp": "webp",
|
|
128
|
-
"image/gif": "gif",
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
// Derive a usable file extension from an engine result's MIME type. The brand
|
|
132
|
-
// engine can return any image Content-Type (image/x-icon, image/bmp,
|
|
133
|
-
// image/avif, …) and may carry a `;charset=` suffix, so an exact-match table
|
|
134
|
-
// alone would fall back to a useless `.bin` that won't render in an <img>.
|
|
135
|
-
// Strip params, then fall back to the MIME subtype itself for any `image/*`.
|
|
136
|
-
function extFromMime(mimeType) {
|
|
137
|
-
const mt = String(mimeType || "")
|
|
138
|
-
.split(";")[0]
|
|
139
|
-
.trim()
|
|
140
|
-
.toLowerCase();
|
|
141
|
-
if (MIME_EXT[mt]) return MIME_EXT[mt];
|
|
142
|
-
const m = /^image\/(?:x-)?([a-z0-9.+-]+)$/.exec(mt);
|
|
143
|
-
if (m) {
|
|
144
|
-
const sub = m[1].replace(/\+xml$/, "");
|
|
145
|
-
if (sub === "svg") return "svg";
|
|
146
|
-
if (sub === "jpeg") return "jpg";
|
|
147
|
-
if (sub === "icon") return "ico";
|
|
148
|
-
return sub; // png, gif, webp, bmp, avif, tiff, heic, …
|
|
149
|
-
}
|
|
150
|
-
return "bin";
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const server = new McpServer(
|
|
154
|
-
{ name: "slidemind", version: "0.1.0" },
|
|
155
|
-
{
|
|
156
|
-
instructions:
|
|
157
|
-
"Build on-brand slide decks under <cwd>/decks/<slug>/slides/*.jsx for the " +
|
|
158
|
-
"presentation's active corporate design, then render + push them into a running " +
|
|
159
|
-
"PowerPoint window via the slidemind add-in. The corporate design (tokens, layouts, " +
|
|
160
|
-
"brand rules) and the .pptx render both live in the add-in — an open PowerPoint host " +
|
|
161
|
-
"with an active corporate design is required, and all brand rules come from that " +
|
|
162
|
-
"design's bundle (design.md), never hardcoded here. " +
|
|
163
|
-
"Always call slidemind_list_open_presentations first to learn which presentation_id to target. " +
|
|
164
|
-
"If connection/readiness is unclear, call slidemind_connection_status before asking the user for help. " +
|
|
165
|
-
"To inspect what's already in a window before editing, call slidemind_read_presentation (summary outline or raw plain text). " +
|
|
166
|
-
"After authoring or editing slides, call slidemind_view_draft to LOOK at your own draft (per-slide images, nothing pushed) and self-correct before slidemind_push_deck. " +
|
|
167
|
-
"The CLI may be paired with several add-in environments (local/dev/prod) at once; the list spans " +
|
|
168
|
-
"all of them and each entry is tagged with its env, and routing to the right backend is automatic — " +
|
|
169
|
-
"just pass the presentation_id back. " +
|
|
170
|
-
"Renders run server-side and never need the task pane awake (a suspended/backgrounded PowerPoint " +
|
|
171
|
-
"is fine); only pushing slides and engine search/download touch the pane, and those wait for it " +
|
|
172
|
-
"and retry automatically. " +
|
|
173
|
-
"For brand imagery, slidemind_search_engine then slidemind_download_result.",
|
|
174
|
-
},
|
|
175
|
-
);
|
|
176
|
-
|
|
177
|
-
server.registerTool(
|
|
178
|
-
"slidemind_list_open_presentations",
|
|
179
|
-
{
|
|
180
|
-
title: "List open PowerPoint windows",
|
|
181
|
-
description:
|
|
182
|
-
"List PowerPoint windows registered with the slidemind local bridge, including retained entries whose task-pane socket is temporarily disconnected. " +
|
|
183
|
-
"Spans every paired add-in environment (local/dev/prod); each entry carries an `env` tag and a stable presId — pass the presId as `presentation_id` to the other tools and routing to that env is automatic. " +
|
|
184
|
-
"If the list is empty the user has no PowerPoint open with the add-in loaded.",
|
|
185
|
-
inputSchema: {},
|
|
186
|
-
},
|
|
187
|
-
async () => {
|
|
188
|
-
try {
|
|
189
|
-
const { listOpenPresentations, toPublicPresentation } =
|
|
190
|
-
await loadBridgeClient();
|
|
191
|
-
const list = await listOpenPresentations();
|
|
192
|
-
return textResult({ presentations: list.map(toPublicPresentation) });
|
|
193
|
-
} catch (err) {
|
|
194
|
-
return errorResult(err);
|
|
195
|
-
}
|
|
196
|
-
},
|
|
197
|
-
);
|
|
198
|
-
|
|
199
|
-
server.registerTool(
|
|
200
|
-
"slidemind_connection_status",
|
|
201
|
-
{
|
|
202
|
-
title: "Diagnose CLI/add-in connection readiness",
|
|
203
|
-
description:
|
|
204
|
-
"Return paired backend reachability and per-window readiness. Use this when a slidemind tool fails or before asking the user for manual action.",
|
|
205
|
-
inputSchema: {},
|
|
206
|
-
},
|
|
207
|
-
async () => {
|
|
208
|
-
try {
|
|
209
|
-
const client = await loadBridgeClient();
|
|
210
|
-
const status = await client.connectionStatus();
|
|
211
|
-
return textResult({
|
|
212
|
-
paired: status.paired,
|
|
213
|
-
backendCount: status.backendCount,
|
|
214
|
-
backends: (status.backends ?? []).map((b) => ({
|
|
215
|
-
env: b.env,
|
|
216
|
-
ok: b.ok,
|
|
217
|
-
readinessV1: b.readinessV1 === true,
|
|
218
|
-
capabilities: b.capabilities ?? null,
|
|
219
|
-
error: b.error,
|
|
220
|
-
summary: b.summary ?? null,
|
|
221
|
-
presentations: (b.presentations ?? []).map(client.toPublicPresentation),
|
|
222
|
-
})),
|
|
223
|
-
presentations: (status.presentations ?? []).map(client.toPublicPresentation),
|
|
224
|
-
});
|
|
225
|
-
} catch (err) {
|
|
226
|
-
return errorResult(err);
|
|
227
|
-
}
|
|
228
|
-
},
|
|
229
|
-
);
|
|
230
|
-
|
|
231
|
-
server.registerTool(
|
|
232
|
-
"slidemind_read_presentation",
|
|
233
|
-
{
|
|
234
|
-
title: "Read the current content of an open presentation",
|
|
235
|
-
description:
|
|
236
|
-
"Pull the CURRENT content of an open PowerPoint window so you can inspect the live deck before editing it. " +
|
|
237
|
-
"mode='summary' returns a per-slide outline (index, layout, title, subtitle); mode='raw' (default) returns each slide's full plain text. " +
|
|
238
|
-
"This is plain text content, NOT editing markup. " +
|
|
239
|
-
"presentation_id is the presId from slidemind_list_open_presentations; it's optional when exactly one window is open, but required to disambiguate when several are open.",
|
|
240
|
-
inputSchema: {
|
|
241
|
-
presentation_id: z
|
|
242
|
-
.string()
|
|
243
|
-
.nullable()
|
|
244
|
-
.optional()
|
|
245
|
-
.describe("presId from slidemind_list_open_presentations. Optional when only one window is open; required to pick one when several are."),
|
|
246
|
-
mode: z
|
|
247
|
-
.enum(["summary", "raw"])
|
|
248
|
-
.nullable()
|
|
249
|
-
.optional()
|
|
250
|
-
.describe("'summary' = per-slide outline; 'raw' (default) = each slide's full plain text."),
|
|
251
|
-
},
|
|
252
|
-
},
|
|
253
|
-
async ({ presentation_id, mode }) => {
|
|
254
|
-
try {
|
|
255
|
-
const client = await loadBridgeClient();
|
|
256
|
-
const registry = await client.requirePaneHost();
|
|
257
|
-
const entry = client.pickPresentationEntry(registry, presentation_id ?? null);
|
|
258
|
-
const res = await client.getPresentationContent({
|
|
259
|
-
presId: entry.presId,
|
|
260
|
-
mode: mode ?? "raw",
|
|
261
|
-
backendURL: entry.backendURL ?? null,
|
|
262
|
-
});
|
|
263
|
-
if (!res?.ok) {
|
|
264
|
-
return errorResult(new Error(res?.error ?? "read presentation failed"));
|
|
265
|
-
}
|
|
266
|
-
return textResult({
|
|
267
|
-
presentation_id: entry.presId,
|
|
268
|
-
mode: res.mode,
|
|
269
|
-
slideCount: res.slideCount,
|
|
270
|
-
slides: res.slides ?? [],
|
|
271
|
-
});
|
|
272
|
-
} catch (err) {
|
|
273
|
-
return errorResult(err);
|
|
274
|
-
}
|
|
275
|
-
},
|
|
276
|
-
);
|
|
277
|
-
|
|
278
|
-
server.registerTool(
|
|
279
|
-
"slidemind_push_deck",
|
|
280
|
-
{
|
|
281
|
-
title: "Push a built deck into PowerPoint",
|
|
282
|
-
description:
|
|
283
|
-
"Build the deck at deck_path and push the resulting slides into the PowerPoint window identified by " +
|
|
284
|
-
"presentation_id (from slidemind_list_open_presentations). By default slides are appended at the end. " +
|
|
285
|
-
"Use insert_at to place after a specific 0-based slide index. Use replace=true to delete existing slides " +
|
|
286
|
-
"first (destructive). slide_ids restricts the push to a subset of the built deck's slide ids and is for " +
|
|
287
|
-
"the append/insert path only — it CANNOT be combined with replace=true (that combination is rejected, " +
|
|
288
|
-
"because the bridge would otherwise wipe the whole presentation). " +
|
|
289
|
-
"The render itself never needs the task pane awake; if the pane is suspended at push time the CLI waits " +
|
|
290
|
-
"up to ~2 min for it (the user just clicks the pane once) and retries safely. If the result carries " +
|
|
291
|
-
"paneSuspended=true, the rendered .pptx was saved to fallbackFile instead — nothing was lost.",
|
|
292
|
-
inputSchema: {
|
|
293
|
-
deck_path: z
|
|
294
|
-
.string()
|
|
295
|
-
.describe("Path to the deck folder (must contain slides/). Absolute or relative to CWD."),
|
|
296
|
-
presentation_id: z
|
|
297
|
-
.string()
|
|
298
|
-
.describe("presId from slidemind_list_open_presentations. Required even when only one window is open."),
|
|
299
|
-
insert_at: z
|
|
300
|
-
.number()
|
|
301
|
-
.int()
|
|
302
|
-
.nonnegative()
|
|
303
|
-
.nullable()
|
|
304
|
-
.optional()
|
|
305
|
-
.describe("0-based slide index to insert after. Omit or null = append at end. Ignored when replace=true."),
|
|
306
|
-
replace: z
|
|
307
|
-
.boolean()
|
|
308
|
-
.optional()
|
|
309
|
-
.describe("If true, delete all existing slides before inserting. Destructive."),
|
|
310
|
-
slide_ids: z
|
|
311
|
-
.array(z.string())
|
|
312
|
-
.nullable()
|
|
313
|
-
.optional()
|
|
314
|
-
.describe("Optional subset of source slide ids to push. Omit or null = all slides in the built deck."),
|
|
315
|
-
},
|
|
316
|
-
},
|
|
317
|
-
async ({ deck_path, presentation_id, insert_at, replace, slide_ids }, extra) => {
|
|
318
|
-
try {
|
|
319
|
-
const { buildAndPush } = await loadBuildAndPush();
|
|
320
|
-
// Relay per-slide render progress as MCP progress notifications when the
|
|
321
|
-
// client asked for them (sent a progressToken). Fire-and-forget — a
|
|
322
|
-
// dropped notification must never fail the push.
|
|
323
|
-
const progressToken = extra?._meta?.progressToken;
|
|
324
|
-
const onProgress =
|
|
325
|
-
progressToken === undefined || typeof extra?.sendNotification !== "function"
|
|
326
|
-
? null
|
|
327
|
-
: (p) => {
|
|
328
|
-
const message =
|
|
329
|
-
p.state === "queued"
|
|
330
|
-
? "render queued behind another render"
|
|
331
|
-
: p.state === "restarted"
|
|
332
|
-
? "render restarted (backend replica was replaced)"
|
|
333
|
-
: p.stage === "assembling"
|
|
334
|
-
? "assembling deck"
|
|
335
|
-
: typeof p.total === "number"
|
|
336
|
-
? `rendering slide ${Math.min((p.done ?? 0) + 1, p.total)}/${p.total}`
|
|
337
|
-
: null;
|
|
338
|
-
void extra
|
|
339
|
-
.sendNotification({
|
|
340
|
-
method: "notifications/progress",
|
|
341
|
-
params: {
|
|
342
|
-
progressToken,
|
|
343
|
-
progress: typeof p.done === "number" ? p.done : 0,
|
|
344
|
-
...(typeof p.total === "number" ? { total: p.total } : {}),
|
|
345
|
-
...(message ? { message } : {}),
|
|
346
|
-
},
|
|
347
|
-
})
|
|
348
|
-
.catch(() => {});
|
|
349
|
-
};
|
|
350
|
-
const result = await buildAndPush({
|
|
351
|
-
deckDir: path.resolve(process.cwd(), deck_path),
|
|
352
|
-
presentationId: presentation_id,
|
|
353
|
-
insertAt: insert_at ?? null,
|
|
354
|
-
replace: replace === true,
|
|
355
|
-
slideIds: slide_ids ?? null,
|
|
356
|
-
onProgress,
|
|
357
|
-
});
|
|
358
|
-
return textResult(result);
|
|
359
|
-
} catch (err) {
|
|
360
|
-
return errorResult(err);
|
|
361
|
-
}
|
|
362
|
-
},
|
|
363
|
-
);
|
|
364
|
-
|
|
365
|
-
server.registerTool(
|
|
366
|
-
"slidemind_search_engine",
|
|
367
|
-
{
|
|
368
|
-
title: "Search an engine for imagery or slides",
|
|
369
|
-
description:
|
|
370
|
-
"Search the active corporate design's engine over the bridge — image/icon engines OR slide engines. " +
|
|
371
|
-
"Uses the add-in's server-side bridge, so it returns the SAME results the in-app design agent sees. " +
|
|
372
|
-
"Returns a searchId and a numbered set of results. For image engines (and visual slide search) you also get a grid image you can view; " +
|
|
373
|
-
"for content slide search each result carries the extracted slide `text` plus metadata (slideNumber, presentationTitle, score). " +
|
|
374
|
-
"intent selects the slide search mode: 'content' (text is the signal) or 'visual' (the slide image is the signal); image engines are visual-only. " +
|
|
375
|
-
"Next: reuse an image with slidemind_download_result, view a slide with slidemind_view_slide, insert a slide live with slidemind_insert_slide, or fold a slide into a deck with slidemind_add_slide_to_deck. " +
|
|
376
|
-
"engine_id is optional for image search — omit it to use the corporate design's first image engine; slide search requires engine_id (see slidemind_list_engines). " +
|
|
377
|
-
"presentation_id is OPTIONAL: the engine is not bound to a window, so omit it and the search attaches to any connected window automatically. The window it used is echoed back as `presentation_id` in the result — pass THAT to the follow-up tools (view/insert/add), since the searchId is bound to it.",
|
|
378
|
-
inputSchema: {
|
|
379
|
-
presentation_id: z
|
|
380
|
-
.string()
|
|
381
|
-
.nullable()
|
|
382
|
-
.optional()
|
|
383
|
-
.describe("Optional. Omit to attach to any connected window automatically (the engine isn't window-bound). The window used is returned as `presentation_id`; thread it to slidemind_view_slide / _insert_slide / _add_slide_to_deck."),
|
|
384
|
-
query: z
|
|
385
|
-
.string()
|
|
386
|
-
.describe("Concept query, e.g. 'shield', 'handshake', 'revenue bridge'."),
|
|
387
|
-
engine_id: z
|
|
388
|
-
.string()
|
|
389
|
-
.nullable()
|
|
390
|
-
.optional()
|
|
391
|
-
.describe("Engine id from slidemind_list_engines. Omit/null = first image engine (image search only)."),
|
|
392
|
-
intent: z
|
|
393
|
-
.enum(["auto", "content", "visual"])
|
|
394
|
-
.nullable()
|
|
395
|
-
.optional()
|
|
396
|
-
.describe("Slide search mode: 'content' (text), 'visual' (image), or 'auto'. Image engines ignore this."),
|
|
397
|
-
top: z
|
|
398
|
-
.number()
|
|
399
|
-
.int()
|
|
400
|
-
.positive()
|
|
401
|
-
.nullable()
|
|
402
|
-
.optional()
|
|
403
|
-
.describe("Max number of results to return."),
|
|
404
|
-
},
|
|
405
|
-
},
|
|
406
|
-
async ({ presentation_id, query, engine_id, intent, top }) => {
|
|
407
|
-
try {
|
|
408
|
-
const client = await loadBridgeClient();
|
|
409
|
-
// All engine tools hard-require an open host (the truth lives in the
|
|
410
|
-
// add-in); surface the actionable "open PowerPoint…" message up front.
|
|
411
|
-
const registry = await client.requirePaneHost();
|
|
412
|
-
// The engine is per-user, not window-bound: attach to any connected window
|
|
413
|
-
// when the caller didn't pin one. The chosen presId is echoed back so the
|
|
414
|
-
// agent threads it to the follow-up tools (the searchId is bound to it).
|
|
415
|
-
const entry = client.pickAnyPresentationEntry(registry, presentation_id ?? null);
|
|
416
|
-
const presId = entry.presId;
|
|
417
|
-
const backendURL = entry.backendURL ?? null;
|
|
418
|
-
let engineId = engine_id ?? null;
|
|
419
|
-
const slideIntent = intent === "content" || intent === "visual";
|
|
420
|
-
// Forward the engine subtype on the wire whenever it's derivable (belt-
|
|
421
|
-
// and-suspenders against an add-in regression where a missing subtype no
|
|
422
|
-
// longer defaulted to "image"): an explicit slide intent targets a slide
|
|
423
|
-
// engine; the pickImageEngine default branch is an image engine. When the
|
|
424
|
-
// subtype is unknown we omit it and let the add-in resolve it.
|
|
425
|
-
let subtype = slideIntent ? "slide" : null;
|
|
426
|
-
if (!engineId) {
|
|
427
|
-
if (slideIntent) {
|
|
428
|
-
return errorResult(
|
|
429
|
-
new Error(
|
|
430
|
-
"slide search requires engine_id. Call slidemind_list_engines (type='search', subtype='slide') to find one.",
|
|
431
|
-
),
|
|
432
|
-
);
|
|
433
|
-
}
|
|
434
|
-
const { materializeBundle, readEnginesFromDir, pickImageEngine } =
|
|
435
|
-
await loadBundle();
|
|
436
|
-
const mat = await materializeBundle(presId, { backendURL });
|
|
437
|
-
const pick = pickImageEngine(readEnginesFromDir(mat.dir));
|
|
438
|
-
if (!pick) {
|
|
439
|
-
return errorResult(
|
|
440
|
-
new Error(
|
|
441
|
-
"This corporate design has no brand engines to search. Pass engine_id or ask the add-in owner to wire an image engine.",
|
|
442
|
-
),
|
|
443
|
-
);
|
|
444
|
-
}
|
|
445
|
-
engineId = pick.id;
|
|
446
|
-
subtype = "image";
|
|
447
|
-
}
|
|
448
|
-
// Window-agnostic: tries the picked pane, then fails over to any other
|
|
449
|
-
// live window on the same backend if it's suspended/frozen. res.presId is
|
|
450
|
-
// the window that actually answered — echo THAT back (the searchId is
|
|
451
|
-
// bound to it) so follow-up view/insert/add target the right pane.
|
|
452
|
-
const res = await client.engineSearchAnyPane({
|
|
453
|
-
registry,
|
|
454
|
-
explicitPresId: presentation_id ?? null,
|
|
455
|
-
engineId,
|
|
456
|
-
query,
|
|
457
|
-
subtype,
|
|
458
|
-
intent: intent ?? null,
|
|
459
|
-
top: top ?? null,
|
|
460
|
-
});
|
|
461
|
-
const ranPresId = res?.presId ?? presId;
|
|
462
|
-
const content = [];
|
|
463
|
-
const grid = imageContentFromDataUri(res?.gridDataUri);
|
|
464
|
-
if (grid) content.push(grid);
|
|
465
|
-
// Prefer the new `results` array (image + slide engines); fall back to the
|
|
466
|
-
// legacy `thumbnails` shape older add-ins still return for image engines.
|
|
467
|
-
const rawResults = Array.isArray(res?.results)
|
|
468
|
-
? res.results
|
|
469
|
-
: (res?.thumbnails ?? []).map((t, i) => ({ selection: i + 1, title: t.title }));
|
|
470
|
-
const results = rawResults.map((r, i) => ({
|
|
471
|
-
selection: r.selection ?? i + 1,
|
|
472
|
-
title: r.title ?? null,
|
|
473
|
-
...(typeof r.score === "number" ? { score: r.score } : {}),
|
|
474
|
-
...(r.kind ? { kind: r.kind } : {}),
|
|
475
|
-
...(typeof r.text === "string" ? { text: r.text } : {}),
|
|
476
|
-
...(typeof r.slideNumber === "number" ? { slideNumber: r.slideNumber } : {}),
|
|
477
|
-
...(r.presentationTitle ? { presentationTitle: r.presentationTitle } : {}),
|
|
478
|
-
}));
|
|
479
|
-
content.push({
|
|
480
|
-
type: "text",
|
|
481
|
-
text: JSON.stringify(
|
|
482
|
-
{
|
|
483
|
-
ok: res?.ok ?? false,
|
|
484
|
-
// The window the search ran on (auto-attached when the caller didn't
|
|
485
|
-
// pin one; may have failed over from the picked pane). The searchId
|
|
486
|
-
// is bound to it — pass this presentation_id to slidemind_view_slide
|
|
487
|
-
// / _insert_slide / _add_slide_to_deck.
|
|
488
|
-
presentation_id: ranPresId,
|
|
489
|
-
engineId,
|
|
490
|
-
subtype: res?.subtype ?? null,
|
|
491
|
-
intent: res?.intent ?? null,
|
|
492
|
-
searchId: res?.searchId ?? null,
|
|
493
|
-
resultCount: res?.resultCount ?? results.length,
|
|
494
|
-
results,
|
|
495
|
-
},
|
|
496
|
-
null,
|
|
497
|
-
2,
|
|
498
|
-
),
|
|
499
|
-
});
|
|
500
|
-
return { content };
|
|
501
|
-
} catch (err) {
|
|
502
|
-
return errorResult(err);
|
|
503
|
-
}
|
|
504
|
-
},
|
|
505
|
-
);
|
|
506
|
-
|
|
507
|
-
server.registerTool(
|
|
508
|
-
"slidemind_view_draft",
|
|
509
|
-
{
|
|
510
|
-
title: "Look at a deck's draft slides (no push)",
|
|
511
|
-
description:
|
|
512
|
-
"Render the deck's authored slides/*.jsx to per-slide preview images and return them to you WITHOUT pushing anything — the 'look at my own draft before the user sees it' tool. " +
|
|
513
|
-
"Use this AFTER authoring/editing slides and BEFORE slidemind_push_deck (and before the human slidemind review gate): look at each screenshot, fix anything visibly wrong in the JSX, then view again until each slide is sound. " +
|
|
514
|
-
"Renders locally against the materialized bundle, so it needs no awake task pane (and no open host at all once the bundle has been downloaded once). " +
|
|
515
|
-
"Returns one image per freeform slide; purpose-built layout slides (covers/dividers) and reused native .pptx slides have no draft preview and report as a status line. Nothing is pushed, nothing is written to the deck.",
|
|
516
|
-
inputSchema: {
|
|
517
|
-
deck_path: z
|
|
518
|
-
.string()
|
|
519
|
-
.describe("Path to the deck folder (must contain slides/). Absolute or relative to CWD."),
|
|
520
|
-
presentation_id: z
|
|
521
|
-
.string()
|
|
522
|
-
.nullable()
|
|
523
|
-
.optional()
|
|
524
|
-
.describe("Optional presId — only used to fetch the corporate-design bundle if it isn't cached yet. Omit when the deck's bundle is already materialized (the normal case after authoring)."),
|
|
525
|
-
},
|
|
526
|
-
},
|
|
527
|
-
async ({ deck_path, presentation_id }) => {
|
|
528
|
-
try {
|
|
529
|
-
const { viewDraft } = await loadBuildAndPush();
|
|
530
|
-
const deckDir = path.resolve(process.cwd(), deck_path);
|
|
531
|
-
const result = await viewDraft(deckDir, {
|
|
532
|
-
presentationId: presentation_id ?? null,
|
|
533
|
-
});
|
|
534
|
-
const images = [];
|
|
535
|
-
const lines = [];
|
|
536
|
-
for (const slide of result.slides ?? []) {
|
|
537
|
-
const image = slide.dataUri ? imageContentFromDataUri(slide.dataUri) : null;
|
|
538
|
-
if (image) {
|
|
539
|
-
images.push(image);
|
|
540
|
-
lines.push(
|
|
541
|
-
`${slide.fileName} — preview attached (image ${images.length})` +
|
|
542
|
-
(slide.status === "degraded" && slide.note ? ` — ${slide.note}` : ""),
|
|
543
|
-
);
|
|
544
|
-
} else {
|
|
545
|
-
lines.push(`${slide.fileName} — ${slide.note ?? slide.status}`);
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
const header =
|
|
549
|
-
images.length > 0
|
|
550
|
-
? `Rendered ${images.length} draft preview image(s) — NOT pushed to PowerPoint, nothing written to the deck. ` +
|
|
551
|
-
`Judge each screenshot with your eyes, fix what is visibly wrong in the slide JSX, view the draft again, ` +
|
|
552
|
-
`then push with slidemind_push_deck (or build-pptx) once each slide is visually sound.`
|
|
553
|
-
: `No draft preview images were produced — NOTHING was pushed.`;
|
|
554
|
-
const warnLines = (result.warnings ?? []).map((w) => ` warning: ${w}`);
|
|
555
|
-
const text = [header, ...lines, ...warnLines].join("\n");
|
|
556
|
-
return { content: [{ type: "text", text }, ...images] };
|
|
557
|
-
} catch (err) {
|
|
558
|
-
return errorResult(err);
|
|
559
|
-
}
|
|
560
|
-
},
|
|
561
|
-
);
|
|
562
|
-
|
|
563
|
-
server.registerTool(
|
|
564
|
-
"slidemind_list_engines",
|
|
565
|
-
{
|
|
566
|
-
title: "List available engines",
|
|
567
|
-
description:
|
|
568
|
-
"List the LIVE engine catalog for the active corporate design over the bridge (not just the bundle's curated set). " +
|
|
569
|
-
"Spans every paired add-in environment; each engine carries an `env` tag. " +
|
|
570
|
-
"Filter by `type` (collection|search) and/or `subtype` (image|slide|template|content). " +
|
|
571
|
-
"Use the returned engine id with slidemind_search_engine (slide search requires one).",
|
|
572
|
-
inputSchema: {
|
|
573
|
-
presentation_id: z
|
|
574
|
-
.string()
|
|
575
|
-
.nullable()
|
|
576
|
-
.optional()
|
|
577
|
-
.describe("Optional presId — engines are backend-scoped, so this is informational only."),
|
|
578
|
-
type: z
|
|
579
|
-
.enum(["collection", "search"])
|
|
580
|
-
.nullable()
|
|
581
|
-
.optional()
|
|
582
|
-
.describe("Filter by engine type."),
|
|
583
|
-
subtype: z
|
|
584
|
-
.enum(["image", "slide", "template", "content"])
|
|
585
|
-
.nullable()
|
|
586
|
-
.optional()
|
|
587
|
-
.describe("Filter by engine subtype."),
|
|
588
|
-
},
|
|
589
|
-
},
|
|
590
|
-
async ({ type, subtype }) => {
|
|
591
|
-
try {
|
|
592
|
-
const client = await loadBridgeClient();
|
|
593
|
-
const { engines } = await client.listEngines({
|
|
594
|
-
type: type ?? null,
|
|
595
|
-
subtype: subtype ?? null,
|
|
596
|
-
});
|
|
597
|
-
return textResult({ engines });
|
|
598
|
-
} catch (err) {
|
|
599
|
-
return errorResult(err);
|
|
600
|
-
}
|
|
601
|
-
},
|
|
602
|
-
);
|
|
603
|
-
|
|
604
|
-
server.registerTool(
|
|
605
|
-
"slidemind_view_slide",
|
|
606
|
-
{
|
|
607
|
-
title: "View a slide search result",
|
|
608
|
-
description:
|
|
609
|
-
"Fetch a chosen slide search result's preview image so you can see it before reusing it. " +
|
|
610
|
-
"Pass the searchId from slidemind_search_engine and the 1-based selection number. " +
|
|
611
|
-
"Returns an image content block. Then reuse with slidemind_insert_slide (live) or slidemind_add_slide_to_deck (into a deck).",
|
|
612
|
-
inputSchema: {
|
|
613
|
-
presentation_id: z
|
|
614
|
-
.string()
|
|
615
|
-
.describe("The presentation_id returned by slidemind_search_engine (the window the search ran on). The searchId is bound to that window, so this must be exactly that presentation_id."),
|
|
616
|
-
search_id: z
|
|
617
|
-
.number()
|
|
618
|
-
.int()
|
|
619
|
-
.describe("searchId returned by slidemind_search_engine."),
|
|
620
|
-
selection: z
|
|
621
|
-
.number()
|
|
622
|
-
.int()
|
|
623
|
-
.positive()
|
|
624
|
-
.describe("1-based result number to view."),
|
|
625
|
-
},
|
|
626
|
-
},
|
|
627
|
-
async ({ presentation_id, search_id, selection }) => {
|
|
628
|
-
try {
|
|
629
|
-
const client = await loadBridgeClient();
|
|
630
|
-
await client.requirePaneHost();
|
|
631
|
-
const res = await client.viewSlide({
|
|
632
|
-
presId: presentation_id,
|
|
633
|
-
searchId: search_id,
|
|
634
|
-
selection,
|
|
635
|
-
});
|
|
636
|
-
if (!res?.ok || !res.dataUri) {
|
|
637
|
-
return errorResult(new Error(res?.error ?? "slide view returned no image"));
|
|
638
|
-
}
|
|
639
|
-
const image = imageContentFromDataUri(res.dataUri);
|
|
640
|
-
if (!image) {
|
|
641
|
-
return errorResult(new Error("slide view returned an unparseable data URI"));
|
|
642
|
-
}
|
|
643
|
-
return { content: [image] };
|
|
644
|
-
} catch (err) {
|
|
645
|
-
return errorResult(err);
|
|
646
|
-
}
|
|
647
|
-
},
|
|
648
|
-
);
|
|
649
|
-
|
|
650
|
-
server.registerTool(
|
|
651
|
-
"slidemind_insert_slide",
|
|
652
|
-
{
|
|
653
|
-
title: "Insert reused slide(s) live into PowerPoint",
|
|
654
|
-
description:
|
|
655
|
-
"Insert one or more chosen slide search results directly into an open PowerPoint window (the 'reuse → insert live' path). " +
|
|
656
|
-
"Pass the searchId from slidemind_search_engine and the 1-based selection number(s). " +
|
|
657
|
-
"By default the slides are appended; use `at` to insert after a specific 0-based slide index. " +
|
|
658
|
-
"To instead fold reused slides into a deck you are authoring, use slidemind_add_slide_to_deck.",
|
|
659
|
-
inputSchema: {
|
|
660
|
-
presentation_id: z
|
|
661
|
-
.string()
|
|
662
|
-
.describe("The presentation_id returned by slidemind_search_engine (the window the search ran on). The searchId is bound to that window, so this must be exactly that presentation_id."),
|
|
663
|
-
search_id: z
|
|
664
|
-
.number()
|
|
665
|
-
.int()
|
|
666
|
-
.describe("searchId returned by slidemind_search_engine."),
|
|
667
|
-
selections: z
|
|
668
|
-
.array(z.number().int().positive())
|
|
669
|
-
.min(1)
|
|
670
|
-
.describe("1-based result number(s) to insert."),
|
|
671
|
-
at: z
|
|
672
|
-
.number()
|
|
673
|
-
.int()
|
|
674
|
-
.nonnegative()
|
|
675
|
-
.nullable()
|
|
676
|
-
.optional()
|
|
677
|
-
.describe("0-based slide index to insert after. Omit or null = append at end."),
|
|
678
|
-
},
|
|
679
|
-
},
|
|
680
|
-
async ({ presentation_id, search_id, selections, at }) => {
|
|
681
|
-
try {
|
|
682
|
-
const client = await loadBridgeClient();
|
|
683
|
-
await client.requirePaneHost();
|
|
684
|
-
const res = await client.insertSlideLive({
|
|
685
|
-
presId: presentation_id,
|
|
686
|
-
searchId: search_id,
|
|
687
|
-
selections,
|
|
688
|
-
at: at ?? null,
|
|
689
|
-
});
|
|
690
|
-
if (!res?.ok) {
|
|
691
|
-
return errorResult(new Error(res?.error ?? "insert slide failed"));
|
|
692
|
-
}
|
|
693
|
-
return textResult({
|
|
694
|
-
ok: true,
|
|
695
|
-
insertedCount: res.insertedCount ?? selections.length,
|
|
696
|
-
insertedSlideIds: res.insertedSlideIds ?? [],
|
|
697
|
-
});
|
|
698
|
-
} catch (err) {
|
|
699
|
-
return errorResult(err);
|
|
700
|
-
}
|
|
701
|
-
},
|
|
702
|
-
);
|
|
703
|
-
|
|
704
|
-
server.registerTool(
|
|
705
|
-
"slidemind_add_slide_to_deck",
|
|
706
|
-
{
|
|
707
|
-
title: "Fold reused slide(s) into a deck",
|
|
708
|
-
description:
|
|
709
|
-
"Fold one or more chosen slide search results into a deck as native one-slide .pptx files under <deck>/slides/ (the 'reuse → fold into a deck' path). " +
|
|
710
|
-
"These native slides survive re-render: every slidemind_push_deck / build-pptx re-includes them, spliced into play order by their numeric filename prefix alongside the deck's .jsx slides. " +
|
|
711
|
-
"Pass the searchId from slidemind_search_engine and the 1-based selection number(s). " +
|
|
712
|
-
"By default the slides are appended after the last numeric prefix; `at` sets the numeric prefix the first added slide takes. " +
|
|
713
|
-
"To instead insert directly into an open window, use slidemind_insert_slide.",
|
|
714
|
-
inputSchema: {
|
|
715
|
-
presentation_id: z
|
|
716
|
-
.string()
|
|
717
|
-
.describe("The presentation_id returned by slidemind_search_engine (the window the search ran on). The searchId is bound to that window, so this must be exactly that presentation_id."),
|
|
718
|
-
deck_path: z
|
|
719
|
-
.string()
|
|
720
|
-
.describe("Path to the deck folder (slides are written under <deck>/slides/). Absolute or relative to CWD."),
|
|
721
|
-
search_id: z
|
|
722
|
-
.number()
|
|
723
|
-
.int()
|
|
724
|
-
.describe("searchId returned by slidemind_search_engine."),
|
|
725
|
-
selections: z
|
|
726
|
-
.array(z.number().int().positive())
|
|
727
|
-
.min(1)
|
|
728
|
-
.describe("1-based result number(s) to fold into the deck."),
|
|
729
|
-
at: z
|
|
730
|
-
.number()
|
|
731
|
-
.int()
|
|
732
|
-
.nonnegative()
|
|
733
|
-
.nullable()
|
|
734
|
-
.optional()
|
|
735
|
-
.describe("Numeric prefix the first added slide takes. Omit or null = append after the last existing prefix."),
|
|
736
|
-
},
|
|
737
|
-
},
|
|
738
|
-
async ({ presentation_id, deck_path, search_id, selections, at }) => {
|
|
739
|
-
try {
|
|
740
|
-
const client = await loadBridgeClient();
|
|
741
|
-
await client.requirePaneHost();
|
|
742
|
-
const deckDir = path.resolve(process.cwd(), deck_path);
|
|
743
|
-
const slidesDir = path.join(deckDir, "slides");
|
|
744
|
-
fs.mkdirSync(slidesDir, { recursive: true });
|
|
745
|
-
|
|
746
|
-
// Extract each picked slide's bytes, then let the shared helper compute
|
|
747
|
-
// the play-order filenames — the SAME prefix math the CLI's add-slide
|
|
748
|
-
// uses (src/lib/reused-slides.mjs), so the two paths never diverge.
|
|
749
|
-
const { reusedSlideTargets } = await loadReusedSlides();
|
|
750
|
-
const extracted = [];
|
|
751
|
-
for (const selection of selections) {
|
|
752
|
-
const res = await client.extractSlide({
|
|
753
|
-
presId: presentation_id,
|
|
754
|
-
searchId: search_id,
|
|
755
|
-
selection,
|
|
756
|
-
});
|
|
757
|
-
if (!res?.ok || !res.base64) {
|
|
758
|
-
return errorResult(
|
|
759
|
-
new Error(
|
|
760
|
-
`add slide failed for selection ${selection}: ${res?.error ?? "no slide bytes returned"}`,
|
|
761
|
-
),
|
|
762
|
-
);
|
|
763
|
-
}
|
|
764
|
-
extracted.push({ selection, slideId: res.slideId ?? null, base64: res.base64 });
|
|
765
|
-
}
|
|
766
|
-
const existing = fs
|
|
767
|
-
.readdirSync(slidesDir)
|
|
768
|
-
.filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"));
|
|
769
|
-
const targets = reusedSlideTargets(existing, extracted, at);
|
|
770
|
-
const written = extracted.map((slide, i) => {
|
|
771
|
-
const { filename } = targets[i];
|
|
772
|
-
const target = path.join(slidesDir, filename);
|
|
773
|
-
fs.writeFileSync(target, Buffer.from(slide.base64, "base64"));
|
|
774
|
-
return {
|
|
775
|
-
selection: slide.selection,
|
|
776
|
-
filename,
|
|
777
|
-
reference: `./slides/${filename}`,
|
|
778
|
-
path: target,
|
|
779
|
-
};
|
|
780
|
-
});
|
|
781
|
-
return textResult({ ok: true, deckDir, written });
|
|
782
|
-
} catch (err) {
|
|
783
|
-
return errorResult(err);
|
|
784
|
-
}
|
|
785
|
-
},
|
|
786
|
-
);
|
|
787
|
-
|
|
788
|
-
// Fetch one chosen result over the bridge and write it into the deck's
|
|
789
|
-
// assets/. Returns the written reference plus a `recolor` descriptor so the
|
|
790
|
-
// agent never has to open the SVG to learn how it recolours. Throws on a failed
|
|
791
|
-
// download so a batch can record a per-item error without aborting the rest.
|
|
792
|
-
async function downloadOneResult({
|
|
793
|
-
client,
|
|
794
|
-
presId,
|
|
795
|
-
searchId,
|
|
796
|
-
assetsDir,
|
|
797
|
-
selection,
|
|
798
|
-
iconColor,
|
|
799
|
-
}) {
|
|
800
|
-
const res = await client.engineDownload({
|
|
801
|
-
presId,
|
|
802
|
-
searchId,
|
|
803
|
-
selection,
|
|
804
|
-
iconColor: iconColor ?? null,
|
|
805
|
-
});
|
|
806
|
-
if (!res?.ok) {
|
|
807
|
-
throw new Error(res?.error ?? "download failed");
|
|
808
|
-
}
|
|
809
|
-
const ext = extFromMime(res.mimeType);
|
|
810
|
-
const filename = `engine-${searchId}-${selection}.${ext}`;
|
|
811
|
-
const target = path.join(assetsDir, filename);
|
|
812
|
-
if (res.svgMarkup && ext === "svg") {
|
|
813
|
-
// The (possibly recoloured) SVG markup is the canonical asset.
|
|
814
|
-
fs.writeFileSync(target, res.svgMarkup, "utf8");
|
|
815
|
-
} else if (typeof res.base64 === "string" && res.base64.length > 0) {
|
|
816
|
-
fs.writeFileSync(target, Buffer.from(res.base64, "base64"));
|
|
817
|
-
} else if (res.svgMarkup) {
|
|
818
|
-
// SVG bytes arrived only as markup (mimeType lacked an svg hint).
|
|
819
|
-
fs.writeFileSync(target, res.svgMarkup, "utf8");
|
|
820
|
-
} else {
|
|
821
|
-
throw new Error("engine result carried neither base64 bytes nor svgMarkup");
|
|
822
|
-
}
|
|
823
|
-
return {
|
|
824
|
-
selection,
|
|
825
|
-
path: target,
|
|
826
|
-
reference: `./assets/${filename}`,
|
|
827
|
-
mimeType: res.mimeType,
|
|
828
|
-
recolor: describeRecolor(res.svgMarkup ?? null, res.mimeType, iconColor ?? null),
|
|
829
|
-
};
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
server.registerTool(
|
|
833
|
-
"slidemind_download_result",
|
|
834
|
-
{
|
|
835
|
-
title: "Download chosen engine result(s) into a deck",
|
|
836
|
-
description:
|
|
837
|
-
"Fetch the bytes of one or more results from a prior slidemind_search_engine call and write them into the deck's assets/ folder. " +
|
|
838
|
-
"Reference each from slide JSX as <img src=\"./assets/<file>\">. " +
|
|
839
|
-
"Each result carries a `recolor` descriptor ({ recolorable, mode, applied, note }) — so you NEVER need to open the SVG to learn how it recolours. " +
|
|
840
|
-
"To colour an icon, pass icon_color (a hex you resolve from a corporate-design.css token): the bridge bakes it into the saved SVG, correct in BOTH the HTML preview and the .pptx render. " +
|
|
841
|
-
"Download several icons (each in its own colour) in ONE call via `selections: [{ selection, icon_color? }, …]` — they download concurrently. Use `selection` for one, or `selections` for many (don't pass both).",
|
|
842
|
-
inputSchema: {
|
|
843
|
-
presentation_id: z
|
|
844
|
-
.string()
|
|
845
|
-
.describe("The presentation_id returned by slidemind_search_engine (the window the search ran on). The searchId is bound to that window, so this must be exactly that presentation_id."),
|
|
846
|
-
deck_path: z
|
|
847
|
-
.string()
|
|
848
|
-
.describe("Path to the deck folder (assets are written under <deck>/assets/)."),
|
|
849
|
-
search_id: z
|
|
850
|
-
.number()
|
|
851
|
-
.int()
|
|
852
|
-
.describe("searchId returned by slidemind_search_engine."),
|
|
853
|
-
selection: z
|
|
854
|
-
.number()
|
|
855
|
-
.int()
|
|
856
|
-
.positive()
|
|
857
|
-
.nullable()
|
|
858
|
-
.optional()
|
|
859
|
-
.describe("1-based result number to download (single). Omit when using `selections`."),
|
|
860
|
-
icon_color: z
|
|
861
|
-
.string()
|
|
862
|
-
.nullable()
|
|
863
|
-
.optional()
|
|
864
|
-
.describe("Optional hex colour (e.g. '#0000FF') to bake into a single SVG result."),
|
|
865
|
-
selections: z
|
|
866
|
-
.array(
|
|
867
|
-
z.object({
|
|
868
|
-
selection: z
|
|
869
|
-
.number()
|
|
870
|
-
.int()
|
|
871
|
-
.positive()
|
|
872
|
-
.describe("1-based result number."),
|
|
873
|
-
icon_color: z
|
|
874
|
-
.string()
|
|
875
|
-
.nullable()
|
|
876
|
-
.optional()
|
|
877
|
-
.describe("Optional hex colour to bake into this SVG."),
|
|
878
|
-
}),
|
|
879
|
-
)
|
|
880
|
-
.nullable()
|
|
881
|
-
.optional()
|
|
882
|
-
.describe("Batch: several results to download concurrently, each in its own colour."),
|
|
883
|
-
},
|
|
884
|
-
},
|
|
885
|
-
async ({ presentation_id, deck_path, search_id, selection, icon_color, selections }) => {
|
|
886
|
-
try {
|
|
887
|
-
const batch = Array.isArray(selections) && selections.length > 0;
|
|
888
|
-
const jobs = batch
|
|
889
|
-
? selections.map((s) => ({
|
|
890
|
-
selection: s.selection,
|
|
891
|
-
iconColor: s.icon_color ?? null,
|
|
892
|
-
}))
|
|
893
|
-
: typeof selection === "number"
|
|
894
|
-
? [{ selection, iconColor: icon_color ?? null }]
|
|
895
|
-
: [];
|
|
896
|
-
if (jobs.length === 0) {
|
|
897
|
-
return errorResult(
|
|
898
|
-
new Error(
|
|
899
|
-
"Provide `selection` (one result) or `selections` (a batch). Both are missing.",
|
|
900
|
-
),
|
|
901
|
-
);
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
const client = await loadBridgeClient();
|
|
905
|
-
await client.requirePaneHost();
|
|
906
|
-
const deckDir = path.resolve(process.cwd(), deck_path);
|
|
907
|
-
const assetsDir = path.join(deckDir, "assets");
|
|
908
|
-
fs.mkdirSync(assetsDir, { recursive: true });
|
|
909
|
-
|
|
910
|
-
// Concurrent — each download is its own bridge round-trip, so issuing them
|
|
911
|
-
// in parallel turns N serial waits into one. allSettled so one bad
|
|
912
|
-
// selection doesn't sink the rest of the batch.
|
|
913
|
-
const settled = await Promise.allSettled(
|
|
914
|
-
jobs.map((job) =>
|
|
915
|
-
downloadOneResult({
|
|
916
|
-
client,
|
|
917
|
-
presId: presentation_id,
|
|
918
|
-
searchId: search_id,
|
|
919
|
-
assetsDir,
|
|
920
|
-
selection: job.selection,
|
|
921
|
-
iconColor: job.iconColor,
|
|
922
|
-
}),
|
|
923
|
-
),
|
|
924
|
-
);
|
|
925
|
-
const results = settled.map((r, i) =>
|
|
926
|
-
r.status === "fulfilled"
|
|
927
|
-
? r.value
|
|
928
|
-
: {
|
|
929
|
-
selection: jobs[i].selection,
|
|
930
|
-
error: r.reason?.message ? r.reason.message : String(r.reason),
|
|
931
|
-
},
|
|
932
|
-
);
|
|
933
|
-
|
|
934
|
-
// Single-selection calls keep the original flat shape (back-compat);
|
|
935
|
-
// a batch returns { results: [...] }.
|
|
936
|
-
if (!batch) {
|
|
937
|
-
const only = results[0];
|
|
938
|
-
if (only.error) return errorResult(new Error(only.error));
|
|
939
|
-
return textResult(only);
|
|
940
|
-
}
|
|
941
|
-
return textResult({ results });
|
|
942
|
-
} catch (err) {
|
|
943
|
-
return errorResult(err);
|
|
944
|
-
}
|
|
945
|
-
},
|
|
946
|
-
);
|
|
947
|
-
|
|
948
|
-
async function main() {
|
|
949
|
-
const transport = new StdioServerTransport();
|
|
950
|
-
await server.connect(transport);
|
|
951
|
-
// Logging on stdout would corrupt the JSON-RPC stream — keep it on stderr.
|
|
952
|
-
process.stderr.write("slidemind MCP server running on stdio\n");
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
main().catch((err) => {
|
|
956
|
-
process.stderr.write(`slidemind MCP fatal: ${err && err.stack ? err.stack : err}\n`);
|
|
957
|
-
process.exit(1);
|
|
958
|
-
});
|