slidemind 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +7 -6
- package/README.md +19 -7
- package/bin/slidemind.mjs +377 -39
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +66 -15
- package/skills/search/SKILL.md +15 -2
- package/src/bridge/build-and-push.mjs +11 -0
- package/src/bridge/bundle.mjs +15 -4
- package/src/bridge/capture.mjs +20 -2
- package/src/bridge/client.mjs +99 -0
- package/src/builders/build.mjs +53 -23
- package/src/builders/gate-server.mjs +163 -22
- package/src/builders/plan-markdown-edit.mjs +120 -0
- package/src/builders/plan-overlay.js +1261 -230
- package/src/builders/plan.mjs +146 -25
- package/src/builders/red-thread-backbone.mjs +194 -0
- package/src/builders/review-overlay.js +44 -7
- package/src/builders/review.mjs +72 -10
- package/src/builders/storyboard-markdown.mjs +118 -0
- package/src/builders/storyboard-schema.mjs +180 -0
- package/src/builders/visual-families.mjs +32 -0
- package/src/pptx/app-props.mjs +222 -0
- package/src/pptx/hidden-ole.mjs +125 -0
- package/src/pptx/image-extensions.mjs +190 -0
- package/src/pptx/ooxml.mjs +169 -0
- package/src/pptx/read-local.mjs +206 -0
- package/src/pptx/slide-swap.mjs +429 -0
- package/src/pptx/zip.mjs +30 -0
|
@@ -22,12 +22,20 @@ The moment this skill activates on an authoring task, **create a todo list of th
|
|
|
22
22
|
1. `slidemind ready` — session preflight (*First step* below)
|
|
23
23
|
2. Clarify with the user — the content **and the output target** (*Intake → plan* below; needs no host, so it can run while `ready` executes)
|
|
24
24
|
3. Plan sign-off — `slidemind plan <deck>` (decks; single slides stay in chat)
|
|
25
|
-
4.
|
|
26
|
-
5.
|
|
27
|
-
6.
|
|
28
|
-
7.
|
|
25
|
+
4. Concept mockups — `slidemind concepts <deck>`, then rebuild each toward its mockup (a deck authored from an approved plan; see *The create flow* below)
|
|
26
|
+
5. Author the slides
|
|
27
|
+
6. Draft check — `slidemind view-draft`
|
|
28
|
+
7. Review gate — `slidemind review`, the moment the deck builds clean
|
|
29
|
+
8. Deliver — `slidemind push` or `slidemind build-pptx`, whichever target was locked in stage 2
|
|
29
30
|
|
|
30
|
-
**Deliver is the last todo — the task ends when the slides are delivered.** Don't add a "sanity pass" or "report back" step after it: the visual QA already happened at the draft check and the user already approved at the review gate, so on approval you push/build directly and stop. Each stage's full definition and the ordering invariants live in the bundle's `workflow/flow.md` — read it right after `ready` succeeds.
|
|
31
|
+
**Deliver is the last todo — the task ends when the slides are delivered.** Don't add a "sanity pass" or "report back" step after it: the visual QA already happened at the draft check and the user already approved at the review gate, so on approval you push/build directly and stop. Each stage's full definition and the ordering invariants live in the bundle's `workflow/flow.md` — read it right after `ready` succeeds.
|
|
32
|
+
|
|
33
|
+
**Concept mockups (stage 4) are a mandatory deck stage, not an optional aid.** After plan sign-off, for a deck authored from an approved plan, you **always** run `slidemind concepts <deck>` and rebuild each `concept-slide-NN.png` toward its target — this is the local-harness equivalent of the in-app manager's *unconditional* concept pass on every full-deck delegation (`generateConceptImages` fires for `approvedPlan && !targetSlideScope`). It degrades benignly: if it returns no mockups (exit 0 — the add-in can't generate them) you author from the storyboards alone, but the *attempt* is not optional. 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.
|
|
34
|
+
|
|
35
|
+
Two gate invariants you must never break — one per gate, equally binding:
|
|
36
|
+
|
|
37
|
+
- **Never jump from clarify to authoring for a deck** — the plan sign-off gate (`slidemind plan <deck>`, stage 3) always sits between. Writing `decks/<slug>/slides/*.jsx` before that gate has returned `DECISION: approved` is the single most common way this workflow goes wrong — it means the user never actually signed off on the story. The only exception is a single slide or a tiny set genuinely handled in-chat (*Intake → plan* below) — that's a real design choice, not a shortcut to take when a deck-sized request feels obvious.
|
|
38
|
+
- **Never jump from authoring to deliver** — the draft check and an approved review gate always sit between.
|
|
31
39
|
|
|
32
40
|
## Bootstrap (set yourself up — don't make the user do it)
|
|
33
41
|
|
|
@@ -65,7 +73,7 @@ Everything about how a deck is authored, styled, and rendered is shaped by the c
|
|
|
65
73
|
|
|
66
74
|
**The open add-in is the source of truth.** `ready` binds to whatever design is open *now*; a deck's `.bundle-ref.json` records the last-resolved design for the render-time etag contract and host-free bundle reads — it is **not** a pinned design preference. So you never "start a session with design A" from a deck ref; you start it from what the add-in has open.
|
|
67
75
|
|
|
68
|
-
**On exit 0** the JSON reports `presId`, `presFP`, `designName`, `bundleDir`, `bundleEtag`, `bound`, and `paneSuspended`, with `nextAction: "Ready."`. Read `workflow/flow.md` and `design.md` from `bundleDir` (cached at `${XDG_CACHE_HOME:-~/.cache}/slidemind/bundles/<presFP>/`, alongside `corporate-design.css`, `layouts/*.jsx`, `layouts/layouts.css`, `assets/`, `samples/`,
|
|
76
|
+
**On exit 0** the JSON reports `presId`, `presFP`, `designName`, `bundleDir`, `bundleEtag`, `bound`, and `paneSuspended`, with `nextAction: "Ready."`. Read `workflow/flow.md` and `design.md` from `bundleDir` (cached at `${XDG_CACHE_HOME:-~/.cache}/slidemind/bundles/<presFP>/`, alongside `corporate-design.css`, `layouts/*.jsx`, `layouts/layouts.css`, `assets/`, `samples/`, and the rest of the add-in-owned `workflow/*.md`) and start planning. A render-ready window whose pane is suspended still exits 0 with `paneSuspended: true` — push/search/read wait for a pane click on their own; **don't treat it as a blocker.**
|
|
69
77
|
|
|
70
78
|
**A bound deck can be ready with NO window at all.** `slidemind ready <deck>` for a deck that was bound once before may exit 0 with `windowless: true` even when no PowerPoint window is registered — the design identity lives in the deck's `.bundle-ref.json` and the bundle syncs over a pane-free route. In that state authoring, `view-draft`, `build`, `review`, and `build-pptx` all work end-to-end (PowerPoint backgrounded or closed); only `push`'s insert, `search`, and `read` need a window — the `nextAction` says exactly that. Don't ask the user to open PowerPoint until one of those pane ops is actually next.
|
|
71
79
|
|
|
@@ -74,20 +82,60 @@ Everything about how a deck is authored, styled, and rendered is shaped by the c
|
|
|
74
82
|
- **`ambiguous_design`** — several windows with **different** corporate designs are open, so `ready` can't choose. The CLI lists the candidates (each with its `env` and window `title`/`fileName`, plus a design fingerprint); if the deck was last built against one of them it's marked `suggested`. **Ask the user which design to build against, naming the windows by `title`/`fileName` (and `env`) — never by raw id** — and mention the suggested one if present ("this deck was last built against X — continue with it?"). Then re-run `slidemind ready --to <presId>` for the window they pick.
|
|
75
83
|
- **Connection blockers** (`not_paired`, `all_unreachable`, `cli_update_required`, `no_window`, `not_render_ready`) — relay the CLI's `nextAction` verbatim (pair / open PowerPoint / click the pane / run `slidemind update`). Pairing is walked through in *Pairing the CLI with the add-in* above. (`no_window` only ever surfaces for an **unbound** deck or deckless `ready` — a bound deck rides the pane-free rung above instead.)
|
|
76
84
|
|
|
77
|
-
**The session spine** (= the stage todos you created up front): `slidemind ready` → read `workflow/flow.md` + `design.md` from `bundleDir` → intake/plan (below) → `slidemind new <slug>` (scaffolds **and** auto-binds the deck to the open design — zero extra download, because `ready` already materialized it) → `slidemind plan <slug>` (plan sign-off gate for decks; single slides stay in chat) → author → `view-draft` → `review` (mandatory, the moment the deck builds clean) → deliver (`push` / `build-pptx`). `slidemind new` best-effort binds when the open design is unambiguous and the bundle is already materialized; if it can't (ambiguous, or no host), it scaffolds anyway and hints to run `slidemind ready <slug>`.
|
|
85
|
+
**The session spine** (= the stage todos you created up front): `slidemind ready` → read `workflow/flow.md` + `design.md` from `bundleDir` → intake/plan (below) → `slidemind new <slug>` (scaffolds **and** auto-binds the deck to the open design — zero extra download, because `ready` already materialized it) → `slidemind plan <slug>` (plan sign-off gate for decks; single slides stay in chat) → `slidemind concepts <slug>` (mandatory concept-mockup pass for a deck, then rebuild toward each) → author → `view-draft` → `review` (mandatory, the moment the deck builds clean) → deliver (`push` / `build-pptx`). `slidemind new` best-effort binds when the open design is unambiguous and the bundle is already materialized; if it can't (ambiguous, or no host), it scaffolds anyway and hints to run `slidemind ready <slug>`.
|
|
78
86
|
|
|
79
87
|
**Backgrounded — or closed — 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 a window** for a bound deck — it captures the slides with a local headless browser and assembles the `.pptx` server-side over the pane-free brand route, streams its own `rendering slide n/m…` progress, and survives arbitrarily long authoring sessions (the old "author 30 minutes and the add-in is gone" failure no longer applies). Only the pane-touching ops (the insert half of `push`, engine search/download, `read`) need a live 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` can't deliver (pane never woke, or no window at all), the rendered `.pptx` was saved next to the deck (`paneSuspended` + `fallbackFile` in the result) — nothing is lost; re-run the push once a window exists. Reserve the full "open PowerPoint…" instruction for when a command actually reports it as the blocker.
|
|
80
88
|
|
|
81
89
|
`slidemind build`, `review`, and `view-draft` run offline, but only against a **previously materialized** bundle — so `ready` (or a prior `ready`) must have succeeded at least once. `build-pptx` additionally needs the backend reachable (the OOXML assembly is server-side), but no PowerPoint window for a bound deck.
|
|
82
90
|
|
|
91
|
+
## Two UX rules (gates + host)
|
|
92
|
+
|
|
93
|
+
- **Announce every gate, with a clickable link.** The moment `slidemind plan` or `slidemind review` starts it prints `ready at <url>` and a `BROWSER:` status line. Immediately tell the user in chat whether a browser tab opened (new tab / reused tab / failed to open) and give the gate URL as a clickable link — every round, and again whenever you report that you're still waiting on their decision.
|
|
94
|
+
- **Add-in unreachable → block and name the tab.** When a host-required command can't reach the add-in (a connection blocker, a pane that never wakes, the *click the slidemind pane* line with no effect), **stop the workflow** — don't skip the step, work around it, or retry silently. Ask the user to go back into the PowerPoint tab of presentation **<title/fileName>** (name it from the command output or `slidemind presentations` — never a raw id), then re-run the failed command once they have.
|
|
95
|
+
|
|
83
96
|
## Intake → plan (you own this; do it in chat)
|
|
84
97
|
|
|
85
98
|
This is your mapping of `workflow/flow.md` stages 1–2 (*clarify* + *plan sign-off*). In-app, a manager runs these; over the CLI the manager role collapses onto **you**, so you run the interview and the sign-off directly with the user. Don't write slide JSX until the plan is agreed.
|
|
86
99
|
|
|
87
100
|
- **Read scope from the wording**, don't ask redundantly: single slide ("make a slide about X"), small set ("add three slides on Y"), or whole deck ("a deck for Friday"). Only ask "one slide, a small set, or a full deck?" when truly ambiguous.
|
|
88
|
-
- **Interview just enough** to pin: audience/goal, the content and its sources (none / pasted / local files / existing deck / web research), and the slide outline (each slide's role + a layout *job* like cover/agenda/section/quote/closing/body — the concrete brand layout is chosen later against the bundle).
|
|
101
|
+
- **Interview just enough** to pin: audience/goal, the content and its sources (none / pasted / local files / existing deck / web research — a source that's already a local `.pptx` you read host-free with `slidemind read <file.pptx>`), and the slide outline (each slide's role + a layout *job* like cover/agenda/section/quote/closing/body — the concrete brand layout is chosen later against the bundle). If the content source resolves to "none", don't silently plan from generic knowledge — say so, and either interview the user for the substance (their claims, numbers, examples) or offer web research before writing storyboards.
|
|
89
102
|
- **Lock the output target — it decides your deliver command.** Into the open PowerPoint (`slidemind push`), or a `.pptx` file (`slidemind build-pptx`) — or an HTML preview / a mix. Infer it when the wording is clear ("add a slide to this deck" → push; "make me a deck I can send around" → a file) and ask only when genuinely ambiguous — but **always confirm the concrete target before delivering** (which window, or which output path).
|
|
90
|
-
- **Replay and get sign-off.** Single slides / tiny sets → a compact in-chat plan (no gate). Decks → persist
|
|
103
|
+
- **Replay and get sign-off.** Single slides / tiny sets → a compact in-chat plan (no gate). Decks → persist a **story-focused plan with body storyboards** in `decks/<slug>/plan.md`, two top-level sections in order:
|
|
104
|
+
- `## Red thread` — one presentation **move** per slide, as bullets or one arrow-separated line. Name the *teaching function* of each slide — short verb+noun labels for what the audience is doing cognitively at that step — never its content: no claims, facts, metrics, client-specific terms, slide copy, generic section names, or wording copied from these instructions.
|
|
105
|
+
- `## Per-slide storyboards` — one `### Slide N: <role>` subsection per slide, each a one-sentence `Core message:` followed by **exactly one** fenced `storyboard-canvas` JSON block.
|
|
106
|
+
|
|
107
|
+
The `storyboard-canvas` is a **body-only spatial sketch**, not final slide design: it plans the body region only and never the template title/header/footer/logo/slide-number areas. Blocks live in a 1600 × 900 coordinate space, kept inside the body-safe area (roughly x 80–1520, y 110–780); position, size, and proximity must express the slide's meaning — hierarchy, sequence, causality, contrast, grouping, flow direction, or the main focus — not random placement. Each block shows only two visible text fields: `designElement` (the visual mechanism in up to 5 words — e.g. "hub-spoke visual", "loop diagram", "timeline", "matrix", "before/after contrast", "scorecard") and `content` (a 1–2 word label), plus `emphasis` ("primary"/"secondary"/"tertiary") for hierarchy. `keyMessage` is the slide's **story beat**: one plain sentence naming what this slide does to move the story forward, stated abstractly — NOT the headline or copy that will appear on the slide, and not the red-thread move restated. `contentNotes` is the **content draft** that delivers that beat — facts, claims, examples, source points, caveats, draft copy, proof points — and carries no design, layout, tone, or authoring-process instructions. It is the slide's full content budget: the create stage and the concept-mockup generator build from `contentNotes` alone and never add substance, so anything not in the notes will not be on the slide. Write them so the slide body could be authored from them without inventing anything — every claim spelled out concretely, with the numbers, named examples, or proof points that make it land. Two or three abstract topic bullets ("X is expensive", "Y improves") is an unfinished draft, not a storyboard.
|
|
108
|
+
|
|
109
|
+
Judge whether two or more slides form a genuine visual series — for example a continuation, progressive reveal, repeated comparison, phased journey, or recurring framework. When they do, give those storyboard objects the same short, stable `visualFamily` identifier. Do not group slides merely because they share a topic, and do not ask the user to make this design judgment. A family tells concept generation to establish one recognizable visual system and adapt it to every member's own `keyMessage`, `contentNotes`, role, and structure. It never permits copying another slide's text, claims, data, or exact geometry, and it never permits dropping approved content to fit the shared concept. Omit `visualFamily` for independent slides.
|
|
110
|
+
|
|
111
|
+
Choose the block count from the slide's meaning and **never default to three** (1 for a hero idea, 2 for a contrast or paired tension, 3 only for three genuinely distinct beats, 4–5 only for a true sequence or comparison set), and vary the spatial composition across slides — avoid repeating the same left/right/bottom alignment. Ban style micromanagement only — no colours, fonts, exact layout names, or final box/card counts: **the create stage owns realisation** of the approved body storyboard in the corporate design. Use this exact JSON shape:
|
|
112
|
+
|
|
113
|
+
```storyboard-canvas
|
|
114
|
+
{
|
|
115
|
+
"version": 1,
|
|
116
|
+
"slide": 1,
|
|
117
|
+
"blocks": [
|
|
118
|
+
{
|
|
119
|
+
"id": "main-visual",
|
|
120
|
+
"x": 420,
|
|
121
|
+
"y": 180,
|
|
122
|
+
"w": 680,
|
|
123
|
+
"h": 280,
|
|
124
|
+
"emphasis": "primary",
|
|
125
|
+
"designElement": "Hub-spoke visual",
|
|
126
|
+
"content": "Operating model"
|
|
127
|
+
}
|
|
128
|
+
],
|
|
129
|
+
"keyMessage": "Shift the audience from thinking about AI tools to thinking about the operating model, so the rest of the deck has a reason to land.",
|
|
130
|
+
"contentNotes": [
|
|
131
|
+
"The operating model has five components: intake (requests land in one queue), tool use (agents draft with approved tools), validation (a human reviews before anything ships), escalation (edge cases route to a named owner), ownership (each workflow has one accountable lead).",
|
|
132
|
+
"Concrete example: contract review — intake via the legal inbox, agent drafts the redline, counsel validates in under 10 minutes vs. 2 hours manual.",
|
|
133
|
+
"Decision for the audience: which single workflow do we pilot this quarter — contract review or customer response drafting?"
|
|
134
|
+
]
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Make the connection to the current deck explicit in the red thread, core messages, `keyMessage`, or `contentNotes` whenever the work extends the open presentation, and fold in any content findings from slides/files you inspected. Then run `slidemind plan <deck>`: it opens the plan as **editable storyboards** — the user reorders/renames/inserts/removes slides on the red-thread strip, drags/resizes/edits blocks and the content draft directly on each slide's canvas, and/or leaves overall feedback — and it **blocks until the user decides** — announce it per *Two UX rules* (browser opened? + the clickable gate URL) and give the shell call a **generous timeout** (the gate waits up to 30 min; if your harness kills the call early, just re-run it — the user's open tab reconnects). Outcomes: `DECISION: approved` → proceed to author; `DECISION: comments` → apply the feedback to `plan.md` — the feedback may include a **generated story-iteration instruction** (when the user restructured the red thread by adding or renaming slides, the gate asks you to build storyboards and content for exactly those moves while keeping the other slides intact) — (the round is persisted verbatim in `<deck>/plan-review.json` if your capture of the output was truncated) and **re-run the gate in the same turn** — after a comments round the user's tab sits on a waiting spinner that only the next `slidemind plan` run releases, so never end your turn, never claim to be "polling", and never send the user back to the browser between rounds (that deadlocks: the tab waits for you while you wait for the tab); `DECISION: pending` → the gate timed out with no answer — not approval, not rejection: don't author; wrap up telling the user the plan still awaits their sign-off and re-run `slidemind plan <deck>` on their go-ahead. Loop until approved. A `plan.md` **approved in a prior gate round and unchanged since** counts as approved (skip straight to authoring); any new or revised plan must pass the gate. When the gate reports the user edited `plan.md` directly, re-read it — the edited storyboards **are** the approved plan; author from them.
|
|
91
139
|
|
|
92
140
|
Keep planning about intent and content. The bundle is already materialized by `slidemind ready`, so `design.md` is there if you need to sanity-check feasibility — but **don't dive into brand/layout/composition detail until create/edit.**
|
|
93
141
|
|
|
@@ -96,12 +144,13 @@ Keep planning about intent and content. The bundle is already materialized by `s
|
|
|
96
144
|
`slidemind ready` already resolved the design source and materialized its bundle; the deck was bound to it by `slidemind ready <deck>` (continuing) or by `slidemind new <slug>`'s auto-bind (new work). From here:
|
|
97
145
|
|
|
98
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.
|
|
99
|
-
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.
|
|
100
|
-
|
|
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
|
+
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`.
|
|
101
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.
|
|
102
|
-
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). **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:
|
|
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:
|
|
103
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).
|
|
104
|
-
- `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 and re-run `slidemind review <deck>` in the same turn** — after a comments round the user's tab waits for the next round, so never end your turn, never claim to be "polling", and never send the user back to the browser between rounds. 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.
|
|
153
|
+
- `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 and re-run `slidemind review <deck>` in the same turn** — after a comments round the user's tab waits for the next round, so never end your turn, never claim to be "polling", and never send the user back to the browser between rounds. 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. **Every round is persisted verbatim in `<deck>/review.json`** — if your shell capture of the gate's output was truncated or lost (backgrounded command, clipped scrollback), read that file for the complete comment list; never guess at missing comments or ask the user to re-type them.
|
|
105
154
|
- `DECISION: pending` → the gate timed out with no answer (the user stepped away). Not approval, not rejection — **do not deliver**. Wrap up your turn: tell the user the preview is still waiting for their review and that on their go-ahead you'll re-run `slidemind review <deck>` (their tab reconnects).
|
|
106
155
|
|
|
107
156
|
`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). **CLI exports happen out of the user's sight, so the flow invariant — *the user sees every change before the task is done* — makes re-review mandatory here: every deliver is preceded by an approved review of exactly what it delivers. After any later slide change, the deck re-enters `slidemind review` before it can be pushed or exported again — never deliver an unreviewed change.** If several presentations are open, confirm **which one to push into** first (it can differ from the design source `slidemind ready` locked in). 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 *First step* above). 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.
|
|
@@ -128,14 +177,16 @@ The bundle's `workflow/*.md` speak the harness-neutral verbs defined in `workflo
|
|
|
128
177
|
|---|---|
|
|
129
178
|
| (session setup — a CLI-only stage before the spine) | `slidemind ready [deck]` — the one-shot session start: connection ladder + open-design resolution + bundle materialize + deck bind. **Run it first, every session** (see *First step* above). `slidemind download-bundle` is the lower-level materialize-only command `ready` now covers — you rarely call it directly |
|
|
130
179
|
| **track the stages** | your harness's task tracker — explicitly create the stage todo list before your first command and keep it current (see *Create a todo list* above) |
|
|
131
|
-
| **clarify** / **plan sign-off** | intake stays in chat with the user (above); for decks, persist `decks/<slug>/plan.md
|
|
180
|
+
| **clarify** / **plan sign-off** | intake stays in chat with the user (above); for decks, persist `decks/<slug>/plan.md` as `## Red thread` + `## Per-slide storyboards` (one `storyboard-canvas` JSON block per slide), then run `slidemind plan <deck>` — the same blocking `DECISION:` gate contract as `slidemind review`, on the plan (renders editable storyboards — red-thread strip + spatial canvases — plus overall feedback; loop until approved) |
|
|
181
|
+
| concept mockups (mandatory deck stage, after plan sign-off) | `slidemind concepts <deck>` — renders one image mockup per approved-plan slide into `decks/<slug>/concepts/concept-slide-NN.png`; the local-harness stand-in for the in-app manager's *unconditional* concept pass on every full-deck delegation (`approvedPlan && !targetSlideScope`). **Read each and rebuild it** toward its target under your constraints (rule owned by `workflow/create.md` → Preconditions — don't restate it). It never hard-fails: on an empty result (exit 0 — the add-in can't generate them) author from the storyboards alone, but for a deck you always run it. Skip only where the add-in's `!targetSlideScope` gate would — a single slide, a tiny in-chat set, or a targeted rework of existing slides |
|
|
132
182
|
| **read** / **list** the workspace | read the files from the materialized bundle dir (`<bundle>/…`) and your `decks/<slug>/` |
|
|
133
183
|
| **create / update a slide file** | write `decks/<slug>/slides/NN-<topic>.jsx` on disk with a **zero-padded numeric prefix** (`01-cover.jsx`, `02-agenda.jsx`, …) — over the CLI, "the ordered set you hand to build & push" is expressed as **numeric-aware filename order**; there is no ordered-list channel, so reordering = renaming files |
|
|
134
184
|
| brand reference samples (studied before composing) | **Read** 3 random images from the materialized bundle's `samples/` dir — the Read tool renders them (see *The create flow* step 1) |
|
|
135
185
|
| **draft check** | `slidemind view-draft <deck>` — renders each `slides/*.jsx` to a per-slide PNG and returns the images to **you**; **no push, no `.pptx`, no review gate**. Look, fix the JSX, view again. Host-free once the bundle is materialized. Freeform slides only — purpose-built (Mode A) and reused native `.pptx` slides report as a status line (judge those at `review`/`build`). This is your self-check; `slidemind review` is the *human* gate |
|
|
136
186
|
| **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) |
|
|
137
187
|
| **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. |
|
|
138
|
-
| **inspect the deck** | `slidemind read [presId] [--mode summary\|raw]` — pull the current content of an open presentation (`summary` = per-slide outline; `raw` = each slide's plain text
|
|
188
|
+
| **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 |
|
|
139
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 |
|
|
140
191
|
| **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 |
|
|
141
192
|
|
package/skills/search/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
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
|
|
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, or from a local PowerPoint file on disk. Finds existing slides and brand imagery — from the engines or from a local .pptx — 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 reuse a slide from another presentation or deck on disk, to ask what's in a local .pptx file, 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
4
|
---
|
|
5
5
|
|
|
6
6
|
# slidemind-search
|
|
@@ -31,6 +31,8 @@ Engine search and slide reuse are **host-required** operations — without an op
|
|
|
31
31
|
|
|
32
32
|
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.
|
|
33
33
|
|
|
34
|
+
**Add-in unreachable → block and name the tab.** When a host-required command can't reach the add-in (a connection blocker, a pane that never wakes, the *click the slidemind pane* line with no effect), **stop** — don't skip the step, work around it, or retry silently. Ask the user to go back into the PowerPoint tab of presentation **<title/fileName>** (name it from the command output or `slidemind presentations` — never a raw id), then re-run the failed command once they have.
|
|
35
|
+
|
|
34
36
|
## Discover → search → pick → reuse
|
|
35
37
|
|
|
36
38
|
Follow `workflow/search.md` for the discipline; this is the CLI shape of its loop.
|
|
@@ -48,6 +50,15 @@ A found slide can be reused two ways. Both extract the picked result(s) as nativ
|
|
|
48
50
|
- **Insert LIVE into an open PowerPoint** — `slidemind insert-slide --search <searchId> --pick <n[,n,…]> --to <searchWindow> [--at <N>]`, where `<searchWindow>` is the presId 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.
|
|
49
51
|
- **Fold into a DECK on disk** — `slidemind add-slide --search <searchId> --pick <n[,n,…]> --deck <deck> --to <searchWindow> [--at <N>]`, where `<searchWindow>` is the presId 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.**
|
|
50
52
|
|
|
53
|
+
### A local `.pptx` file as the source (host-free — no engine, no search)
|
|
54
|
+
|
|
55
|
+
When the slide to reuse already lives in a `.pptx` **on disk** (another presentation, an exported deck), skip discovery entirely — no host, no window, no engine:
|
|
56
|
+
|
|
57
|
+
1. **List the file's slides.** `slidemind read <file.pptx> --mode summary` prints one outline line per slide **in play order**; note the number(s) you want (1-based).
|
|
58
|
+
2. **Fold the picked slide(s) into a deck.** `slidemind add-slide --from <file.pptx> --pick <n[,n,…]> --deck <deck> [--at <N>]`. `--from` is the local-file alternative to `--search` (they are **mutually exclusive**), and it needs no `--to` — there is no window in the loop.
|
|
59
|
+
|
|
60
|
+
This writes the **same** `decks/<slug>/slides/NN-reused-*.pptx` as the engine path, so the durable rule below applies unchanged (deck source-of-truth, spliced by play-order filename, survives every re-render). To deliver a local slide **live** into an open window there is no `insert-slide --from`: fold it into a deck as above, then `slidemind push <deck>`.
|
|
61
|
+
|
|
51
62
|
> **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.
|
|
52
63
|
|
|
53
64
|
> **Building a `.pptx` file from found slides → one combined deck, always.** When the user wants the reused slides as a **`.pptx` file** (rather than a live insert), collect **every** picked slide into **one** deck and render **one** file — never one `.pptx` per slide and never a deck per slide. Fold all picks into the same deck — pass every selection in a single `add-slide` call (`--pick n1,n2,…`), repeating only to cover picks that came from different `searchId`s (always the same `--deck`) — then run `slidemind build-pptx <deck>` once. The render splices every native `slides/*.pptx` into a **single `decks/<slug>/<slug>.pptx`** in play-order, so all the found slides land in one file. That combined `.pptx` is the deliverable; hand back its path. (Need new authored slides interleaved with the reused ones in that same file? That's still one deck — hand off to `slidemind-create-slides` to add the JSX, then `build-pptx` the whole deck once.)
|
|
@@ -69,10 +80,12 @@ These two skills compose. When the user is **authoring** a deck (that's `slidemi
|
|
|
69
80
|
| **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. |
|
|
70
81
|
| **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 |
|
|
71
82
|
| **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) |
|
|
83
|
+
| **read a local `.pptx`** (host-free — no engine, no window) | `slidemind read <file.pptx> --mode summary\|raw` — list/read a local file's slides in play order without a host; use the printed numbers as `--pick` for `add-slide --from` |
|
|
84
|
+
| **fold a LOCAL slide into a deck** (`--from`, host-free) | `slidemind add-slide --from <file.pptx> --pick <n[,n,…]> --deck <deck> [--at N]` → same `decks/<slug>/slides/NN-reused-*.pptx` output as the engine path; `--from` replaces `--search`/`--to` (mutually exclusive with `--search`; no host). Live delivery = fold + `slidemind push <deck>` |
|
|
72
85
|
| **engine fetch** (reuse an image asset) | `slidemind download-result --search <searchId> --pick <n[,n,…]> --deck <deck> --to <searchWindow> [--icon-color <hex[,hex,…]>]` — writes into `decks/<slug>/assets/` (recolor descriptor + tinting are covered in `slidemind-create-slides`) |
|
|
73
86
|
|
|
74
87
|
Everything else in `workflow/search.md` — the engine-discovery discipline, content-vs-visual judgement, the picking and reuse craft — applies to you unchanged.
|
|
75
88
|
|
|
76
89
|
## The engines and the workflow both live in the bundle, not here
|
|
77
90
|
|
|
78
|
-
Do not restate which engines exist, what they contain, or any brand rule — the engine catalog comes from `slidemind ls engines` (
|
|
91
|
+
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.
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
} from "./bundle.mjs";
|
|
37
37
|
import { bundleHasCaptureEngine, captureDeckLocally } from "./capture.mjs";
|
|
38
38
|
import { slideOrderWarning, sortSlideFilenames } from "../lib/slide-order.mjs";
|
|
39
|
+
import { normalizePptxAppPropertiesBase64 } from "../pptx/app-props.mjs";
|
|
39
40
|
|
|
40
41
|
function gatherSlides(deckDir) {
|
|
41
42
|
const slidesDir = path.join(deckDir, "slides");
|
|
@@ -187,6 +188,16 @@ export async function renderDeckToBase64(
|
|
|
187
188
|
if (!result?.base64) {
|
|
188
189
|
throw new Error("render returned no pptx bytes");
|
|
189
190
|
}
|
|
191
|
+
const normalized = normalizePptxAppPropertiesBase64(result.base64);
|
|
192
|
+
if (normalized.changed) {
|
|
193
|
+
result = {
|
|
194
|
+
...result,
|
|
195
|
+
base64: normalized.base64,
|
|
196
|
+
slideCount: normalized.slideCount ?? result.slideCount,
|
|
197
|
+
};
|
|
198
|
+
} else if (normalized.slideCount !== null && result.slideCount == null) {
|
|
199
|
+
result = { ...result, slideCount: normalized.slideCount };
|
|
200
|
+
}
|
|
190
201
|
if (orderWarning) {
|
|
191
202
|
result.warnings = [orderWarning, ...(result.warnings ?? [])];
|
|
192
203
|
}
|
package/src/bridge/bundle.mjs
CHANGED
|
@@ -33,11 +33,13 @@
|
|
|
33
33
|
// ├── samples/* (downloaded from wire.samples[].url — brand reference slides)
|
|
34
34
|
// ├── fonts/* (downloaded from wire.fonts[].url, optional)
|
|
35
35
|
// ├── master/empty-layout.pptx (downloaded from wire.master.url, optional)
|
|
36
|
-
// ├── engines.json (wire.engines[]
|
|
36
|
+
// ├── engines.json (wire.engines[] — legacy: new backends dropped the
|
|
37
|
+
// │ field, so this is [] there; the live catalog via
|
|
38
|
+
// │ `slidemind ls engines` is the source of truth)
|
|
37
39
|
// ├── workflow/*.md (wire.workflowRefs — add-in-owned authoring craft)
|
|
38
40
|
// ├── lint-engine.mjs (wire.lintEngine.source — the add-in's slide-lint engine)
|
|
39
41
|
// ├── primitives.json (wire.lintEngine.primitives — closed component registry)
|
|
40
|
-
// └── bundle.json ({ presFP, version, etag, lintEngineVersion, tokenRoleMap, … })
|
|
42
|
+
// └── bundle.json ({ presFP, name, version, etag, lintEngineVersion, tokenRoleMap, … })
|
|
41
43
|
|
|
42
44
|
import fs from "node:fs";
|
|
43
45
|
import os from "node:os";
|
|
@@ -313,6 +315,9 @@ export async function materializeBundle(
|
|
|
313
315
|
const file = name.endsWith(".jsx") ? name : `${name}.jsx`;
|
|
314
316
|
writeText(path.join("layouts", path.basename(file)), layout.source);
|
|
315
317
|
}
|
|
318
|
+
// New backends dropped `engines` from the bundle wire (engine discovery is
|
|
319
|
+
// live-only now); keep writing the file so older-backend bundles and the
|
|
320
|
+
// pickImageEngine fast path stay intact, but expect [] going forward.
|
|
316
321
|
writeText("engines.json", JSON.stringify(wire.engines ?? [], null, 2));
|
|
317
322
|
|
|
318
323
|
// Brand-agnostic authoring workflow craft (create/edit/composition/layouts/
|
|
@@ -485,6 +490,11 @@ export async function materializeBundle(
|
|
|
485
490
|
// In the brand model presFP == brandId; recorded explicitly so consumers
|
|
486
491
|
// (and humans reading the cache) don't need to know that equivalence.
|
|
487
492
|
brandId: presFP,
|
|
493
|
+
// Live effective brand display name from the wire (owner-set / personal
|
|
494
|
+
// label). Preferred over the design.md H1, which is frozen at authoring
|
|
495
|
+
// time and keeps the auto codename across renames. Null on older backends
|
|
496
|
+
// that don't serve `name` yet.
|
|
497
|
+
name: typeof wire.name === "string" && wire.name.trim() ? wire.name.trim() : null,
|
|
488
498
|
version: wire.version ?? null,
|
|
489
499
|
etag: wire.etag ?? null,
|
|
490
500
|
scope: wire.scope ?? null,
|
|
@@ -646,10 +656,11 @@ export function loadEnginesForDeck(deckDir) {
|
|
|
646
656
|
const IMAGE_ENGINE_KIND_RE = /^(image|icon|photo|illustration|vector)$/i;
|
|
647
657
|
|
|
648
658
|
// True when an engine produces insertable image bytes. Gates on the engine's
|
|
649
|
-
// real `kind`
|
|
659
|
+
// real modality — `kind` for bundle engines.json entries, `subtype` for live
|
|
660
|
+
// `GET /api/cli/engines` catalog entries — NOT a guess over its
|
|
650
661
|
// label/description.
|
|
651
662
|
export function isImageEngine(engine) {
|
|
652
|
-
return IMAGE_ENGINE_KIND_RE.test(engine?.kind ?? "");
|
|
663
|
+
return IMAGE_ENGINE_KIND_RE.test(engine?.kind ?? engine?.subtype ?? "");
|
|
653
664
|
}
|
|
654
665
|
|
|
655
666
|
// Pick the default engine to search: the first image engine, by its real
|
package/src/bridge/capture.mjs
CHANGED
|
@@ -136,8 +136,17 @@ async function withBrowser(fn) {
|
|
|
136
136
|
return fn(null);
|
|
137
137
|
}
|
|
138
138
|
const puppeteer = (await import("puppeteer")).default;
|
|
139
|
+
// `shell` (chrome-headless-shell, same pinned version) instead of the full
|
|
140
|
+
// Chrome binary: on macOS, full Chrome registers with LaunchServices at
|
|
141
|
+
// startup (TransformProcessType → _RegisterApplication), which SIGABRTs —
|
|
142
|
+
// with a "Chrome quit unexpectedly" OS dialog — when the CLI runs inside an
|
|
143
|
+
// agent harness's seatbelt sandbox (Codex, Claude Code), and can hang the
|
|
144
|
+
// launch in others. headless-shell never touches HIServices. Blink/Skia and
|
|
145
|
+
// the bundle-shipped fonts are identical at the same pinned version, so text
|
|
146
|
+
// measurement parity with the add-in's renderer is preserved.
|
|
147
|
+
// SLIDEMIND_CAPTURE_FULL_CHROME=1 forces the full binary for A/B debugging.
|
|
139
148
|
const browser = await puppeteer.launch({
|
|
140
|
-
headless: true,
|
|
149
|
+
headless: process.env.SLIDEMIND_CAPTURE_FULL_CHROME === "1" ? true : "shell",
|
|
141
150
|
protocolTimeout: 600_000,
|
|
142
151
|
args: LAUNCH_ARGS,
|
|
143
152
|
});
|
|
@@ -148,7 +157,16 @@ async function withBrowser(fn) {
|
|
|
148
157
|
await browser.close().catch(() => {});
|
|
149
158
|
if (proc && proc.exitCode == null) {
|
|
150
159
|
await new Promise((resolve) => {
|
|
151
|
-
|
|
160
|
+
// Grace period, then SIGKILL — abandoning a wedged Chrome here leaves
|
|
161
|
+
// orphan processes behind after the CLI exits.
|
|
162
|
+
const timer = setTimeout(() => {
|
|
163
|
+
try {
|
|
164
|
+
proc.kill("SIGKILL");
|
|
165
|
+
} catch {
|
|
166
|
+
/* already gone */
|
|
167
|
+
}
|
|
168
|
+
resolve();
|
|
169
|
+
}, 5000);
|
|
152
170
|
proc.once("exit", () => {
|
|
153
171
|
clearTimeout(timer);
|
|
154
172
|
resolve();
|
package/src/bridge/client.mjs
CHANGED
|
@@ -789,6 +789,96 @@ export async function getDesignBundleForBrandAnyBackend(brandId) {
|
|
|
789
789
|
throw err;
|
|
790
790
|
}
|
|
791
791
|
|
|
792
|
+
// Concept-image generation is a heavy provider call (per-image cap ~180s
|
|
793
|
+
// server-side). Budget just over that so a typed server error wins over a
|
|
794
|
+
// client timeout, but keep it below the relay's ~240s ingress cap — the CLI
|
|
795
|
+
// batches per-slide so no single request approaches that ceiling.
|
|
796
|
+
function conceptTimeoutMs() {
|
|
797
|
+
return (
|
|
798
|
+
parseInt(process.env.SLIDEMIND_BRIDGE_CONCEPT_TIMEOUT_MS || "200000", 10) ||
|
|
799
|
+
200000
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function postConceptImages(
|
|
804
|
+
brandId,
|
|
805
|
+
{ planMarkdown, slideNumbers },
|
|
806
|
+
backendURL,
|
|
807
|
+
) {
|
|
808
|
+
const body = { planMarkdown };
|
|
809
|
+
if (Array.isArray(slideNumbers) && slideNumbers.length > 0) {
|
|
810
|
+
body.slideNumbers = slideNumbers;
|
|
811
|
+
}
|
|
812
|
+
const res = await bridgeFetch(
|
|
813
|
+
`/api/cli/brands/${encodeURIComponent(brandId)}/concept-images`,
|
|
814
|
+
{
|
|
815
|
+
method: "POST",
|
|
816
|
+
headers: { "content-type": "application/json" },
|
|
817
|
+
body: JSON.stringify(body),
|
|
818
|
+
timeoutMs: conceptTimeoutMs(),
|
|
819
|
+
},
|
|
820
|
+
backendURL,
|
|
821
|
+
);
|
|
822
|
+
const data = (await res.json()) ?? {};
|
|
823
|
+
// Tag the response with the backend that answered so a per-slide batch can
|
|
824
|
+
// pin subsequent calls to the same one (routing state only — never printed).
|
|
825
|
+
return { ...data, backendURL };
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// Generate concept mockup images for a brand over the pane-free brand route.
|
|
829
|
+
// Body `{ planMarkdown, slideNumbers? }`; the optional `slideNumbers` filters
|
|
830
|
+
// which storyboard beats are rendered (the CLI batches per-slide). Returns the
|
|
831
|
+
// server's `{ ok, images, slideCount }` augmented with the resolved
|
|
832
|
+
// `backendURL`. When `backendURL` is given it posts directly; otherwise it
|
|
833
|
+
// fans out — like getDesignBundleForBrandAnyBackend, presFP/brandId is globally
|
|
834
|
+
// unique so at most one paired backend answers 2xx. Throws a typed
|
|
835
|
+
// `concept_route_unavailable` when no backend serves the brand (old add-ins,
|
|
836
|
+
// unknown brand, missing grant) so callers can degrade to "continue without".
|
|
837
|
+
export async function generateConceptImagesForBrand(
|
|
838
|
+
brandId,
|
|
839
|
+
{ planMarkdown, slideNumbers = null, backendURL = null } = {},
|
|
840
|
+
) {
|
|
841
|
+
if (!brandId) {
|
|
842
|
+
throw new Error("generateConceptImagesForBrand: brandId is required");
|
|
843
|
+
}
|
|
844
|
+
if (typeof planMarkdown !== "string" || !planMarkdown.trim()) {
|
|
845
|
+
throw new Error("generateConceptImagesForBrand: planMarkdown is required");
|
|
846
|
+
}
|
|
847
|
+
if (backendURL) {
|
|
848
|
+
return postConceptImages(brandId, { planMarkdown, slideNumbers }, backendURL);
|
|
849
|
+
}
|
|
850
|
+
const sessions = sessionsInScope();
|
|
851
|
+
const failures = [];
|
|
852
|
+
for (const s of sessions) {
|
|
853
|
+
try {
|
|
854
|
+
if (!(await backendSupportsConceptImages(s.backendURL))) {
|
|
855
|
+
failures.push({
|
|
856
|
+
env: deriveEnvLabel(s.backendURL),
|
|
857
|
+
reason: "no concept-images route",
|
|
858
|
+
});
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
return await postConceptImages(
|
|
862
|
+
brandId,
|
|
863
|
+
{ planMarkdown, slideNumbers },
|
|
864
|
+
s.backendURL,
|
|
865
|
+
);
|
|
866
|
+
} catch (err) {
|
|
867
|
+
failures.push({
|
|
868
|
+
env: deriveEnvLabel(s.backendURL),
|
|
869
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
|
|
874
|
+
const err = new Error(
|
|
875
|
+
`no paired backend generates concept mockups for design '${brandId}'` +
|
|
876
|
+
`${detail ? ` (${detail})` : ""}.`,
|
|
877
|
+
);
|
|
878
|
+
err.code = "concept_route_unavailable";
|
|
879
|
+
throw err;
|
|
880
|
+
}
|
|
881
|
+
|
|
792
882
|
// List the LIVE engine catalog (NOT the bundle's curated set) for the paired
|
|
793
883
|
// backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
|
|
794
884
|
// when several environments are paired this fans out and tags each engine with
|
|
@@ -905,6 +995,15 @@ export async function backendSupportsBrandRoutes(backendURL) {
|
|
|
905
995
|
return caps?.brandRoutes === true;
|
|
906
996
|
}
|
|
907
997
|
|
|
998
|
+
// Whether a backend generates concept mockup images for a brand over the
|
|
999
|
+
// pane-free `/api/cli/brands/:brandId/concept-images` route (see
|
|
1000
|
+
// generateConceptImagesForBrand). Advertised by newer add-ins only; older ones
|
|
1001
|
+
// omit the flag and the caller degrades to "continue without".
|
|
1002
|
+
export async function backendSupportsConceptImages(backendURL) {
|
|
1003
|
+
const caps = await backendCapabilities(backendURL);
|
|
1004
|
+
return caps?.conceptImages === true;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
908
1007
|
// The add-in's hard version floor: /api/cli/capabilities advertises the oldest
|
|
909
1008
|
// CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
|
|
910
1009
|
// mid-rollout skew otherwise surfaces as confusing route/contract errors deep
|
package/src/builders/build.mjs
CHANGED
|
@@ -945,31 +945,61 @@ function openerChain(target) {
|
|
|
945
945
|
return [{ cmd: "xdg-open", args: [target] }];
|
|
946
946
|
}
|
|
947
947
|
|
|
948
|
+
// How long to give a platform opener to exit before assuming it handed the URL
|
|
949
|
+
// off successfully. `open`/`xdg-open`/`start` all exit within milliseconds; an
|
|
950
|
+
// opener still running after this window is treated as launched, not failed.
|
|
951
|
+
const OPENER_EXIT_GRACE_MS = 2000;
|
|
952
|
+
|
|
948
953
|
// Open a local file path OR an http URL in the user's default browser, trying
|
|
949
|
-
// each platform opener in turn.
|
|
950
|
-
//
|
|
951
|
-
//
|
|
952
|
-
//
|
|
953
|
-
//
|
|
954
|
-
|
|
954
|
+
// each platform opener in turn. Must NEVER crash: if the opener binary is
|
|
955
|
+
// missing (e.g. a headless Linux box with no xdg-open) the spawned child would
|
|
956
|
+
// otherwise emit an unhandled 'error' and take down the whole CLI.
|
|
957
|
+
//
|
|
958
|
+
// Resolves `true` when an opener plausibly launched (exit 0, or still running
|
|
959
|
+
// after the grace window), `false` when the whole chain failed — missing
|
|
960
|
+
// binaries AND nonzero exits both advance the chain, so a present-but-broken
|
|
961
|
+
// `xdg-open` (headless box, no browser) is reported as a failure instead of
|
|
962
|
+
// silently swallowed. Never rejects. On `false` the target is printed for the
|
|
963
|
+
// user to open manually unless `quiet` — callers with their own messaging (the
|
|
964
|
+
// review/plan gates) pass quiet:true and speak for themselves.
|
|
965
|
+
export function openInBrowser(target, { quiet = false } = {}) {
|
|
955
966
|
const chain = openerChain(target);
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
967
|
+
return new Promise((resolve) => {
|
|
968
|
+
const tryAt = (i) => {
|
|
969
|
+
if (i >= chain.length) {
|
|
970
|
+
if (!quiet) {
|
|
971
|
+
process.stderr.write(`couldn't open a browser — open ${target} manually\n`);
|
|
972
|
+
}
|
|
973
|
+
resolve(false);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
const { cmd, args } = chain[i];
|
|
977
|
+
let child;
|
|
978
|
+
try {
|
|
979
|
+
child = spawn(cmd, args, { stdio: "ignore", detached: true });
|
|
980
|
+
} catch {
|
|
981
|
+
tryAt(i + 1);
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
let settled = false;
|
|
985
|
+
const settle = (ok) => {
|
|
986
|
+
if (settled) return;
|
|
987
|
+
settled = true;
|
|
988
|
+
clearTimeout(timer);
|
|
989
|
+
if (ok) resolve(true);
|
|
990
|
+
else tryAt(i + 1);
|
|
991
|
+
};
|
|
992
|
+
// Still running after the grace window ⇒ it took the URL; don't hold the
|
|
993
|
+
// caller hostage to an opener that lingers. unref'd so it can't keep the
|
|
994
|
+
// process alive either way.
|
|
995
|
+
const timer = setTimeout(() => settle(true), OPENER_EXIT_GRACE_MS);
|
|
996
|
+
if (timer.unref) timer.unref();
|
|
997
|
+
child.on("error", () => settle(false));
|
|
998
|
+
child.on("exit", (code) => settle(code === 0));
|
|
999
|
+
child.unref();
|
|
1000
|
+
};
|
|
1001
|
+
tryAt(0);
|
|
1002
|
+
});
|
|
973
1003
|
}
|
|
974
1004
|
|
|
975
1005
|
function parseArgs(argv) {
|