slidemind 0.3.5 → 0.3.13

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.
@@ -142,10 +142,14 @@ Follow `workflow/search.md` for the discipline; this is the CLI shape of its loo
142
142
  4. **Read / view, then pick.** For **content** results, read the per-slide **text + score** the
143
143
  search prints — that is the on-the-wire signal; you usually don't need the picture. For
144
144
  **visual** results, scan the numbered thumbnail list; when the search prints a **contact sheet**
145
- path, Read that ONE file — every candidate is on it with its selection number burned in — and
146
- when it says there is none on this path, pull individual previews with `slidemind view-slide
147
- --search <searchId> --pick <n>` (it writes one result's preview to a file you can Read), or
148
- `slidemind search … --json` for the structured payload. Pick by **selection number(s)**.
145
+ path, Read that ONE file — every candidate is on it with its selection number burned in. With no
146
+ contact sheet, previews depend on the result type: **slide-library results** support `slidemind
147
+ view-slide --search <searchId> --pick <n>` (writes one result's preview to a file you can Read);
148
+ **image/icon results have NO preview on this path** — `view-slide` answers "no image returned"
149
+ for them, so never call it on an image search, and never retry it hoping for a different answer.
150
+ For icons, pick by title + score, `download-result` the pick (downloads are cheap), Read the
151
+ saved file, and re-search once if it was wrong — two failed downloads for the same need means
152
+ mark it unresolved and move on. Pick by **selection number(s)**.
149
153
  5. **Read the source deck when the story matters.** A found slide came out of a real presentation,
150
154
  and often the answer the user needs is in the *rest* of it — how the slide was framed, what came
151
155
  before and after, whether that deck already covers more of the ask. `slidemind read-source
@@ -237,7 +241,7 @@ insert live, fold into a deck, engine fetch). You are the local harness — this
237
241
  | **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 never needed. No window needed where the add-in serves the pane-free routes. |
238
242
  | **engine search** (with `intent`) | `slidemind search "<query>" --engine <id> --intent <auto\|content\|visual> [--top N] [--deck <deck>]` — leave `--to` off → a `searchId` + numbered results, plus the exact follow-up commands for the path it took (`--deck` routes by that deck's design when several environments are connected) |
239
243
  | **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. |
240
- | **pull an on-demand thumbnail** for a stored result | `slidemind view-slide --search <searchId> --pick <n>` — writes one result's preview image to a file (add `--to <searchWindow>` only on the relayed path) · or `slidemind search … --json` for the structured payload. |
244
+ | **pull an on-demand thumbnail** for a stored result | `slidemind view-slide --search <searchId> --pick <n>` — SLIDE-library results only; image/icon results have no preview ("no image returned") — download and Read the file instead (add `--to <searchWindow>` only on the relayed path) · or `slidemind search … --json` for the structured payload. |
241
245
  | **read a result's SOURCE deck** (the presentation it came from) | `slidemind read-source --search <searchId> --pick <n> [--from-slide N] [--to-slide N] [--out <file.md>]` — Markdown of the whole source presentation, one section per slide (add `--to <searchWindow>` only on the relayed path). Indexed text, not the live file. Slide results only. |
242
246
  | **insert live** (reuse a slide → the open window) | `slidemind insert-slide --search <searchId> --pick <n[,n,…]> --to <presId> [--at N]` — **host-required**: the slide lands in that open window |
243
247
  | **fold into a deck** (reuse a slide → deck source) | `slidemind add-slide --search <searchId> --pick <n[,n,…]> --deck <deck> [--at N] [--json]` → writes `decks/<slug>/slides/NN-reused-*.pptx` (add `--to <searchWindow>` only on the relayed path, where extraction runs on the search's window) |
@@ -273,10 +273,10 @@ export async function renderDeckToBase64(
273
273
  // server splices them back from. Keyed by slide file name, like `slides`.
274
274
  const conversions = loadConversions(deckDir);
275
275
  const hasConversions = Object.keys(conversions).length > 0;
276
- for (const file of conversionsMissingPreserveSource(conversions)) {
276
+ for (const file of conversionsMissingPreserveSource(conversions, deckDir)) {
277
277
  process.stderr.write(
278
- `note: ${file} has preserved native objects but no conversions/*.slide.pptx ` +
279
- `to splice them from — re-run 'slidemind adopt' for that deck\n`,
278
+ `note: ${file} has preserved native objects or a sourced chart but no ` +
279
+ `conversions/*.slide.pptx to read them from — re-run 'slidemind adopt' for that deck\n`,
280
280
  );
281
281
  }
282
282
 
@@ -30,7 +30,6 @@
30
30
  // ├── assets/brand-images.md (on-demand manifest of the reusable master images,
31
31
  // │ built from wire.extractedImages — design.md only
32
32
  // │ carries a sparse pointer + per-kind counts)
33
- // ├── samples/* (downloaded from wire.samples[].url — brand reference slides)
34
33
  // ├── fonts/* (downloaded from wire.fonts[].url, optional)
35
34
  // ├── master/empty-layout.pptx (downloaded from wire.master.url, optional)
36
35
  // ├── engines.json (wire.engines[] — legacy: new backends dropped the
@@ -87,6 +86,33 @@ export function readMaterializedBundleMeta(presFP) {
87
86
  }
88
87
  }
89
88
 
89
+ // Every design materialized on this machine: one entry per cache dir that
90
+ // carries a readable bundle.json. `ready` reports these so the agent can ask
91
+ // the user WHICH design to build against when more than one is at hand —
92
+ // no network, never throws.
93
+ export function listCachedBundles() {
94
+ const root = bundleCacheRoot();
95
+ let entries = [];
96
+ try {
97
+ entries = fs.readdirSync(root, { withFileTypes: true });
98
+ } catch {
99
+ return [];
100
+ }
101
+ const out = [];
102
+ for (const entry of entries) {
103
+ if (!entry.isDirectory()) continue;
104
+ const dir = path.join(root, entry.name);
105
+ try {
106
+ const meta = JSON.parse(fs.readFileSync(path.join(dir, "bundle.json"), "utf8"));
107
+ const presFP = meta?.presFP ?? entry.name;
108
+ out.push({ presFP, dir, meta });
109
+ } catch {
110
+ /* half-written or foreign dir — not a design */
111
+ }
112
+ }
113
+ return out;
114
+ }
115
+
90
116
  // Wipe the whole materialized bundle cache so the next build/push/download
91
117
  // re-fetches every design bundle fresh from the add-in. `slidemind init` calls
92
118
  // this: init is the deliberate (re)setup point — and `slidemind update`
@@ -164,22 +190,6 @@ async function downloadInto(targetPath, url) {
164
190
  fs.writeFileSync(targetPath, buf);
165
191
  }
166
192
 
167
- // File extension for a sample image, derived from its wire `contentType`. The
168
- // sample's stable `id` is the basename; samples carry no `filename` on the wire.
169
- function sampleExt(contentType) {
170
- switch (contentType) {
171
- case "image/jpeg":
172
- return ".jpg";
173
- case "image/webp":
174
- return ".webp";
175
- case "image/svg+xml":
176
- return ".svg";
177
- case "image/png":
178
- default:
179
- return ".png";
180
- }
181
- }
182
-
183
193
  const REUSABLE_KIND_ORDER = [
184
194
  "logo",
185
195
  "icon",
@@ -235,11 +245,11 @@ function buildReusableImagesManifest(extractedImages) {
235
245
 
236
246
  // Sync the bundle for `presId`. Idempotent: every call rewrites all inline
237
247
  // text from the freshly fetched wire (the wire is fetched on every call
238
- // anyway), downloads only binaries whose per-item blob etag changed (samples
239
- // are write-once by id), and prunes files the wire no longer serves. There is
248
+ // anyway), downloads only binaries whose per-item blob etag changed, and
249
+ // prunes files the wire no longer serves. There is
240
250
  // deliberately NO staleness token to compare — the materialized dir is always
241
251
  // a per-item projection of the last wire, so authored-layer edits that bypass
242
- // a bundle re-produce (engines, image-source guidelines, sample curation) and
252
+ // a bundle re-produce (engines, image-source guidelines) and
243
253
  // deployment-owned content (workflow refs, the lint engine) can never go
244
254
  // stale here. `force` re-downloads binaries even when their etags match.
245
255
  // Target: `presId` (pane registry), or `brandId` in options for the pane-free
@@ -376,7 +386,7 @@ export async function materializeBundle(
376
386
  return;
377
387
  }
378
388
  if (!force && exists && !etag) {
379
- return; // write-once binary (samples carry no etag) — presence is currency
389
+ return; // write-once binary without an etag — presence is currency
380
390
  }
381
391
  downloads.push({ rel, target, url, etag: etag ?? null, label });
382
392
  };
@@ -408,21 +418,6 @@ export async function materializeBundle(
408
418
  "master empty-layout.pptx",
409
419
  );
410
420
  }
411
- // Per-CD sample slides (the brand's own reference slides) — write-once
412
- // captures keyed by the sample's stable `id`; extension derived from
413
- // `contentType`.
414
- for (const sample of wire.samples ?? []) {
415
- if (!sample?.url || !sample?.id) continue;
416
- queueBinary(
417
- path.join(
418
- "samples",
419
- `${path.basename(sample.id)}${sampleExt(sample.contentType)}`,
420
- ),
421
- sample.url,
422
- null,
423
- `sample ${sample.id}`,
424
- );
425
- }
426
421
  // Reusable master images (logos, decorations, illustrations the add-in's
427
422
  // vision pass lifted from the brand's own master and flagged `reusable`).
428
423
  // Each carries a `workspacePath` (e.g. `assets/master-<sha8>.<ext>`) that the
@@ -522,6 +517,21 @@ export async function materializeBundle(
522
517
  role: f.role,
523
518
  family: f.family,
524
519
  filename: path.basename(f.filename),
520
+ // Face descriptors (multi-face families: Regular/Bold/Italic/…).
521
+ // Without them every face of a family registers descriptor-less and
522
+ // the last one wins for ALL weights — bold measured with the regular
523
+ // face. Older backends omit them; default to a plain 400/normal face.
524
+ weight:
525
+ typeof f.weight === "number" && f.weight >= 100 && f.weight <= 900
526
+ ? f.weight
527
+ : 400,
528
+ style: f.style === "italic" ? "italic" : "normal",
529
+ variable:
530
+ f.variable &&
531
+ typeof f.variable.minWeight === "number" &&
532
+ typeof f.variable.maxWeight === "number"
533
+ ? { minWeight: f.variable.minWeight, maxWeight: f.variable.maxWeight }
534
+ : null,
525
535
  })),
526
536
  binaries,
527
537
  fetchedAt: new Date().toISOString(),
@@ -529,9 +539,13 @@ export async function materializeBundle(
529
539
  };
530
540
  fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf8");
531
541
 
542
+ // Last, after every write AND the prune — the overlay must win over the wire.
543
+ const overlaid = applyBundleOverlay(dir);
544
+
532
545
  return {
533
546
  dir,
534
547
  presFP,
548
+ overlaid,
535
549
  etag: wire.etag ?? null,
536
550
  version: wire.version ?? null,
537
551
  // Which backend actually served the wire — meaningful on the brandId
@@ -545,6 +559,47 @@ export async function materializeBundle(
545
559
  };
546
560
  }
547
561
 
562
+ // Copy `$SLIDEMIND_BUNDLE_OVERLAY/**` over a materialized bundle. Experiment-only
563
+ // hook for the skill-optimization loop (LOOP_PLAN.md): it lets a run vary the
564
+ // add-in-owned workflow/design docs without a deploy. Deliberately undocumented
565
+ // in user-facing help. The wire re-syncs every text file on EVERY materialize
566
+ // (and the prune deletes anything it doesn't own), so this re-applies each time.
567
+ export function applyBundleOverlay(dir) {
568
+ const overlay = process.env.SLIDEMIND_BUNDLE_OVERLAY;
569
+ if (!overlay) return 0;
570
+ let copied = 0;
571
+ try {
572
+ if (!fs.statSync(overlay).isDirectory()) return 0;
573
+ fs.cpSync(overlay, dir, {
574
+ recursive: true,
575
+ force: true,
576
+ // Docs only (.md/.css): the bundle dir also holds lint-engine.mjs and
577
+ // capture-engine.mjs, which the CLI dynamic-imports — an overlay must
578
+ // never be able to swap executable code, and text files re-sync from the
579
+ // wire on every materialize so a doc overlay can't persist past the
580
+ // experiment (binaries would).
581
+ filter: (src) => {
582
+ let st;
583
+ try {
584
+ st = fs.lstatSync(src);
585
+ } catch {
586
+ return false; // dangling symlink — skip, never fail the materialize
587
+ }
588
+ if (st.isDirectory()) return true;
589
+ if (!/\.(md|css)$/i.test(src)) return false;
590
+ copied += 1;
591
+ return true;
592
+ },
593
+ });
594
+ } catch (err) {
595
+ // The overlay is an experiment convenience — a bad overlay must not break
596
+ // ready/build/push after an otherwise successful sync.
597
+ process.stderr.write(`bundle overlay skipped: ${err.message}\n`);
598
+ return 0;
599
+ }
600
+ return copied;
601
+ }
602
+
548
603
  // Delete files the current sync doesn't own and collapse empty directories.
549
604
  // `expected` holds dir-relative paths exactly as `path.join` built them, so
550
605
  // the comparison is platform-consistent.
@@ -114,6 +114,12 @@ export function buildCaptureInput(deckDir, bundleDir) {
114
114
  family: f.family,
115
115
  filename: path.basename(f.filename),
116
116
  bytesB64: fs.readFileSync(fontPath).toString("base64"),
117
+ // Face descriptors — the capture engine emits per-face @font-face
118
+ // weight/style rules from these (engine defaults 400/normal when a
119
+ // pre-descriptor meta.json omits them).
120
+ weight: f.weight,
121
+ style: f.style,
122
+ variable: f.variable ?? null,
117
123
  });
118
124
  }
119
125
 
@@ -139,8 +145,9 @@ export function buildCaptureInput(deckDir, bundleDir) {
139
145
  }
140
146
 
141
147
  // Launch a headless Chrome, run `fn`, then tear it down — mirrors the add-in's
142
- // withBrowser (waits for the process to fully exit before cleanup).
143
- async function withBrowser(fn) {
148
+ // withBrowser (waits for the process to fully exit before cleanup). Exported
149
+ // for the draft-check grid overlay, which reuses the same pinned Chrome.
150
+ export async function withBrowser(fn) {
144
151
  // Test hook: run the engine with no real browser. Only safe with a fake
145
152
  // engine that ignores its `browser` argument — never set in production.
146
153
  if (process.env.SLIDEMIND_CAPTURE_BROWSERLESS === "1") {
@@ -156,11 +163,25 @@ async function withBrowser(fn) {
156
163
  // the bundle-shipped fonts are identical at the same pinned version, so text
157
164
  // measurement parity with the add-in's renderer is preserved.
158
165
  // SLIDEMIND_CAPTURE_FULL_CHROME=1 forces the full binary for A/B debugging.
159
- const browser = await puppeteer.launch({
160
- headless: process.env.SLIDEMIND_CAPTURE_FULL_CHROME === "1" ? true : "shell",
161
- protocolTimeout: 600_000,
162
- args: LAUNCH_ARGS,
163
- });
166
+ const launch = (args) =>
167
+ puppeteer.launch({
168
+ headless: process.env.SLIDEMIND_CAPTURE_FULL_CHROME === "1" ? true : "shell",
169
+ protocolTimeout: 600_000,
170
+ args,
171
+ });
172
+ let browser;
173
+ try {
174
+ browser = await launch(LAUNCH_ARGS);
175
+ } catch (err) {
176
+ // Codex's workspace-write seatbelt denies the mach-port rendezvous the
177
+ // browser process uses to hand off to its renderer ("bootstrap_check_in
178
+ // … Permission denied"), so a multi-process launch dies before the first
179
+ // page. Single-process keeps the renderer in-process — no rendezvous —
180
+ // and paints identically (same Blink/Skia, same fonts). Retried only on a
181
+ // launch failure, so every other environment keeps the stock launch.
182
+ if (!/Failed to launch the browser process/i.test(String(err?.message ?? err))) throw err;
183
+ browser = await launch([...LAUNCH_ARGS, "--single-process", "--no-zygote"]);
184
+ }
164
185
  try {
165
186
  return await fn(browser);
166
187
  } finally {
@@ -193,7 +214,7 @@ async function withBrowser(fn) {
193
214
  * Throws if the engine is missing/corrupt or the local browser can't launch —
194
215
  * the caller decides whether to fall back to the server render path.
195
216
  */
196
- export async function captureDeckLocally({ deckDir, bundleDir, onProgress }) {
217
+ export async function captureDeckLocally({ deckDir, bundleDir, onProgress, only = null }) {
197
218
  const enginePath = path.join(bundleDir, "capture-engine.mjs");
198
219
  if (!fs.existsSync(enginePath)) {
199
220
  throw new Error(`no capture engine in bundle ${bundleDir}`);
@@ -212,5 +233,62 @@ export async function captureDeckLocally({ deckDir, bundleDir, onProgress }) {
212
233
  }
213
234
 
214
235
  const input = buildCaptureInput(deckDir, bundleDir);
236
+ // `only`: capture a subset of the authored slides (a per-slide builder
237
+ // re-checking its own file) — the engine renders each slide on its own, so
238
+ // dropping the others from the input is exact, not an approximation.
239
+ if (Array.isArray(only) && only.length > 0) {
240
+ const keep = new Set(only);
241
+ for (const name of Object.keys(input.slides)) {
242
+ if (!keep.has(name)) delete input.slides[name];
243
+ }
244
+ }
215
245
  return withBrowser((browser) => engine.captureDeck(browser, input, { onProgress }));
216
246
  }
247
+
248
+ /**
249
+ * The `<Chart>` stand-in runtime the add-in exports from the SAME artifact
250
+ * (`chartPreview: { scripts: [{ filename, source }], tokensCss }`), for the
251
+ * host-free HTML preview (`slidemind build`, and the review gate that serves
252
+ * it). The CLI ships no chart source: the page inlines these before any slide
253
+ * is evaluated, exactly where the add-in's own page loads them.
254
+ *
255
+ * null when the bundle has no engine or the engine predates charts. That is
256
+ * never a silent degradation: such a bundle's lint engine (same deployment)
257
+ * rejects `<Chart>` as an unknown component before a page is ever built.
258
+ */
259
+ /**
260
+ * The exporter's connector geometry (`connector-derive`) as a browser script
261
+ * exported from the same artifact (`connectorPreview: { globalName, source }`).
262
+ * The HTML preview's connector overlay calls it on the measured DOM rects, so
263
+ * the preview draws the route and arrowhead the .pptx will have. The CLI ships
264
+ * no connector geometry of its own. null when the bundle has no engine or the
265
+ * engine predates the export (the overlay then draws nothing and says so).
266
+ */
267
+ export async function loadConnectorPreview(bundleDir) {
268
+ if (!bundleDir || !bundleHasCaptureEngine(bundleDir)) return null;
269
+ const enginePath = path.join(bundleDir, "capture-engine.mjs");
270
+ const version = readBundleMeta(bundleDir).captureEngineVersion ?? "0";
271
+ const engine = await import(
272
+ `${pathToFileURL(enginePath).href}?v=${encodeURIComponent(version)}`
273
+ );
274
+ const preview = engine.connectorPreview;
275
+ if (!preview || typeof preview.source !== "string") return null;
276
+ return { globalName: String(preview.globalName || "SlidemindConnectorDerive"), source: preview.source };
277
+ }
278
+
279
+ export async function loadChartPreview(bundleDir) {
280
+ if (!bundleDir || !bundleHasCaptureEngine(bundleDir)) return null;
281
+ const enginePath = path.join(bundleDir, "capture-engine.mjs");
282
+ const version = readBundleMeta(bundleDir).captureEngineVersion ?? "0";
283
+ const engine = await import(
284
+ `${pathToFileURL(enginePath).href}?v=${encodeURIComponent(version)}`
285
+ );
286
+ const preview = engine.chartPreview;
287
+ if (!preview || !Array.isArray(preview.scripts)) return null;
288
+ return {
289
+ scripts: preview.scripts.filter(
290
+ (s) => s && typeof s.filename === "string" && typeof s.source === "string",
291
+ ),
292
+ tokensCss: typeof preview.tokensCss === "string" ? preview.tokensCss : "",
293
+ };
294
+ }
@@ -164,9 +164,15 @@ function transientRetryDelays() {
164
164
  // information) from "this session is dead" (the user's problem) — see
165
165
  // resolveSearchBackend.
166
166
  function sessionInvalidError() {
167
+ // Deliberately does NOT say `logout --all`: a 401 means the add-in answered
168
+ // "no such session" (revoked in Settings → Connect CLI, or expired). Running
169
+ // `setup` with a fresh command replaces this backend's record in place, so
170
+ // nothing has to be wiped first — and a user with two environments who wipes
171
+ // everything on every 401 is how the 2026-08-27 reconnect loop happened.
167
172
  const err = new Error(
168
- "slidemind session is invalid or expired. Run `slidemind logout --all`, then " +
169
- "reconnect with a fresh command from the add-in's Connect CLI panel.",
173
+ "slidemind session is invalid or expired: the add-in no longer knows this session " +
174
+ "(revoked under Settings → General → Connect CLI, or expired). Reconnect with a fresh " +
175
+ "command from that panel; other environments and your Microsoft 365 sign-in stay as they are.",
170
176
  );
171
177
  err.status = 401;
172
178
  err.code = "session_invalid";
@@ -191,13 +197,13 @@ function isTransientBridgeError(err) {
191
197
  return /relay unreachable/i.test(String(err.message || ""));
192
198
  }
193
199
 
194
- async function withTransientRetry(fn) {
200
+ async function withTransientRetry(fn, isRetryable = isTransientBridgeError) {
195
201
  const delays = transientRetryDelays();
196
202
  for (let attempt = 0; ; attempt += 1) {
197
203
  try {
198
204
  return await fn();
199
205
  } catch (err) {
200
- if (attempt >= delays.length || !isTransientBridgeError(err)) throw err;
206
+ if (attempt >= delays.length || !isRetryable(err)) throw err;
201
207
  await new Promise((resolve) => setTimeout(resolve, delays[attempt]));
202
208
  }
203
209
  }
@@ -531,10 +537,18 @@ async function listPresentationsForBackend(backendURL) {
531
537
  async function readinessForBackend(backendURL) {
532
538
  const env = deriveEnvLabel(backendURL);
533
539
  try {
534
- const res = await bridgeFetch(
535
- "/api/cli/readiness",
536
- { timeoutMs: listTimeoutMs() },
537
- backendURL,
540
+ // Retry only a 503: the add-in answers that (code `backend_unavailable`)
541
+ // while its session store cannot respond, and a 404 here means an older
542
+ // add-in without the readiness route, which the fallback below handles
543
+ // without waiting out the retry delays.
544
+ const res = await withTransientRetry(
545
+ () =>
546
+ bridgeFetch(
547
+ "/api/cli/readiness",
548
+ { timeoutMs: listTimeoutMs() },
549
+ backendURL,
550
+ ),
551
+ (err) => err?.status === 503 && err?.code !== "pane_suspended",
538
552
  );
539
553
  const data = (await res.json()) ?? {};
540
554
  const presentations = Array.isArray(data.presentations)
@@ -591,6 +605,12 @@ export async function connectionStatus() {
591
605
  backendURL,
592
606
  ok: false,
593
607
  error: r.reason?.message ? r.reason.message : String(r.reason),
608
+ // Machine code of the failure (`backend_unavailable`, `session_invalid`,
609
+ // …) so the readiness ladder can tell "retry shortly" from "re-pair".
610
+ code: typeof r.reason?.code === "string" ? r.reason.code : null,
611
+ // The HTTP status too: a proxy's 503 carries no machine code (or only a
612
+ // prose `error`), and the ladder must still read it as "wait".
613
+ status: typeof r.reason?.status === "number" ? r.reason.status : null,
594
614
  presentations: [],
595
615
  };
596
616
  });
@@ -1112,13 +1132,19 @@ function conceptTimeoutMs() {
1112
1132
 
1113
1133
  async function postConceptImages(
1114
1134
  brandId,
1115
- { planMarkdown, slideNumbers },
1135
+ { planMarkdown, slideNumbers, references },
1116
1136
  backendURL,
1117
1137
  ) {
1118
1138
  const body = { planMarkdown };
1119
1139
  if (Array.isArray(slideNumbers) && slideNumbers.length > 0) {
1120
1140
  body.slideNumbers = slideNumbers;
1121
1141
  }
1142
+ // The user's own images, inline: `{ role: "concept" | "style", image:
1143
+ // <data URI>, slides?: number[], name? }`. A concept replaces the generated
1144
+ // mockup for its slides; a style reference leads the generation.
1145
+ if (Array.isArray(references) && references.length > 0) {
1146
+ body.references = references;
1147
+ }
1122
1148
  const res = await bridgeFetch(
1123
1149
  `/api/cli/brands/${encodeURIComponent(brandId)}/concept-images`,
1124
1150
  {
@@ -1136,8 +1162,9 @@ async function postConceptImages(
1136
1162
  }
1137
1163
 
1138
1164
  // Generate concept mockup images for a brand over the pane-free brand route.
1139
- // Body `{ planMarkdown, slideNumbers? }`; the optional `slideNumbers` filters
1140
- // which storyboard beats are rendered (the CLI batches per-slide). Returns the
1165
+ // Body `{ planMarkdown, slideNumbers?, references? }`; the optional `slideNumbers`
1166
+ // filters which storyboard beats are rendered (the CLI batches per-slide);
1167
+ // `references` carries the user's own concept/style images inline. Returns the
1141
1168
  // server's `{ ok, images, slideCount }` augmented with the resolved
1142
1169
  // `backendURL`. When `backendURL` is given it posts directly; otherwise it
1143
1170
  // fans out — like getDesignBundleForBrandAnyBackend, presFP/brandId is globally
@@ -1146,7 +1173,7 @@ async function postConceptImages(
1146
1173
  // unknown brand, missing grant) so callers can degrade to "continue without".
1147
1174
  export async function generateConceptImagesForBrand(
1148
1175
  brandId,
1149
- { planMarkdown, slideNumbers = null, backendURL = null } = {},
1176
+ { planMarkdown, slideNumbers = null, references = null, backendURL = null } = {},
1150
1177
  ) {
1151
1178
  if (!brandId) {
1152
1179
  throw new Error("generateConceptImagesForBrand: brandId is required");
@@ -1155,7 +1182,11 @@ export async function generateConceptImagesForBrand(
1155
1182
  throw new Error("generateConceptImagesForBrand: planMarkdown is required");
1156
1183
  }
1157
1184
  if (backendURL) {
1158
- return postConceptImages(brandId, { planMarkdown, slideNumbers }, backendURL);
1185
+ return postConceptImages(
1186
+ brandId,
1187
+ { planMarkdown, slideNumbers, references },
1188
+ backendURL,
1189
+ );
1159
1190
  }
1160
1191
  const sessions = sessionsInScope();
1161
1192
  const failures = [];
@@ -1170,7 +1201,7 @@ export async function generateConceptImagesForBrand(
1170
1201
  }
1171
1202
  return await postConceptImages(
1172
1203
  brandId,
1173
- { planMarkdown, slideNumbers },
1204
+ { planMarkdown, slideNumbers, references },
1174
1205
  s.backendURL,
1175
1206
  );
1176
1207
  } catch (err) {
@@ -1214,15 +1245,15 @@ async function postGenerateImage(brandId, body, backendURL) {
1214
1245
  }
1215
1246
 
1216
1247
  // Generate ONE regular image over the pane-free brand route
1217
- // (`slidemind imagen`). Body `{ prompt, size?, quality?, brandStyle? }`;
1218
- // returns the server's `{ ok, image, styleReferences }` augmented with the
1248
+ // (`slidemind imagen`). Body `{ prompt, size?, quality? }`;
1249
+ // returns the server's `{ ok, image }` augmented with the
1219
1250
  // resolved `backendURL`. Fan-out and degradation mirror
1220
1251
  // generateConceptImagesForBrand: throws a typed `imagen_route_unavailable`
1221
1252
  // when no paired backend serves the route (older add-ins, unknown brand,
1222
1253
  // missing grant) so callers can print the one actionable fix.
1223
1254
  export async function generateImageForBrand(
1224
1255
  brandId,
1225
- { prompt, size = null, quality = null, brandStyle = false, backendURL = null } = {},
1256
+ { prompt, size = null, quality = null, backendURL = null } = {},
1226
1257
  ) {
1227
1258
  if (!brandId) {
1228
1259
  throw new Error("generateImageForBrand: brandId is required");
@@ -1233,7 +1264,6 @@ export async function generateImageForBrand(
1233
1264
  const body = { prompt };
1234
1265
  if (size) body.size = size;
1235
1266
  if (quality) body.quality = quality;
1236
- if (brandStyle) body.brandStyle = true;
1237
1267
  if (backendURL) {
1238
1268
  return postGenerateImage(brandId, body, backendURL);
1239
1269
  }
@@ -3316,20 +3346,26 @@ export function resolveDesignSource(
3316
3346
  return { status: "ambiguous", ...dedupeCandidates(matches) };
3317
3347
  }
3318
3348
 
3319
- const distinctDesigns = new Set(all.map(designKey));
3349
+ // A live pane outranks a suspended one for the DESIGN decision: a
3350
+ // backgrounded — or closed and not yet purged — window must not turn the one
3351
+ // design the user actually has in front of them into a question. Only when
3352
+ // nothing is live do the suspended windows decide.
3353
+ const live = all.filter(isPaneReadyPresentation);
3354
+ const pool = live.length > 0 ? live : all;
3355
+ const distinctDesigns = new Set(pool.map(designKey));
3320
3356
  if (distinctDesigns.size === 1) {
3321
3357
  // One design open (possibly one window mirrored across backends, or several
3322
3358
  // windows on the SAME CD). Pick via the mirror tie-break, else a live pane,
3323
3359
  // else the first — the design is unambiguous either way.
3324
3360
  const entry =
3325
- tieBreakMatches(all, deckPresFP) ??
3326
- all.find(isPaneReadyPresentation) ??
3327
- all[0];
3361
+ tieBreakMatches(pool, deckPresFP) ??
3362
+ pool.find(isPaneReadyPresentation) ??
3363
+ pool[0];
3328
3364
  return { status: "resolved", entry };
3329
3365
  }
3330
3366
  // ≥2 distinct designs open — the only case that asks the user (the deck ref
3331
3367
  // does NOT auto-pick; it only annotates `suggested`).
3332
- return { status: "ambiguous", ...dedupeCandidates(all) };
3368
+ return { status: "ambiguous", ...dedupeCandidates(pool) };
3333
3369
  }
3334
3370
 
3335
3371
  // Pick a target presentation given the (possibly multi-backend) registry and an
@@ -160,6 +160,7 @@ export function readSessions() {
160
160
  backendURL: s.backendURL,
161
161
  sessionToken: s.sessionToken,
162
162
  label: typeof s.label === "string" && s.label ? s.label : deriveEnvLabel(s.backendURL),
163
+ ...(typeof s.oid === "string" && s.oid ? { oid: s.oid } : {}),
163
164
  ...(typeof s.msGraphRefreshToken === "string" && s.msGraphRefreshToken
164
165
  ? { msGraphRefreshToken: s.msGraphRefreshToken }
165
166
  : {}),
@@ -199,6 +200,7 @@ function writeAll(sessions, defaultBackend) {
199
200
  backendURL: s.backendURL,
200
201
  sessionToken: s.sessionToken,
201
202
  label: s.label || deriveEnvLabel(s.backendURL),
203
+ ...(s.oid ? { oid: s.oid } : {}),
202
204
  ...(s.msGraphRefreshToken ? { msGraphRefreshToken: s.msGraphRefreshToken } : {}),
203
205
  })),
204
206
  };
@@ -220,15 +222,32 @@ function writeAll(sessions, defaultBackend) {
220
222
  // pairing that session with the previous user's delegated Graph token would
221
223
  // fetch one person's content under another's session. `slidemind login` again
222
224
  // is one command; a mismatched identity is not recoverable by the user.
223
- export function upsertSession({ backendURL, sessionToken, label = null }) {
225
+ export function upsertSession({ backendURL, sessionToken, label = null, oid = null }) {
224
226
  if (!backendURL || !sessionToken) {
225
227
  throw new Error("upsertSession: backendURL and sessionToken are required");
226
228
  }
227
- const sessions = readSessions().filter((s) => s.backendURL !== backendURL);
229
+ const all = readSessions();
230
+ const previous = all.find((s) => s.backendURL === backendURL);
231
+ const sessions = all.filter((s) => s.backendURL !== backendURL);
232
+ // A re-pair replaces the add-in session, not the Microsoft 365 sign-in — but
233
+ // only when the SAME person re-paired: a claim can hand a shared machine to
234
+ // someone else, who must not inherit the previous user's delegated token.
235
+ // Identity is the add-in-reported `oid`; unknown on either side means drop.
236
+ const samePerson = Boolean(oid) && previous?.oid === oid;
237
+ // Dropping the field detaches the record from a keychain-stored secret; the
238
+ // secret itself has to go too, or the previous user's refresh token stays
239
+ // readable at rest for whoever shares this login keychain.
240
+ if (!samePerson && previous?.msGraphRefreshToken === KEYCHAIN_MARKER && keychainUsable()) {
241
+ keychainDelete(backendURL);
242
+ }
228
243
  sessions.push({
229
244
  backendURL,
230
245
  sessionToken,
231
246
  label: label || deriveEnvLabel(backendURL),
247
+ ...(oid ? { oid } : {}),
248
+ ...(samePerson && previous?.msGraphRefreshToken
249
+ ? { msGraphRefreshToken: previous.msGraphRefreshToken }
250
+ : {}),
232
251
  });
233
252
  const def = getDefaultBackend() || backendURL;
234
253
  writeAll(sessions, def);