slidemind 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/slidemind.mjs CHANGED
@@ -68,7 +68,7 @@ function printHelp() {
68
68
  ` slidemind review <deck> Open a spatial preview review; blocks until the user\n` +
69
69
  ` approves or leaves comments (host-free)\n` +
70
70
  ` --port <n> bind a fixed port (default: ephemeral)\n` +
71
- ` 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` +
72
72
  ` --to <presId> target a specific window (required if >1 open)\n` +
73
73
  ` --out <path> output path (default <deck>/<deck>.pptx)\n` +
74
74
  ` slidemind view-draft <deck> Render the deck's slides to per-slide PNGs (no push, no .pptx)\n` +
@@ -77,8 +77,10 @@ function printHelp() {
77
77
  ` --to <presId> window to fetch the bundle from (only if not cached)\n` +
78
78
  ` --out <dir> output dir (default <deck>/.draft-preview/)\n` +
79
79
  ` --open open each rendered PNG\n` +
80
- ` slidemind search "<query>" Search an engine for images or slides (requires an open host)\n` +
81
- ` --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` +
82
84
  ` --intent <i> auto | content | visual (slide engines)\n` +
83
85
  ` --top <N> max results\n` +
84
86
  ` --to <presId> target a specific window (required if >1 open)\n` +
@@ -88,8 +90,8 @@ function printHelp() {
88
90
  ` --pick <n> 1-based selection number to view\n` +
89
91
  ` --to <presId> the window the search ran on (required if >1 open)\n` +
90
92
  ` --out <dir> output dir (default: a slidemind temp dir)\n` +
91
- ` slidemind ls engines List the live engine catalog for an active pane\n` +
92
- ` --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` +
93
95
  ` --subtype <s> image | slide | template | content\n` +
94
96
  ` --to <presId> target a specific window/backend\n` +
95
97
  ` --all include every paired backend\n` +
@@ -112,6 +114,12 @@ function printHelp() {
112
114
  ` --deck <deck> deck to write the asset(s) into\n` +
113
115
  ` --to <presId> the window the search ran on (required if >1 open)\n` +
114
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` +
115
123
  ` slidemind download-bundle [deck] Materialize the active corporate design's bundle locally\n` +
116
124
  ` --to <presId> target a specific window\n` +
117
125
  ` --force re-download even if the cached bundle is current\n` +
@@ -306,6 +314,103 @@ function renderProgressPrinter() {
306
314
  };
307
315
  }
308
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
+
309
414
  async function cmdBuildPptx(rest) {
310
415
  const { deck, passthrough } = splitDeckArg(rest, "build-pptx");
311
416
  let to = null;
@@ -328,6 +433,57 @@ async function cmdBuildPptx(rest) {
328
433
  if (!fs.existsSync(path.join(deckDir, "slides"))) {
329
434
  fail(`expected 'slides/' folder in ${deckDir}`);
330
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
+
331
487
  // The .pptx is rendered server-side by the add-in (one exporter, one truth).
332
488
  // A bound deck renders pane-free over the brand routes — no PowerPoint
333
489
  // window needed; unbound decks (or --to) resolve through the pane registry.
@@ -433,10 +589,96 @@ async function cmdViewDraft(rest) {
433
589
  }
434
590
  }
435
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
+
436
677
  async function cmdSearch(rest) {
437
678
  let asJson = false;
438
679
  let to = null;
439
680
  let engineId = null;
681
+ let resultType = null;
440
682
  let intent = null;
441
683
  let top = null;
442
684
  const queryParts = [];
@@ -450,6 +692,12 @@ async function cmdSearch(rest) {
450
692
  } else if (a === "--engine" || a === "-e" || a === "--engineId") {
451
693
  engineId = rest[++i];
452
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
+ }
453
701
  } else if (a === "--intent") {
454
702
  intent = rest[++i];
455
703
  if (!intent) fail("--intent expects auto|content|visual");
@@ -472,7 +720,7 @@ async function cmdSearch(rest) {
472
720
  const query = queryParts.join(" ").trim();
473
721
  if (!query) {
474
722
  fail(
475
- `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]`,
476
724
  );
477
725
  }
478
726
 
@@ -484,6 +732,9 @@ async function cmdSearch(rest) {
484
732
  const bundle = await import(
485
733
  pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
486
734
  );
735
+ const { resolveAssetExt } = await import(
736
+ pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
737
+ );
487
738
  const registry = await client.requirePaneHost();
488
739
  // The engine is per-user, not window-bound — so search just attaches to any
489
740
  // connected window (preferring a live one). No "which window?" prompt; pass
@@ -493,42 +744,64 @@ async function cmdSearch(rest) {
493
744
  const presId = entry.presId;
494
745
  const backendURL = entry.backendURL ?? null;
495
746
 
496
- // Slide search needs an explicit engine — the bundle's image-engine default
497
- // only applies to image/visual intent. content/visual on a slide engine is
498
- // the caller's choice via --engine.
747
+ // An explicit content/visual intent means "search slides", not imagery.
499
748
  const slideIntent = intent === "content" || intent === "visual";
500
749
  // Thread the engine subtype on the wire whenever the CLI can determine it:
501
750
  // - the pickImageEngine default branch is, by construction, an image engine;
502
- // - an explicit slide intent (--intent content|visual) targets a slide
503
- // engine.
751
+ // - an explicit slide intent (--intent content|visual) targets slides.
504
752
  // This is belt-and-suspenders for an add-in regression where a missing
505
753
  // subtype no longer defaulted to "image"; when the subtype is unknown we omit
506
754
  // it and let the add-in resolve it from the snapshot.
507
755
  let subtype = slideIntent ? "slide" : null;
508
756
  if (!engineId) {
509
757
  if (slideIntent) {
510
- fail(
511
- "slide search requires --engine <id>. Run 'slidemind ls engines --type search --subtype slide' to find one.",
512
- );
513
- }
514
- const mat = await bundle.materializeBundle(presId, { backendURL });
515
- let pick = bundle.pickImageEngine(bundle.readEnginesFromDir(mat.dir));
516
- if (!pick) {
517
- // New backends no longer ship engines[] on the bundle wire — discovery
518
- // is live-only. Fall back to the catalog the add-in's own design agent
519
- // uses (its list_engines tool ≙ `slidemind ls engines`).
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".
520
770
  const live = await client
521
771
  .listEngines({ subtype: "image", backendURL })
522
772
  .catch(() => ({ engines: [] }));
523
- pick = bundle.pickImageEngine(live.engines);
524
- }
525
- if (!pick) {
526
- fail(
527
- "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.",
528
- );
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";
529
790
  }
530
- engineId = pick.id;
531
- 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;
532
805
  }
533
806
 
534
807
  // Window-agnostic: tries the picked pane, then fails over to any other live
@@ -540,6 +813,7 @@ async function cmdSearch(rest) {
540
813
  engineId,
541
814
  query,
542
815
  subtype,
816
+ resultType,
543
817
  intent: intent ?? null,
544
818
  top: top ?? null,
545
819
  });
@@ -550,7 +824,7 @@ async function cmdSearch(rest) {
550
824
  return;
551
825
  }
552
826
  if (!res?.ok) {
553
- process.stderr.write(`search failed: ${res?.error ?? "unknown error"}\n`);
827
+ process.stderr.write(`search failed: ${searchFailureHint(res?.error, engineId)}\n`);
554
828
  return;
555
829
  }
556
830
  // Prefer the new `results` array (image + slide engines); fall back to the
@@ -559,11 +833,18 @@ async function cmdSearch(rest) {
559
833
  ? res.results
560
834
  : (res.thumbnails ?? []).map((t, i) => ({ selection: i + 1, title: t.title }));
561
835
  const count = res.resultCount ?? results.length;
836
+ const targetLabel = bundle.isSearchScopeId(engineId)
837
+ ? `scope ${engineId}`
838
+ : `engine ${engineId}`;
562
839
  process.stdout.write(
563
- `searchId ${res.searchId} — ${count} result(s) from engine ${engineId}` +
840
+ `searchId ${res.searchId} — ${count} result(s) from ${targetLabel}` +
564
841
  (res.intent ? ` (${res.intent})` : "") +
565
842
  `\n`,
566
843
  );
844
+ if (count === 0) {
845
+ writeEmptySearchDiagnostic(engineId, bundle);
846
+ return;
847
+ }
567
848
  const isContent = res.intent === "content";
568
849
  results.forEach((r, i) => {
569
850
  const sel = r.selection ?? i + 1;
@@ -577,10 +858,27 @@ async function cmdSearch(rest) {
577
858
  }
578
859
  }
579
860
  } else {
580
- // Visual / image results: the numbered list.
581
- 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`);
582
869
  }
583
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
+ }
584
882
  // Tailor the next-step hint to the result kind: slide verbs (insert-slide /
585
883
  // add-slide) for slide results, the image-download hint otherwise. The result
586
884
  // subtype is authoritative; fall back to a per-result `kind` for older
@@ -609,7 +907,7 @@ async function cmdSearch(rest) {
609
907
  async function cmdLs(rest) {
610
908
  const [sub, ...subRest] = rest;
611
909
  if (sub !== "engines") {
612
- 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]");
613
911
  }
614
912
  let type = null;
615
913
  let subtype = null;
@@ -620,9 +918,10 @@ async function cmdLs(rest) {
620
918
  const a = subRest[i];
621
919
  if (a === "--type") {
622
920
  type = subRest[++i];
623
- if (!type) fail("--type expects collection|search");
624
- if (!["collection", "search"].includes(type)) {
625
- 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}'`);
626
925
  }
627
926
  } else if (a === "--subtype") {
628
927
  subtype = subRest[++i];
@@ -659,7 +958,18 @@ async function cmdLs(rest) {
659
958
  return;
660
959
  }
661
960
  if (engines.length === 0) {
662
- 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
+ );
663
973
  return;
664
974
  }
665
975
  // Show the env tag only when more than one environment contributed engines.
@@ -668,7 +978,15 @@ async function cmdLs(rest) {
668
978
  const kind = [e.type, e.subtype].filter(Boolean).join("/");
669
979
  const env = multiEnv && e.env ? `[${e.env}] ` : "";
670
980
  const desc = e.description ? ` — ${e.description}` : "";
671
- 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
+ );
672
990
  }
673
991
  }
674
992
 
@@ -867,18 +1185,61 @@ async function cmdAddSlide(rest) {
867
1185
  const reused = await import(
868
1186
  pathToFileURL(path.join(SRC_DIR, "lib", "reused-slides.mjs")).href
869
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.
870
1194
  const extracted = [];
871
- for (const pick of picks) {
872
- const res = await client.extractSlide({
873
- presId,
874
- searchId,
875
- selection: pick,
876
- backendURL,
877
- });
878
- if (!res?.ok || !res.base64) {
879
- 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 });
880
1198
  }
881
- 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;
882
1243
  }
883
1244
  const existing = fs
884
1245
  .readdirSync(slidesDir)
@@ -887,7 +1248,11 @@ async function cmdAddSlide(rest) {
887
1248
  const written = [];
888
1249
  extracted.forEach((slide, i) => {
889
1250
  const target = path.join(slidesDir, targets[i].filename);
890
- 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
+ }
891
1256
  written.push(path.relative(process.cwd(), target));
892
1257
  });
893
1258
  process.stdout.write(
@@ -957,7 +1322,7 @@ async function cmdViewSlide(rest) {
957
1322
  const client = await import(
958
1323
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
959
1324
  );
960
- const { extFromMime } = await import(
1325
+ const { resolveAssetExt } = await import(
961
1326
  pathToFileURL(path.join(SRC_DIR, "bridge", "engine-download.mjs")).href
962
1327
  );
963
1328
  const registry = await client.requirePaneHost();
@@ -979,8 +1344,15 @@ async function cmdViewSlide(rest) {
979
1344
  ? path.resolve(process.cwd(), outDir)
980
1345
  : fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-view-"));
981
1346
  fs.mkdirSync(dir, { recursive: true });
982
- const target = path.join(dir, `slide-${searchId}-${pick}.${extFromMime(m[1])}`);
983
- 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);
984
1356
  process.stdout.write(
985
1357
  `Wrote preview of result ${pick} (searchId ${searchId}) to ${target}\n`,
986
1358
  );
@@ -1058,7 +1430,11 @@ async function cmdDownloadResult(rest) {
1058
1430
  ? r.value
1059
1431
  : { selection: picks[i], error: r.reason?.message ?? String(r.reason) },
1060
1432
  );
1433
+ const { formatSize } = await import(
1434
+ pathToFileURL(path.join(SRC_DIR, "bridge", "image-dimensions.mjs")).href
1435
+ );
1061
1436
  let ok = 0;
1437
+ let anySized = false;
1062
1438
  const lines = [];
1063
1439
  for (const r of results) {
1064
1440
  if (r.error) {
@@ -1067,12 +1443,21 @@ async function cmdDownloadResult(rest) {
1067
1443
  }
1068
1444
  ok++;
1069
1445
  const note = r.recolor?.note ? ` — ${r.recolor.note}` : "";
1070
- 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}`);
1071
1452
  }
1072
1453
  process.stdout.write(
1073
1454
  `Downloaded ${ok}/${results.length} result(s) into ${path.relative(process.cwd(), assetsDir)}:\n` +
1074
1455
  lines.join("\n") +
1075
- `\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
+ : ""),
1076
1461
  );
1077
1462
  if (ok === 0) process.exit(1);
1078
1463
  }
@@ -1561,6 +1946,10 @@ async function cmdReady(rest) {
1561
1946
  const client = await import(
1562
1947
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
1563
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);
1564
1953
 
1565
1954
  // --- Network (one parallel round): readiness ladder + the merged registry. ---
1566
1955
  const [statusRaw, detailed] = await Promise.all([
@@ -1878,6 +2267,9 @@ async function cmdDoctor(rest) {
1878
2267
  const client = await import(
1879
2268
  pathToFileURL(path.join(SRC_DIR, "bridge", "client.mjs")).href
1880
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);
1881
2273
  if (sessions.length === 0) {
1882
2274
  const pub = {
1883
2275
  paired: false,
@@ -2610,6 +3002,9 @@ async function main(rawArgv) {
2610
3002
  case "review":
2611
3003
  await cmdReview(rest);
2612
3004
  return 0;
3005
+ case "imagen":
3006
+ await cmdImagen(rest);
3007
+ break;
2613
3008
  case "build-pptx":
2614
3009
  await cmdBuildPptx(rest);
2615
3010
  return 0;