slidemind 0.1.8 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/slidemind.mjs CHANGED
@@ -60,11 +60,15 @@ function printHelp() {
60
60
  ` as a markdown editor + live preview + overall feedback box,\n` +
61
61
  ` blocks until the user decides, prints DECISION: approved | comments\n` +
62
62
  ` --port <n> bind a fixed port (default: ephemeral)\n` +
63
+ ` slidemind concepts <deck> After plan sign-off, generate one concept mockup PNG per planned\n` +
64
+ ` slide into <deck>/concepts/concept-slide-NN.png (requires a host).\n` +
65
+ ` Read each as a design target, then rebuild it as slide JSX.\n` +
66
+ ` Optional aid — skips cleanly (exit 0) if unavailable.\n` +
63
67
  ` slidemind build <deck> Build self-contained HTML\n` +
64
68
  ` slidemind review <deck> Open a spatial preview review; blocks until the user\n` +
65
69
  ` approves or leaves comments (host-free)\n` +
66
70
  ` --port <n> bind a fixed port (default: ephemeral)\n` +
67
- ` slidemind build-pptx <deck> Export native .pptx via the add-in (requires an open host)\n` +
71
+ ` slidemind build-pptx <deck> Export native .pptx — via the add-in for compose decks (host), LOCALLY for template packs (host-free)\n` +
68
72
  ` --to <presId> target a specific window (required if >1 open)\n` +
69
73
  ` --out <path> output path (default <deck>/<deck>.pptx)\n` +
70
74
  ` slidemind view-draft <deck> Render the deck's slides to per-slide PNGs (no push, no .pptx)\n` +
@@ -73,8 +77,10 @@ function printHelp() {
73
77
  ` --to <presId> window to fetch the bundle from (only if not cached)\n` +
74
78
  ` --out <dir> output dir (default <deck>/.draft-preview/)\n` +
75
79
  ` --open open each rendered PNG\n` +
76
- ` slidemind search "<query>" Search an engine for images or slides (requires an open host)\n` +
77
- ` --engine <id> engine to search (default: first image engine; required for slides)\n` +
80
+ ` slidemind search "<query>" Search an engine or scope for images or slides (requires an open host)\n` +
81
+ ` --engine <id> engine, or scope:all | scope:sharepoint to search unfiltered\n` +
82
+ ` (default: first image engine; scope:all for slide intents)\n` +
83
+ ` --result-type <t> slide | image — required for a scope:* target\n` +
78
84
  ` --intent <i> auto | content | visual (slide engines)\n` +
79
85
  ` --top <N> max results\n` +
80
86
  ` --to <presId> target a specific window (required if >1 open)\n` +
@@ -84,8 +90,8 @@ function printHelp() {
84
90
  ` --pick <n> 1-based selection number to view\n` +
85
91
  ` --to <presId> the window the search ran on (required if >1 open)\n` +
86
92
  ` --out <dir> output dir (default: a slidemind temp dir)\n` +
87
- ` slidemind ls engines List the live engine catalog for an active pane\n` +
88
- ` --type <t> collection | search\n` +
93
+ ` slidemind ls engines List the live engine catalog + search scopes for an active pane\n` +
94
+ ` --type <t> collection | search | scope\n` +
89
95
  ` --subtype <s> image | slide | template | content\n` +
90
96
  ` --to <presId> target a specific window/backend\n` +
91
97
  ` --all include every paired backend\n` +
@@ -95,11 +101,12 @@ function printHelp() {
95
101
  ` --pick <n,..> 1-based selection number(s)\n` +
96
102
  ` --to <presId> target window (required if >1 open)\n` +
97
103
  ` --at <N> insert after slide N (0-based); default = append\n` +
98
- ` slidemind add-slide [opts] Fold reused slide search results into a deck as native .pptx\n` +
99
- ` --search <id> searchId from 'slidemind search'\n` +
104
+ ` slidemind add-slide [opts] Fold reused slide(s) into a deck as native .pptx\n` +
105
+ ` --search <id> searchId from 'slidemind search' (requires a host)\n` +
106
+ ` --from <file.pptx> fold slide(s) from a local .pptx instead (host-free)\n` +
100
107
  ` --pick <n,..> 1-based selection number(s)\n` +
101
108
  ` --deck <deck> deck to add the slides to\n` +
102
- ` --to <presId> target the window the search ran on (required if >1 open)\n` +
109
+ ` --to <presId> target the window the search ran on (required if >1 open; ignored with --from)\n` +
103
110
  ` --at <N> numeric prefix to start at (default: append)\n` +
104
111
  ` slidemind download-result [opts] Download image/icon search result(s) into a deck's assets/ (requires a host)\n` +
105
112
  ` --search <id> searchId from 'slidemind search'\n` +
@@ -107,6 +114,12 @@ function printHelp() {
107
114
  ` --deck <deck> deck to write the asset(s) into\n` +
108
115
  ` --to <presId> the window the search ran on (required if >1 open)\n` +
109
116
  ` --icon-color <hex[,hex,..]> bake a colour into SVG result(s) (one for all, or one per --pick)\n` +
117
+ ` slidemind imagen "<prompt>" Generate ONE image with the add-in's image model into a deck's assets/ (paired backend, pane-free)\n` +
118
+ ` --deck <deck> bound deck to write the image into (required)\n` +
119
+ ` --out <name> file name under assets/ (default imagen-N.png)\n` +
120
+ ` --size <s> auto|1024x1024|1536x1024|1024x1536 (default auto)\n` +
121
+ ` --quality <q> low|medium|high|auto\n` +
122
+ ` --brand-style condition on the CD's reference samples\n` +
110
123
  ` slidemind download-bundle [deck] Materialize the active corporate design's bundle locally\n` +
111
124
  ` --to <presId> target a specific window\n` +
112
125
  ` --force re-download even if the cached bundle is current\n` +
@@ -114,7 +127,8 @@ function printHelp() {
114
127
  ` --all include retained suspended entries\n` +
115
128
  ` slidemind doctor Diagnose CLI/add-in pairing, backend, and pane readiness\n` +
116
129
  ` --json print machine-readable status\n` +
117
- ` slidemind read [presId] Pull the current content of an open presentation (requires a host)\n` +
130
+ ` slidemind read [presId|file.pptx] Pull the content of an open presentation (requires a host),\n` +
131
+ ` or of a local .pptx path (read host-free from disk)\n` +
118
132
  ` --mode <m> summary | raw (default: raw)\n` +
119
133
  ` --to <presId> target a specific window (required if >1 open)\n` +
120
134
  ` --json print the raw content payload\n` +
@@ -149,6 +163,15 @@ function fail(msg, code = 1) {
149
163
  process.exit(code);
150
164
  }
151
165
 
166
+ // The local PowerPoint files `read` and `add-slide --from` accept host-free.
167
+ // All are OPC (zip) OOXML packages with the same ppt/presentation.xml +
168
+ // ppt/slides/ structure — presentation (.pptx/.pptm), template (.potx/.potm),
169
+ // slideshow (.ppsx/.ppsm) — so the src/pptx/ reader/swapper handle them
170
+ // identically. Presentation IDs are opaque and never carry these extensions,
171
+ // so this stays a safe discriminator for "positional is a local file, not a
172
+ // presId".
173
+ const LOCAL_PPTX_RE = /\.(pptx|pptm|potx|potm|ppsx|ppsm)$/i;
174
+
152
175
  function resolveDecksDir() {
153
176
  if (process.env.SLIDEMIND_DECKS) {
154
177
  return path.resolve(process.env.SLIDEMIND_DECKS);
@@ -168,12 +191,25 @@ function resolveDeckArg(arg) {
168
191
  return path.join(decksDir, arg);
169
192
  }
170
193
 
171
- // Best-effort human name of a materialized design: the first `# ` heading in
172
- // the bundle's design.md, with the add-in's trailing " — design rules" suffix
173
- // stripped. Returns null on any failure (missing file, no heading) so callers
174
- // fall back to the raw presFP. Shared by `ready` and `new`'s auto-bind.
194
+ // Best-effort human name of a materialized design. Prefers the live `name`
195
+ // the wire carries (persisted into bundle.json by materializeBundle) — the
196
+ // owner-set brand name, which survives renames. Falls back to the first `# `
197
+ // heading in design.md (frozen at authoring time, so it may still show the
198
+ // original auto codename), with the " — design rules" suffix stripped.
199
+ // Returns null on any failure so callers fall back to the raw presFP.
200
+ // Shared by `ready` and `new`'s auto-bind.
175
201
  function readDesignName(bundleDir) {
176
202
  if (!bundleDir) return null;
203
+ try {
204
+ const meta = JSON.parse(
205
+ fs.readFileSync(path.join(bundleDir, "bundle.json"), "utf8"),
206
+ );
207
+ if (typeof meta.name === "string" && meta.name.trim()) {
208
+ return meta.name.trim();
209
+ }
210
+ } catch {
211
+ /* no meta or unreadable — fall through to the design.md heading */
212
+ }
177
213
  try {
178
214
  const md = fs.readFileSync(path.join(bundleDir, "design.md"), "utf8");
179
215
  for (const raw of md.split("\n")) {
@@ -278,6 +314,103 @@ function renderProgressPrinter() {
278
314
  };
279
315
  }
280
316
 
317
+ // Generate one regular image via the paired add-in's image model and write it
318
+ // into the deck's assets/. Pane-free: routes by the deck's bound design
319
+ // (.bundle-ref.json), so it works with PowerPoint backgrounded or closed.
320
+ async function cmdImagen(rest) {
321
+ let prompt = null;
322
+ let deck = null;
323
+ let outName = null;
324
+ let size = null;
325
+ let quality = null;
326
+ let brandStyle = false;
327
+ for (let i = 0; i < rest.length; i++) {
328
+ const a = rest[i];
329
+ if (a === "--deck") {
330
+ deck = rest[++i];
331
+ if (!deck) fail("--deck expects a deck");
332
+ } else if (a === "--out" || a === "-o") {
333
+ outName = rest[++i];
334
+ if (!outName) fail("--out expects a file name");
335
+ } else if (a === "--size") {
336
+ size = rest[++i];
337
+ if (!["auto", "1024x1024", "1536x1024", "1024x1536"].includes(size ?? "")) {
338
+ fail("--size expects auto|1024x1024|1536x1024|1024x1536");
339
+ }
340
+ } else if (a === "--quality") {
341
+ quality = rest[++i];
342
+ if (!["low", "medium", "high", "auto"].includes(quality ?? "")) {
343
+ fail("--quality expects low|medium|high|auto");
344
+ }
345
+ } else if (a === "--brand-style") {
346
+ brandStyle = true;
347
+ } else if (a.startsWith("-")) {
348
+ fail(`unknown flag: ${a}`);
349
+ } else if (prompt === null) {
350
+ prompt = a;
351
+ } else {
352
+ fail(`unexpected argument: ${a} (quote the prompt as one argument)`);
353
+ }
354
+ }
355
+ if (!prompt || !prompt.trim() || !deck) {
356
+ fail(
357
+ 'usage: slidemind imagen "<prompt>" --deck <deck> [--out <name.png>] ' +
358
+ "[--size auto|1024x1024|1536x1024|1024x1536] [--quality low|medium|high|auto] [--brand-style]",
359
+ );
360
+ }
361
+ const deckDir = resolveDeckArg(deck);
362
+ const bundleMod = await import(
363
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
364
+ );
365
+ const ref = bundleMod.readDeckBundleRef(deckDir);
366
+ if (!ref?.presFP) {
367
+ fail(
368
+ `deck ${path.basename(deckDir)} is not bound to a design yet — run ` +
369
+ `'slidemind ready ${deck}' first (imagen routes by the deck's bound design).`,
370
+ );
371
+ }
372
+ const client = await import(
373
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
374
+ );
375
+ process.stdout.write(" generating image (this can take a minute)…\n");
376
+ let result;
377
+ try {
378
+ result = await client.generateImageForBrand(ref.presFP, {
379
+ prompt,
380
+ size,
381
+ quality,
382
+ brandStyle,
383
+ });
384
+ } catch (err) {
385
+ if (err?.code === "imagen_route_unavailable") {
386
+ fail(
387
+ `${err.message}\n The paired add-in does not offer image generation yet — ` +
388
+ `it ships with the next add-in update; until then add pictures to assets/ by hand ` +
389
+ `or via 'slidemind download-result'.`,
390
+ );
391
+ }
392
+ throw err;
393
+ }
394
+ const m = /^data:image\/(png|jpe?g);base64,(.+)$/.exec(result?.image ?? "");
395
+ if (!m) fail("backend returned no image data");
396
+ const ext = m[1] === "png" ? "png" : "jpeg";
397
+ const assetsDir = path.join(deckDir, "assets");
398
+ fs.mkdirSync(assetsDir, { recursive: true });
399
+ let file = outName;
400
+ if (!file) {
401
+ let n = 1;
402
+ while (fs.existsSync(path.join(assetsDir, `imagen-${n}.${ext}`))) n++;
403
+ file = `imagen-${n}.${ext}`;
404
+ }
405
+ const outPath = path.join(assetsDir, file);
406
+ fs.writeFileSync(outPath, Buffer.from(m[2], "base64"));
407
+ const styled = result.styleReferences > 0 ? ` (styled on ${result.styleReferences} brand sample(s))` : "";
408
+ process.stdout.write(
409
+ `Wrote ${path.relative(process.cwd(), outPath)}${styled}\n` +
410
+ ` Reference it from slide props as "assets/${file}" (e.g. summaryImage, illustration).\n`,
411
+ );
412
+ }
413
+
281
414
  async function cmdBuildPptx(rest) {
282
415
  const { deck, passthrough } = splitDeckArg(rest, "build-pptx");
283
416
  let to = null;
@@ -300,6 +433,57 @@ async function cmdBuildPptx(rest) {
300
433
  if (!fs.existsSync(path.join(deckDir, "slides"))) {
301
434
  fail(`expected 'slides/' folder in ${deckDir}`);
302
435
  }
436
+
437
+ // Template packs (mode: fill-template) build LOCALLY: the deck's slides are
438
+ // declarative fills over a template .pptx that ships in the pack, so no
439
+ // add-in render is involved and no PowerPoint host is needed. Compose decks
440
+ // take the bridge path below UNTOUCHED — the mode probe never throws, so a
441
+ // compose deck with an unmaterialized bundle still self-heals in the render.
442
+ const resolveMod = await import(
443
+ pathToFileURL(path.join(SRC_DIR, "builders", "resolve-skill.mjs")).href
444
+ );
445
+ if (resolveMod.resolveDeckMode(deckDir).mode === "fill-template") {
446
+ const skill = resolveMod.resolveSkillForDeck(deckDir);
447
+ if (to) {
448
+ fail(
449
+ "--to targets a PowerPoint window, but fill-template decks build " +
450
+ "locally from the pack's template — drop --to (use 'slidemind push' " +
451
+ "to get slides into a window).",
452
+ );
453
+ }
454
+ // In-deck freeform slides (create-slides JSX next to the master-backed
455
+ // ones) render to sibling NN.pptx artifacts first — the only step that
456
+ // talks to the add-in; unchanged freeform slides keep the build offline.
457
+ const freeform = await import(
458
+ pathToFileURL(path.join(SRC_DIR, "pptx", "fill", "freeform.mjs")).href
459
+ );
460
+ const ff = await freeform.ensureFreeformRenders({
461
+ deckDir,
462
+ locale: skill.locale ?? "en",
463
+ srcDir: SRC_DIR,
464
+ onLog: (line) => process.stdout.write(` ${line}\n`),
465
+ });
466
+ for (const w of ff.warnings) process.stderr.write(` warning: ${w}\n`);
467
+
468
+ const engine = await import(
469
+ pathToFileURL(path.join(SRC_DIR, "pptx", "fill", "engine.mjs")).href
470
+ );
471
+ const result = await engine.buildFillTemplateDeck({
472
+ deckDir,
473
+ skill,
474
+ outPath,
475
+ onLog: (line) => process.stdout.write(` ${line}\n`),
476
+ });
477
+ process.stdout.write(
478
+ `Wrote ${result.outPath} (${result.slideCount} slides) from template ` +
479
+ `'${skill.templateKey}' — host-free\n`,
480
+ );
481
+ for (const w of result.warnings ?? []) {
482
+ process.stderr.write(` warning: ${w}\n`);
483
+ }
484
+ return;
485
+ }
486
+
303
487
  // The .pptx is rendered server-side by the add-in (one exporter, one truth).
304
488
  // A bound deck renders pane-free over the brand routes — no PowerPoint
305
489
  // window needed; unbound decks (or --to) resolve through the pane registry.
@@ -405,10 +589,96 @@ async function cmdViewDraft(rest) {
405
589
  }
406
590
  }
407
591
 
592
+ // Turn the add-in's deliberately terse search failures into something actionable.
593
+ // It withholds detail on purpose: the raw message used to carry the built OData
594
+ // filter and physical index names, and "Engine not found" doubles as a permission
595
+ // denial so engine existence stays undisclosed. So the CLI can't diagnose — it can
596
+ // only name the next move.
597
+ function searchFailureHint(error, engineId) {
598
+ const raw = error ?? "unknown error";
599
+ if (/engine not found/i.test(raw)) {
600
+ return (
601
+ `${raw} — '${engineId}' is unknown or not visible to you. ` +
602
+ `Re-run 'slidemind ls engines' (ids can be retired) and pick from the live list, ` +
603
+ `or search 'scope:all' to skip engines entirely.`
604
+ );
605
+ }
606
+ if (/^search failed$/i.test(raw)) {
607
+ return (
608
+ `${raw} — the add-in withholds the reason (it can leak index internals); ` +
609
+ `the detail is in the add-in's logs. Retry once, then try 'scope:all'.`
610
+ );
611
+ }
612
+ return raw;
613
+ }
614
+
615
+ // Zero results is now a routine, ambiguous outcome: the add-in answers "success,
616
+ // nothing" for a corpus that isn't connected, one that is still embedding, and a
617
+ // folder filter that matches nothing — all indistinguishable on the wire. Say that
618
+ // instead of letting an empty list read as "this content does not exist".
619
+ function writeEmptySearchDiagnostic(engineId, bundleMod) {
620
+ const isScope = bundleMod.isSearchScopeId(engineId);
621
+ const lines = [
622
+ ` No results — this does NOT mean the content doesn't exist. Any of these looks identical here:`,
623
+ ` · nothing is connected yet, so there is no corpus to search`,
624
+ ` · the corpus is still indexing (content appears progressively)`,
625
+ ];
626
+ if (!isScope) {
627
+ lines.push(
628
+ ` · this engine is a saved FOLDER FILTER and the content sits outside it`,
629
+ );
630
+ }
631
+ lines.push(` Next:`);
632
+ if (!isScope) {
633
+ lines.push(
634
+ ` · widen to the unfiltered corpus: slidemind search "<query>" --engine ${bundleMod.SCOPE_ALL} --result-type slide`,
635
+ );
636
+ }
637
+ lines.push(
638
+ ` · re-word the query (content search matches phrasing, so a synonym can decide a hit)`,
639
+ ` · slidemind ls engines — see every engine and scope you can reach`,
640
+ );
641
+ if (isScope) {
642
+ lines.push(
643
+ ` · if the unfiltered corpus is also empty, report it as "not found" and name what you searched — don't author a replacement as if reuse had been tried`,
644
+ );
645
+ }
646
+ process.stdout.write(`${lines.join("\n")}\n`);
647
+ }
648
+
649
+ // Write the search's numbered collage to a file and tell the agent to look at it.
650
+ //
651
+ // The add-in composes ONE bounded 800x600 image showing every candidate with its
652
+ // selection number burned in — the same picture the in-app agent sees. That is the
653
+ // intended way to choose a visual result: one look at one file, rather than N
654
+ // per-result previews (which is also why the relay ack carries no per-result bytes
655
+ // — a corpus image is multi-megabyte and N of them can't cross the relay).
656
+ //
657
+ // Best-effort by design: a missing/…unparseable grid must never fail a search that
658
+ // otherwise returned usable results. Content-intent searches have no grid at all —
659
+ // their signal is the printed text.
660
+ function writeSearchGrid(gridDataUri, searchId, resolveExt) {
661
+ if (typeof gridDataUri !== "string" || !gridDataUri.startsWith("data:")) {
662
+ return null;
663
+ }
664
+ try {
665
+ const m = /^data:([^;]+);base64,(.+)$/.exec(gridDataUri);
666
+ if (!m) return null;
667
+ const bytes = Buffer.from(m[2], "base64");
668
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-grid-"));
669
+ const target = path.join(dir, `grid-${searchId}.${resolveExt(m[1], bytes)}`);
670
+ fs.writeFileSync(target, bytes);
671
+ return target;
672
+ } catch {
673
+ return null;
674
+ }
675
+ }
676
+
408
677
  async function cmdSearch(rest) {
409
678
  let asJson = false;
410
679
  let to = null;
411
680
  let engineId = null;
681
+ let resultType = null;
412
682
  let intent = null;
413
683
  let top = null;
414
684
  const queryParts = [];
@@ -422,6 +692,12 @@ async function cmdSearch(rest) {
422
692
  } else if (a === "--engine" || a === "-e" || a === "--engineId") {
423
693
  engineId = rest[++i];
424
694
  if (!engineId) fail("--engine expects an engineId");
695
+ } else if (a === "--result-type" || a === "--resultType") {
696
+ resultType = rest[++i];
697
+ if (!resultType) fail("--result-type expects slide|image");
698
+ if (!["slide", "image"].includes(resultType)) {
699
+ fail(`--result-type expects slide|image, got '${resultType}'`);
700
+ }
425
701
  } else if (a === "--intent") {
426
702
  intent = rest[++i];
427
703
  if (!intent) fail("--intent expects auto|content|visual");
@@ -444,7 +720,7 @@ async function cmdSearch(rest) {
444
720
  const query = queryParts.join(" ").trim();
445
721
  if (!query) {
446
722
  fail(
447
- `usage: slidemind search "<query>" [--engine <id>] [--intent auto|content|visual] [--top <N>] [--to <presId>] [--json]`,
723
+ `usage: slidemind search "<query>" [--engine <id|scope:all|scope:sharepoint>] [--result-type slide|image] [--intent auto|content|visual] [--top <N>] [--to <presId>] [--json]`,
448
724
  );
449
725
  }
450
726
 
@@ -456,6 +732,9 @@ async function cmdSearch(rest) {
456
732
  const bundle = await import(
457
733
  pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
458
734
  );
735
+ const { resolveAssetExt } = await import(
736
+ pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
737
+ );
459
738
  const registry = await client.requirePaneHost();
460
739
  // The engine is per-user, not window-bound — so search just attaches to any
461
740
  // connected window (preferring a live one). No "which window?" prompt; pass
@@ -465,33 +744,64 @@ async function cmdSearch(rest) {
465
744
  const presId = entry.presId;
466
745
  const backendURL = entry.backendURL ?? null;
467
746
 
468
- // Slide search needs an explicit engine — the bundle's image-engine default
469
- // only applies to image/visual intent. content/visual on a slide engine is
470
- // the caller's choice via --engine.
747
+ // An explicit content/visual intent means "search slides", not imagery.
471
748
  const slideIntent = intent === "content" || intent === "visual";
472
749
  // Thread the engine subtype on the wire whenever the CLI can determine it:
473
750
  // - the pickImageEngine default branch is, by construction, an image engine;
474
- // - an explicit slide intent (--intent content|visual) targets a slide
475
- // engine.
751
+ // - an explicit slide intent (--intent content|visual) targets slides.
476
752
  // This is belt-and-suspenders for an add-in regression where a missing
477
753
  // subtype no longer defaulted to "image"; when the subtype is unknown we omit
478
754
  // it and let the add-in resolve it from the snapshot.
479
755
  let subtype = slideIntent ? "slide" : null;
480
756
  if (!engineId) {
481
757
  if (slideIntent) {
482
- fail(
483
- "slide search requires --engine <id>. Run 'slidemind ls engines --type search --subtype slide' to find one.",
484
- );
485
- }
486
- const mat = await bundle.materializeBundle(presId, { backendURL });
487
- const pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
488
- if (!pick) {
489
- fail(
490
- "This corporate design has no brand engines to search. Ask the add-in owner to wire an image/icon engine.",
491
- );
758
+ // Slide search with no engine named: search the corpus UNFILTERED rather
759
+ // than refusing. An engine is a saved folder filter over the add-in's
760
+ // shared corpus, so a workspace can hold a full corpus and zero slide
761
+ // engines — demanding --engine here dead-ended exactly that case.
762
+ engineId = bundle.SCOPE_ALL;
763
+ resultType = resultType ?? "slide";
764
+ } else {
765
+ // Imagery: resolve a real image engine (a scope is not a brand image
766
+ // source). Ask the LIVE catalog first — the same one the add-in's own
767
+ // design agent reads — and fall back to the materialized bundle only when
768
+ // the host can't answer. Bundle-first would be stale: engine ids can be
769
+ // retired server-side, and a dead id surfaces as "Engine not found".
770
+ const live = await client
771
+ .listEngines({ subtype: "image", backendURL })
772
+ .catch(() => ({ engines: [] }));
773
+ let pick = bundle.pickImageEngine(live.engines);
774
+ if (!pick) {
775
+ // Best-effort: a bundle that can't be materialized (e.g. an incomplete
776
+ // one) must not replace the actionable "wire an image engine" message
777
+ // below with a bundle error — the live catalog is the real answer here.
778
+ const mat = await bundle
779
+ .materializeBundle(presId, { backendURL })
780
+ .catch(() => null);
781
+ if (mat) pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
782
+ }
783
+ if (!pick) {
784
+ fail(
785
+ "This corporate design has no image engines to search. Run 'slidemind ls engines' to see what exists, or ask the add-in owner to wire an image/icon engine.",
786
+ );
787
+ }
788
+ engineId = pick.id;
789
+ subtype = "image";
492
790
  }
493
- engineId = pick.id;
494
- subtype = "image";
791
+ }
792
+
793
+ // A scope has no subtype of its own, so the index must be named explicitly —
794
+ // the add-in rejects a scope search without it. Default to slides: reusing an
795
+ // existing slide is what an unfiltered corpus search is overwhelmingly for.
796
+ if (bundle.isSearchScopeId(engineId)) {
797
+ resultType = resultType ?? "slide";
798
+ subtype = resultType;
799
+ } else if (resultType) {
800
+ // Harmless but meaningless on an engine, whose own subtype is authoritative.
801
+ process.stderr.write(
802
+ `note: --result-type is ignored for engine '${engineId}' (it applies to scope:* targets); the engine's own type decides.\n`,
803
+ );
804
+ resultType = null;
495
805
  }
496
806
 
497
807
  // Window-agnostic: tries the picked pane, then fails over to any other live
@@ -503,6 +813,7 @@ async function cmdSearch(rest) {
503
813
  engineId,
504
814
  query,
505
815
  subtype,
816
+ resultType,
506
817
  intent: intent ?? null,
507
818
  top: top ?? null,
508
819
  });
@@ -513,7 +824,7 @@ async function cmdSearch(rest) {
513
824
  return;
514
825
  }
515
826
  if (!res?.ok) {
516
- process.stderr.write(`search failed: ${res?.error ?? "unknown error"}\n`);
827
+ process.stderr.write(`search failed: ${searchFailureHint(res?.error, engineId)}\n`);
517
828
  return;
518
829
  }
519
830
  // Prefer the new `results` array (image + slide engines); fall back to the
@@ -522,11 +833,18 @@ async function cmdSearch(rest) {
522
833
  ? res.results
523
834
  : (res.thumbnails ?? []).map((t, i) => ({ selection: i + 1, title: t.title }));
524
835
  const count = res.resultCount ?? results.length;
836
+ const targetLabel = bundle.isSearchScopeId(engineId)
837
+ ? `scope ${engineId}`
838
+ : `engine ${engineId}`;
525
839
  process.stdout.write(
526
- `searchId ${res.searchId} — ${count} result(s) from engine ${engineId}` +
840
+ `searchId ${res.searchId} — ${count} result(s) from ${targetLabel}` +
527
841
  (res.intent ? ` (${res.intent})` : "") +
528
842
  `\n`,
529
843
  );
844
+ if (count === 0) {
845
+ writeEmptySearchDiagnostic(engineId, bundle);
846
+ return;
847
+ }
530
848
  const isContent = res.intent === "content";
531
849
  results.forEach((r, i) => {
532
850
  const sel = r.selection ?? i + 1;
@@ -540,10 +858,27 @@ async function cmdSearch(rest) {
540
858
  }
541
859
  }
542
860
  } else {
543
- // Visual / image results: the numbered list.
544
- process.stdout.write(` ${sel}. ${r.title ?? "(untitled)"}\n`);
861
+ // Visual / image results: the numbered list. An image result carries its
862
+ // intrinsic size from the engine index — show it, so the box can be sized
863
+ // to the real aspect ratio at authoring time rather than after the fetch.
864
+ const dims =
865
+ r.kind === "image" && Number.isFinite(r.width) && Number.isFinite(r.height)
866
+ ? ` ${r.width}×${r.height}`
867
+ : "";
868
+ process.stdout.write(` ${sel}. ${r.title ?? "(untitled)"}${dims}\n`);
545
869
  }
546
870
  });
871
+ // Visual results: point at the ONE collage rather than making the caller pull
872
+ // each preview separately. The numbers burned into it match the list above.
873
+ if (!isContent) {
874
+ const gridPath = writeSearchGrid(res.gridDataUri, res.searchId, resolveAssetExt);
875
+ if (gridPath) {
876
+ process.stdout.write(
877
+ `Contact sheet of all ${count} result(s) — LOOK AT THIS FILE to choose, ` +
878
+ `don't fetch previews one by one: ${gridPath}\n`,
879
+ );
880
+ }
881
+ }
547
882
  // Tailor the next-step hint to the result kind: slide verbs (insert-slide /
548
883
  // add-slide) for slide results, the image-download hint otherwise. The result
549
884
  // subtype is authoritative; fall back to a per-result `kind` for older
@@ -572,7 +907,7 @@ async function cmdSearch(rest) {
572
907
  async function cmdLs(rest) {
573
908
  const [sub, ...subRest] = rest;
574
909
  if (sub !== "engines") {
575
- fail("usage: slidemind ls engines [--type collection|search] [--subtype image|slide|template|content] [--json]");
910
+ fail("usage: slidemind ls engines [--type collection|search|scope] [--subtype image|slide|template|content] [--json]");
576
911
  }
577
912
  let type = null;
578
913
  let subtype = null;
@@ -583,9 +918,10 @@ async function cmdLs(rest) {
583
918
  const a = subRest[i];
584
919
  if (a === "--type") {
585
920
  type = subRest[++i];
586
- if (!type) fail("--type expects collection|search");
587
- if (!["collection", "search"].includes(type)) {
588
- fail(`--type expects collection|search, got '${type}'`);
921
+ if (!type) fail("--type expects collection|search|scope");
922
+ // "scope" narrows to the non-engine targets (scope:all / scope:sharepoint).
923
+ if (!["collection", "search", "scope"].includes(type)) {
924
+ fail(`--type expects collection|search|scope, got '${type}'`);
589
925
  }
590
926
  } else if (a === "--subtype") {
591
927
  subtype = subRest[++i];
@@ -622,7 +958,18 @@ async function cmdLs(rest) {
622
958
  return;
623
959
  }
624
960
  if (engines.length === 0) {
625
- process.stderr.write("No engines available for the active corporate design.\n");
961
+ // Deliberately not "nothing to search": an engine is a saved folder filter
962
+ // over the add-in's shared corpus, so an empty catalog says only that nobody
963
+ // saved a filter. An older add-in that predates scopes lands here too.
964
+ const { SCOPE_ALL } = await import(
965
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
966
+ );
967
+ process.stderr.write(
968
+ "No engines or scopes matched. An engine is a saved folder filter over the corpus, " +
969
+ "so an empty list does NOT mean there is nothing to search — try " +
970
+ `'slidemind search "<query>" --engine ${SCOPE_ALL} --result-type slide' ` +
971
+ "to search it unfiltered.\n",
972
+ );
626
973
  return;
627
974
  }
628
975
  // Show the env tag only when more than one environment contributed engines.
@@ -631,7 +978,15 @@ async function cmdLs(rest) {
631
978
  const kind = [e.type, e.subtype].filter(Boolean).join("/");
632
979
  const env = multiEnv && e.env ? `[${e.env}] ` : "";
633
980
  const desc = e.description ? ` — ${e.description}` : "";
634
- process.stdout.write(`${env}${e.id} · ${e.label ?? e.id} · ${kind}${desc}\n`);
981
+ // A scope takes --result-type instead of having a subtype of its own; spell
982
+ // that out so the agent doesn't have to infer it from the missing subtype.
983
+ const hint =
984
+ e.type === "scope"
985
+ ? ` (needs --result-type ${(e.capabilities?.resultTypes ?? ["slide", "image"]).join("|")})`
986
+ : "";
987
+ process.stdout.write(
988
+ `${env}${e.id} · ${e.label ?? e.id} · ${kind}${hint}${desc}\n`,
989
+ );
635
990
  }
636
991
  }
637
992
 
@@ -713,6 +1068,7 @@ async function cmdAddSlide(rest) {
713
1068
  let deck = null;
714
1069
  let at = null;
715
1070
  let to = null;
1071
+ let from = null;
716
1072
  for (let i = 0; i < rest.length; i++) {
717
1073
  const a = rest[i];
718
1074
  if (a === "--search") {
@@ -720,6 +1076,9 @@ async function cmdAddSlide(rest) {
720
1076
  const n = parseInt(next, 10);
721
1077
  if (!Number.isFinite(n)) fail(`--search expects a searchId, got '${next}'`);
722
1078
  searchId = n;
1079
+ } else if (a === "--from") {
1080
+ from = rest[++i];
1081
+ if (!from) fail("--from expects a path to a .pptx file");
723
1082
  } else if (a === "--pick") {
724
1083
  picks = parsePicks(rest[++i]);
725
1084
  } else if (a === "--deck") {
@@ -741,8 +1100,69 @@ async function cmdAddSlide(rest) {
741
1100
  fail(`unexpected argument: ${a}`);
742
1101
  }
743
1102
  }
1103
+
1104
+ // Host-free local source: fold picked slides from a .pptx on disk (no bridge
1105
+ // client, no requirePaneHost). Mutually exclusive with --search; --to is
1106
+ // accepted-but-ignored here (symmetric with the other reuse commands — there's
1107
+ // no window to target when the source is a local file).
1108
+ if (from !== null) {
1109
+ if (searchId !== null) fail("--from and --search are mutually exclusive");
1110
+ if (!picks || !deck) {
1111
+ fail("usage: slidemind add-slide --from <file.pptx> --pick <n[,n,...]> --deck <deck> [--at <N>]");
1112
+ }
1113
+ const fromPath = path.isAbsolute(from)
1114
+ ? from
1115
+ : path.resolve(process.cwd(), from);
1116
+ let st = null;
1117
+ try {
1118
+ st = fs.statSync(fromPath);
1119
+ } catch {
1120
+ st = null;
1121
+ }
1122
+ if (!st || !st.isFile() || !LOCAL_PPTX_RE.test(fromPath)) {
1123
+ fail(`--from: '${from}' is not an existing PowerPoint file (.pptx/.pptm/.potx/.potm/.ppsx/.ppsm)`);
1124
+ }
1125
+ const deckDir = resolveDeckArg(deck);
1126
+ const slidesDir = path.join(deckDir, "slides");
1127
+ fs.mkdirSync(slidesDir, { recursive: true });
1128
+
1129
+ const { foldPickedSlides } = await import(
1130
+ pathToFileURL(path.join(SRC_DIR, "pptx", "slide-swap.mjs")).href
1131
+ );
1132
+ let results;
1133
+ try {
1134
+ results = foldPickedSlides(fs.readFileSync(fromPath), picks, {
1135
+ sourceName: path.basename(fromPath),
1136
+ });
1137
+ } catch (err) {
1138
+ fail(`add-slide failed: ${err.message}`);
1139
+ }
1140
+
1141
+ // Same filename math + write loop as the --search branch; results already
1142
+ // carry a `.slideId` and `.bytes` (a Uint8Array of a one-slide package).
1143
+ const reused = await import(
1144
+ pathToFileURL(path.join(SRC_DIR, "lib", "reused-slides.mjs")).href
1145
+ );
1146
+ const existing = fs
1147
+ .readdirSync(slidesDir)
1148
+ .filter((f) => f.endsWith(".jsx") || f.endsWith(".pptx"));
1149
+ const targets = reused.reusedSlideTargets(existing, results, at);
1150
+ const written = [];
1151
+ results.forEach((r, i) => {
1152
+ const target = path.join(slidesDir, targets[i].filename);
1153
+ fs.writeFileSync(target, Buffer.from(r.bytes));
1154
+ written.push(path.relative(process.cwd(), target));
1155
+ });
1156
+ process.stdout.write(
1157
+ `Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
1158
+ written.map((w) => ` ${w}`).join("\n") +
1159
+ `\nThey are native .pptx in slides/ — re-run 'slidemind build-pptx ${deck}' or 'slidemind push ${deck}' to render the deck with them spliced in.\n`,
1160
+ );
1161
+ return;
1162
+ }
1163
+
744
1164
  if (searchId === null || !picks || !deck) {
745
- fail("usage: slidemind add-slide --search <id> --pick <n[,n,...]> --deck <deck> [--to <presId>] [--at <N>]");
1165
+ fail("usage: slidemind add-slide (--search <id> | --from <file.pptx>) --pick <n[,n,...]> --deck <deck> [--to <presId>] [--at <N>]");
746
1166
  }
747
1167
  const deckDir = resolveDeckArg(deck);
748
1168
  const slidesDir = path.join(deckDir, "slides");
@@ -765,18 +1185,61 @@ async function cmdAddSlide(rest) {
765
1185
  const reused = await import(
766
1186
  pathToFileURL(path.join(SRC_DIR, "lib", "reused-slides.mjs")).href
767
1187
  );
1188
+ const { createTransferProgress } = await import(
1189
+ pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
1190
+ );
1191
+ // Stream each pick to a provisional file: the play-order filename can only be
1192
+ // computed once every pick is in hand, and buffering a multi-megabyte slide in
1193
+ // memory just to name it would defeat the streaming path.
768
1194
  const extracted = [];
769
- for (const pick of picks) {
770
- const res = await client.extractSlide({
771
- presId,
772
- searchId,
773
- selection: pick,
774
- backendURL,
775
- });
776
- if (!res?.ok || !res.base64) {
777
- fail(`add-slide failed for selection ${pick}: ${res?.error ?? "no slide bytes returned"}`);
1195
+ const cleanupTemps = () => {
1196
+ for (const s of extracted) {
1197
+ if (s.tempPath) fs.rmSync(s.tempPath, { force: true });
778
1198
  }
779
- extracted.push({ selection: pick, slideId: res.slideId ?? null, base64: res.base64 });
1199
+ };
1200
+ try {
1201
+ for (const pick of picks) {
1202
+ const tempPath = path.join(slidesDir, `.reused-${searchId}-${pick}.part`);
1203
+ if (typeof client.extractSlideToFile === "function") {
1204
+ try {
1205
+ const res = await client.extractSlideToFile({
1206
+ presId,
1207
+ searchId,
1208
+ selection: pick,
1209
+ destPath: tempPath,
1210
+ backendURL,
1211
+ onProgress: createTransferProgress(`extracting slide ${pick}`),
1212
+ });
1213
+ if (!res?.bytesWritten) throw new Error("stream produced no bytes");
1214
+ extracted.push({ selection: pick, slideId: res.slideId ?? null, tempPath });
1215
+ continue;
1216
+ } catch (err) {
1217
+ fs.rmSync(tempPath, { force: true });
1218
+ // Route absent (bodyless 404 / 501) → this add-in predates the streaming
1219
+ // route; fall through to the capped base64 ack path below.
1220
+ const routeMissing =
1221
+ (err?.status === 404 && !err?.code) || err?.status === 501;
1222
+ if (!routeMissing) {
1223
+ cleanupTemps();
1224
+ fail(`add-slide failed for selection ${pick}: ${err.message}`);
1225
+ }
1226
+ }
1227
+ }
1228
+ const res = await client.extractSlide({
1229
+ presId,
1230
+ searchId,
1231
+ selection: pick,
1232
+ backendURL,
1233
+ });
1234
+ if (!res?.ok || !res.base64) {
1235
+ cleanupTemps();
1236
+ fail(`add-slide failed for selection ${pick}: ${res?.error ?? "no slide bytes returned"}`);
1237
+ }
1238
+ extracted.push({ selection: pick, slideId: res.slideId ?? null, base64: res.base64 });
1239
+ }
1240
+ } catch (err) {
1241
+ cleanupTemps();
1242
+ throw err;
780
1243
  }
781
1244
  const existing = fs
782
1245
  .readdirSync(slidesDir)
@@ -785,7 +1248,11 @@ async function cmdAddSlide(rest) {
785
1248
  const written = [];
786
1249
  extracted.forEach((slide, i) => {
787
1250
  const target = path.join(slidesDir, targets[i].filename);
788
- fs.writeFileSync(target, Buffer.from(slide.base64, "base64"));
1251
+ if (slide.tempPath) {
1252
+ fs.renameSync(slide.tempPath, target);
1253
+ } else {
1254
+ fs.writeFileSync(target, Buffer.from(slide.base64, "base64"));
1255
+ }
789
1256
  written.push(path.relative(process.cwd(), target));
790
1257
  });
791
1258
  process.stdout.write(
@@ -855,7 +1322,7 @@ async function cmdViewSlide(rest) {
855
1322
  const client = await import(
856
1323
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
857
1324
  );
858
- const { extFromMime } = await import(
1325
+ const { resolveAssetExt } = await import(
859
1326
  pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
860
1327
  );
861
1328
  const registry = await client.requirePaneHost();
@@ -877,8 +1344,15 @@ async function cmdViewSlide(rest) {
877
1344
  ? path.resolve(process.cwd(), outDir)
878
1345
  : fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-view-"));
879
1346
  fs.mkdirSync(dir, { recursive: true });
880
- const target = path.join(dir, `slide-${searchId}-${pick}.${extFromMime(m[1])}`);
881
- fs.writeFileSync(target, Buffer.from(m[2], "base64"));
1347
+ // Extension from the BYTES, not the declared MIME — engines inherit a
1348
+ // Content-Type from upstream and it is demonstrably wrong for some corpus
1349
+ // images (PNG served as image/jpeg).
1350
+ const bytes = Buffer.from(m[2], "base64");
1351
+ const target = path.join(
1352
+ dir,
1353
+ `slide-${searchId}-${pick}.${resolveAssetExt(m[1], bytes)}`,
1354
+ );
1355
+ fs.writeFileSync(target, bytes);
882
1356
  process.stdout.write(
883
1357
  `Wrote preview of result ${pick} (searchId ${searchId}) to ${target}\n`,
884
1358
  );
@@ -956,7 +1430,11 @@ async function cmdDownloadResult(rest) {
956
1430
  ? r.value
957
1431
  : { selection: picks[i], error: r.reason?.message ?? String(r.reason) },
958
1432
  );
1433
+ const { formatSize } = await import(
1434
+ pathToFileURL(path.join(SRC_DIR, "bridge", "image-dimensions.mjs")).href
1435
+ );
959
1436
  let ok = 0;
1437
+ let anySized = false;
960
1438
  const lines = [];
961
1439
  for (const r of results) {
962
1440
  if (r.error) {
@@ -965,16 +1443,197 @@ async function cmdDownloadResult(rest) {
965
1443
  }
966
1444
  ok++;
967
1445
  const note = r.recolor?.note ? ` — ${r.recolor.note}` : "";
968
- lines.push(` ${r.reference} (${r.recolor?.mode ?? "?"})${note}`);
1446
+ // The intrinsic size is the agent's only handle on the asset's aspect
1447
+ // ratio — without it the <img> box is a guess and the render stretches.
1448
+ const dims = formatSize(r.size);
1449
+ anySized ||= Boolean(dims);
1450
+ const size = dims ? ` ${dims}` : " (size unknown)";
1451
+ lines.push(` ${r.reference}${size} (${r.recolor?.mode ?? "?"})${note}`);
969
1452
  }
970
1453
  process.stdout.write(
971
1454
  `Downloaded ${ok}/${results.length} result(s) into ${path.relative(process.cwd(), assetsDir)}:\n` +
972
1455
  lines.join("\n") +
973
- `\nReference each from slide JSX as <img src="./assets/...">.\n`,
1456
+ `\nReference each from slide JSX as <img src="./assets/...">.\n` +
1457
+ (anySized
1458
+ ? `Sizes above are the assets' intrinsic width×height — give each <img> a box ` +
1459
+ `with that aspect ratio, or the render stretches it to whatever box you wrote.\n`
1460
+ : ""),
974
1461
  );
975
1462
  if (ok === 0) process.exit(1);
976
1463
  }
977
1464
 
1465
+ // Strip the `data:<mime>;base64,` prefix off a data URI and return the raw
1466
+ // base64 payload (or null when the URI isn't a base64 data URI we can decode).
1467
+ function base64FromDataUri(dataUri) {
1468
+ if (typeof dataUri !== "string") return null;
1469
+ const m = /^data:[^;,]*;base64,(.*)$/s.exec(dataUri.trim());
1470
+ return m ? m[1] : null;
1471
+ }
1472
+
1473
+ async function cmdConceptImages(rest) {
1474
+ let deck = null;
1475
+ for (let i = 0; i < rest.length; i++) {
1476
+ const a = rest[i];
1477
+ if (a === "--deck") {
1478
+ deck = rest[++i];
1479
+ if (!deck) fail("--deck expects a deck name or path");
1480
+ } else if (a.startsWith("-")) {
1481
+ fail(`unknown flag: ${a}`);
1482
+ } else if (deck === null) {
1483
+ deck = a;
1484
+ } else {
1485
+ fail(`unexpected argument: ${a}`);
1486
+ }
1487
+ }
1488
+ if (!deck) fail("usage: slidemind concepts <deck>");
1489
+
1490
+ const deckDir = resolveDeckArg(deck);
1491
+ const planPath = path.join(deckDir, "plan.md");
1492
+ if (!fs.existsSync(planPath)) {
1493
+ fail(
1494
+ `no plan.md in ${path.relative(process.cwd(), deckDir) || deckDir}. ` +
1495
+ `Run \`slidemind plan ${deck}\` to compose and sign off a plan first.`,
1496
+ );
1497
+ }
1498
+ const planMarkdown = fs.readFileSync(planPath, "utf8");
1499
+
1500
+ const bundle = await import(
1501
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
1502
+ );
1503
+ const brandId = bundle.readDeckBundleRef(deckDir)?.presFP ?? null;
1504
+ if (!brandId) {
1505
+ fail(
1506
+ `deck ${deck} is not bound to a corporate design (no .bundle-ref.json). ` +
1507
+ `Run \`slidemind ready ${deck}\` to bind it first.`,
1508
+ );
1509
+ }
1510
+
1511
+ // Plan sign-off is advisory here, not a hard gate: warn but proceed if the
1512
+ // plan wasn't approved, so an agent can still fetch mockups while iterating.
1513
+ const plan = await import(
1514
+ pathToFileURL(path.join(BUILDERS_DIR, "plan.mjs")).href
1515
+ );
1516
+ const planState = plan.loadPlanState(deckDir);
1517
+ if (planState?.decision !== "approved") {
1518
+ process.stderr.write(
1519
+ `slidemind: note — ${deck}'s plan is not signed off yet ` +
1520
+ `(run \`slidemind plan ${deck}\`); generating concept mockups from the current plan.md anyway.\n`,
1521
+ );
1522
+ }
1523
+
1524
+ const client = await import(
1525
+ pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
1526
+ );
1527
+
1528
+ const skipNote = () => {
1529
+ process.stdout.write(
1530
+ "no concept mockups generated — continuing without " +
1531
+ "(this add-in doesn't generate concept mockups; author from the storyboards).\n",
1532
+ );
1533
+ process.exit(0);
1534
+ };
1535
+
1536
+ // First request learns slideCount (and pins the serving backend for the rest).
1537
+ let first;
1538
+ try {
1539
+ first = await client.generateConceptImagesForBrand(brandId, {
1540
+ planMarkdown,
1541
+ slideNumbers: [1],
1542
+ });
1543
+ } catch (err) {
1544
+ if (err?.code === "concept_route_unavailable") skipNote();
1545
+ throw err;
1546
+ }
1547
+
1548
+ const backendURL = first.backendURL ?? null;
1549
+ const slideCount = Number.isFinite(first.slideCount) ? first.slideCount : 0;
1550
+ const images = [];
1551
+ const collect = (res) => {
1552
+ if (Array.isArray(res?.images)) images.push(...res.images);
1553
+ };
1554
+ collect(first);
1555
+
1556
+ // Family variants depend on their lowest-numbered anchor. Run independent
1557
+ // slides + anchors first, then variants; the server cache remains a safety
1558
+ // net for older clients and retries.
1559
+ const families = await import(
1560
+ pathToFileURL(path.join(BUILDERS_DIR, "visual-families.mjs")).href
1561
+ );
1562
+ const waves = families.conceptGenerationWaves(planMarkdown, slideCount);
1563
+ const CONCURRENCY = 3;
1564
+ async function runWave(slides) {
1565
+ let cursor = 0;
1566
+ async function worker() {
1567
+ for (;;) {
1568
+ const idx = cursor++;
1569
+ if (idx >= slides.length) return;
1570
+ const n = slides[idx];
1571
+ try {
1572
+ const res = await client.generateConceptImagesForBrand(brandId, {
1573
+ planMarkdown,
1574
+ slideNumbers: [n],
1575
+ backendURL,
1576
+ });
1577
+ collect(res);
1578
+ } catch (err) {
1579
+ process.stderr.write(
1580
+ `slidemind: concept mockup for slide ${n} failed: ${err?.message ?? err}\n`,
1581
+ );
1582
+ }
1583
+ }
1584
+ }
1585
+ await Promise.all(
1586
+ Array.from({ length: Math.min(CONCURRENCY, slides.length) }, () => worker()),
1587
+ );
1588
+ }
1589
+ await runWave(waves.firstWave);
1590
+ await runWave(waves.variants);
1591
+
1592
+ if (images.length === 0) {
1593
+ process.stdout.write(
1594
+ "no concept mockups generated — continuing without.\n",
1595
+ );
1596
+ process.exit(0);
1597
+ }
1598
+
1599
+ const conceptsDir = path.join(deckDir, "concepts");
1600
+ fs.mkdirSync(conceptsDir, { recursive: true });
1601
+ const written = [];
1602
+ for (const img of images) {
1603
+ const n = Number(img?.slideNumber);
1604
+ if (!Number.isFinite(n) || n < 1) continue;
1605
+ const b64 = base64FromDataUri(img?.dataUri);
1606
+ if (!b64) continue;
1607
+ const name = `concept-slide-${String(n).padStart(2, "0")}.png`;
1608
+ const abs = path.join(conceptsDir, name);
1609
+ fs.writeFileSync(abs, Buffer.from(b64, "base64"));
1610
+ written.push(abs);
1611
+ }
1612
+
1613
+ // Drop stale mockups from a previous, longer plan — any concept-slide-NN.png
1614
+ // whose slide number now exceeds the plan's slideCount.
1615
+ for (const entry of fs.readdirSync(conceptsDir)) {
1616
+ const m = /^concept-slide-(\d+)\.png$/.exec(entry);
1617
+ if (m && Number(m[1]) > slideCount) {
1618
+ fs.rmSync(path.join(conceptsDir, entry), { force: true });
1619
+ }
1620
+ }
1621
+
1622
+ if (written.length === 0) {
1623
+ process.stdout.write(
1624
+ "no concept mockups generated — continuing without.\n",
1625
+ );
1626
+ process.exit(0);
1627
+ }
1628
+
1629
+ written.sort();
1630
+ process.stdout.write(
1631
+ written.join("\n") +
1632
+ `\nWrote ${written.length} concept mockup(s) to ${conceptsDir} — ` +
1633
+ "Read each, then rebuild as slide JSX (workflow/create.md → Preconditions).\n",
1634
+ );
1635
+ }
1636
+
978
1637
  async function cmdDownloadBundle(rest) {
979
1638
  let to = null;
980
1639
  let force = false;
@@ -1287,6 +1946,10 @@ async function cmdReady(rest) {
1287
1946
  const client = await import(
1288
1947
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
1289
1948
  );
1949
+ // Same opt-out as doctor: `ready` OWNS the cli_update_required blocker (it is
1950
+ // terminal, `actor: "agent"`, with the update command as its nextAction), so
1951
+ // the raw header throw would only replace a structured answer with a worse one.
1952
+ client.setDiagnosticMode(true);
1290
1953
 
1291
1954
  // --- Network (one parallel round): readiness ladder + the merged registry. ---
1292
1955
  const [statusRaw, detailed] = await Promise.all([
@@ -1604,6 +2267,9 @@ async function cmdDoctor(rest) {
1604
2267
  const client = await import(
1605
2268
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
1606
2269
  );
2270
+ // doctor diagnoses skew — it must never be killed BY skew. The version floor
2271
+ // still reaches the reader, as the `cli_update_required` rung of the ladder.
2272
+ client.setDiagnosticMode(true);
1607
2273
  if (sessions.length === 0) {
1608
2274
  const pub = {
1609
2275
  paired: false,
@@ -1652,14 +2318,54 @@ async function cmdDoctor(rest) {
1652
2318
  process.stdout.write(`Next action: ${status.nextAction}\n`);
1653
2319
  }
1654
2320
 
2321
+ // Print a read result — the `{ ok, mode, slideCount, slides[] }` shape, whether
2322
+ // it came from the add-in (open presentation) or a local .pptx. Shared by both
2323
+ // `cmdRead` branches so their output is byte-for-byte identical.
2324
+ function printReadResult(res, asJson) {
2325
+ if (asJson) {
2326
+ process.stdout.write(JSON.stringify(res, null, 2) + "\n");
2327
+ return;
2328
+ }
2329
+ const slides = Array.isArray(res.slides) ? res.slides : [];
2330
+ if (slides.length === 0) {
2331
+ process.stderr.write("(presentation has no slides)\n");
2332
+ return;
2333
+ }
2334
+ if (res.mode === "summary") {
2335
+ for (const s of slides) {
2336
+ const label = s.title || "(untitled)";
2337
+ const sub = s.subtitle ? ` — ${s.subtitle}` : "";
2338
+ const layout = s.layout ? ` [${s.layout}]` : "";
2339
+ process.stdout.write(
2340
+ `${String(s.index).padStart(3)}.${layout} ${label}${sub}\n`,
2341
+ );
2342
+ }
2343
+ } else {
2344
+ slides.forEach((s, i) => {
2345
+ if (i > 0) process.stdout.write("\n");
2346
+ const layout = s.layout ? ` (${s.layout})` : "";
2347
+ process.stdout.write(`── Slide ${s.index}${layout} ──\n`);
2348
+ process.stdout.write(`${s.text && s.text.length ? s.text : "(no text)"}\n`);
2349
+ });
2350
+ }
2351
+ }
2352
+
1655
2353
  // Pull the current content of an open presentation. Content is window-bound, so
1656
2354
  // pick exactly one window (positional presId or --to; pickPresentationEntry errors
1657
2355
  // listing the windows if several are open and none was named). `--mode summary`
1658
2356
  // prints a per-slide outline; default `raw` prints each slide's plain text.
2357
+ //
2358
+ // A positional arg with a local PowerPoint extension (LOCAL_PPTX_RE: .pptx and
2359
+ // its .pptm/.potx/.potm/.ppsx/.ppsm siblings) AND resolving to an existing
2360
+ // regular file is read host-free from disk instead (no bridge). The gate is
2361
+ // strict: a nonexistent path or a real presId falls through to the host path
2362
+ // unchanged, and it is skipped when --to is given (--to targets an open window).
1659
2363
  async function cmdRead(rest) {
1660
2364
  let presId = null;
1661
2365
  let mode = "raw";
1662
2366
  let asJson = false;
2367
+ let toGiven = false;
2368
+ let fileArg = null; // the positional (a candidate local .pptx path)
1663
2369
  for (let i = 0; i < rest.length; i++) {
1664
2370
  const a = rest[i];
1665
2371
  if (a === "--mode") {
@@ -1671,16 +2377,47 @@ async function cmdRead(rest) {
1671
2377
  } else if (a === "--to") {
1672
2378
  presId = rest[++i];
1673
2379
  if (!presId) fail("--to expects a presId");
2380
+ toGiven = true;
1674
2381
  } else if (a === "--json") {
1675
2382
  asJson = true;
1676
2383
  } else if (a.startsWith("-")) {
1677
2384
  fail(`unknown flag: ${a}`);
1678
2385
  } else if (!presId) {
1679
- presId = a; // positional presId
2386
+ presId = a; // positional presId (or local .pptx path)
2387
+ fileArg = a;
1680
2388
  } else {
1681
2389
  fail(`unexpected argument: ${a}`);
1682
2390
  }
1683
2391
  }
2392
+
2393
+ // Local-file gate: applies ONLY to the positional, and never when --to is set.
2394
+ // The positional must end in .pptx and resolve to an existing regular file;
2395
+ // anything else (missing path, real presId) falls through to the host path.
2396
+ if (fileArg && !toGiven && LOCAL_PPTX_RE.test(fileArg)) {
2397
+ const p = path.isAbsolute(fileArg)
2398
+ ? fileArg
2399
+ : path.resolve(process.cwd(), fileArg);
2400
+ let st = null;
2401
+ try {
2402
+ st = fs.statSync(p);
2403
+ } catch {
2404
+ st = null;
2405
+ }
2406
+ if (st && st.isFile()) {
2407
+ const { readLocalPptx } = await import(
2408
+ pathToFileURL(path.join(SRC_DIR, "pptx", "read-local.mjs")).href
2409
+ );
2410
+ let res;
2411
+ try {
2412
+ res = readLocalPptx(fs.readFileSync(p), { mode });
2413
+ } catch (err) {
2414
+ fail(`read failed: ${err.message}`);
2415
+ }
2416
+ printReadResult(res, asJson);
2417
+ return;
2418
+ }
2419
+ }
2420
+
1684
2421
  const client = await import(
1685
2422
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
1686
2423
  );
@@ -1694,32 +2431,7 @@ async function cmdRead(rest) {
1694
2431
  if (!res?.ok) {
1695
2432
  fail(`read failed: ${res?.error ?? "unknown error"}`);
1696
2433
  }
1697
- if (asJson) {
1698
- process.stdout.write(JSON.stringify(res, null, 2) + "\n");
1699
- return;
1700
- }
1701
- const slides = Array.isArray(res.slides) ? res.slides : [];
1702
- if (slides.length === 0) {
1703
- process.stderr.write("(presentation has no slides)\n");
1704
- return;
1705
- }
1706
- if (res.mode === "summary") {
1707
- for (const s of slides) {
1708
- const label = s.title || "(untitled)";
1709
- const sub = s.subtitle ? ` — ${s.subtitle}` : "";
1710
- const layout = s.layout ? ` [${s.layout}]` : "";
1711
- process.stdout.write(
1712
- `${String(s.index).padStart(3)}.${layout} ${label}${sub}\n`,
1713
- );
1714
- }
1715
- } else {
1716
- slides.forEach((s, i) => {
1717
- if (i > 0) process.stdout.write("\n");
1718
- const layout = s.layout ? ` (${s.layout})` : "";
1719
- process.stdout.write(`── Slide ${s.index}${layout} ──\n`);
1720
- process.stdout.write(`${s.text && s.text.length ? s.text : "(no text)"}\n`);
1721
- });
1722
- }
2434
+ printReadResult(res, asJson);
1723
2435
  }
1724
2436
 
1725
2437
  async function loadBridgeModule(name) {
@@ -2221,7 +2933,22 @@ async function cmdUpdate(rest) {
2221
2933
  : "slidemind@latest";
2222
2934
  await ui.section("release", ref ? `pinned ${spec}` : "latest");
2223
2935
  ui.step({ label: "install", value: spec, note: "global (npm)" });
2224
- await spawnInherit(npmBin(), ["install", "-g", "--loglevel=error", spec]);
2936
+ try {
2937
+ await spawnInherit(npmBin(), ["install", "-g", "--loglevel=error", spec]);
2938
+ } catch (err) {
2939
+ console.error(
2940
+ "\nslidemind update could not write to npm's global package directory.\n" +
2941
+ "This usually happens on macOS/Linux when Node was installed with a system-owned npm prefix.\n\n" +
2942
+ "Avoid `sudo npm`. Use Homebrew/nvm, or move npm's global prefix into your home directory:\n\n" +
2943
+ " mkdir -p ~/.npm-global\n" +
2944
+ " npm config set prefix ~/.npm-global\n" +
2945
+ " echo 'export PATH=\"$HOME/.npm-global/bin:$PATH\"' >> ~/.zshrc\n" +
2946
+ " source ~/.zshrc\n" +
2947
+ " npm install -g slidemind\n\n" +
2948
+ "Then run `slidemind init --for all`.\n",
2949
+ );
2950
+ throw err;
2951
+ }
2225
2952
 
2226
2953
  if (target !== "none") {
2227
2954
  await spawnInherit("slidemind", ["init", "--for", target]);
@@ -2275,6 +3002,9 @@ async function main(rawArgv) {
2275
3002
  case "review":
2276
3003
  await cmdReview(rest);
2277
3004
  return 0;
3005
+ case "imagen":
3006
+ await cmdImagen(rest);
3007
+ break;
2278
3008
  case "build-pptx":
2279
3009
  await cmdBuildPptx(rest);
2280
3010
  return 0;
@@ -2299,6 +3029,9 @@ async function main(rawArgv) {
2299
3029
  case "download-result":
2300
3030
  await cmdDownloadResult(rest);
2301
3031
  return 0;
3032
+ case "concepts":
3033
+ await cmdConceptImages(rest);
3034
+ return 0;
2302
3035
  case "download-bundle":
2303
3036
  await cmdDownloadBundle(rest);
2304
3037
  return 0;