slidemind 0.1.9 → 0.2.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slidemind",
3
- "version": "0.1.9",
3
+ "version": "0.2.0",
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": {
@@ -146,7 +146,7 @@ Keep planning about intent and content. The bundle is already materialized by `s
146
146
  1. **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.
147
147
  2. **Read, then author.** Read the 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 — `workflow/flow.md` you already read after `ready`.) 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. The approved `plan.md` is a **content contract, not a rendering spec** — you drew its storyboards yourself, but approval covered the story and content, not the sketch's geometry; how to interpret the `storyboard-canvas` blocks at author time (design hypothesis: reinterpret freely, never transcribe coordinates, block geometry, or block labels) is owned by `workflow/create.md` → Preconditions. **Generate concept mockups first — mandatory for a deck (stage 4).** Before authoring a deck from an approved plan, run `slidemind concepts <deck>` — the local-harness equivalent of the in-app manager's *unconditional* concept pass (`generateConceptImages` fires for every `approvedPlan && !targetSlideScope` delegation). Then **Read** each `decks/<slug>/concepts/concept-slide-NN.png` as the per-slide design target and rebuild it under your constraints — the rebuild rule is owned by `workflow/create.md` → Preconditions (reproduce the mockup's composition, hierarchy, graphic mechanism, AND granularity — its organism count, badges, satellites, and annotation layers are part of the target, and restraint is a colour budget, never an element budget; texts follow the story). It never hard-fails: if it produces no mockups (exit 0 — the add-in can't generate them) author from the storyboards alone. Skip it only where the add-in's `!targetSlideScope` gate would — a single slide, a tiny in-chat set, or a targeted rework of existing slides.
148
148
  When storyboard objects share a `visualFamily`, rebuild those mockups as one recognizable visual series. The lowest-numbered member is the anchor. Preserve its shape vocabulary, framing, hierarchy, annotation style, illustration treatment, and spatial rhythm across the family, but adapt every member's element count, sizing, arrangement, labels, emphasis, and connections to its own complete `keyMessage`, `contentNotes`, role, and structure. Never copy another member's text, claims, or data, never force its exact geometry, and never omit approved content because it does not fit the anchor composition.
149
- 3. **Imagery from the engine.** Discover the available image engines by listing them — `slidemind ls engines --subtype image` (the local-harness equivalent of the in-app design agent's `list_engines` tool) — judge which engine fits from its label and description, then search it and place results. (`slidemind search` without `--engine` picks an image engine the same way.) 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. The slide can come from an engine search **or** from a local `.pptx` on disk (`add-slide --from <file.pptx> --pick <n> --deck <slug>`, host-free); both fold in identically — see `slidemind-search`.
149
+ 3. **Imagery from the engine.** Discover the available image engines by listing them — `slidemind ls engines --subtype image` (the local-harness equivalent of the in-app design agent's `list_engines` tool) — judge which engine fits from its label and description, then search it and place results. (`slidemind search` without `--engine` picks an image engine the same way.) The engine search/reuse mechanics live in **`slidemind-search`** — use it to find and fetch imagery; the create-only detail — sizing a fetched asset's box and recolouring an icon — is in *Placing engine imagery* 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. The slide can come from an engine search **or** from a local `.pptx` on disk (`add-slide --from <file.pptx> --pick <n> --deck <slug>`, host-free); both fold in identically — see `slidemind-search`.
150
150
  4. **Look at your own draft first (you, the agent).** This is the **draft check** in `workflow/build-and-export.md` — your pre-check, *before* any human sees the deck. Run `slidemind view-draft <deck>`: it renders each authored `slides/*.jsx` to a per-slide PNG and hands the images back to you — **nothing is pushed, no `.pptx` is written, no review gate opens**. Read every image the way the audience will, fix anything visibly wrong **in the JSX** (re-express, never delete), and view again until each slide is sound. One round usually suffices. It runs locally against the materialized bundle, so it needs no awake pane and no open host once the bundle has been downloaded once. Two faithful limits to know: a slide on a **purpose-built (Mode A) layout** (cover/divider/agenda) and a **reused native `.pptx`** slide have no freeform region to capture, so they come back as a status line, not an image — judge those at the full-deck `review`/`build` step below. Do this self-check before the human review gate; don't push a draft you haven't looked at.
151
151
  5. **The review gate — the moment the deck builds clean.** This is the **review gate** in `workflow/build-and-export.md`; the CLI's gate command is `slidemind review <deck>`. Run it the instant the authored set builds clean — before any deliver, without asking whether the user wants a review. It builds the HTML preview first (the same lint the render enforces, so a gate that *opens* is a clean build; while lint errors remain it exits non-zero — fix and re-run), then opens the browser with a spatial comment overlay and **blocks until the user decides** (host-free — no open PowerPoint needed for the gate). Announce it per *Two UX rules*: tell the user the browser opened and give the clickable gate URL. **Blocking is the point: give the shell call a generous timeout (the gate waits up to 30 min) and let it sit — a long quiet wait means the user is reviewing, not that something hung. Never cancel it; if your harness kills the call early, just re-run it (the user's open tab reconnects).** The verdict prints to stdout:
152
152
  - `DECISION: approved` → proceed to the deliver the user chose at intake: `slidemind push <deck>` (into PowerPoint) or `slidemind build-pptx <deck>` (to a file).
@@ -157,12 +157,13 @@ Keep planning about intent and content. The bundle is already materialized by `s
157
157
 
158
158
  **On a clean approve + deliver, the task is done.** Report what landed (slide count + target) and stop — no sanity pass, no re-verification. The visual QA already happened at the draft check (step 4) and the user signed off at the review gate.
159
159
 
160
- ## Recolouring engine icons (create-authoring specifics)
160
+ ## Placing engine imagery (create-authoring specifics)
161
161
 
162
- Finding and fetching engine imagery is `slidemind-search`'s job (`slidemind search` → `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.
162
+ Finding and fetching engine imagery is `slidemind-search`'s job (`slidemind search` → `slidemind download-result`). What follows is **create-only**: how to size and colour a fetched asset so it reads on-brand in your JSX, honoured in both the `slidemind build` preview and the render.
163
163
 
164
164
  Engine results land as files referenced with `<img src="./assets/<file>">`. These facts are specific to the local harness (the bundle's `workflow/*.md` stay harness-neutral):
165
165
 
166
+ - **Size the box from the asset's own dimensions.** Both `slidemind search` (from the engine index, before any fetch) and `slidemind download-result` (measured from the delivered bytes) print each image's intrinsic `width×height`. **Use it.** A plain `<img>` is stretched to whatever box you write, so a box whose aspect ratio disagrees with the asset ships a distorted picture. Derive one side from your layout and compute the other from the printed ratio. `object-fit: contain`/`cover` is honoured in the render as well as the preview, but it is a *fallback*, not a substitute: it converts a wrong ratio into letterboxing or a crop, both of which you chose blind. When the size prints as `(size unknown)`, the format carried no readable header — give the asset a square-ish box and check it at the draft check.
166
167
  - **Never open the SVG to find out whether it recolours.** `slidemind download-result` prints a `recolor` descriptor for each asset — `{ recolorable, mode, applied, note }`, where `mode` is `currentColor`, `mono`, `multicolor`, or `raster`. Read it; that *is* the answer.
167
168
  - **Two ways to colour an icon, both honoured in the `slidemind build` preview *and* the render:**
168
169
  - **Bake it in (prefer this):** pass `--icon-color <hex>` — a hex you resolve from a token in the bundle's `corporate-design.css` — to `slidemind download-result`. Works for any `recolorable` glyph and the colour is in the saved file, so preview and render always agree.
@@ -186,8 +187,8 @@ The bundle's `workflow/*.md` speak the harness-neutral verbs defined in `workflo
186
187
  | **build & push** | `slidemind push <deck>` (into the open PowerPoint) · `slidemind build-pptx <deck>` (to a `.pptx` file) — deliver whichever target was locked at intake; both render server-side via the bridge, and for a bound deck the render is pane-free (`build-pptx` needs no PowerPoint window at all) |
187
188
  | **the review gate** | `slidemind review <deck>` — builds the HTML preview (lint-clean or it doesn't open), serves it 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 → deliver (`slidemind push` / `build-pptx`); on comments → edit the cited files and re-run `slidemind review` (prior round shows as ghost pins). CLI delivers happen out of the user's sight, so **every deliver follows an approved review of what it delivers — re-run `review` after any slide change**. `push`/`build-pptx` never pause; only `review` does. |
188
189
  | **inspect the deck** | `slidemind read [presId\|file.pptx] [--mode summary\|raw]` — one verb, two sources: pull the current content of an open presentation (a presId; host) **or** read a local `.pptx` on disk (a file path; host-free — no PowerPoint, no pairing). `summary` = per-slide outline; `raw` = each slide's plain text — to inspect what's there before editing. Plain text, not authoring markup |
189
- | **discover engines** (`list_engines`) | `slidemind ls engines [--type <collection\|search>] [--subtype <image\|slide\|template\|content>]` — the live catalog is the only engine list (the bundle carries none); pick an engine `id` from it, never guess one |
190
- | **engine search / fetch** | **`slidemind-search`** owns the engine search + reuse mechanics (`slidemind search` → `slidemind download-result`, plus whole-slide reuse via `add-slide`). For placing a fetched icon in your JSX, pass `--icon-color` / batch with `--pick <n,n,…>` and colour per *Recolouring engine icons* below |
190
+ | **discover engines** (`list_engines`) | `slidemind ls engines [--type <collection\|search\|scope>] [--subtype <image\|slide\|template\|content>]` — the live catalog is the only engine list (the bundle carries none); pick an `id` from it, never guess one. It also lists **search scopes** for unfiltered corpus search — those belong to `slidemind-search`, not to imagery |
191
+ | **engine search / fetch** | **`slidemind-search`** owns the engine search + reuse mechanics (`slidemind search` → `slidemind download-result`, plus whole-slide reuse via `add-slide`). For placing a fetched asset in your JSX, size its box from the printed `width×height`, pass `--icon-color` / batch with `--pick <n,n,…>` and colour per *Placing engine imagery* below |
191
192
  | **see** a live slide | no CLI analog — once delivered, the user is looking at the result; your visual authority runs at the draft check and the review gate instead |
192
193
 
193
194
  Everything else in `workflow/*.md` — the composition craft, the substrate/authoring rules, the layout discovery and slot/working-area rules, the visual quality bar (applied at the draft check) — applies to you unchanged.
@@ -37,9 +37,10 @@ The pane-touching ops here (`search`, `view_slide`, `insert-slide`, `download-re
37
37
 
38
38
  Follow `workflow/search.md` for the discipline; this is the CLI shape of its loop.
39
39
 
40
- 1. **Discover engines — never guess an id.** Run `slidemind ls 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` 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.
41
- 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.
42
- 3. **Search — no window to choose.** `slidemind search "<query>" --engine <id> --intent <auto|content|visual> [--top N]` (leave `--to` off — search attaches to any connected window itself). It returns a `searchId` plus numbered results, and prints **which window it ran on** (`searchId N ran on window <presId>`). **Thread that `presId` into every follow-up** as `--to <presId>` (view-slide / insert-slide / add-slide / download-result): the `searchId` is bound to that window, so a follow-up aimed at a different window won't find it.
40
+ 1. **Discover targets — never guess an id.** Run `slidemind ls engines` and read the catalog before searching. It lists two kinds of target: **engines** (`--type collection|search`) and **scopes** (`--type scope` — `scope:all`, and `scope:sharepoint` once a site is connected), which search the corpus unfiltered. Filter with `--subtype <image|slide|template|content>` (e.g. `slidemind ls engines --type search --subtype slide`). The catalog is **per-user, not per-presentation** — there is no presId to scope; spanning every paired environment, each row is tagged with its `env`. (`--to` is accepted for symmetry but ignored.) Pick an **id** from the list — do not invent one. **An empty or thin engine list is not "nothing to search"** (see `workflow/search.md`): fall through to a scope.
41
+ 2. **Choose the target + intent.** Slide engines (`subtype slide`) and scopes 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.
42
+ 3. **Search — no window to choose.** `slidemind search "<query>" --engine <id> --intent <auto|content|visual> [--top N]` (leave `--to` off — search attaches to any connected window itself). A **scope** target additionally needs `--result-type slide|image` (it has no subtype of its own; the CLI defaults it to `slide`). Omit `--engine` entirely and the CLI picks for you: a slide intent searches `scope:all` for slides, anything else resolves a live image engine. It returns a `searchId` plus numbered results, and prints **which window it ran on** (`searchId N ran on window <presId>`). **Thread that `presId` into every follow-up** as `--to <presId>` (view-slide / insert-slide / add-slide / download-result): the `searchId` is bound to that window, so a follow-up aimed at a different window won't find it.
43
+ - **On `0 result(s)`, read the diagnostic the CLI prints and act on it** — it lists the causes that look identical from here (nothing connected, still indexing, the engine's folder filter excludes the content) and the next moves. Reuse from a scope works exactly like reuse from an engine, so widening costs you nothing.
43
44
  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 --search <searchId> --pick <n> --to <presId>` (it writes the preview to a file you can Read), or `slidemind search … --json` for the structured payload. Pick by **selection number(s)**.
44
45
  5. **Reuse.** Insert live or fold into a deck — see the next section.
45
46
 
@@ -65,6 +66,8 @@ This writes the **same** `decks/<slug>/slides/NN-reused-*.pptx` as the engine pa
65
66
 
66
67
  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 and `--icon-color`) is documented where it's used, in `slidemind-create-slides`.
67
68
 
69
+ Image results print their **intrinsic `width×height`** next to the title — the engine indexes it, so the aspect ratio is yours *before* you fetch anything. Carry it into the pick: a result whose shape doesn't suit the slot you have in mind is the wrong pick, whatever it depicts. (`download-result` re-states the size, measured from the delivered bytes.)
70
+
68
71
  ## Working with slidemind-create-slides
69
72
 
70
73
  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.
@@ -75,8 +78,9 @@ These two skills compose. When the user is **authoring** a deck (that's `slidemi
75
78
 
76
79
  | Verb (`workflow/search.md`) | Your equivalent (local harness) |
77
80
  |---|---|
78
- | **discover engines** ("list what you can reach") | `slidemind ls engines [--type …] [--subtype …]` — returns the engine catalog (pick an `id`; never guess one). The catalog is backend/user-scoped; `--to` is accepted but ignored (informational only). |
81
+ | **discover engines** ("list what you can reach") | `slidemind ls engines [--type collection\|search\|scope] [--subtype …]` — returns the engine catalog **plus the search scopes** (pick an `id`; never guess one). The catalog is backend/user-scoped; `--to` is accepted but ignored (informational only). |
79
82
  | **engine search** (with `intent`) | `slidemind search "<query>" --engine <id> --intent <auto\|content\|visual> [--top N]` — leave `--to` off (attaches to any window) → a `searchId` + numbered results, **plus the presId it ran on** (thread that in as `--to` on the follow-ups below) |
83
+ | **search a scope** (unfiltered corpus, no engine) | `slidemind search "<query>" --engine <scope:all\|scope:sharepoint> --result-type <slide\|image> [--intent …]` — `--result-type` is required for a scope (defaults to `slide`). Results reuse identically to engine results. |
80
84
  | **pull an on-demand thumbnail** for a stored result | `slidemind view-slide --search <searchId> --pick <n> --to <searchWindow>` — writes one result's preview image to a file (`--to` = the window the search returned) · or `slidemind search … --json` for the structured payload. |
81
85
  | **insert live** (reuse a slide → the open window) | `slidemind insert-slide --search <searchId> --pick <n[,n,…]> --to <searchWindow> [--at N]` — `--to` = the window the search returned; the slide lands there |
82
86
  | **fold into a deck** (reuse a slide → deck source) | `slidemind add-slide --search <searchId> --pick <n[,n,…]> --deck <deck> --to <searchWindow> [--at N]` → writes `decks/<slug>/slides/NN-reused-*.pptx`; `--to` = the window the search returned (extraction runs there) |
@@ -89,3 +93,5 @@ Everything else in `workflow/search.md` — the engine-discovery discipline, con
89
93
  ## The engines and the workflow both live in the bundle, not here
90
94
 
91
95
  Do not restate which engines exist, what they contain, or any brand rule — the engine catalog comes from `slidemind ls engines` (live discovery — the bundle carries no engine list), 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.
96
+
97
+ `workflow/search.md` also owns the judgement this skill deliberately does **not** restate: what an engine actually is (a saved filter over a shared corpus, not a library of its own), when to reach for a scope instead, and why zero results are not evidence that the content doesn't exist. Read it before concluding a search came up empty.
@@ -648,11 +648,29 @@ export function loadEnginesForDeck(deckDir) {
648
648
  return readEnginesFromDir(resolveBundleDirForDeck(deckDir));
649
649
  }
650
650
 
651
+ // Reserved non-engine search targets. An engine is a saved FILTER over the
652
+ // add-in's shared corpus; a scope searches that corpus unfiltered. They ride in
653
+ // the same id slot as engines — engine ids are UUIDs and can never contain ":",
654
+ // so the namespace can't collide. Kept in sync with the add-in's
655
+ // `types/search-scope.ts` / `models/domain/search_scopes.py`.
656
+ export const SCOPE_PREFIX = "scope:";
657
+ export const SCOPE_ALL = "scope:all";
658
+ export const SCOPE_SHAREPOINT = "scope:sharepoint";
659
+
660
+ // True when an id names a search scope rather than an engine. Matches unknown
661
+ // `scope:*` values too, so a typo is reported as a bad scope by the add-in
662
+ // instead of being looked up as an engine and reported as "Engine not found".
663
+ export function isSearchScopeId(id) {
664
+ return typeof id === "string" && id.startsWith(SCOPE_PREFIX);
665
+ }
666
+
651
667
  // Image-producing engine kinds — the modality "image" plus the finer image
652
668
  // content kinds an authored bundle may carry. Excludes "slide" (a slide-search
653
- // engine returns whole slides, not insertable bytes). Mirrors the add-in's
654
- // local-bridge `engine_not_image` gate so the CLI never picks an engine the
655
- // bridge would reject.
669
+ // engine returns whole slides, not insertable bytes) and scopes (whose subtype
670
+ // is empty — they are not a brand image source, so the imagery default must not
671
+ // silently resolve to one). Mirrors the add-in's local-bridge
672
+ // `engine_not_image` gate so the CLI never picks an engine the bridge would
673
+ // reject.
656
674
  const IMAGE_ENGINE_KIND_RE = /^(image|icon|photo|illustration|vector)$/i;
657
675
 
658
676
  // True when an engine produces insertable image bytes. Gates on the engine's
@@ -51,6 +51,42 @@ export function compareCliVersions(a, b) {
51
51
  return 0;
52
52
  }
53
53
 
54
+ // The one error every version-floor check raises, so the probe path
55
+ // (assertCliVersionSupported) and the per-response header path are
56
+ // indistinguishable to callers and to the skills that relay the text.
57
+ function cliUpdateRequiredError(min) {
58
+ const err = new Error(
59
+ `slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
60
+ `supports (${min}). Run \`slidemind update\` (or ` +
61
+ "`npm install -g slidemind@latest`), then retry.",
62
+ );
63
+ err.code = "cli_update_required";
64
+ return err;
65
+ }
66
+
67
+ // `doctor` and `ready` exist to EXPLAIN a broken connection, version skew
68
+ // included — they render it as a structured blocker with an `actor` and a
69
+ // nextAction. Throwing the raw error out of bridgeFetch would break the one
70
+ // command that diagnoses the problem, so they opt out of the header check
71
+ // (their own ladder still reports cli_update_required, terminally).
72
+ let diagnosticMode = false;
73
+ export function setDiagnosticMode(on) {
74
+ diagnosticMode = on !== false;
75
+ }
76
+
77
+ // Every add-in response carries the version floor in `x-slidemind-min-cli`.
78
+ // Checking it here covers routes no capability probe guards — the pane ops
79
+ // (search/read/insert) and pairing itself — for zero extra round-trips, and it
80
+ // fires BEFORE the status handling below so a stale CLI hitting a route that
81
+ // does not exist yet reads "run slidemind update" instead of a bare 404.
82
+ function enforceVersionFloor(response) {
83
+ if (diagnosticMode) return;
84
+ const min = response?.headers?.["x-slidemind-min-cli"];
85
+ if (typeof min !== "string" || !min) return;
86
+ if (compareCliVersions(cliVersion(), min) >= 0) return;
87
+ throw cliUpdateRequiredError(min);
88
+ }
89
+
54
90
  function isLoopbackHostname(hostname) {
55
91
  return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
56
92
  }
@@ -199,6 +235,7 @@ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
199
235
  }
200
236
  throw err;
201
237
  }
238
+ enforceVersionFloor(response);
202
239
  if (response.status === 401) {
203
240
  throw new Error(
204
241
  "slidemind session is invalid or expired. Re-pair: open the add-in, " +
@@ -267,6 +304,7 @@ function nodeRequest(urlString, init = {}) {
267
304
  resolve({
268
305
  status: res.statusCode ?? 0,
269
306
  statusText: res.statusMessage ?? "",
307
+ headers: res.headers,
270
308
  body: Buffer.concat(chunks).toString("utf8"),
271
309
  });
272
310
  });
@@ -290,6 +328,163 @@ function nodeRequest(urlString, init = {}) {
290
328
  });
291
329
  }
292
330
 
331
+ // A streaming sibling of `nodeRequest`, for responses too large to hold in
332
+ // memory. `nodeRequest` concatenates the whole body and decodes it as UTF-8 —
333
+ // fine for JSON, but it would both balloon memory and CORRUPT binary. This pipes
334
+ // a success response straight to `destPath` and never materializes it.
335
+ //
336
+ // Error and JSON bodies are still buffered: both are small by construction (an
337
+ // error message, or the inline SVG the download route answers with), and the
338
+ // caller needs them whole to act on.
339
+ function nodeRequestToFile(urlString, init = {}, destPath) {
340
+ const url = new URL(urlString);
341
+ const isHttps = url.protocol === "https:";
342
+ const transport = isHttps ? https : http;
343
+ const body = init.body ?? null;
344
+ const headers = { ...(init.headers ?? {}) };
345
+ if (
346
+ body &&
347
+ headers["content-length"] === undefined &&
348
+ headers["Content-Length"] === undefined
349
+ ) {
350
+ headers["content-length"] = Buffer.byteLength(body);
351
+ }
352
+ const timeoutMs =
353
+ init.timeoutMs ??
354
+ (parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) || 35000);
355
+
356
+ return new Promise((resolve, reject) => {
357
+ const req = transport.request(
358
+ url,
359
+ {
360
+ method: init.method ?? "GET",
361
+ headers,
362
+ rejectUnauthorized: isHttps
363
+ ? !isLoopbackHostname(url.hostname)
364
+ : undefined,
365
+ },
366
+ (res) => {
367
+ const status = res.statusCode ?? 0;
368
+ const contentType = String(res.headers["content-type"] ?? "");
369
+ const buffered = status < 200 || status >= 300 || contentType.includes("application/json");
370
+ if (buffered) {
371
+ const chunks = [];
372
+ res.on("data", (c) => chunks.push(Buffer.from(c)));
373
+ res.on("end", () =>
374
+ resolve({
375
+ status,
376
+ statusText: res.statusMessage ?? "",
377
+ headers: res.headers,
378
+ body: Buffer.concat(chunks).toString("utf8"),
379
+ streamed: false,
380
+ }),
381
+ );
382
+ res.on("error", reject);
383
+ return;
384
+ }
385
+ const out = fs.createWriteStream(destPath);
386
+ let bytesWritten = 0;
387
+ const total = Number.parseInt(
388
+ String(res.headers["content-length"] ?? ""),
389
+ 10,
390
+ );
391
+ res.on("data", (c) => {
392
+ bytesWritten += c.length;
393
+ init.onProgress?.(bytesWritten, Number.isFinite(total) ? total : null);
394
+ });
395
+ res.pipe(out);
396
+ // A stream that dies mid-flight must reject, not resolve with a truncated
397
+ // file — the caller deletes the partial and surfaces the failure.
398
+ res.on("error", (err) => {
399
+ out.destroy();
400
+ reject(err);
401
+ });
402
+ out.on("error", reject);
403
+ out.on("finish", () =>
404
+ resolve({
405
+ status,
406
+ statusText: res.statusMessage ?? "",
407
+ headers: res.headers,
408
+ bytesWritten,
409
+ streamed: true,
410
+ }),
411
+ );
412
+ },
413
+ );
414
+ req.setTimeout(timeoutMs, () => {
415
+ const err = new Error(
416
+ `slidemind bridge request timed out after ${timeoutMs}ms`,
417
+ );
418
+ err.code = "bridge_timeout";
419
+ req.destroy(err);
420
+ });
421
+ req.on("error", reject);
422
+ if (body) req.write(body);
423
+ req.end();
424
+ });
425
+ }
426
+
427
+ // `bridgeFetch` for a response streamed to disk. Shares the session lookup,
428
+ // auth header and non-2xx error mapping so a streaming call fails exactly like
429
+ // every other bridge call.
430
+ async function bridgeStreamToFile(pathPart, init, destPath, backendURL = null) {
431
+ const session = sessionForBackend(backendURL);
432
+ const url = `${session.backendURL}${pathPart}`;
433
+ const headers = {
434
+ accept: "application/octet-stream, application/json",
435
+ ...init.headers,
436
+ authorization: `Bearer ${session.sessionToken}`,
437
+ };
438
+ let response;
439
+ try {
440
+ response = await nodeRequestToFile(url, { ...init, headers }, destPath);
441
+ } catch (err) {
442
+ if (
443
+ err &&
444
+ /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(
445
+ String(err.message || err),
446
+ )
447
+ ) {
448
+ throw new Error(
449
+ `slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
450
+ "if it persists, re-pair with `slidemind pair`.",
451
+ );
452
+ }
453
+ throw err;
454
+ }
455
+ enforceVersionFloor(response);
456
+ if (response.status === 401) {
457
+ throw new Error(
458
+ "slidemind session is invalid or expired. Re-pair: open the add-in, " +
459
+ 'click "Connect CLI", and run the shown `slidemind pair …`.',
460
+ );
461
+ }
462
+ if (response.status < 200 || response.status >= 300) {
463
+ let parsed = null;
464
+ try {
465
+ parsed = response.body ? JSON.parse(response.body) : null;
466
+ } catch {
467
+ /* non-JSON error body */
468
+ }
469
+ const machineCode =
470
+ parsed && typeof parsed.code === "string" ? parsed.code : null;
471
+ const humanError =
472
+ parsed && typeof parsed.error === "string" ? parsed.error : null;
473
+ const detail =
474
+ humanError ?? (response.body ? response.body.slice(0, 400) : "");
475
+ const err = new Error(
476
+ `slidemind bridge ${response.status} ${response.statusText} for ${pathPart}` +
477
+ (detail ? `: ${detail}` : ""),
478
+ );
479
+ err.status = response.status;
480
+ const code = machineCode ?? humanError;
481
+ if (code) err.code = code;
482
+ if (parsed) err.body = parsed;
483
+ throw err;
484
+ }
485
+ return response;
486
+ }
487
+
293
488
  // List one backend's open presentations, tagging each entry with its origin
294
489
  // (backendURL + a short env label) so the merged registry can be routed and
295
490
  // displayed per environment. Transient blips retry per-backend.
@@ -879,6 +1074,82 @@ export async function generateConceptImagesForBrand(
879
1074
  throw err;
880
1075
  }
881
1076
 
1077
+ // gpt-image calls routinely take 60-120s; budget like the concept relay so a
1078
+ // typed server error wins over a client timeout.
1079
+ function imagenTimeoutMs() {
1080
+ return (
1081
+ parseInt(process.env.SLIDEMIND_BRIDGE_IMAGEN_TIMEOUT_MS || "200000", 10) ||
1082
+ 200000
1083
+ );
1084
+ }
1085
+
1086
+ async function postGenerateImage(brandId, body, backendURL) {
1087
+ const res = await bridgeFetch(
1088
+ `/api/cli/brands/${encodeURIComponent(brandId)}/images`,
1089
+ {
1090
+ method: "POST",
1091
+ headers: { "content-type": "application/json" },
1092
+ body: JSON.stringify(body),
1093
+ timeoutMs: imagenTimeoutMs(),
1094
+ },
1095
+ backendURL,
1096
+ );
1097
+ const data = (await res.json()) ?? {};
1098
+ return { ...data, backendURL };
1099
+ }
1100
+
1101
+ // Generate ONE regular image over the pane-free brand route
1102
+ // (`slidemind imagen`). Body `{ prompt, size?, quality?, brandStyle? }`;
1103
+ // returns the server's `{ ok, image, styleReferences }` augmented with the
1104
+ // resolved `backendURL`. Fan-out and degradation mirror
1105
+ // generateConceptImagesForBrand: throws a typed `imagen_route_unavailable`
1106
+ // when no paired backend serves the route (older add-ins, unknown brand,
1107
+ // missing grant) so callers can print the one actionable fix.
1108
+ export async function generateImageForBrand(
1109
+ brandId,
1110
+ { prompt, size = null, quality = null, brandStyle = false, backendURL = null } = {},
1111
+ ) {
1112
+ if (!brandId) {
1113
+ throw new Error("generateImageForBrand: brandId is required");
1114
+ }
1115
+ if (typeof prompt !== "string" || !prompt.trim()) {
1116
+ throw new Error("generateImageForBrand: prompt is required");
1117
+ }
1118
+ const body = { prompt };
1119
+ if (size) body.size = size;
1120
+ if (quality) body.quality = quality;
1121
+ if (brandStyle) body.brandStyle = true;
1122
+ if (backendURL) {
1123
+ return postGenerateImage(brandId, body, backendURL);
1124
+ }
1125
+ const sessions = sessionsInScope();
1126
+ const failures = [];
1127
+ for (const s of sessions) {
1128
+ try {
1129
+ if (!(await backendSupportsImageGeneration(s.backendURL))) {
1130
+ failures.push({
1131
+ env: deriveEnvLabel(s.backendURL),
1132
+ reason: "no image-generation route (update the add-in)",
1133
+ });
1134
+ continue;
1135
+ }
1136
+ return await postGenerateImage(brandId, body, s.backendURL);
1137
+ } catch (err) {
1138
+ failures.push({
1139
+ env: deriveEnvLabel(s.backendURL),
1140
+ reason: err instanceof Error ? err.message : String(err),
1141
+ });
1142
+ }
1143
+ }
1144
+ const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
1145
+ const err = new Error(
1146
+ `no paired backend generates images for design '${brandId}'` +
1147
+ `${detail ? ` (${detail})` : ""}.`,
1148
+ );
1149
+ err.code = "imagen_route_unavailable";
1150
+ throw err;
1151
+ }
1152
+
882
1153
  // List the LIVE engine catalog (NOT the bundle's curated set) for the paired
883
1154
  // backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
884
1155
  // when several environments are paired this fans out and tags each engine with
@@ -1004,6 +1275,14 @@ export async function backendSupportsConceptImages(backendURL) {
1004
1275
  return caps?.conceptImages === true;
1005
1276
  }
1006
1277
 
1278
+ // Whether a backend generates regular images over the pane-free
1279
+ // `/api/cli/brands/:brandId/images` route (see generateImageForBrand).
1280
+ // Advertised by newer add-ins only.
1281
+ export async function backendSupportsImageGeneration(backendURL) {
1282
+ const caps = await backendCapabilities(backendURL);
1283
+ return caps?.imageGeneration === true;
1284
+ }
1285
+
1007
1286
  // The add-in's hard version floor: /api/cli/capabilities advertises the oldest
1008
1287
  // CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
1009
1288
  // mid-rollout skew otherwise surfaces as confusing route/contract errors deep
@@ -1013,13 +1292,7 @@ export async function assertCliVersionSupported(backendURL) {
1013
1292
  const caps = await backendCapabilities(backendURL);
1014
1293
  const min = typeof caps?.minCliVersion === "string" ? caps.minCliVersion : null;
1015
1294
  if (!min || compareCliVersions(cliVersion(), min) >= 0) return;
1016
- const err = new Error(
1017
- `slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
1018
- `supports (${min}). Run \`slidemind update\` (or ` +
1019
- "`npm install -g slidemind@latest`), then retry.",
1020
- );
1021
- err.code = "cli_update_required";
1022
- throw err;
1295
+ throw cliUpdateRequiredError(min);
1023
1296
  }
1024
1297
 
1025
1298
  // Rebuild a job failure into the exact error shape bridgeFetch throws for the
@@ -1330,11 +1603,17 @@ async function engineOpWithPaneWait(presId, backendURL, attempt) {
1330
1603
  // slide results carrying per-slide text + metadata). `subtype`/`intent`/`top`
1331
1604
  // are passed through for slide engines (image engines ignore intent); omit them
1332
1605
  // and the server uses its defaults (back-compat with older add-ins).
1606
+ //
1607
+ // `engineId` may also be a SCOPE sentinel (`scope:all` / `scope:sharepoint`) —
1608
+ // an unfiltered search over the whole corpus rather than one engine's saved
1609
+ // folder filter. A scope has no subtype to derive its index from, so it needs an
1610
+ // explicit `resultType`; the add-in rejects a scope search without one.
1333
1611
  export async function engineSearch({
1334
1612
  presId,
1335
1613
  engineId,
1336
1614
  query,
1337
1615
  subtype = null,
1616
+ resultType = null,
1338
1617
  intent = null,
1339
1618
  top = null,
1340
1619
  backendURL = null,
@@ -1350,6 +1629,8 @@ export async function engineSearch({
1350
1629
  const be = backendURL || (await resolveBackendForPres(presId));
1351
1630
  const body = { engineId, query };
1352
1631
  if (subtype) body.subtype = subtype;
1632
+ // Additive: omitted unless set, so the body an older add-in sees is unchanged.
1633
+ if (resultType) body.resultType = resultType;
1353
1634
  if (intent) body.intent = intent;
1354
1635
  if (typeof top === "number") body.top = top;
1355
1636
  const run = async (pid, b) => {
@@ -1384,6 +1665,7 @@ export async function engineSearchAnyPane({
1384
1665
  engineId,
1385
1666
  query,
1386
1667
  subtype = null,
1668
+ resultType = null,
1387
1669
  intent = null,
1388
1670
  top = null,
1389
1671
  }) {
@@ -1417,6 +1699,7 @@ export async function engineSearchAnyPane({
1417
1699
  engineId,
1418
1700
  query,
1419
1701
  subtype,
1702
+ resultType,
1420
1703
  intent,
1421
1704
  top,
1422
1705
  backendURL: cand.backendURL ?? primaryBackend,
@@ -1483,6 +1766,130 @@ export async function engineDownload({
1483
1766
  });
1484
1767
  }
1485
1768
 
1769
+ // Stream a chosen engine result's bytes to `destPath`. The size-unbounded
1770
+ // download: the pane's Socket.IO ack carries only a signed source reference, and
1771
+ // the add-in server fetches it and pipes the body here — so nothing buffers the
1772
+ // whole image and there is no base64 4/3 inflation. `engineDownload` above sends
1773
+ // the bytes through the pane ack instead and is therefore capped by the relay's
1774
+ // 10 MB frame limit; prefer this for anything that might be large.
1775
+ //
1776
+ // An SVG result comes back as JSON (`{ ok, mimeType, svgMarkup }`) rather than a
1777
+ // byte stream: it is kilobytes and its recolour happens pane-side. Callers get
1778
+ // `{ svgMarkup }` in that case and `{ bytesWritten, mimeType }` otherwise, so they
1779
+ // can persist whichever arrived.
1780
+ export async function engineDownloadToFile({
1781
+ presId,
1782
+ searchId,
1783
+ selection,
1784
+ destPath,
1785
+ iconColor = null,
1786
+ backendURL = null,
1787
+ onProgress = null,
1788
+ }) {
1789
+ if (!presId) throw new Error("engineDownloadToFile: presId is required");
1790
+ if (typeof searchId !== "number") {
1791
+ throw new Error("engineDownloadToFile: searchId (number) is required");
1792
+ }
1793
+ if (typeof selection !== "number") {
1794
+ throw new Error("engineDownloadToFile: selection (number) is required");
1795
+ }
1796
+ if (!destPath) throw new Error("engineDownloadToFile: destPath is required");
1797
+ const be = backendURL || (await resolveBackendForPres(presId));
1798
+ const body = { searchId, selection };
1799
+ if (iconColor) body.iconColor = iconColor;
1800
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
1801
+ const res = await bridgeStreamToFile(
1802
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/download-stream`,
1803
+ {
1804
+ method: "POST",
1805
+ headers: { "content-type": "application/json" },
1806
+ body: JSON.stringify(body),
1807
+ // A large image legitimately takes longer than an engine op: the server
1808
+ // is fetching from SharePoint/blob AND piping. Separate budget so raising
1809
+ // it doesn't loosen every other relay call.
1810
+ timeoutMs:
1811
+ parseInt(
1812
+ process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
1813
+ 10,
1814
+ ) || 600000,
1815
+ onProgress,
1816
+ },
1817
+ destPath,
1818
+ b,
1819
+ );
1820
+ if (!res.streamed) {
1821
+ // The inline-SVG answer (kilobytes) arrives as JSON, not as a byte stream.
1822
+ const parsed = res.body ? JSON.parse(res.body) : {};
1823
+ return { ok: true, streamed: false, ...parsed };
1824
+ }
1825
+ const warningsHeader = res.headers?.["x-slidemind-warnings"];
1826
+ let warnings;
1827
+ if (warningsHeader) {
1828
+ try {
1829
+ warnings = JSON.parse(warningsHeader);
1830
+ } catch {
1831
+ // A malformed header must not fail a download that otherwise succeeded.
1832
+ }
1833
+ }
1834
+ return {
1835
+ ok: true,
1836
+ streamed: true,
1837
+ mimeType: String(res.headers?.["content-type"] ?? "") || null,
1838
+ bytesWritten: res.bytesWritten,
1839
+ ...(warnings ? { warnings } : {}),
1840
+ };
1841
+ });
1842
+ }
1843
+
1844
+ // Stream a chosen slide-search result to `destPath` as a native one-slide .pptx.
1845
+ // The size-unbounded sibling of `extractSlide`: the pane ack carries only the
1846
+ // request, and the add-in server performs it and pipes the (base64-decoded) bytes
1847
+ // here. `extractSlide` puts the whole slide through the pane's Socket.IO ack as
1848
+ // base64, so it is capped at ~7 MB of real bytes — which real reused slides reach.
1849
+ export async function extractSlideToFile({
1850
+ presId,
1851
+ searchId,
1852
+ selection,
1853
+ destPath,
1854
+ backendURL = null,
1855
+ onProgress = null,
1856
+ }) {
1857
+ if (!presId) throw new Error("extractSlideToFile: presId is required");
1858
+ if (typeof searchId !== "number") {
1859
+ throw new Error("extractSlideToFile: searchId (number) is required");
1860
+ }
1861
+ if (typeof selection !== "number") {
1862
+ throw new Error("extractSlideToFile: selection (number) is required");
1863
+ }
1864
+ if (!destPath) throw new Error("extractSlideToFile: destPath is required");
1865
+ const be = backendURL || (await resolveBackendForPres(presId));
1866
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
1867
+ const res = await bridgeStreamToFile(
1868
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-extract-stream`,
1869
+ {
1870
+ method: "POST",
1871
+ headers: { "content-type": "application/json" },
1872
+ body: JSON.stringify({ searchId, selection }),
1873
+ timeoutMs:
1874
+ parseInt(
1875
+ process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
1876
+ 10,
1877
+ ) || 600000,
1878
+ onProgress,
1879
+ },
1880
+ destPath,
1881
+ b,
1882
+ );
1883
+ const title = res.headers?.["x-slidemind-presentation-title"];
1884
+ return {
1885
+ ok: true,
1886
+ bytesWritten: res.bytesWritten,
1887
+ slideId: res.headers?.["x-slidemind-slide-id"] ?? undefined,
1888
+ presentationTitle: title ? decodeURIComponent(String(title)) : undefined,
1889
+ };
1890
+ });
1891
+ }
1892
+
1486
1893
  // Extract a chosen slide-search result as a standalone one-slide .pptx (base64),
1487
1894
  // WITHOUT inserting it anywhere — the "fold into a deck" path. Relayed through
1488
1895
  // the pane like search/download.