slidemind 0.1.2 → 0.1.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.
@@ -33,7 +33,7 @@ Authoring and rendering hard-require an **open PowerPoint host with the slidemin
33
33
 
34
34
  > **Empty list vs. not paired — different fixes.** An *empty* registry means no PowerPoint is connected (step 3 → ask the user to open it / enable Connect CLI). A *not paired* error means this machine has no session — ask the user to pair once via the add-in's **"Connect CLI"** (see Bootstrap); don't ask them to open PowerPoint for that.
35
35
 
36
- **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.
36
+ **Backgrounded PowerPoint is expected to work.** Do not pre-emptively ask the user to focus PowerPoint or the task pane, and never ask them to keep it foregrounded while something runs. The render itself (`build-pptx`, the render half of `push`) **never needs the task pane awake** — it captures the slides with a local headless browser and assembles the `.pptx` server-side — streams its own `rendering slide n/m…` progress, and survives long decks. Only the pane-touching ops (the insert half of `push`, engine search/download) need the pane, and the CLI handles that itself: it waits up to ~2 min, prints one *"click the slidemind pane"* line when user help is genuinely needed, and retries safely. If you see that line, relay it once and keep waiting — do not rebuild, re-plan, or babysit. If a `push` still times out, the rendered `.pptx` was saved next to the deck (`paneSuspended` + `fallbackFile` in the result) — nothing is lost; retry the push after the user clicks the pane. Reserve the full "open PowerPoint…" instruction in (3) for when nothing is registered at all.
37
37
 
38
38
  Local HTML preview (`slidemind build`) runs offline, but it reads a **previously materialized bundle** — so the host must have been available at least once.
39
39
 
@@ -56,11 +56,17 @@ Keep planning about intent and content. Don't materialize the bundle or read bra
56
56
  - `workflow/` — the add-in-owned authoring craft: `create.md`, `edit.md`, `composition.md`, `layouts.md`, `authoring.md`, `build-and-export.md`.
57
57
  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.
58
58
  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/NN-<topic>.jsx` — zero-padded numeric prefix from the first file on (play order is filename order here; see the slide-file row in the command mapping), 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.
59
- 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).
60
- 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.
59
+ 5. **Imagery from the engine.** Per the `### Brand Image Sources` block in `design.md`, search the brand engine and place results. The engine search/reuse mechanics live in **`slidemind-search`** — use it to find and fetch imagery; the create-only icon-recolour detail is in *Recolouring engine icons* below. To pull in an **existing whole slide** (not just an image), use `slidemind-search`'s `add-slide` — it folds the slide into `decks/<slug>/slides/` as a native `.pptx` that coexists with your JSX in play order and survives re-push.
60
+ 6. **Preview review, then push.** This is `build_and_push` in `workflow/build-and-export.md`, including its preview review gate — the local harness has a native port: before the FIRST export of new slides, run `slidemind review <deck>`. It builds the HTML preview, opens it in the browser with a spatial comment overlay, and **blocks until the user decides** (host-free — no open PowerPoint needed for the gate). The command then prints its verdict to stdout:
61
+ - `DECISION: approved` → proceed to the export the user actually asked for: `slidemind push <deck>` (into PowerPoint) or `slidemind build-pptx <deck>` (to a file).
62
+ - `DECISION: comments` → the user left spatially-anchored notes, each citing a `slides/<file>.jsx:line:col`, the source line, and the click position (plus a readonly-layout note where the click landed on a layout slot). **Edit the cited files in place at the given line**, then **re-run `slidemind review <deck>`** — the prior round shows as read-only ghost pins so the user can confirm each note was addressed. Loop until approved. Nothing is exported on a comments round.
63
+
64
+ `slidemind review` is the only step that pauses; `push`/`build-pptx` never pause — they render pane-free (local capture + server-side assembly, with live per-slide progress). One approval covers the task: push follow-up fixes without re-running review unless the user wants another look. If several presentations are open, confirm **which one to push into** first (it can differ from the CD source). After approval, run the push without asking; it works while PowerPoint is backgrounded, and the CLI itself waits + retries if the pane is needed (see the precondition gate). If a push still times out, the rendered `.pptx` is saved next to the deck — re-run `push` after the user clicks the pane, without rebuilding.
61
65
  7. **Sanity pass.** Run the checks in `workflow/create.md` before reporting done.
62
66
 
63
- ## Recolouring engine icons (CLI specifics)
67
+ ## Recolouring engine icons (create-authoring specifics)
68
+
69
+ Finding and fetching engine imagery is `slidemind-search`'s job (`slidemind_search_engine` → `slidemind_download_result`). What follows is **create-only**: how to colour a fetched icon so it reads on-brand in your JSX, honoured in both the `slidemind build` preview and the render.
64
70
 
65
71
  Engine results land as files referenced with `<img src="./assets/<file>">`. The bundle's `workflow/*.md` are written for the in-app render; these two facts are specific to the local harness:
66
72
 
@@ -77,22 +83,22 @@ The bundle's `workflow/*.md` are written for the in-app design agent and name **
77
83
  | In `workflow/*.md` (in-app agent) | Your equivalent (local harness) |
78
84
  |---|---|
79
85
  | `build_and_push` | `slidemind push <deck>` (into the open PowerPoint) · `slidemind build-pptx <deck>` (to a `.pptx` file) — both render server-side via the bridge |
80
- | `engine_search` | MCP `slidemind_search_engine({ presentation_id, query })` → returns a `searchId` + numbered results |
81
- | `engine_add` / `insert_image` | MCP `slidemind_download_result({ presentation_id, deck_path, search_id, selection, icon_color? })` — or batch many at once with `selections: [{ selection, icon_color? }, …]`. Writes each asset into `decks/<slug>/assets/`, returns a `./assets/<file>` reference for `<img src=...>` plus a `recolor` descriptor (see *Recolouring engine icons*) |
86
+ | `engine_search` / `engine_add` / `insert_image` | **`slidemind-search`** owns the engine search + reuse mechanics (`slidemind_search_engine` → `slidemind_download_result`, plus whole-slide reuse via `add-slide`). For placing a fetched icon in your JSX, pass `icon_color` / batch with `selections: [{ selection, icon_color }, …]` and colour per *Recolouring engine icons* below |
82
87
  | writing `slides/<topic>.jsx` in the chat workspace — unprefixed kebab-case names; play order = the ordered list passed to `build_and_push` | write `decks/<slug>/slides/NN-<topic>.jsx` on disk with a **zero-padded numeric prefix** (`01-cover.jsx`, `02-agenda.jsx`, …) — over the CLI play order is **numeric-aware filename order**; there is no ordered-list channel, so reordering = renaming files. The bundle's "don't pad with index prefixes" rule is in-app-only; here it inverts |
83
88
  | reading workspace files (`design.md`, `layouts/`, `workflow/*`) | read them from the materialized bundle dir (`<bundle>/…`) and your `decks/<slug>/` |
84
89
  | reference-slide thumbnails injected at `add_slide` (in-app persona) | **Read** 3 random images from the materialized bundle's `samples/` dir — the Read tool renders them (see *The create flow* step 3) |
85
90
  | `present_plan_for_review` (manager-owned) | **you** run intake → plan with the user (above); persist `decks/<slug>/plan.md` for decks |
91
+ | the `build_and_push` **preview review gate** (pause on rendered previews → approve / per-element comments) | `slidemind review <deck>` — a native port of the same gate: serves the HTML preview with a spatial comment overlay and **blocks until the user decides**, then prints `DECISION: approved` or `DECISION: comments` (each comment cited to `slides/<file>.jsx:line:col`). On approve → run the export (`slidemind push` / `build-pptx`); on comments → edit the cited files and re-run `slidemind review` (prior round shows as ghost pins). One approval covers the task. `push`/`build-pptx` never pause; only `review` does. |
86
92
 
87
93
  Everything else in `workflow/*.md` — the composition craft, the substrate/authoring rules, the layout discovery and slot/working-area rules, the sanity bar — applies to you unchanged.
88
94
 
89
95
  ## The brand and the workflow both live in the bundle, not here
90
96
 
91
- Do not restate brand values (colour, type scale, spacing, layout names, accent discipline) or re-derive the authoring/composition craft in this file or in slide files — they are in the bundle's `design.md` and `workflow/*.md`. Read them and follow them. Don't load external UI/design-taste plugins during slide work; compose against the bundle's layouts and tokens. If a render returns `bundle_etag_stale`, the corporate design moved — re-run `slidemind download-bundle <deck>` and re-read.
97
+ Do not restate brand values (colour, type scale, spacing, layout names, accent discipline) or re-derive the authoring/composition craft in this file or in slide files — they are in the bundle's `design.md` and `workflow/*.md`. Read them and follow them. Don't load external UI/design-taste plugins during slide work; compose against the bundle's layouts and tokens. If the corporate design moves mid-task (`bundle_etag_stale`), the CLI self-heals: it refreshes the bundle and retries the render on its own — afterwards re-read the bundle's `design.md` before authoring anything new, since the rules may have changed.
92
98
 
93
99
  ## Failure modes the render lint can't catch
94
100
 
95
- Render-time lint rejects contract violations (deny-listed CSS, bad imports, remote `<img>` URLs — defined in the bundle's `design.md`). It can't catch content-shaped failures; check for these in the sanity pass:
101
+ Render-time lint rejects contract violations (unsupported CSS, bad imports, remote `<img>` URLs — the add-in's capability-table engine; `slidemind build` runs the same engine locally from the bundle). It can't catch content-shaped failures; check for these in the sanity pass:
96
102
 
97
103
  - Layout-default / placeholder text leaking through an unfilled prop.
98
104
  - A bullet list standing in for a structured idea (process, comparison, timeline).
@@ -0,0 +1,75 @@
1
+ ---
2
+ name: slidemind-search
3
+ description: Discover, search, and reuse content from the active corporate design's engines — image/icon engines and slide libraries — across whatever environment the slidemind PowerPoint add-in is wired to. Finds existing slides and brand imagery, then reuses them either by inserting them live into an open PowerPoint or by folding them into a deck as native one-slide .pptx files that survive re-render. Use whenever the user asks to find or reuse an existing slide ("is there a slide on X in our library", "find the Q3 results slide", "drop in / reuse our pricing slide"), to search the brand's icon/image engines, or to ask what slide or image libraries exist. Skip when the user wants to author NEW slide content from scratch — that is slidemind-create-slides — or to merely open, present, or rename a deck.
4
+ ---
5
+
6
+ # slidemind-search
7
+
8
+ This skill is **thin workflow glue for the local harness**. The capability it drives — engine discovery, image/slide search, and slide reuse — runs on the **slidemind PowerPoint add-in**: the engines, the corporate design they belong to, and the search/extraction execution all live there and reach you over the add-in's cloud relay. The **craft** of using it well — how to discover engines, choose content-vs-visual search, pick a result, and reuse a slide responsibly — is authored once by the add-in and served on the materialized bundle as `workflow/search.md`, **byte-identical** to what the in-app design agent reads.
9
+
10
+ So this file owns only what is genuinely CLI-specific: the **host precondition**, the **discover → search → pick → reuse** loop, the **reuse targets** (insert live vs fold into a deck), and the **command mapping** that translates the add-in's in-app verb names (used in `workflow/search.md`) into your `slidemind` commands and MCP tools. **Read `workflow/search.md` from the bundle; never restate the craft here.**
11
+
12
+ ## Bootstrap (set yourself up — don't make the user do it)
13
+
14
+ This skill drives the `slidemind` CLI + its MCP server (`slidemind_list_open_presentations`, `slidemind_list_engines`, `slidemind_search_engine`, `slidemind_view_slide`, `slidemind_insert_slide`, `slidemind_add_slide_to_deck`, `slidemind_download_result`). The CLI reaches PowerPoint through the add-in's **cloud relay** — it doesn't run any local server. Patch what's missing yourself; the **pairing** step needs the user once (they hold the add-in's identity):
15
+
16
+ - **CLI missing** (`slidemind` not on `PATH`, or the `slidemind_*` MCP tools absent): `npm install -g slidemind`, then `slidemind init --for <claude|codex|opencode|all>` (registers the MCP server + (re)deploys this skill). The harness may need a restart for the `slidemind_*` tools to appear.
17
+ - **Not paired** (a command says *slidemind is not paired*): the one step you can't do for the user. Tell them: in the slidemind add-in click **"Connect CLI"**, then paste the shown `slidemind pair …` command into this terminal. It's one-time per environment. `slidemind whoami` lists every paired environment.
18
+
19
+ This is the same bootstrap as `slidemind-create-slides`; if you set it up there it's already set up here.
20
+
21
+ ## Precondition gate (check this first, every time)
22
+
23
+ Engine search and slide reuse are **pane-touching, host-required** operations — the same gate `slidemind-create-slides` describes. Without an open PowerPoint host with the slidemind add-in loaded and an active corporate design, there are no engines to query and nowhere to insert.
24
+
25
+ 1. Run `slidemind presentations` (or MCP `slidemind_list_open_presentations`).
26
+ 2. **≥1 registered → you're set.** The engine catalog is **per-user, not bound to a window**, so search and discovery attach to **any** connected window automatically. **Never ask the user which window to search with**, and don't pass `--to`/`presentation_id` to search just to disambiguate — leave it off and let the CLI grab one. (A window only matters when you *insert a slide live*, and even then it's simply the window the search already ran on — see Reuse.)
27
+ 3. **None registered → stop.** Tell the user: *open PowerPoint, load the slidemind add-in, and pick or create a corporate design, then retry.*
28
+
29
+ > **Empty list vs. not paired — different fixes.** An *empty* registry means no PowerPoint is connected (ask the user to open it / enable Connect CLI). A *not paired* error means this machine has no session — ask the user to pair once via **"Connect CLI"** (see Bootstrap). They are different problems; don't conflate them.
30
+
31
+ The pane-touching ops here (`search`, `view_slide`, `insert-slide`, `download-result`) wait for the pane themselves: the CLI waits up to ~2 min, prints one *"click the slidemind pane"* line when user help is genuinely needed, and retries safely. Relay that line once and keep waiting — don't re-plan or babysit.
32
+
33
+ ## Discover → search → pick → reuse
34
+
35
+ Follow `workflow/search.md` for the discipline; this is the CLI shape of its loop.
36
+
37
+ 1. **Discover engines — never guess an id.** Run `slidemind ls engines` (MCP `slidemind_list_engines`) and read the catalog before searching. Filter to what you need with `--type <collection|search>` and `--subtype <image|slide|template|content>` (e.g. `slidemind ls engines --type search --subtype slide` for slide libraries). The catalog is **per-user, not per-presentation** — there is no presId to scope; spanning every paired environment, each engine is tagged with its `env`. (`--to`/`presentation_id` is accepted for symmetry with the other commands but ignored — it does not narrow the catalog.) Pick an engine **id** from the list — do not invent one.
38
+ 2. **Choose the engine + intent.** Slide engines (`subtype slide`) support two intents: **content** (the slide's text is the signal — title, body, tables, notes) and **visual** (the slide image is the signal). Image/icon engines (`subtype image`) are **visual-only**. Match the intent to the ask: "a slide that says X" / "covers topic Y" → `--intent content`; "a slide that looks like X" / brand imagery → `--intent visual`. `--intent auto` lets the engine decide.
39
+ 3. **Search — no window to choose.** `slidemind search "<query>" --engine <id> --intent <auto|content|visual> [--top N]` (MCP `slidemind_search_engine({ query, engine_id?, intent?, top? })` — `presentation_id` is **optional; omit it**). Search attaches to any connected window itself and returns a `searchId` plus numbered results, and it tells you **which window it ran on** (the CLI prints it; the MCP tool echoes it back as `presentation_id`). **Thread that returned `presentation_id` into every follow-up** (view / insert / add-slide): the `searchId` is bound to that window, so a follow-up aimed at a different window won't find it.
40
+ 4. **Read / view, then pick.** For **content** results, read the per-slide **text + score** the search prints — that is the on-the-wire signal; you usually don't need the picture. For **visual** results, scan the numbered thumbnail list; pull a single result's image on demand with `slidemind_view_slide({ presentation_id, search_id, selection })` (or `slidemind search … --json` for the structured payload). Pick by **selection number(s)**.
41
+ 5. **Reuse.** Insert live or fold into a deck — see the next section.
42
+
43
+ ## Reuse targets — insert live, or fold into a deck
44
+
45
+ A found slide can be reused two ways. Both extract the picked result(s) as native PowerPoint slides — **reuse is JSX-free; reused slides are native `.pptx`, never JSX.**
46
+
47
+ - **Insert LIVE into an open PowerPoint** — `slidemind insert-slide --search <searchId> --pick <n[,n,…]> --to <searchWindow> [--at <N>]` (MCP `slidemind_insert_slide({ presentation_id, search_id, selections, at? })`), where `<searchWindow>` is the `presentation_id` the search returned. The picked slides land in **that** window (the one the search ran on; at position `--at`, else appended). If the slide must go into a *specific* open window, run the search with `--to <thatWindow>` so the search — and therefore the insert — happen there.
48
+ - **Fold into a DECK on disk** — `slidemind add-slide --search <searchId> --pick <n[,n,…]> --deck <deck> --to <searchWindow> [--at <N>]` (MCP `slidemind_add_slide_to_deck({ presentation_id, deck_path, search_id, selections, at? })`), where `<searchWindow>` is the `presentation_id` the search returned — the extraction must run on the window that holds the `searchId`. Each picked result is written as a native one-slide file into `decks/<slug>/slides/NN-reused-*.pptx`, in play order; no live window is changed. Use this when the reused slide should become **part of a deck the user keeps and re-renders.**
49
+
50
+ > **The durable rule:** a slide folded into a deck with `add-slide` is **deck source-of-truth**. The render pipeline splices native `slides/*.pptx` into the deck **by play-order filename**, alongside any authored `.jsx`, so a reused slide **survives every `build-pptx` and `push` re-render** — it is never lost on re-push. Reorder it the same way you reorder JSX: rename its numeric prefix.
51
+
52
+ For brand **imagery** (icons/images, not whole slides), the reuse step is `slidemind_download_result` — it writes the asset into the deck's `assets/`. That mechanism (including the icon-recolor descriptor) is documented where it's used, in `slidemind-create-slides`.
53
+
54
+ ## Working with slidemind-create-slides
55
+
56
+ These two skills compose. When the user is **authoring** a deck (that's `slidemind-create-slides`) and wants an **existing** slide incorporated, reach for this skill's `add-slide`: it folds the existing slide into the same `decks/<slug>/slides/` directory as the authored JSX. The reused `.pptx` and the authored `.jsx` coexist in play order (numeric filename prefix) and the reused slide is **not lost on re-push**. Hand back to `slidemind-create-slides` for any new content authoring.
57
+
58
+ ## Command mapping (CLI ⇄ the bundle's `workflow/search.md`)
59
+
60
+ `workflow/search.md` is written for the in-app design agent and names **its** verbs. You are the local harness — translate as you read:
61
+
62
+ | In `workflow/search.md` (in-app agent) | Your equivalent (local harness) |
63
+ |---|---|
64
+ | `list_engines` | `slidemind ls engines [--type …] [--subtype …]` · MCP `slidemind_list_engines({ type?, subtype? })` — returns the engine catalog (pick an `id`; never guess one). The catalog is backend/user-scoped; `--to`/`presentation_id` is accepted but ignored (informational only). |
65
+ | `engine_search` (with `intent`) | `slidemind search "<query>" --engine <id> --intent <auto\|content\|visual> [--top N]` · MCP `slidemind_search_engine({ query, engine_id?, intent?, top? })` — `presentation_id` **optional** (attaches to any window) → a `searchId` + numbered results, **plus the `presentation_id` it ran on** (thread that into the follow-ups below) |
66
+ | view a result on demand (the in-app agent has **no** dedicated verb — it reads the search collage) | MCP `slidemind_view_slide({ presentation_id, search_id, selection })` — pull one result's image (`presentation_id` = the window the search returned) · or `slidemind search … --json` for the structured payload. **CLI/MCP-only** capability: there is no in-app `view_result`/`read_slide` tool for engine results. |
67
+ | `insert` (reuse a slide → live) | `slidemind insert-slide --search <searchId> --pick <n[,n,…]> --to <searchWindow> [--at N]` · MCP `slidemind_insert_slide({ presentation_id, search_id, selections, at? })` — `presentation_id`/`--to` = the window the search returned; the slide lands there |
68
+ | reuse a slide → into a deck (CLI-only) | `slidemind add-slide --search <searchId> --pick <n[,n,…]> --deck <deck> --to <searchWindow> [--at N]` · MCP `slidemind_add_slide_to_deck({ presentation_id, deck_path, search_id, selections, at? })` → writes `decks/<slug>/slides/NN-reused-*.pptx`; `presentation_id`/`--to` = the window the search returned (extraction runs there) |
69
+ | `engine_add` / `insert_image` (reuse an image asset) | MCP `slidemind_download_result({ presentation_id, deck_path, search_id, selection })` — writes into `decks/<slug>/assets/` (recolor descriptor + tinting are covered in `slidemind-create-slides`) |
70
+
71
+ Everything else in `workflow/search.md` — the engine-discovery discipline, content-vs-visual judgement, the picking and reuse craft — applies to you unchanged.
72
+
73
+ ## The engines and the workflow both live in the bundle, not here
74
+
75
+ Do not restate which engines exist, what they contain, or any brand rule — the engine catalog comes from `slidemind ls engines` (and the bundle's `engines.json`), and the search/reuse craft is in `workflow/search.md`. Read them and follow them. If the corporate design moves mid-task, re-list engines before searching again, since the catalog may have changed.
@@ -18,12 +18,16 @@ import {
18
18
  renderPptx,
19
19
  replaceSlides,
20
20
  requireHost,
21
+ translateDeck,
22
+ waitForPaneReady,
21
23
  } from "./client.mjs";
22
24
  import {
23
25
  materializeBundle,
24
26
  readDeckBundleRef,
27
+ resolveBundleDirForDeck,
25
28
  writeDeckBundleRef,
26
29
  } from "./bundle.mjs";
30
+ import { bundleHasCaptureEngine, captureDeckLocally } from "./capture.mjs";
27
31
  import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
28
32
 
29
33
  function gatherSlides(deckDir) {
@@ -31,17 +35,26 @@ function gatherSlides(deckDir) {
31
35
  if (!fs.existsSync(slidesDir) || !fs.statSync(slidesDir).isDirectory()) {
32
36
  throw new Error(`Expected 'slides/' folder in ${deckDir}`);
33
37
  }
34
- const files = sortSlideFilenames(
35
- fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx")),
38
+ // Authored JSX slides AND reused native .pptx slides share one play-order
39
+ // namespace (filename order, numeric-aware). The server splices the native
40
+ // .pptx in by the SAME sorted-filename key, so the two kinds interleave by
41
+ // their numeric prefix regardless of extension.
42
+ const all = sortSlideFilenames(
43
+ fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx")),
36
44
  );
37
- if (files.length === 0) {
38
- throw new Error(`No .jsx slides under ${slidesDir}`);
45
+ if (all.length === 0) {
46
+ throw new Error(`No .jsx or .pptx slides under ${slidesDir}`);
39
47
  }
40
48
  const slides = {};
41
- for (const f of files) {
42
- slides[f] = fs.readFileSync(path.join(slidesDir, f), "utf8");
49
+ const nativeSlides = {};
50
+ for (const f of all) {
51
+ if (f.endsWith(".pptx")) {
52
+ nativeSlides[f] = fs.readFileSync(path.join(slidesDir, f)).toString("base64");
53
+ } else {
54
+ slides[f] = fs.readFileSync(path.join(slidesDir, f), "utf8");
55
+ }
43
56
  }
44
- return { slides, orderWarning: slideOrderWarning(files) };
57
+ return { slides, nativeSlides, orderWarning: slideOrderWarning(all) };
45
58
  }
46
59
 
47
60
  // Collect deck-local assets (icons the agent downloaded, deck photos) as
@@ -66,16 +79,27 @@ function gatherDeckAssets(deckDir) {
66
79
  return out;
67
80
  }
68
81
 
69
- // Render a deck to base64 .pptx over the bridge. Pass the deck's authored-against
70
- // bundle etag (when recorded) so the server can detect CD skew.
71
- export async function renderDeckToBase64(deckDir, { presId, backendURL = null }) {
72
- const { slides, orderWarning } = gatherSlides(deckDir);
82
+ // Render a deck to base64 .pptx. Two paths, picked per deck:
83
+ // • LOCAL CAPTURE (preferred): the bundle carries a capture engine → run the
84
+ // slow Chrome capture HERE, then POST the captured deck to the server's
85
+ // `/translate` route (fast, IP stays server-side). No 240s cloud render.
86
+ // • SERVER RENDER (fallback): no capture engine in the bundle, or local Chrome
87
+ // unavailable → POST slides to the render-jobs / sync route as before.
88
+ // Either way a stale etag self-heals: the bundle is re-materialized, the deck
89
+ // ref rewritten, and the render retried once against the current design.
90
+ export async function renderDeckToBase64(
91
+ deckDir,
92
+ { presId, backendURL = null, onProgress = null },
93
+ ) {
94
+ const { slides, nativeSlides, orderWarning } = gatherSlides(deckDir);
95
+ const hasJsx = Object.keys(slides).length > 0;
73
96
  const deckAssets = gatherDeckAssets(deckDir);
74
97
  // Bind the deck to a materialized bundle if it isn't already. Without a
75
98
  // `.bundle-ref.json` the etag below is null (server-side skew detection
76
- // silently no-ops) AND the local HTML/editor preview can't resolve the
77
- // deck's design files. `download-bundle <deck>` normally writes this; here we
78
- // self-heal so `push`/`build-pptx` work even when it wasn't run explicitly.
99
+ // silently no-ops), the local HTML/editor preview can't resolve the deck's
100
+ // design files, AND local capture has no engine/bundle to read. `download-
101
+ // bundle <deck>` normally writes this; here we self-heal so `push`/`build-pptx`
102
+ // work even when it wasn't run explicitly.
79
103
  let ref = readDeckBundleRef(deckDir);
80
104
  if (!ref?.etag) {
81
105
  try {
@@ -84,19 +108,68 @@ export async function renderDeckToBase64(deckDir, { presId, backendURL = null })
84
108
  ref = readDeckBundleRef(deckDir);
85
109
  } catch {
86
110
  /* best-effort: the add-in still resolves the CD server-side, so the
87
- render itself succeeds — only skew detection / local preview binding
88
- are skipped when the bundle can't be materialized here. */
111
+ server render path still succeeds — only skew detection / local preview
112
+ binding / local capture are skipped when the bundle can't materialize. */
89
113
  }
90
114
  }
91
- const result = await renderPptx({
92
- presId,
93
- slides,
94
- deckAssets,
95
- etag: ref?.etag ?? null,
96
- backendURL,
97
- });
115
+
116
+ const bundleDir = resolveBundleDirForDeck(deckDir);
117
+ // Local capture only applies to authored JSX slides. A native-only deck (just
118
+ // reused .pptx) has nothing to capture, so it goes straight to the server
119
+ // render, which splices the native slides in.
120
+ let useLocalCapture = hasJsx && bundleHasCaptureEngine(bundleDir);
121
+
122
+ // One attempt at the chosen etag. Local capture that can't run (no Chrome,
123
+ // corrupt engine) falls back to the server render for this and later attempts;
124
+ // translate/render FAILURES (etag-stale, per-slide errors) propagate. Native
125
+ // .pptx slides ride alongside on either path so the server splices them by
126
+ // play-order filename.
127
+ const attemptRender = async (etag) => {
128
+ if (useLocalCapture) {
129
+ let captured;
130
+ try {
131
+ captured = await captureDeckLocally({ deckDir, bundleDir, onProgress });
132
+ } catch (err) {
133
+ process.stderr.write(
134
+ `local capture unavailable (${err instanceof Error ? err.message : String(err)}) — ` +
135
+ "falling back to the server render\n",
136
+ );
137
+ useLocalCapture = false;
138
+ return renderPptx({ presId, slides, deckAssets, nativeSlides, etag, backendURL, onProgress });
139
+ }
140
+ const n = Array.isArray(captured?.slides) ? captured.slides.length : 0;
141
+ onProgress?.({ done: n, total: n, stage: "assembling" });
142
+ return translateDeck({ presId, captured, nativeSlides, etag, backendURL });
143
+ }
144
+ return renderPptx({ presId, slides, deckAssets, nativeSlides, etag, backendURL, onProgress });
145
+ };
146
+
147
+ let result;
148
+ try {
149
+ result = await attemptRender(ref?.etag ?? null);
150
+ } catch (err) {
151
+ if (err?.code !== "bundle_etag_stale") throw err;
152
+ // The active corporate design moved on since the deck was authored. The
153
+ // design truth lives in the add-in, so the right move is forward: refresh
154
+ // the local bundle (so previews/lint/capture-engine match), re-bind the
155
+ // deck, and render once against the CURRENT design. A second stale (CD
156
+ // changed again mid-flight) propagates.
157
+ process.stderr.write(
158
+ "corporate design changed since this deck was authored — refreshing the " +
159
+ "bundle and retrying the render against the current design…\n",
160
+ );
161
+ const mat = await materializeBundle(presId, {
162
+ backendURL,
163
+ preferBrandId: ref?.presFP ?? null,
164
+ });
165
+ writeDeckBundleRef(deckDir, { presFP: mat.presFP, etag: mat.etag });
166
+ // The bundle dir is keyed by presFP, so a refreshed capture engine landed
167
+ // in place — re-evaluate in case it appeared (or vanished) on this sync.
168
+ useLocalCapture = bundleHasCaptureEngine(bundleDir);
169
+ result = await attemptRender(mat.etag);
170
+ }
98
171
  if (!result?.base64) {
99
- throw new Error("render-pptx returned no pptx bytes");
172
+ throw new Error("render returned no pptx bytes");
100
173
  }
101
174
  if (orderWarning) {
102
175
  result.warnings = [orderWarning, ...(result.warnings ?? [])];
@@ -110,6 +183,7 @@ export async function buildAndPush({
110
183
  insertAt = null,
111
184
  replace = false,
112
185
  slideIds = null,
186
+ onProgress = null,
113
187
  }) {
114
188
  if (!deckDir) throw new Error("buildAndPush: deckDir is required");
115
189
  // `slideIds` selects a subset of the BUILT deck's slides to push (a SOURCE
@@ -125,32 +199,96 @@ export async function buildAndPush({
125
199
  );
126
200
  }
127
201
 
202
+ const deckRef = readDeckBundleRef(deckDir);
128
203
  const registry = await requireHost();
129
- const entry = pickPresentationEntry(registry, presentationId);
204
+ const entry = pickPresentationEntry(registry, presentationId, {
205
+ preferBrandId: deckRef?.presFP ?? null,
206
+ });
130
207
  const presId = entry.presId;
131
208
  const backendURL = entry.backendURL ?? null;
132
209
 
133
- const result = await renderDeckToBase64(deckDir, { presId, backendURL });
210
+ const result = await renderDeckToBase64(deckDir, {
211
+ presId,
212
+ backendURL,
213
+ onProgress,
214
+ });
134
215
  const base64 = result.base64;
135
216
  const bytes = Buffer.byteLength(base64, "base64");
136
217
  const mode = replace ? "replace" : "insert";
137
218
  const operationId = `push-${randomUUID()}`;
138
219
 
139
- const doPush = () =>
220
+ // The SAME operationId on every attempt: the pane keeps an LRU of applied
221
+ // operationIds and drops duplicates, so a retry after an ambiguous failure
222
+ // can never double-insert.
223
+ const doPush = (targetPresId, targetBackendURL) =>
140
224
  replace
141
- ? replaceSlides({ presId, base64, operationId, backendURL })
225
+ ? replaceSlides({
226
+ presId: targetPresId,
227
+ base64,
228
+ operationId,
229
+ backendURL: targetBackendURL,
230
+ })
142
231
  : insertSlides({
143
- presId,
232
+ presId: targetPresId,
144
233
  base64,
145
234
  insertAt,
146
235
  sourceSlideIds: slideIds,
147
236
  operationId,
148
- backendURL,
237
+ backendURL: targetBackendURL,
149
238
  });
150
239
 
151
- const resp = await doPush();
240
+ let resp;
241
+ let livePresId = presId;
242
+ try {
243
+ resp = await doPush(presId, backendURL);
244
+ } catch (err) {
245
+ // Two recoverable shapes, both meaning "nothing was delivered":
246
+ // - pane_suspended (503): the server waited ~25s for a reconnect and gave
247
+ // up — the pane needs the user (foreground PowerPoint / click the pane).
248
+ // - 404: the presId vanished from the registry — a pane reconnect mints a
249
+ // NEW presId for the same window (presId churn).
250
+ // Wait client-side for the pane (rematching by fileName+brandId covers the
251
+ // churn), then retry ONCE. A `bridge_timeout` is NOT caught: the insert
252
+ // may already have been delivered, and the operationId dedup only helps if
253
+ // we re-send — which we deliberately leave to the user for that case.
254
+ const recoverable = err?.code === "pane_suspended" || err?.status === 404;
255
+ if (!recoverable) throw err;
256
+ const live = await waitForPaneReady(presId, {
257
+ backendURL,
258
+ fileName: entry.fileName ?? entry.title ?? null,
259
+ brandId: entry.brandId ?? null,
260
+ onWait: (seconds) =>
261
+ process.stderr.write(
262
+ "task pane suspended — click the slidemind pane in PowerPoint to " +
263
+ `wake it (waiting up to ${seconds}s)…\n`,
264
+ ),
265
+ });
266
+ if (!live) {
267
+ if (err?.code !== "pane_suspended") throw err;
268
+ // The pane never woke up. Don't lose the render: save the .pptx next to
269
+ // the deck and tell the caller (bin prints the recovery instructions).
270
+ const fallbackFile = path.join(
271
+ deckDir,
272
+ `${path.basename(deckDir)}.pptx`,
273
+ );
274
+ fs.writeFileSync(fallbackFile, Buffer.from(base64, "base64"));
275
+ return {
276
+ presId,
277
+ mode,
278
+ bytes,
279
+ operationId,
280
+ response: null,
281
+ warnings: result.warnings ?? [],
282
+ skippedSlides: result.skippedSlides ?? [],
283
+ paneSuspended: true,
284
+ fallbackFile,
285
+ };
286
+ }
287
+ livePresId = live.presId;
288
+ resp = await doPush(live.presId, live.backendURL ?? backendURL);
289
+ }
152
290
  return {
153
- presId,
291
+ presId: livePresId,
154
292
  mode,
155
293
  bytes,
156
294
  operationId,
@@ -161,19 +299,25 @@ export async function buildAndPush({
161
299
  }
162
300
 
163
301
  // Render a deck to a .pptx file (the `slidemind build-pptx` path). Still
164
- // requires an open host because the render happens server-side.
302
+ // requires an open host because the render happens server-side — but NOT an
303
+ // awake task pane: the render runs in the add-in's cloud backend.
165
304
  export async function buildPptxFile({
166
305
  deckDir,
167
306
  presentationId = null,
168
307
  outPath = null,
308
+ onProgress = null,
169
309
  }) {
170
310
  if (!deckDir) throw new Error("buildPptxFile: deckDir is required");
311
+ const deckRef = readDeckBundleRef(deckDir);
171
312
  const registry = await requireHost();
172
- const entry = pickPresentationEntry(registry, presentationId);
313
+ const entry = pickPresentationEntry(registry, presentationId, {
314
+ preferBrandId: deckRef?.presFP ?? null,
315
+ });
173
316
  const presId = entry.presId;
174
317
  const result = await renderDeckToBase64(deckDir, {
175
318
  presId,
176
319
  backendURL: entry.backendURL ?? null,
320
+ onProgress,
177
321
  });
178
322
  const out =
179
323
  outPath || path.join(deckDir, `${path.basename(deckDir)}.pptx`);