slidemind 0.1.3 → 0.1.4

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.
@@ -5,6 +5,7 @@
5
5
  // backend runs render/bundle in-process and relays insert/replace/engine to the
6
6
  // open PowerPoint pane. No localhost, no shared loopback token, no cert.
7
7
 
8
+ import { randomUUID } from "node:crypto";
8
9
  import http from "node:http";
9
10
  import https from "node:https";
10
11
 
@@ -338,19 +339,44 @@ export async function requireHost() {
338
339
  );
339
340
  }
340
341
 
342
+ // Tie-break a multi-backend presId collision without bothering the user when
343
+ // only one candidate can actually serve the deck:
344
+ // (a) exactly one CONNECTED pane whose active CD matches the deck's bundle
345
+ // ref (`preferBrandId` — the deck's presFP, which equals the pane's
346
+ // brandId by the bundle-cache contract);
347
+ // (b) else exactly one connected pane with ANY active CD (brandId set) —
348
+ // e.g. a local dev backend connected without a CD next to prod with one.
349
+ // Returns the winning entry or null when genuinely ambiguous.
350
+ function tieBreakMatches(matches, preferBrandId) {
351
+ const live = matches.filter((m) => m.connected !== false && !m.suspendedAt);
352
+ if (preferBrandId) {
353
+ const want = live.filter((m) => m.brandId === preferBrandId);
354
+ if (want.length === 1) return want[0];
355
+ }
356
+ const withCd = live.filter((m) => m.brandId);
357
+ if (withCd.length === 1) return withCd[0];
358
+ return null;
359
+ }
360
+
341
361
  // Resolve which backend a presId lives on via a LIVE fan-out list (never a
342
362
  // cached map — a presId can move backends on re-pair/redeploy). Throws an
343
363
  // actionable message on a miss, or an ambiguity error when the same presId is
344
- // open on more than one reachable backend.
345
- export async function resolveBackendForPres(presId) {
364
+ // open on more than one reachable backend and no tie-break rule settles it.
365
+ export async function resolveBackendForPres(
366
+ presId,
367
+ { preferBrandId = null } = {},
368
+ ) {
346
369
  if (!presId) throw new Error("resolveBackendForPres: presId is required");
347
370
  const { presentations } = await fanOutPresentations();
348
371
  const matches = presentations.filter((p) => p.presId === presId);
349
372
  if (matches.length === 1) return matches[0].backendURL;
350
373
  if (matches.length > 1) {
374
+ const winner = tieBreakMatches(matches, preferBrandId);
375
+ if (winner) return winner.backendURL;
351
376
  const envs = matches.map((m) => m.env).join(", ");
352
377
  throw new Error(
353
- `Presentation '${presId}' is open on multiple environments: ${envs}. ` +
378
+ `Presentation '${presId}' is open on multiple environments: ${envs}, ` +
379
+ "and no single connected one has the deck's corporate design active. " +
354
380
  "Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
355
381
  );
356
382
  }
@@ -360,11 +386,79 @@ export async function resolveBackendForPres(presId) {
360
386
  );
361
387
  }
362
388
 
389
+ // Wait for a presentation's task pane to come back after a `pane_suspended`
390
+ // (PowerPoint backgrounded → webview suspended → socket dropped). Polls the
391
+ // live registry; succeeds on the same presId reconnecting, OR on a CONNECTED
392
+ // entry matching `fileName` + `brandId` — a pane reconnect can mint a NEW
393
+ // presId for the same window, so identity-by-content covers the churn. Returns
394
+ // the live registry entry (caller re-targets from it), or null on timeout.
395
+ // `onWait` fires once with the wait budget in seconds — the caller's single
396
+ // actionable "click the pane" line.
397
+ export async function waitForPaneReady(
398
+ presId,
399
+ {
400
+ backendURL = null,
401
+ fileName = null,
402
+ brandId = null,
403
+ timeoutMs = null,
404
+ pollMs = null,
405
+ onWait = null,
406
+ } = {},
407
+ ) {
408
+ const waitMs =
409
+ timeoutMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_TIMEOUT_MS", 120000);
410
+ const stepMs = pollMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_POLL_MS", 3000);
411
+ const deadline = Date.now() + waitMs;
412
+ let notified = false;
413
+ for (;;) {
414
+ let list = [];
415
+ try {
416
+ list = backendURL
417
+ ? await listPresentationsForBackend(backendURL)
418
+ : (await fanOutPresentations()).presentations;
419
+ } catch {
420
+ /* transient list failure — poll again until the deadline */
421
+ }
422
+ const connected = list.filter(
423
+ (p) => p.connected !== false && !p.suspendedAt,
424
+ );
425
+ const same = connected.find((p) => p.presId === presId);
426
+ if (same) return same;
427
+ if (fileName || brandId) {
428
+ const rematch = connected.find(
429
+ (p) =>
430
+ (!fileName || p.fileName === fileName || p.title === fileName) &&
431
+ (!brandId || p.brandId === brandId),
432
+ );
433
+ if (rematch) return rematch;
434
+ }
435
+ if (Date.now() >= deadline) return null;
436
+ if (!notified) {
437
+ notified = true;
438
+ onWait?.(Math.round(waitMs / 1000));
439
+ }
440
+ await sleep(stepMs);
441
+ }
442
+ }
443
+
444
+ // The one actionable line a human needs when a pane op is waiting. stderr so
445
+ // it reaches the terminal from the CLI and the MCP server log alike.
446
+ function paneWaitNotice(seconds) {
447
+ process.stderr.write(
448
+ "task pane suspended — click the slidemind pane in PowerPoint to wake it " +
449
+ `(waiting up to ${seconds}s)…\n`,
450
+ );
451
+ }
452
+
363
453
  // GET the full design bundle (tokens, css, layouts, designRules, signed asset
364
454
  // URLs, engines) for the presentation's active corporate design.
365
- export async function getDesignBundle(presId, { backendURL = null } = {}) {
455
+ export async function getDesignBundle(
456
+ presId,
457
+ { backendURL = null, preferBrandId = null } = {},
458
+ ) {
366
459
  if (!presId) throw new Error("getDesignBundle: presId is required");
367
- const be = backendURL || (await resolveBackendForPres(presId));
460
+ const be =
461
+ backendURL || (await resolveBackendForPres(presId, { preferBrandId }));
368
462
  return withTransientRetry(async () => {
369
463
  const res = await bridgeFetch(
370
464
  `/api/cli/presentations/${encodeURIComponent(presId)}/design-bundle`,
@@ -375,35 +469,333 @@ export async function getDesignBundle(presId, { backendURL = null } = {}) {
375
469
  });
376
470
  }
377
471
 
472
+ // List the LIVE engine catalog (NOT the bundle's curated set) for the paired
473
+ // backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
474
+ // when several environments are paired this fans out and tags each engine with
475
+ // its short `env` label (never the backendURL — the no-URL rule). Filter by
476
+ // engine `type` (collection|search) and/or `subtype` (image|slide|template|
477
+ // content). Returns a flat `{ engines: [...] }` like the single-backend route.
478
+ async function listEnginesForBackend(backendURL, { type = null, subtype = null } = {}) {
479
+ return withTransientRetry(async () => {
480
+ const params = new URLSearchParams();
481
+ if (type) params.set("type", type);
482
+ if (subtype) params.set("subtype", subtype);
483
+ const qs = params.toString();
484
+ const res = await bridgeFetch(
485
+ `/api/cli/engines${qs ? `?${qs}` : ""}`,
486
+ { timeoutMs: listTimeoutMs() },
487
+ backendURL,
488
+ );
489
+ const data = await res.json();
490
+ const arr = Array.isArray(data?.engines) ? data.engines : [];
491
+ const env = deriveEnvLabel(backendURL);
492
+ return arr.map((e) => ({ ...e, env }));
493
+ });
494
+ }
495
+
496
+ export async function listEngines({ type = null, subtype = null } = {}) {
497
+ const sessions = sessionsInScope();
498
+ const settled = await Promise.allSettled(
499
+ sessions.map((s) => listEnginesForBackend(s.backendURL, { type, subtype })),
500
+ );
501
+ const engines = [];
502
+ const failures = [];
503
+ let fulfilledCount = 0;
504
+ settled.forEach((r, i) => {
505
+ if (r.status === "fulfilled") {
506
+ fulfilledCount += 1;
507
+ engines.push(...r.value);
508
+ } else {
509
+ failures.push({ env: deriveEnvLabel(sessions[i].backendURL), err: r.reason });
510
+ }
511
+ });
512
+ // Mirror listOpenPresentations: only surface an error when nothing answered.
513
+ if (engines.length === 0 && fulfilledCount === 0 && failures.length > 0) {
514
+ if (failures.length === 1) throw failures[0].err;
515
+ throw new Error(
516
+ "slidemind: every paired backend failed to list engines:\n" +
517
+ failures.map((f) => ` ${f.env}: ${f.err.message}`).join("\n"),
518
+ );
519
+ }
520
+ return { engines };
521
+ }
522
+
523
+ // ---------------------------------------------------------------------------
524
+ // Server-side OOXML render
525
+ // ---------------------------------------------------------------------------
526
+ //
527
+ // Two protocols, newest first:
528
+ // 1. Render JOBS (preferred): POST creates a job, the CLI polls cheap status
529
+ // GETs and fetches the result when done. A full deck render routinely
530
+ // outlives the cloud ingress's ~240s request cap — only short polling
531
+ // requests survive that reliably, and they carry per-slide progress.
532
+ // 2. Sync render (fallback): one POST held open for the whole render. Kept
533
+ // for older backends that don't serve /api/cli/capabilities yet.
534
+
535
+ function envInt(name, fallback) {
536
+ const n = parseInt(process.env[name] || "", 10);
537
+ return Number.isFinite(n) && n > 0 ? n : fallback;
538
+ }
539
+
540
+ function sleep(ms) {
541
+ return new Promise((resolve) => setTimeout(resolve, ms));
542
+ }
543
+
544
+ // One capability probe per backend per process. Keyed by the RESOLVED backend
545
+ // URL (callers always resolve before probing), so multiple paired backends on
546
+ // mixed versions each get their own answer.
547
+ const renderJobsCapability = new Map();
548
+
549
+ function backendSupportsRenderJobs(backendURL) {
550
+ let probe = renderJobsCapability.get(backendURL);
551
+ if (!probe) {
552
+ probe = (async () => {
553
+ try {
554
+ const res = await bridgeFetch(
555
+ "/api/cli/capabilities",
556
+ { timeoutMs: listTimeoutMs() },
557
+ backendURL,
558
+ );
559
+ const data = await res.json();
560
+ return data?.renderJobs === true;
561
+ } catch {
562
+ // Older backends 404 this path (with a non-JSON body) — sync render.
563
+ return false;
564
+ }
565
+ })();
566
+ renderJobsCapability.set(backendURL, probe);
567
+ }
568
+ return probe;
569
+ }
570
+
571
+ // Rebuild a job failure into the exact error shape bridgeFetch throws for the
572
+ // sync route, so callers (etag self-heal, MCP error texts) can't tell the
573
+ // protocols apart.
574
+ function jobFailureError(jobError, presId) {
575
+ const status = jobError?.status ?? 500;
576
+ const detail = jobError?.error ?? "render_failed";
577
+ const err = new Error(
578
+ `slidemind bridge ${status} render job failed for ${presId}: ${detail}`,
579
+ );
580
+ err.status = status;
581
+ err.code = detail;
582
+ err.body = { error: detail, ...(jobError?.extra ?? {}) };
583
+ return err;
584
+ }
585
+
586
+ async function renderViaSync({ presId, body, backendURL }) {
587
+ const res = await bridgeFetch(
588
+ `/api/cli/presentations/${encodeURIComponent(presId)}/render-pptx`,
589
+ {
590
+ method: "POST",
591
+ headers: { "content-type": "application/json" },
592
+ body: JSON.stringify(body),
593
+ // Server-side OOXML render is a heavy operation — a full deck of richly
594
+ // composed slides routinely runs well past the 35s default. Treat it like
595
+ // a mutation: give it a generous default and let env override it.
596
+ timeoutMs:
597
+ parseInt(process.env.SLIDEMIND_BRIDGE_RENDER_TIMEOUT_MS || "300000", 10) ||
598
+ 300000,
599
+ },
600
+ backendURL,
601
+ );
602
+ return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
603
+ }
604
+
605
+ async function renderViaJob({ presId, body, backendURL, onProgress }) {
606
+ const pollMs = envInt("SLIDEMIND_BRIDGE_RENDER_POLL_MS", 2500);
607
+ const deadlineMs = envInt("SLIDEMIND_BRIDGE_RENDER_DEADLINE_MS", 900000);
608
+ const resultTimeoutMs = envInt(
609
+ "SLIDEMIND_BRIDGE_RENDER_RESULT_TIMEOUT_MS",
610
+ 120000,
611
+ );
612
+
613
+ const createJob = async () => {
614
+ // Deliberately NOT wrapped in withTransientRetry: its blanket 404 retry
615
+ // would re-upload a multi-MB slide payload, and creation errors (stale
616
+ // etag, no CD, unknown presId) are definitive.
617
+ const res = await bridgeFetch(
618
+ `/api/cli/presentations/${encodeURIComponent(presId)}/render-jobs`,
619
+ {
620
+ method: "POST",
621
+ headers: { "content-type": "application/json" },
622
+ body: JSON.stringify(body),
623
+ timeoutMs:
624
+ parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) ||
625
+ 35000,
626
+ },
627
+ backendURL,
628
+ );
629
+ const data = await res.json();
630
+ if (!data?.jobId) throw new Error("render-jobs returned no jobId");
631
+ return data.jobId;
632
+ };
633
+
634
+ let jobId = await createJob();
635
+ let resubmitted = false;
636
+ let lastReported = "";
637
+ const deadline = Date.now() + deadlineMs;
638
+
639
+ for (;;) {
640
+ if (Date.now() > deadline) {
641
+ const err = new Error(
642
+ `slidemind render did not finish within ${deadlineMs}ms ` +
643
+ "(raise SLIDEMIND_BRIDGE_RENDER_DEADLINE_MS for very large decks). " +
644
+ "The render keeps running server-side — retrying may pick up a warm cache.",
645
+ );
646
+ err.code = "bridge_timeout";
647
+ throw err;
648
+ }
649
+ await sleep(pollMs);
650
+
651
+ let status;
652
+ try {
653
+ const res = await bridgeFetch(
654
+ `/api/cli/render-jobs/${encodeURIComponent(jobId)}`,
655
+ { timeoutMs: 15000 },
656
+ backendURL,
657
+ );
658
+ status = await res.json();
659
+ } catch (err) {
660
+ // The job record vanished (backend Redis flush/restart) — same recovery
661
+ // as a lost replica: resubmit once.
662
+ if (err?.code === "job_not_found") {
663
+ if (resubmitted) throw jobFailureError({ status: 500, error: "job_lost" }, presId);
664
+ resubmitted = true;
665
+ onProgress?.({ state: "restarted" });
666
+ jobId = await createJob();
667
+ continue;
668
+ }
669
+ // Rate-limited poll: honor the window, then resume.
670
+ if (err?.status === 429) {
671
+ const retryAfterSec = err?.body?.retryAfterSec;
672
+ await sleep(Math.min((retryAfterSec || 5) * 1000, 30000));
673
+ continue;
674
+ }
675
+ // Anything else (network blip, 5xx, replica restart) — polling is
676
+ // idempotent; just poll again until the deadline.
677
+ continue;
678
+ }
679
+
680
+ if (onProgress) {
681
+ const snapshot = JSON.stringify([status.state, status.progress]);
682
+ if (snapshot !== lastReported) {
683
+ lastReported = snapshot;
684
+ onProgress({ state: status.state, ...(status.progress ?? {}) });
685
+ }
686
+ }
687
+
688
+ if (status.state === "failed") {
689
+ const jobError = status.error ?? { status: 500, error: "render_failed" };
690
+ // The rendering replica died (deploy, crash, scale-in). Renders are
691
+ // idempotent — resubmit once before giving up.
692
+ if (jobError.error === "job_lost" && !resubmitted) {
693
+ resubmitted = true;
694
+ onProgress?.({ state: "restarted" });
695
+ jobId = await createJob();
696
+ continue;
697
+ }
698
+ throw jobFailureError(jobError, presId);
699
+ }
700
+
701
+ if (status.state === "done") {
702
+ // The result may be tens of MB — generous timeout, and a couple of
703
+ // retries for transient blips (the stored result is re-fetchable).
704
+ for (let attempt = 0; ; attempt += 1) {
705
+ try {
706
+ const res = await bridgeFetch(
707
+ `/api/cli/render-jobs/${encodeURIComponent(jobId)}/result`,
708
+ { timeoutMs: resultTimeoutMs },
709
+ backendURL,
710
+ );
711
+ return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
712
+ } catch (err) {
713
+ if (
714
+ attempt >= 2 ||
715
+ err?.code === "result_expired" ||
716
+ err?.code === "job_not_found"
717
+ ) {
718
+ throw err;
719
+ }
720
+ await sleep(pollMs);
721
+ }
722
+ }
723
+ }
724
+ }
725
+ }
726
+
378
727
  // Server-side OOXML render: ship authored slide JSX (+ deck-local assets) and
379
728
  // get back a native .pptx (base64) rendered against the presentation's own CD.
380
729
  // `slides`: { "<file>.jsx": "<jsx source>" }. `deckAssets`: { "assets/<f>": "<base64>" }.
730
+ // `onProgress` (optional) receives `{ state, done?, total?, stage? }` ticks on
731
+ // the job protocol; the sync fallback emits none.
381
732
  export async function renderPptx({
382
733
  presId,
383
734
  slides,
384
735
  deckAssets = null,
736
+ nativeSlides = null,
385
737
  etag = null,
386
738
  backendURL = null,
739
+ onProgress = null,
387
740
  }) {
388
741
  if (!presId) throw new Error("renderPptx: presId is required");
389
- if (!slides || Object.keys(slides).length === 0) {
390
- throw new Error("renderPptx: slides is required");
742
+ const hasSlides = slides && Object.keys(slides).length > 0;
743
+ const hasNative = nativeSlides && Object.keys(nativeSlides).length > 0;
744
+ if (!hasSlides && !hasNative) {
745
+ throw new Error("renderPptx: slides or nativeSlides is required");
391
746
  }
392
747
  const be = backendURL || (await resolveBackendForPres(presId));
393
- const body = { slides };
748
+ const body = { slides: slides ?? {} };
394
749
  if (deckAssets && Object.keys(deckAssets).length > 0) {
395
750
  body.deckAssets = deckAssets;
396
751
  }
752
+ // Reused native one-slide .pptx (base64), keyed by play-order filename — the
753
+ // server splices them in by numeric-aware sorted filename (contract #7).
754
+ if (hasNative) body.nativeSlides = nativeSlides;
755
+ if (etag) body.etag = etag;
756
+
757
+ if (await backendSupportsRenderJobs(be)) {
758
+ try {
759
+ return await renderViaJob({ presId, body, backendURL: be, onProgress });
760
+ } catch (err) {
761
+ // The backend speaks the protocol but its job store is down — the sync
762
+ // route renders in-process without Redis, so fall back to it.
763
+ if (err?.code !== "jobs_unavailable") throw err;
764
+ }
765
+ }
766
+ return renderViaSync({ presId, body, backendURL: be });
767
+ }
768
+
769
+ // Translate a deck the CLI captured LOCALLY (the shipped capture engine) into a
770
+ // native .pptx. The slow Chrome work already ran client-side, so this is a fast
771
+ // single synchronous request — the server runs only the IP-sensitive boxes→OOXML
772
+ // half. Returns the same shape as renderPptx ({ ok, base64, slideCount, … }).
773
+ export async function translateDeck({
774
+ presId,
775
+ captured,
776
+ nativeSlides = null,
777
+ etag = null,
778
+ backendURL = null,
779
+ }) {
780
+ if (!presId) throw new Error("translateDeck: presId is required");
781
+ if (!captured) throw new Error("translateDeck: captured is required");
782
+ const be = backendURL || (await resolveBackendForPres(presId));
783
+ const body = { captured };
784
+ // Reused native one-slide .pptx (base64), keyed by play-order filename — the
785
+ // server splices them in alongside the locally-captured JSX slides (contract
786
+ // #7). The slow capture already ran client-side; native slides are pass-through.
787
+ if (nativeSlides && Object.keys(nativeSlides).length > 0) {
788
+ body.nativeSlides = nativeSlides;
789
+ }
397
790
  if (etag) body.etag = etag;
398
791
  const res = await bridgeFetch(
399
- `/api/cli/presentations/${encodeURIComponent(presId)}/render-pptx`,
792
+ `/api/cli/presentations/${encodeURIComponent(presId)}/translate`,
400
793
  {
401
794
  method: "POST",
402
795
  headers: { "content-type": "application/json" },
403
796
  body: JSON.stringify(body),
404
- // Server-side OOXML render is a heavy operation — a full deck of richly
405
- // composed slides routinely runs well past the 35s default. Treat it like
406
- // a mutation: give it a generous default and let env override it.
797
+ // The body carries inlined image bytes and the response a base64 .pptx —
798
+ // bandwidth, not server compute. Give it the render-grade timeout.
407
799
  timeoutMs:
408
800
  parseInt(process.env.SLIDEMIND_BRIDGE_RENDER_TIMEOUT_MS || "300000", 10) ||
409
801
  300000,
@@ -413,30 +805,65 @@ export async function renderPptx({
413
805
  return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
414
806
  }
415
807
 
808
+ // Run a pane-relayed engine op; on `pane_suspended` (the server already waited
809
+ // ~25s for a reconnect) wait CLIENT-side for the pane to wake — one actionable
810
+ // line, then a single retry against the live entry (covers presId churn too).
811
+ // Engine ops are idempotent reads, so the retry is safe.
812
+ async function engineOpWithPaneWait(presId, backendURL, attempt) {
813
+ try {
814
+ return await attempt(presId, backendURL);
815
+ } catch (err) {
816
+ if (err?.code !== "pane_suspended") throw err;
817
+ const live = await waitForPaneReady(presId, {
818
+ backendURL,
819
+ onWait: paneWaitNotice,
820
+ });
821
+ if (!live) throw err;
822
+ return attempt(live.presId, live.backendURL ?? backendURL);
823
+ }
824
+ }
825
+
416
826
  // Brand-engine search through the local bridge. New add-ins run this
417
827
  // server-side with the relayed user token; older add-ins may fall back to a
418
- // task-pane proxy. Returns a searchId + numbered result thumbnails.
419
- export async function engineSearch({ presId, engineId, query, backendURL = null }) {
828
+ // task-pane proxy. Returns a searchId + numbered results (image thumbnails, or
829
+ // slide results carrying per-slide text + metadata). `subtype`/`intent`/`top`
830
+ // are passed through for slide engines (image engines ignore intent); omit them
831
+ // and the server uses its defaults (back-compat with older add-ins).
832
+ export async function engineSearch({
833
+ presId,
834
+ engineId,
835
+ query,
836
+ subtype = null,
837
+ intent = null,
838
+ top = null,
839
+ backendURL = null,
840
+ }) {
420
841
  if (!presId) throw new Error("engineSearch: presId is required");
421
842
  if (!engineId) throw new Error("engineSearch: engineId is required");
422
843
  if (!query) throw new Error("engineSearch: query is required");
423
844
  const be = backendURL || (await resolveBackendForPres(presId));
424
- const res = await bridgeFetch(
425
- `/api/cli/presentations/${encodeURIComponent(presId)}/engine/search`,
426
- {
427
- method: "POST",
428
- headers: { "content-type": "application/json" },
429
- body: JSON.stringify({ engineId, query }),
430
- // Engine ops relay through the pane: the server may wait up to ~25s for
431
- // a suspended pane to reconnect, then up to 60s for the pane's ack —
432
- // stay above that sum so typed errors win over client timeouts.
433
- timeoutMs:
434
- parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
435
- 90000,
436
- },
437
- be,
438
- );
439
- return res.json(); // { ok, searchId, resultCount, gridDataUri, thumbnails }
845
+ const body = { engineId, query };
846
+ if (subtype) body.subtype = subtype;
847
+ if (intent) body.intent = intent;
848
+ if (typeof top === "number") body.top = top;
849
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
850
+ const res = await bridgeFetch(
851
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/search`,
852
+ {
853
+ method: "POST",
854
+ headers: { "content-type": "application/json" },
855
+ body: JSON.stringify(body),
856
+ // Engine ops relay through the pane: the server may wait up to ~25s for
857
+ // a suspended pane to reconnect, then up to 60s for the pane's ack —
858
+ // stay above that sum so typed errors win over client timeouts.
859
+ timeoutMs:
860
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
861
+ 90000,
862
+ },
863
+ b,
864
+ );
865
+ return res.json(); // { ok, searchId, engineId, subtype, intent, resultCount, gridDataUri?, results }
866
+ });
440
867
  }
441
868
 
442
869
  // Fetch a chosen engine result's bytes through the local bridge. For image
@@ -459,21 +886,134 @@ export async function engineDownload({
459
886
  const be = backendURL || (await resolveBackendForPres(presId));
460
887
  const body = { searchId, selection };
461
888
  if (iconColor) body.iconColor = iconColor;
462
- const res = await bridgeFetch(
463
- `/api/cli/presentations/${encodeURIComponent(presId)}/engine/download`,
464
- {
465
- method: "POST",
466
- headers: { "content-type": "application/json" },
467
- body: JSON.stringify(body),
468
- // Same relay path as engine/search — outlast the server's reconnect
469
- // wait + pane ack timeout.
470
- timeoutMs:
471
- parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
472
- 90000,
473
- },
474
- be,
475
- );
476
- return res.json(); // { ok, base64, mimeType, svgMarkup }
889
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
890
+ const res = await bridgeFetch(
891
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/download`,
892
+ {
893
+ method: "POST",
894
+ headers: { "content-type": "application/json" },
895
+ body: JSON.stringify(body),
896
+ // Same relay path as engine/search — outlast the server's reconnect
897
+ // wait + pane ack timeout.
898
+ timeoutMs:
899
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
900
+ 90000,
901
+ },
902
+ b,
903
+ );
904
+ return res.json(); // { ok, base64, mimeType, svgMarkup }
905
+ });
906
+ }
907
+
908
+ // Extract a chosen slide-search result as a standalone one-slide .pptx (base64),
909
+ // WITHOUT inserting it anywhere — the "fold into a deck" path. Relayed through
910
+ // the pane like search/download.
911
+ export async function extractSlide({
912
+ presId,
913
+ searchId,
914
+ selection,
915
+ backendURL = null,
916
+ }) {
917
+ if (!presId) throw new Error("extractSlide: presId is required");
918
+ if (typeof searchId !== "number") {
919
+ throw new Error("extractSlide: searchId (number) is required");
920
+ }
921
+ if (typeof selection !== "number") {
922
+ throw new Error("extractSlide: selection (number) is required");
923
+ }
924
+ const be = backendURL || (await resolveBackendForPres(presId));
925
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
926
+ const res = await bridgeFetch(
927
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-extract`,
928
+ {
929
+ method: "POST",
930
+ headers: { "content-type": "application/json" },
931
+ body: JSON.stringify({ searchId, selection }),
932
+ timeoutMs:
933
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
934
+ 90000,
935
+ },
936
+ b,
937
+ );
938
+ return res.json(); // { ok, base64, slideId?, presentationTitle? }
939
+ });
940
+ }
941
+
942
+ // Insert one or more chosen slide-search results live into the open
943
+ // presentation (the "reuse → insert live" path). Relayed through the pane.
944
+ export async function insertSlideLive({
945
+ presId,
946
+ searchId,
947
+ selections,
948
+ at = null,
949
+ backendURL = null,
950
+ }) {
951
+ if (!presId) throw new Error("insertSlideLive: presId is required");
952
+ if (typeof searchId !== "number") {
953
+ throw new Error("insertSlideLive: searchId (number) is required");
954
+ }
955
+ if (!Array.isArray(selections) || selections.length === 0) {
956
+ throw new Error("insertSlideLive: selections (number[]) is required");
957
+ }
958
+ const be = backendURL || (await resolveBackendForPres(presId));
959
+ // Live insert is a MUTATION, so it carries an operationId for the add-in's
960
+ // runMutationOnce dedup (the slide-insert handler keys an LRU on it). Generate
961
+ // it ONCE here, before engineOpWithPaneWait — the body is built once and
962
+ // reused across the wrapper's single pane_suspended retry, so the SAME id
963
+ // rides both attempts and a retry can never double-insert. Mirrors how
964
+ // buildAndPush makes one push operationId reused across attempts.
965
+ const operationId = `insert-${randomUUID()}`;
966
+ const body = { searchId, selections, operationId };
967
+ if (typeof at === "number") body.at = at;
968
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
969
+ const res = await bridgeFetch(
970
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-insert`,
971
+ {
972
+ method: "POST",
973
+ headers: { "content-type": "application/json" },
974
+ body: JSON.stringify(body),
975
+ timeoutMs:
976
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
977
+ 90000,
978
+ },
979
+ b,
980
+ );
981
+ return res.json(); // { ok, insertedCount, insertedSlideIds }
982
+ });
983
+ }
984
+
985
+ // Fetch a chosen slide-search result's thumbnail/preview as a data URI for
986
+ // on-demand viewing (the agent doesn't have to extract the whole .pptx just to
987
+ // look at a slide). Relayed through the pane.
988
+ export async function viewSlide({
989
+ presId,
990
+ searchId,
991
+ selection,
992
+ backendURL = null,
993
+ }) {
994
+ if (!presId) throw new Error("viewSlide: presId is required");
995
+ if (typeof searchId !== "number") {
996
+ throw new Error("viewSlide: searchId (number) is required");
997
+ }
998
+ if (typeof selection !== "number") {
999
+ throw new Error("viewSlide: selection (number) is required");
1000
+ }
1001
+ const be = backendURL || (await resolveBackendForPres(presId));
1002
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
1003
+ const res = await bridgeFetch(
1004
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-view`,
1005
+ {
1006
+ method: "POST",
1007
+ headers: { "content-type": "application/json" },
1008
+ body: JSON.stringify({ searchId, selection }),
1009
+ timeoutMs:
1010
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
1011
+ 90000,
1012
+ },
1013
+ b,
1014
+ );
1015
+ return res.json(); // { ok, dataUri, mimeType }
1016
+ });
477
1017
  }
478
1018
 
479
1019
  export async function insertSlides({
@@ -532,7 +1072,13 @@ export async function replaceSlides({
532
1072
  // `backendURL`/`env` after a fan-out list), or throws with a helpful message if
533
1073
  // the choice is ambiguous. Callers that already hold the entry can pass its
534
1074
  // `backendURL` straight to the targeted ops, skipping a second fan-out.
535
- export function pickPresentationEntry(registry, explicitPresId) {
1075
+ // `preferBrandId` (the deck's bundle-ref presFP) tie-breaks a presId open on
1076
+ // several backends — see tieBreakMatches.
1077
+ export function pickPresentationEntry(
1078
+ registry,
1079
+ explicitPresId,
1080
+ { preferBrandId = null } = {},
1081
+ ) {
536
1082
  if (explicitPresId) {
537
1083
  const matches = registry.filter((p) => p.presId === explicitPresId);
538
1084
  if (matches.length === 0) {
@@ -542,9 +1088,12 @@ export function pickPresentationEntry(registry, explicitPresId) {
542
1088
  );
543
1089
  }
544
1090
  if (matches.length > 1) {
1091
+ const winner = tieBreakMatches(matches, preferBrandId);
1092
+ if (winner) return winner;
545
1093
  const envs = matches.map((m) => m.env || "?").join(", ");
546
1094
  throw new Error(
547
- `Presentation '${explicitPresId}' is open on multiple environments: ${envs}. ` +
1095
+ `Presentation '${explicitPresId}' is open on multiple environments: ${envs}, ` +
1096
+ "and no single connected one has the deck's corporate design active. " +
548
1097
  "Disambiguate with --backend <env|label> (or SLIDEMIND_BACKEND).",
549
1098
  );
550
1099
  }
@@ -557,6 +1106,13 @@ export function pickPresentationEntry(registry, explicitPresId) {
557
1106
  );
558
1107
  }
559
1108
  if (registry.length === 1) return registry[0];
1109
+ // ONE window mirrored across several paired backends is not a user choice —
1110
+ // apply the same tie-break as the explicit-presId path. Several DIFFERENT
1111
+ // windows stay a user decision.
1112
+ if (new Set(registry.map((p) => p.presId)).size === 1) {
1113
+ const winner = tieBreakMatches(registry, preferBrandId);
1114
+ if (winner) return winner;
1115
+ }
560
1116
  const list = registry
561
1117
  .map(
562
1118
  (p) =>
@@ -569,6 +1125,31 @@ export function pickPresentationEntry(registry, explicitPresId) {
569
1125
  );
570
1126
  }
571
1127
 
1128
+ // Window-AGNOSTIC picker for engine ops (discovery / search / result view). The
1129
+ // engine catalog is per-user and NOT bound to any window, so any connected pane
1130
+ // can run the search — there is nothing for the caller to disambiguate. When no
1131
+ // explicit presId is given we attach to one automatically (preferring a live,
1132
+ // non-suspended pane so we don't have to wake a backgrounded one) instead of
1133
+ // throwing the "multiple windows, pick --to" error pickPresentationEntry raises.
1134
+ // An explicit presId still resolves exactly as pickPresentationEntry.
1135
+ //
1136
+ // CAVEAT: the searchId the add-in returns is bound to the pane that ran the
1137
+ // search (each window keeps its own search-context store). So follow-up
1138
+ // view/extract/insert must target the SAME entry this returns — surface its
1139
+ // presId to the caller so it can thread it back.
1140
+ export function pickAnyPresentationEntry(registry, explicitPresId, opts = {}) {
1141
+ if (explicitPresId) return pickPresentationEntry(registry, explicitPresId, opts);
1142
+ if (!Array.isArray(registry) || registry.length === 0) {
1143
+ throw new Error(
1144
+ "No PowerPoint windows registered with the slidemind add-in. " +
1145
+ "Open a presentation, ensure the add-in is loaded, and try again.",
1146
+ );
1147
+ }
1148
+ return (
1149
+ registry.find((p) => p.connected !== false && !p.suspendedAt) ?? registry[0]
1150
+ );
1151
+ }
1152
+
572
1153
  // Back-compat shim: returns just the presId string. Prefer
573
1154
  // pickPresentationEntry when you need the origin backend.
574
1155
  export function pickPresentation(registry, explicitPresId) {