slidemind 0.1.3 → 0.1.5

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