slidemind 0.0.27 → 0.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,7 +72,7 @@ If `slidemind presentations` returns nothing, the add-in isn't running — open
72
72
 
73
73
  ## Icon / image search
74
74
 
75
- `slidemind search "<query>"` searches the active corporate design's **brand engine** (icons/images) over the local bridge — proxied to the open PowerPoint task pane, so it returns the same results the in-app design agent sees (an open host is required). It prints a `searchId` plus numbered results; the agent then fetches a chosen result into the deck's `assets/` via the MCP tool `slidemind_download_result`. The agent-facing flow (`slidemind_search_engine` → `slidemind_download_result`) is owned by the add-in's design bundle and read by the skill. There is no local icon library anymore.
75
+ `slidemind search "<query>"` searches the active corporate design's **brand engine** (icons/images) over the local bridge, using the add-in's server-side bridge when available so it returns the same results the in-app design agent sees (an open host is required). It prints a `searchId` plus numbered results; the agent then fetches a chosen result into the deck's `assets/` via the MCP tool `slidemind_download_result`. The agent-facing flow (`slidemind_search_engine` → `slidemind_download_result`) is owned by the add-in's design bundle and read by the skill. There is no local icon library anymore.
76
76
 
77
77
  ## Install into a coding-agent harness
78
78
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidemind",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "description": "Connect Claude Code, Codex & opencode to the slidemind PowerPoint add-in — let your coding agent build and edit native, on-brand PowerPoint decks against your active corporate design, from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,7 @@ Authoring and rendering hard-require an **open PowerPoint host with the slidemin
34
34
  2. One registered → that's your target `presentation_id`. **Several → ask the user which**, by `title`/`fileName` (never raw ids) — once for **which presentation's corporate design to download**, and again at render time for **which presentation to push into** (the CD source and the push target may be different presentations).
35
35
  3. **None registered → stop.** Tell the user: *open PowerPoint, load the slidemind add-in, and pick or create a corporate design, then retry.*
36
36
 
37
- **Connections drop when PowerPoint is backgrounded — try first, reconnect only on failure.** The add-in's task pane suspends when PowerPoint isn't the focused app, so its bridge connection can drop and `slidemind presentations` may briefly go empty even though PowerPoint is still open with a deck. So **always just run the command first — never pre-emptively ask the user to do anything.** Only if a command fails with *no host registered* (or a bridge-connection error) do you ask the user to **click into PowerPoint and focus the slidemind task pane** so it reconnects, then **retry the same command** — the deck and bundle are already prepared, so it's just a re-run. Reserve the full "open PowerPoint…" instruction in (3) for when nothing is registered at all.
37
+ **Backgrounded PowerPoint is expected to work.** Do not pre-emptively ask the user to focus PowerPoint or the task pane. The bridge retains the registered presentation and pane-free routes keep working; live push/replace waits for the task-pane socket to reconnect if it truly dropped. Only if a command fails with *no host registered* (or a bridge wait timeout) do you ask the user to click the slidemind task pane once so it reconnects, then retry the same command — the deck and bundle are already prepared. Reserve the full "open PowerPoint…" instruction in (3) for when nothing is registered at all.
38
38
 
39
39
  Local HTML preview (`slidemind build`) runs offline, but it reads a **previously materialized bundle** — so the host must have been available at least once.
40
40
 
@@ -56,9 +56,9 @@ Keep planning about intent and content. Don't materialize the bundle or read bra
56
56
  - `corporate-design.css`, `layouts/*.jsx`, `layouts/layouts.css`, `assets/`, `samples/` (the brand's own reference slides), `engines.json`.
57
57
  - `workflow/` — the add-in-owned authoring craft: `create.md`, `edit.md`, `composition.md`, `layouts.md`, `authoring.md`, `build-and-export.md`.
58
58
  3. **Study brand samples (before composing).** The materialized bundle's `samples/` dir holds the brand's own reference slides. If it's non-empty, pick **3 at random** and **Read** each (the Read tool renders images) *before* writing any JSX — this is the local-harness stand-in for the in-app persona's reference thumbnails, and it is what keeps a deck looking on-brand rather than merely compliant. Study them for whitespace rhythm, shape density, hierarchy, accent use, and layering, then compose in that spirit (never copy a sample 1:1). This is `composition.md`'s samples discipline; skip only when `samples/` is empty.
59
- 4. **Read, then author.** Read `workflow/create.md` (the procedure), `workflow/layouts.md` + `workflow/authoring.md` (how to compose + the substrate rules), and the deck's `design.md` + the specific `layouts/*.jsx` the plan uses. Then write `decks/<slug>/slides/*.jsx` — one default export per slide, composing one bundle layout (Mode A) or a blank-content layout with freeform children (Mode B), exactly as those docs prescribe. Pull in `workflow/composition.md` when the plan has 3+ parallel ideas, a repeated skeleton, a pacing/rhythm need, or a slide that's merely compliant.
59
+ 4. **Read, then author.** Read the four create-stage references together — `workflow/create.md` (the procedure), `workflow/composition.md` (the composition baseline), `workflow/layouts.md` (palette + working area), and `workflow/authoring.md` (the substrate rules) — plus the deck's `design.md` and the specific `layouts/*.jsx` the plan uses. (That is the read set `workflow/create.md` → Context loading prescribes; follow it as written.) Then write `decks/<slug>/slides/*.jsx` — one default export per slide, composing one bundle layout (Mode A) or a blank-content layout with freeform children (Mode B), exactly as those docs prescribe.
60
60
  5. **Imagery from the engine.** Per the `### Brand Image Sources` block in `design.md`, search the brand engine and place results (see the command mapping — this is `engine_search`/`engine_add` in the workflow docs).
61
- 6. **Render / push.** `slidemind push <deck>` (into PowerPoint) or `slidemind build-pptx <deck>` (to a file) — both render server-side. This is `build_and_push` in `workflow/build-and-export.md`. If several presentations are open, confirm **which one to push into** first (it can differ from the CD source). **Run the push without asking otherwise**; if it fails because the host disconnected (PowerPoint was backgrounded / the task pane suspended), ask the user to focus PowerPoint and retry — don't rebuild anything, just re-run `push`.
61
+ 6. **Render / push.** `slidemind push <deck>` (into PowerPoint) or `slidemind build-pptx <deck>` (to a file) — both render server-side. This is `build_and_push` in `workflow/build-and-export.md`. If several presentations are open, confirm **which one to push into** first (it can differ from the CD source). **Run the push without asking otherwise**; it should work while PowerPoint is backgrounded. If it times out waiting for a true task-pane reconnect, ask the user to click the slidemind task pane once and re-run `push` without rebuilding.
62
62
  7. **Sanity pass.** Run the checks in `workflow/create.md` before reporting done.
63
63
 
64
64
  ## Recolouring engine icons (CLI specifics)
@@ -10,10 +10,10 @@
10
10
 
11
11
  import fs from "node:fs";
12
12
  import path from "node:path";
13
+ import { randomUUID } from "node:crypto";
13
14
 
14
15
  import {
15
16
  insertSlides,
16
- listOpenPresentations,
17
17
  pickPresentation,
18
18
  renderPptx,
19
19
  replaceSlides,
@@ -128,86 +128,31 @@ export async function buildAndPush({
128
128
  const base64 = result.base64;
129
129
  const bytes = Buffer.byteLength(base64, "base64");
130
130
  const mode = replace ? "replace" : "insert";
131
+ const operationId = `push-${randomUUID()}`;
131
132
 
132
133
  const doPush = () =>
133
134
  replace
134
- ? replaceSlides({ presId, base64 })
135
- : insertSlides({ presId, base64, insertAt, sourceSlideIds: slideIds });
135
+ ? replaceSlides({ presId, base64, operationId })
136
+ : insertSlides({
137
+ presId,
138
+ base64,
139
+ insertAt,
140
+ sourceSlideIds: slideIds,
141
+ operationId,
142
+ });
136
143
 
137
- let resp;
138
- try {
139
- resp = await pushWithPaneRetry(doPush, presId);
140
- } catch (err) {
141
- // The pane stayed backgrounded through every retry. The render already
142
- // succeeded (it's pane-free), so don't lose it: write the bytes to
143
- // <deck>.pptx and report a clean, resumable outcome instead of throwing.
144
- if (err && err.code === "pane_suspended") {
145
- const fallbackFile = path.join(deckDir, `${path.basename(deckDir)}.pptx`);
146
- fs.writeFileSync(fallbackFile, Buffer.from(base64, "base64"));
147
- return {
148
- presId,
149
- mode,
150
- bytes,
151
- paneSuspended: true,
152
- fallbackFile,
153
- warnings: result.warnings ?? [],
154
- skippedSlides: result.skippedSlides ?? [],
155
- };
156
- }
157
- throw err;
158
- }
144
+ const resp = await doPush();
159
145
  return {
160
146
  presId,
161
147
  mode,
162
148
  bytes,
149
+ operationId,
163
150
  response: resp,
164
151
  warnings: result.warnings ?? [],
165
152
  skippedSlides: result.skippedSlides ?? [],
166
153
  };
167
154
  }
168
155
 
169
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
170
-
171
- // Push into the live deck (insert/replace) tolerating a transiently backgrounded
172
- // task pane. Retry ONLY the definitive pre-ack `pane_suspended` (the registry
173
- // entry exists but its socket is gone, so the emit provably never happened —
174
- // re-sending the same bytes is safe). NEVER retry a `bridge_timeout`: there the
175
- // emit may have been delivered, and insert is append-only, so a retry could
176
- // double-insert.
177
- async function pushWithPaneRetry(doPush, presId, attempts = 3) {
178
- let lastErr;
179
- for (let i = 0; i < attempts; i++) {
180
- try {
181
- return await doPush();
182
- } catch (err) {
183
- if (!err || err.code !== "pane_suspended") throw err;
184
- lastErr = err;
185
- if (i === attempts - 1) break;
186
- // Give the user a moment to foreground PowerPoint; resolve as soon as the
187
- // pane re-registers as connected, up to a growing window.
188
- await waitForConnected(presId, 2000 * (i + 1));
189
- }
190
- }
191
- throw lastErr;
192
- }
193
-
194
- async function waitForConnected(presId, windowMs) {
195
- const deadline = Date.now() + windowMs;
196
- while (Date.now() < deadline) {
197
- try {
198
- const reg = (await listOpenPresentations()).find(
199
- (p) => p.presId === presId,
200
- );
201
- // `connected` is undefined against an older add-in — treat that as ready
202
- // so we still retry rather than stall.
203
- if (reg && reg.connected !== false) return;
204
- } catch {
205
- /* bridge momentarily unreachable; keep polling within the window */
206
- }
207
- await sleep(500);
208
- }
209
- }
210
-
211
156
  // Render a deck to a .pptx file (the `slidemind build-pptx` path). Still
212
157
  // requires an open host because the render happens server-side.
213
158
  export async function buildPptxFile({
@@ -358,16 +358,27 @@ export function loadEnginesForDeck(deckDir) {
358
358
  return readEnginesFromDir(resolveBundleDirForDeck(deckDir));
359
359
  }
360
360
 
361
- // Pick the default engine to search: the first image/icon engine, else the
362
- // first engine. Single source of truth for the heuristic shared by the CLI
363
- // dispatcher (`slidemind search`) and the MCP `slidemind_search_engine` tool.
361
+ // Image-producing engine kinds — the modality "image" plus the finer image
362
+ // content kinds an authored bundle may carry. Excludes "slide" (a slide-search
363
+ // engine returns whole slides, not insertable bytes). Mirrors the add-in's
364
+ // local-bridge `engine_not_image` gate so the CLI never picks an engine the
365
+ // bridge would reject.
366
+ const IMAGE_ENGINE_KIND_RE = /^(image|icon|photo|illustration|vector)$/i;
367
+
368
+ // True when an engine produces insertable image bytes. Gates on the engine's
369
+ // real `kind` (its modality from the design bundle) — NOT a guess over its
370
+ // label/description.
371
+ export function isImageEngine(engine) {
372
+ return IMAGE_ENGINE_KIND_RE.test(engine?.kind ?? "");
373
+ }
374
+
375
+ // Pick the default engine to search: the first image engine, by its real
376
+ // `kind`. Returns null when the CD exposes no image engine (e.g. only a
377
+ // slide-search engine) — callers surface a "wire an image engine" message
378
+ // rather than falling back to an engine the bridge can't insert. Single source
379
+ // of truth for the heuristic shared by the CLI dispatcher (`slidemind search`)
380
+ // and the MCP `slidemind_search_engine` tool.
364
381
  export function pickImageEngine(engines) {
365
382
  const list = Array.isArray(engines) ? engines : [];
366
- return (
367
- list.find((e) =>
368
- /image|icon/i.test(`${e.kind ?? ""} ${e.label ?? ""} ${e.description ?? ""}`),
369
- ) ||
370
- list[0] ||
371
- null
372
- );
383
+ return list.find(isImageEngine) ?? null;
373
384
  }
@@ -127,7 +127,8 @@ function nodeRequest(urlString, init = {}) {
127
127
  }
128
128
 
129
129
  const timeoutMs =
130
- parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) || 35000;
130
+ init.timeoutMs ??
131
+ (parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) || 35000);
131
132
  return new Promise((resolve, reject) => {
132
133
  const req = transport.request(
133
134
  url,
@@ -232,9 +233,9 @@ export async function renderPptx({
232
233
  return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
233
234
  }
234
235
 
235
- // Brand-engine search, proxied through the open task pane. Engine-agnostic
236
- // (image/icon engines today; slide engines later). Returns a searchId + a
237
- // numbered set of result thumbnails for the agent to choose from.
236
+ // Brand-engine search through the local bridge. New add-ins run this
237
+ // server-side with the relayed user token; older add-ins may fall back to a
238
+ // task-pane proxy. Returns a searchId + numbered result thumbnails.
238
239
  export async function engineSearch({ presId, engineId, query }) {
239
240
  if (!presId) throw new Error("engineSearch: presId is required");
240
241
  if (!engineId) throw new Error("engineSearch: engineId is required");
@@ -250,9 +251,9 @@ export async function engineSearch({ presId, engineId, query }) {
250
251
  return res.json(); // { ok, searchId, resultCount, gridDataUri, thumbnails }
251
252
  }
252
253
 
253
- // Fetch a chosen engine result's bytes (proxied through the task pane). For
254
- // image engines this returns SVG/PNG bytes (+ optional recoloured svgMarkup)
255
- // WITHOUT inserting onto a slide.
254
+ // Fetch a chosen engine result's bytes through the local bridge. For image
255
+ // engines this returns SVG/PNG bytes (+ optional recoloured svgMarkup) WITHOUT
256
+ // inserting onto a slide.
256
257
  export async function engineDownload({
257
258
  presId,
258
259
  searchId,
@@ -279,22 +280,31 @@ export async function engineDownload({
279
280
  return res.json(); // { ok, base64, mimeType, svgMarkup }
280
281
  }
281
282
 
282
- export async function insertSlides({ presId, base64, insertAt = null, sourceSlideIds = null }) {
283
+ export async function insertSlides({
284
+ presId,
285
+ base64,
286
+ insertAt = null,
287
+ sourceSlideIds = null,
288
+ operationId = null,
289
+ }) {
283
290
  if (!presId) throw new Error("insertSlides: presId is required");
284
291
  if (!base64) throw new Error("insertSlides: base64 is required");
285
- const body = { base64, insertAt, sourceSlideIds };
292
+ const body = { base64, insertAt, sourceSlideIds, operationId };
286
293
  const res = await bridgeFetch(
287
294
  `/local/presentations/${encodeURIComponent(presId)}/insert`,
288
295
  {
289
296
  method: "POST",
290
297
  headers: { "content-type": "application/json" },
291
298
  body: JSON.stringify(body),
299
+ timeoutMs:
300
+ parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
301
+ 1805000,
292
302
  },
293
303
  );
294
304
  return res.json().catch(() => ({}));
295
305
  }
296
306
 
297
- export async function replaceSlides({ presId, base64 }) {
307
+ export async function replaceSlides({ presId, base64, operationId = null }) {
298
308
  if (!presId) throw new Error("replaceSlides: presId is required");
299
309
  if (!base64) throw new Error("replaceSlides: base64 is required");
300
310
  const res = await bridgeFetch(
@@ -302,7 +312,10 @@ export async function replaceSlides({ presId, base64 }) {
302
312
  {
303
313
  method: "POST",
304
314
  headers: { "content-type": "application/json" },
305
- body: JSON.stringify({ base64 }),
315
+ body: JSON.stringify({ base64, operationId }),
316
+ timeoutMs:
317
+ parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
318
+ 1805000,
306
319
  },
307
320
  );
308
321
  return res.json().catch(() => ({}));
@@ -44,6 +44,26 @@ function firstExisting(candidates) {
44
44
  return null;
45
45
  }
46
46
 
47
+ // Does a design root actually carry brand files? A root qualifies if it has the
48
+ // corporate-design CSS or a layouts dir that holds at least one real layout
49
+ // (a `*.jsx` or `layouts.css`). A bare/junk-only `layouts/` — e.g. one left
50
+ // behind on disk holding only a macOS `.DS_Store` — must NOT count, or it would
51
+ // mask the "materialize the bundle first" guidance for a deck that hasn't
52
+ // downloaded one yet.
53
+ function carriesDesign(root, cssName) {
54
+ if (!root) return false;
55
+ if (fs.existsSync(path.join(root, cssName))) return true;
56
+ const layoutsDir = path.join(root, "layouts");
57
+ if (!fs.existsSync(layoutsDir)) return false;
58
+ try {
59
+ return fs
60
+ .readdirSync(layoutsDir)
61
+ .some((f) => f.endsWith(".jsx") || f === "layouts.css");
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
47
67
  // First existing candidate, or `fallback` when none exist. Encodes the
48
68
  // deck-override → bundle → skill precedence in one place so every resolved path
49
69
  // shares the same lookup rule.
@@ -73,12 +93,7 @@ export function resolveSkill(skillName, planMeta = {}, opts = {}) {
73
93
  const designRoots = [
74
94
  bundleDir,
75
95
  fs.existsSync(skillDir) ? skillDir : null,
76
- ].filter(
77
- (r) =>
78
- r &&
79
- (fs.existsSync(path.join(r, cssName)) ||
80
- fs.existsSync(path.join(r, "layouts"))),
81
- );
96
+ ].filter((r) => carriesDesign(r, cssName));
82
97
  if (designRoots.length === 0) {
83
98
  throw new Error(
84
99
  `No design source for skill "${skillName}". The corporate design now lives ` +
@@ -7,8 +7,8 @@
7
7
  //
8
8
  // Tools:
9
9
  // slidemind_list_open_presentations
10
- // Lists PowerPoint windows that have the slidemind add-in connected to
11
- // the local bridge.
10
+ // Lists PowerPoint windows registered with the local bridge, including
11
+ // retained entries whose task-pane socket is temporarily disconnected.
12
12
  //
13
13
  // slidemind_push_deck
14
14
  // Renders a deck server-side via the add-in and pushes its slides into one
@@ -16,7 +16,7 @@
16
16
  //
17
17
  // slidemind_search_engine
18
18
  // Searches the active corporate design's brand engine (icons/images today,
19
- // slide engines later) over the bridge — proxied to the open task pane.
19
+ // slide engines later) over the bridge.
20
20
  //
21
21
  // slidemind_download_result
22
22
  // Fetches a chosen search result's bytes and writes them into a deck's
@@ -66,12 +66,13 @@ function errorResult(err) {
66
66
  let text = err && err.message ? err.message : String(err);
67
67
  const code = err && err.code;
68
68
  // Known transient states are recoverable without rebuilding anything — tell
69
- // the agent exactly how to resume so it pauses for a pane focus instead of
70
- // aborting the deck.
69
+ // the agent exactly how to resume if the bridge timed out waiting for a true
70
+ // task-pane reconnect. Normal backgrounded PowerPoint should not reach this.
71
71
  if (code === "pane_suspended") {
72
72
  text +=
73
- "\n\nRecoverable: ask the user to click the slidemind task pane to bring " +
74
- "PowerPoint to the foreground, then retry this call — nothing needs rebuilding.";
73
+ "\n\nRecoverable: the render is already done, but the task-pane bridge did " +
74
+ "not reconnect before the wait timeout. Ask the user to click the slidemind " +
75
+ "task pane once so it reconnects, then retry this same call.";
75
76
  } else if (code === "engine_auth_expired") {
76
77
  text +=
77
78
  "\n\nRecoverable: ask the user to click the slidemind task pane for ~1s so " +
@@ -140,7 +141,7 @@ server.registerTool(
140
141
  {
141
142
  title: "List open PowerPoint windows",
142
143
  description:
143
- "List PowerPoint windows that currently have the slidemind add-in connected to the local bridge. " +
144
+ "List PowerPoint windows registered with the slidemind local bridge, including retained entries whose task-pane socket is temporarily disconnected. " +
144
145
  "Each entry has a stable presId — pass it as `presentation_id` to slidemind_push_deck. " +
145
146
  "If the list is empty the user has no PowerPoint open with the add-in loaded.",
146
147
  inputSchema: {},
@@ -202,23 +203,6 @@ server.registerTool(
202
203
  replace: replace === true,
203
204
  slideIds: slide_ids ?? null,
204
205
  });
205
- if (result.paneSuspended) {
206
- return {
207
- content: [
208
- {
209
- type: "text",
210
- text:
211
- "PowerPoint is backgrounded, so the rendered slides couldn't be " +
212
- "inserted yet. The render succeeded and was saved to " +
213
- `${result.fallbackFile}. Ask the user to click the slidemind task ` +
214
- "pane to bring PowerPoint to the foreground, then call " +
215
- "slidemind_push_deck again — the deck is already built, so this " +
216
- "just retries the insert.",
217
- },
218
- ],
219
- isError: true,
220
- };
221
- }
222
206
  return textResult(result);
223
207
  } catch (err) {
224
208
  return errorResult(err);
@@ -232,7 +216,7 @@ server.registerTool(
232
216
  title: "Search the brand engine for imagery",
233
217
  description:
234
218
  "Search the active corporate design's brand engine over the bridge (icons/images today; slide engines later). " +
235
- "Proxied to the open PowerPoint task pane, so it returns the SAME results the in-app design agent sees. " +
219
+ "Uses the add-in's server-side bridge when available, so it returns the SAME results the in-app design agent sees. " +
236
220
  "Returns a searchId and a numbered set of result thumbnails (and a grid image you can view). " +
237
221
  "Pick a number, then call slidemind_download_result with the searchId + selection. " +
238
222
  "engine_id is optional — omit it to use the corporate design's first image engine.",