slidemind 0.2.3 → 0.3.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.
@@ -10,14 +10,22 @@ import { randomUUID } from "node:crypto";
10
10
  import fs from "node:fs";
11
11
  import http from "node:http";
12
12
  import https from "node:https";
13
+ import os from "node:os";
13
14
  import path from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
16
 
16
17
  import {
17
18
  deriveEnvLabel,
19
+ envPhrase,
18
20
  getSessionFor,
21
+ isMultiEnv,
19
22
  readSessions,
20
23
  } from "./config.mjs";
24
+ import {
25
+ ensureSignedIn,
26
+ getGraphToken,
27
+ getSharePointToken,
28
+ } from "./graph-auth.mjs";
21
29
  import { updateCliInstruction } from "../install-channel.mjs";
22
30
 
23
31
  // This CLI's own version, for the add-in's minCliVersion floor (see
@@ -150,6 +158,29 @@ function transientRetryDelays() {
150
158
  .filter((n) => Number.isFinite(n) && n >= 0);
151
159
  }
152
160
 
161
+ // A 401 from either bridge transport. Carries the remediation as its message
162
+ // AND a machine-readable shape, because callers that probe several backends in
163
+ // a row have to tell "this environment doesn't have that record" (routing
164
+ // information) from "this session is dead" (the user's problem) — see
165
+ // resolveSearchBackend.
166
+ function sessionInvalidError() {
167
+ const err = new Error(
168
+ "slidemind session is invalid or expired. Run `slidemind logout --all`, then " +
169
+ "reconnect with a fresh command from the add-in's Connect CLI panel.",
170
+ );
171
+ err.status = 401;
172
+ err.code = "session_invalid";
173
+ return err;
174
+ }
175
+
176
+ // True for the 401 class above, and for anything else that arrives with a 401
177
+ // status (an older path, or a rethrown response error).
178
+ export function isSessionInvalidError(err) {
179
+ if (!err) return false;
180
+ if (err.status === 401 || err.code === "session_invalid") return true;
181
+ return /slidemind session is invalid or expired/i.test(String(err.message ?? ""));
182
+ }
183
+
153
184
  function isTransientBridgeError(err) {
154
185
  if (!err) return false;
155
186
  // pane_suspended needs the user (foreground PowerPoint), not a blind retry —
@@ -230,19 +261,14 @@ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
230
261
  } catch (err) {
231
262
  if (err && /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(String(err.message || err))) {
232
263
  throw new Error(
233
- `slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
264
+ `slidemind relay unreachable${envPhrase(deriveEnvLabel(session.backendURL), "on")}. Check your connection; ` +
234
265
  "if it persists, reconnect from the add-in's Connect CLI panel.",
235
266
  );
236
267
  }
237
268
  throw err;
238
269
  }
239
270
  enforceVersionFloor(response);
240
- if (response.status === 401) {
241
- throw new Error(
242
- "slidemind session is invalid or expired. Run `slidemind logout --all`, then " +
243
- "reconnect with a fresh command from the add-in's Connect CLI panel.",
244
- );
245
- }
271
+ if (response.status === 401) throw sessionInvalidError();
246
272
  if (response.status < 200 || response.status >= 300) {
247
273
  let parsed = null;
248
274
  try {
@@ -272,6 +298,10 @@ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
272
298
  return {
273
299
  json: async () => (response.body ? JSON.parse(response.body) : null),
274
300
  text: async () => response.body,
301
+ // Exposed for routes that carry their answer partly in headers (the
302
+ // source-presentation read reports slide counts and truncation there, so a
303
+ // caller learns the deck was windowed without parsing the Markdown).
304
+ headers: response.headers ?? {},
275
305
  };
276
306
  }
277
307
 
@@ -447,19 +477,14 @@ async function bridgeStreamToFile(pathPart, init, destPath, backendURL = null) {
447
477
  )
448
478
  ) {
449
479
  throw new Error(
450
- `slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
480
+ `slidemind relay unreachable${envPhrase(deriveEnvLabel(session.backendURL), "on")}. Check your connection; ` +
451
481
  "if it persists, reconnect from the add-in's Connect CLI panel.",
452
482
  );
453
483
  }
454
484
  throw err;
455
485
  }
456
486
  enforceVersionFloor(response);
457
- if (response.status === 401) {
458
- throw new Error(
459
- "slidemind session is invalid or expired. Run `slidemind logout --all`, then " +
460
- "reconnect with a fresh command from the add-in's Connect CLI panel.",
461
- );
462
- }
487
+ if (response.status === 401) throw sessionInvalidError();
463
488
  if (response.status < 200 || response.status >= 300) {
464
489
  let parsed = null;
465
490
  try {
@@ -797,6 +822,21 @@ export async function requirePaneHost({ timeoutMs = null } = {}) {
797
822
  return requireHost();
798
823
  }
799
824
 
825
+ // "Is a task pane available RIGHT NOW?" — the non-throwing twin of
826
+ // requirePaneHost, for paths where an open pane is an OPTION rather than a
827
+ // precondition (a stale permission snapshot can be routed around by relaying
828
+ // through a live pane, which signs each request with a fresh browser token).
829
+ // Never waits for hydration and never authors a blocker: an empty array means
830
+ // "no, carry on with what you were doing", not "stop".
831
+ export async function paneReadyPresentations() {
832
+ try {
833
+ const { presentations } = await listOpenPresentationsDetailed();
834
+ return presentations.filter(isPaneReadyPresentation);
835
+ } catch {
836
+ return [];
837
+ }
838
+ }
839
+
800
840
  // Tie-break a multi-backend presId collision without bothering the user when
801
841
  // only one candidate can actually serve the deck:
802
842
  // (a) exactly one CONNECTED pane whose active CD matches the deck's bundle
@@ -852,17 +892,23 @@ export async function resolveBackendForPres(
852
892
  // the live registry entry (caller re-targets from it), or null on timeout.
853
893
  // `onWait` fires once with the wait budget in seconds — the caller's single
854
894
  // actionable "click the pane" line.
895
+ //
896
+ // `otherPresIds` are windows the caller already knows are NOT the target (the
897
+ // rest of the registry it picked from). They are excluded from the last-resort
898
+ // design-fingerprint match — see below.
855
899
  export async function waitForPaneReady(
856
900
  presId,
857
901
  {
858
902
  backendURL = null,
859
903
  fileName = null,
860
904
  brandId = null,
905
+ otherPresIds = null,
861
906
  timeoutMs = null,
862
907
  pollMs = null,
863
908
  onWait = null,
864
909
  } = {},
865
910
  ) {
911
+ const notTheTarget = new Set(otherPresIds ?? []);
866
912
  const waitMs =
867
913
  timeoutMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_TIMEOUT_MS", 120000);
868
914
  const stepMs = pollMs ?? envInt("SLIDEMIND_BRIDGE_PANE_READY_POLL_MS", 3000);
@@ -889,6 +935,25 @@ export async function waitForPaneReady(
889
935
  (!brandId || p.brandId === brandId),
890
936
  );
891
937
  if (rematch) return rematch;
938
+ // Last resort: match on the design fingerprint alone (presFP == brandId).
939
+ // A reconnect can report a different name for the same window — the user
940
+ // hit Save As, or the pane reports the title where it used to report the
941
+ // file name — but the active corporate design does not change with it.
942
+ // Only when EXACTLY one connected window carries it: two windows on the
943
+ // same design are indistinguishable, and pushing into the wrong one is
944
+ // worse than waiting.
945
+ //
946
+ // A window the caller ALREADY saw next to the target is not a churned
947
+ // target — it is a different presentation that merely shares the design
948
+ // (most tenants run exactly one), and retargeting to it would append the
949
+ // deck's slides to an unrelated file. Most tenants run a single corporate
950
+ // design, so brandId alone is not window identity.
951
+ if (fileName && brandId) {
952
+ const byBrand = connected.filter(
953
+ (p) => p.brandId === brandId && !notTheTarget.has(p.presId),
954
+ );
955
+ if (byBrand.length === 1) return byBrand[0];
956
+ }
892
957
  }
893
958
  if (Date.now() >= deadline) return null;
894
959
  if (!notified) {
@@ -908,6 +973,16 @@ function paneWaitNotice(seconds) {
908
973
  );
909
974
  }
910
975
 
976
+ // The window is gone from the registry but its pane may already be back under a
977
+ // new presId (churn on every reconnect) — nothing for the user to do here, so
978
+ // say what the CLI is doing rather than asking for a click.
979
+ function paneRetargetNotice(seconds) {
980
+ process.stderr.write(
981
+ "the target PowerPoint window re-registered — re-matching it by corporate " +
982
+ `design (up to ${seconds}s)…\n`,
983
+ );
984
+ }
985
+
911
986
  // GET the full design bundle (tokens, css, layouts, designRules, signed asset
912
987
  // URLs, engines) for the presentation's active corporate design.
913
988
  export async function getDesignBundle(
@@ -985,6 +1060,45 @@ export async function getDesignBundleForBrandAnyBackend(brandId) {
985
1060
  throw err;
986
1061
  }
987
1062
 
1063
+ // The corporate designs this user may build against, straight off the CLI
1064
+ // session — no open window, no pane. One entry per design: `{ presFP, name,
1065
+ // env }`, where presFP IS the brandId every pane-free route is keyed by. Only
1066
+ // backends advertising `brandsList` are asked (older ones have no such route);
1067
+ // a backend that fails is skipped rather than failing the whole answer, so one
1068
+ // unreachable environment can't hide another's designs.
1069
+ export async function listBrands() {
1070
+ const out = [];
1071
+ for (const s of sessionsInScope()) {
1072
+ if (!(await backendSupportsBrandsList(s.backendURL))) continue;
1073
+ const env = deriveEnvLabel(s.backendURL);
1074
+ try {
1075
+ const res = await bridgeFetch(
1076
+ "/api/cli/brands",
1077
+ { timeoutMs: listTimeoutMs() },
1078
+ s.backendURL,
1079
+ );
1080
+ const data = (await res.json()) ?? {};
1081
+ const arr = Array.isArray(data.brands) ? data.brands : [];
1082
+ for (const b of arr) {
1083
+ // The wire may name the design by either of its two equivalent keys
1084
+ // (presFP on the bundle contract, brandId on the routes) — normalize to
1085
+ // the one the deck ref stores.
1086
+ const presFP = b?.presFP ?? b?.brandId ?? b?.id ?? null;
1087
+ if (!presFP) continue;
1088
+ out.push({
1089
+ presFP,
1090
+ name: b?.name ?? b?.designName ?? null,
1091
+ env,
1092
+ backendURL: s.backendURL,
1093
+ });
1094
+ }
1095
+ } catch {
1096
+ /* unreachable / not granted — that environment simply contributes none */
1097
+ }
1098
+ }
1099
+ return out;
1100
+ }
1101
+
988
1102
  // Concept-image generation is a heavy provider call (per-image cap ~180s
989
1103
  // server-side). Budget just over that so a typed server error wins over a
990
1104
  // client timeout, but keep it below the relay's ~240s ingress cap — the CLI
@@ -1151,12 +1265,169 @@ export async function generateImageForBrand(
1151
1265
  throw err;
1152
1266
  }
1153
1267
 
1268
+ // Deterministic PPTX→JSX conversion is a per-slide OOXML parse + a render pass
1269
+ // per converted slide — a whole foreign deck runs in render-grade time, not
1270
+ // list-grade. Budget like the render routes.
1271
+ function convertTimeoutMs() {
1272
+ return (
1273
+ parseInt(process.env.SLIDEMIND_BRIDGE_CONVERT_TIMEOUT_MS || "300000", 10) ||
1274
+ 300000
1275
+ );
1276
+ }
1277
+
1278
+ // Minimal multipart/form-data body. The route takes an uploaded PPTX, and
1279
+ // nodeRequest writes a Buffer verbatim — no fetch/FormData involved, so the
1280
+ // envelope is assembled here (fields first, file last, boundary never appearing
1281
+ // in the parts because it carries a random suffix).
1282
+ function multipartBody(fields, file) {
1283
+ const boundary = `----slidemind${randomUUID().replace(/-/g, "")}`;
1284
+ const chunks = [];
1285
+ for (const [name, value] of Object.entries(fields)) {
1286
+ if (value === null || value === undefined) continue;
1287
+ chunks.push(
1288
+ Buffer.from(
1289
+ `--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`,
1290
+ ),
1291
+ );
1292
+ }
1293
+ // The file name reaches this from a path on disk; a quote or a newline in it
1294
+ // would break out of the Content-Disposition header and forge a part.
1295
+ const safeFileName = String(file.fileName).replace(/[\r\n"\\]/g, "_");
1296
+ chunks.push(
1297
+ Buffer.from(
1298
+ `--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; ` +
1299
+ `filename="${safeFileName}"\r\nContent-Type: ${file.contentType}\r\n\r\n`,
1300
+ ),
1301
+ Buffer.from(file.bytes),
1302
+ Buffer.from(`\r\n--${boundary}--\r\n`),
1303
+ );
1304
+ return {
1305
+ body: Buffer.concat(chunks),
1306
+ contentType: `multipart/form-data; boundary=${boundary}`,
1307
+ };
1308
+ }
1309
+
1310
+ // Whether a backend deterministically converts foreign PPTX slides to jsx
1311
+ // (`POST /api/cli/brands/:brandId/convert-pptx`). Advertised only by add-ins
1312
+ // running with the reverse-conversion flag on; everywhere else `slidemind
1313
+ // adopt` folds untagged slides in as native .pptx exactly as before.
1314
+ export async function backendSupportsConvertPptx(backendURL) {
1315
+ const caps = await backendCapabilities(backendURL);
1316
+ return caps?.convertPptx === true;
1317
+ }
1318
+
1319
+ async function postConvertPptx(brandId, { bytes, fileName, slides }, backendURL) {
1320
+ const { body, contentType } = multipartBody(
1321
+ {
1322
+ // 1-based play-order slide numbers to convert. Omitted = the whole deck;
1323
+ // `adopt` always names the untagged ones so tagged slides are not re-parsed.
1324
+ slides: Array.isArray(slides) && slides.length > 0 ? slides.join(",") : null,
1325
+ },
1326
+ {
1327
+ name: "file",
1328
+ fileName: path.basename(fileName || "deck.pptx"),
1329
+ contentType:
1330
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
1331
+ bytes,
1332
+ },
1333
+ );
1334
+ const res = await bridgeFetch(
1335
+ `/api/cli/brands/${encodeURIComponent(brandId)}/convert-pptx`,
1336
+ {
1337
+ method: "POST",
1338
+ headers: { "content-type": contentType },
1339
+ body,
1340
+ timeoutMs: convertTimeoutMs(),
1341
+ },
1342
+ backendURL,
1343
+ );
1344
+ const data = (await res.json()) ?? {};
1345
+ return { ...data, backendURL };
1346
+ }
1347
+
1348
+ // Convert a FILE deck's foreign slides to converted-dialect jsx over the
1349
+ // pane-free brand route (`slidemind adopt`). Body is multipart: the .pptx plus
1350
+ // the 1-based slide numbers to convert. Returns the server's
1351
+ // `{ slides, layouts, assets, sourceBaselines, conversionEvidence, skipped,
1352
+ // warnings }` (add-in `ConvertPptxResponse`) augmented with the resolved
1353
+ // `backendURL`. Identity and evidence are keyed by each slide's jsx `path`, not
1354
+ // carried on the slide — `src/lib/adopt.mjs` `convertedByNumber()` is the one
1355
+ // place that key shape is unfolded.
1356
+ //
1357
+ // LIVE decks are NOT convertible through this route in v1: converting an open
1358
+ // presentation needs a slide export + tag read over the relay, and neither
1359
+ // event exists (design D9) — the add-in path owns that flow.
1360
+ //
1361
+ // Fan-out and degradation mirror generateConceptImagesForBrand: throws a typed
1362
+ // `convert_route_unavailable` when no paired backend serves the route (older
1363
+ // add-ins, the reverse flag off, unknown brand, missing grant) so `adopt` falls
1364
+ // back to the native fold instead of failing.
1365
+ export async function convertPptxForBrand(
1366
+ brandId,
1367
+ { bytes, fileName = "deck.pptx", slides = null, backendURL = null } = {},
1368
+ ) {
1369
+ if (!brandId) throw new Error("convertPptxForBrand: brandId is required");
1370
+ if (!bytes) throw new Error("convertPptxForBrand: bytes is required");
1371
+ if (backendURL) {
1372
+ return postConvertPptx(brandId, { bytes, fileName, slides }, backendURL);
1373
+ }
1374
+ const sessions = sessionsInScope();
1375
+ const failures = [];
1376
+ for (const s of sessions) {
1377
+ try {
1378
+ if (!(await backendSupportsConvertPptx(s.backendURL))) {
1379
+ failures.push({
1380
+ env: deriveEnvLabel(s.backendURL),
1381
+ reason: "no convert-pptx route",
1382
+ });
1383
+ continue;
1384
+ }
1385
+ return await postConvertPptx(
1386
+ brandId,
1387
+ { bytes, fileName, slides },
1388
+ s.backendURL,
1389
+ );
1390
+ } catch (err) {
1391
+ failures.push({
1392
+ env: deriveEnvLabel(s.backendURL),
1393
+ reason: err instanceof Error ? err.message : String(err),
1394
+ });
1395
+ }
1396
+ }
1397
+ const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
1398
+ const err = new Error(
1399
+ `no paired backend converts foreign slides for design '${brandId}'` +
1400
+ `${detail ? ` (${detail})` : ""}.`,
1401
+ );
1402
+ err.code = "convert_route_unavailable";
1403
+ throw err;
1404
+ }
1405
+
1154
1406
  // List the LIVE engine catalog (NOT the bundle's curated set) for the paired
1155
1407
  // backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
1156
1408
  // when several environments are paired this fans out and tags each engine with
1157
1409
  // its short `env` label (never the backendURL — the no-URL rule). Filter by
1158
1410
  // engine `type` (collection|search) and/or `subtype` (image|slide|template|
1159
- // content). Returns a flat `{ engines: [...] }` like the single-backend route.
1411
+ // content). Returns a flat `{ engines: [...], catalog }` like the single-backend
1412
+ // route.
1413
+ //
1414
+ // `catalog` is the pane-free route's summary — `{totalEngines, totalScopes,
1415
+ // filtered, emptyEngines}`, counted BEFORE the type/subtype filter — and it is
1416
+ // what separates "this filter matched nothing" from "this deployment has no
1417
+ // engines". Older backends (and the pane-relay path) send none; it stays null
1418
+ // there and every caller falls back to its pre-summary wording.
1419
+ function readCatalogSummary(data) {
1420
+ const c = data?.catalog;
1421
+ if (!c || typeof c !== "object" || Array.isArray(c)) return null;
1422
+ const num = (v) => (Number.isFinite(v) ? v : 0);
1423
+ return {
1424
+ totalEngines: num(c.totalEngines),
1425
+ totalScopes: num(c.totalScopes),
1426
+ filtered: Boolean(c.filtered),
1427
+ emptyEngines: num(c.emptyEngines),
1428
+ };
1429
+ }
1430
+
1160
1431
  async function listEnginesForBackend(backendURL, { type = null, subtype = null } = {}) {
1161
1432
  return withTransientRetry(async () => {
1162
1433
  const params = new URLSearchParams();
@@ -1166,29 +1437,69 @@ async function listEnginesForBackend(backendURL, { type = null, subtype = null }
1166
1437
  const res = await bridgeFetch(
1167
1438
  `/api/cli/engines${qs ? `?${qs}` : ""}`,
1168
1439
  // Pane-relayed on the add-in side — needs the engine budget, not the 8s
1169
- // list timeout (which bailed before the pane could ever answer).
1170
- { timeoutMs: engineTimeoutMs() },
1440
+ // list timeout (which bailed before the pane could ever answer). The
1441
+ // delegated tokens authorize the pane-free catalog (login_required
1442
+ // without them); older add-ins ignore the headers.
1443
+ { timeoutMs: engineTimeoutMs(), headers: await graphHeaders(backendURL) },
1171
1444
  backendURL,
1172
1445
  );
1173
1446
  const data = await res.json();
1174
1447
  const arr = Array.isArray(data?.engines) ? data.engines : [];
1175
1448
  const env = deriveEnvLabel(backendURL);
1176
- return arr.map((e) => ({ ...e, env }));
1449
+ return {
1450
+ engines: arr.map((e) => ({ ...e, env })),
1451
+ catalog: readCatalogSummary(data),
1452
+ };
1177
1453
  });
1178
1454
  }
1179
1455
 
1456
+ // A scope (scope:all / scope:sharepoint) is not one environment's object: it is
1457
+ // the same saved search target, named identically on every deployment. Pooling
1458
+ // two backends' catalogs therefore repeats it verbatim — same id, same label —
1459
+ // which reads as two different things to choose between. Real engines DO differ
1460
+ // per environment and keep their duplicates; their `env` tag tells them apart.
1461
+ function isScopeRow(e) {
1462
+ return e?.type === "scope" || /^scope:/.test(String(e?.id ?? ""));
1463
+ }
1464
+
1180
1465
  export async function listEngines({ type = null, subtype = null, backendURL = null } = {}) {
1181
1466
  const sessions = backendURL ? [sessionForBackend(backendURL)] : sessionsInScope();
1182
1467
  const settled = await Promise.allSettled(
1183
1468
  sessions.map((s) => listEnginesForBackend(s.backendURL, { type, subtype })),
1184
1469
  );
1185
1470
  const engines = [];
1471
+ const catalogs = [];
1186
1472
  const failures = [];
1473
+ // Which environments actually contributed. NOT derivable from the pooled rows
1474
+ // any more: a second environment that only repeated the scopes leaves no row
1475
+ // of its own, and the human renderer keys its `[env]` tags off this.
1476
+ const envs = [];
1477
+ // Scope id → the one pooled row kept for it.
1478
+ const scopeRows = new Map();
1187
1479
  let fulfilledCount = 0;
1188
1480
  settled.forEach((r, i) => {
1189
1481
  if (r.status === "fulfilled") {
1190
1482
  fulfilledCount += 1;
1191
- engines.push(...r.value);
1483
+ envs.push(deriveEnvLabel(sessions[i].backendURL));
1484
+ for (const e of r.value.engines) {
1485
+ const id = typeof e?.id === "string" ? e.id : null;
1486
+ if (id && isScopeRow(e)) {
1487
+ const kept = scopeRows.get(id);
1488
+ if (kept) {
1489
+ // Contributed by more than one environment, so it belongs to none
1490
+ // of them in particular — drop the tag rather than crediting
1491
+ // whichever backend happened to answer first.
1492
+ kept.env = null;
1493
+ continue;
1494
+ }
1495
+ const row = { ...e };
1496
+ scopeRows.set(id, row);
1497
+ engines.push(row);
1498
+ continue;
1499
+ }
1500
+ engines.push(e);
1501
+ }
1502
+ if (r.value.catalog) catalogs.push(r.value.catalog);
1192
1503
  } else {
1193
1504
  failures.push({ env: deriveEnvLabel(sessions[i].backendURL), err: r.reason });
1194
1505
  }
@@ -1201,7 +1512,24 @@ export async function listEngines({ type = null, subtype = null, backendURL = nu
1201
1512
  failures.map((f) => ` ${f.env}: ${f.err.message}`).join("\n"),
1202
1513
  );
1203
1514
  }
1204
- return { engines };
1515
+ // Fanned out, the summary is the sum of what each environment reported: the
1516
+ // engines are pooled into one list, so their counts pool too. SCOPES are the
1517
+ // exception — the same named targets everywhere, deduped above — so summing
1518
+ // them double-counts; the distinct total is the largest environment's count,
1519
+ // or the number of distinct scope rows actually pooled when that is larger.
1520
+ // (The catalog counts are pre-filter and the rows post-filter, which is why
1521
+ // both terms are needed.) `filtered` is the same question asked of every
1522
+ // backend, so any one of them saying yes is the answer. Null when no backend
1523
+ // supplied one (old add-in / pane relay).
1524
+ const catalog = catalogs.length
1525
+ ? {
1526
+ totalEngines: catalogs.reduce((n, c) => n + c.totalEngines, 0),
1527
+ totalScopes: Math.max(...catalogs.map((c) => c.totalScopes), scopeRows.size),
1528
+ filtered: catalogs.some((c) => c.filtered),
1529
+ emptyEngines: catalogs.reduce((n, c) => n + c.emptyEngines, 0),
1530
+ }
1531
+ : null;
1532
+ return { engines, catalog, envs };
1205
1533
  }
1206
1534
 
1207
1535
  // ---------------------------------------------------------------------------
@@ -1230,7 +1558,12 @@ function sleep(ms) {
1230
1558
  // mixed versions each get their own answer.
1231
1559
  const backendCapabilitiesCache = new Map();
1232
1560
 
1233
- function backendCapabilities(backendURL) {
1561
+ // The probe result, failure included. Every ordinary caller wants the graceful
1562
+ // `{}` (an older backend simply 404s this path), but a caller that is DIAGNOSING
1563
+ // has to see a 401 — "this session is revoked" must never be reported as "this
1564
+ // add-in is too old". Kept as one cached promise so the distinction costs no
1565
+ // extra round-trip.
1566
+ function backendCapabilitiesResult(backendURL) {
1234
1567
  let probe = backendCapabilitiesCache.get(backendURL);
1235
1568
  if (!probe) {
1236
1569
  probe = (async () => {
@@ -1240,11 +1573,11 @@ function backendCapabilities(backendURL) {
1240
1573
  { timeoutMs: listTimeoutMs() },
1241
1574
  backendURL,
1242
1575
  );
1243
- return (await res.json()) ?? {};
1244
- } catch {
1576
+ return { caps: (await res.json()) ?? {}, error: null };
1577
+ } catch (err) {
1245
1578
  // Older backends 404 this path (with a non-JSON body) — no
1246
1579
  // capabilities: sync render, no version floor.
1247
- return {};
1580
+ return { caps: {}, error: err };
1248
1581
  }
1249
1582
  })();
1250
1583
  backendCapabilitiesCache.set(backendURL, probe);
@@ -1252,6 +1585,10 @@ function backendCapabilities(backendURL) {
1252
1585
  return probe;
1253
1586
  }
1254
1587
 
1588
+ async function backendCapabilities(backendURL) {
1589
+ return (await backendCapabilitiesResult(backendURL)).caps;
1590
+ }
1591
+
1255
1592
  async function backendSupportsRenderJobs(backendURL) {
1256
1593
  const caps = await backendCapabilities(backendURL);
1257
1594
  return caps?.renderJobs === true;
@@ -1284,6 +1621,45 @@ export async function backendSupportsImageGeneration(backendURL) {
1284
1621
  return caps?.imageGeneration === true;
1285
1622
  }
1286
1623
 
1624
+ // Whether a backend can list the corporate designs a user may build against
1625
+ // (`GET /api/cli/brands`). It is what lets `ready` bind a deck with PowerPoint
1626
+ // closed — without it the only way to learn a design's identity is an open
1627
+ // window. Advertised by newer add-ins only.
1628
+ export async function backendSupportsBrandsList(backendURL) {
1629
+ const caps = await backendCapabilities(backendURL);
1630
+ return caps?.brandsList === true;
1631
+ }
1632
+
1633
+ // Whether a backend can read the SOURCE deck behind a chosen slide search
1634
+ // result (`/api/cli/presentations/:presId/source-presentation`). Advertised by
1635
+ // newer add-ins only; older ones omit the flag and the caller must say so
1636
+ // rather than surfacing a bare 404.
1637
+ export async function backendSupportsSourcePresentation(backendURL) {
1638
+ const caps = await backendCapabilities(backendURL);
1639
+ return caps?.sourcePresentation === true;
1640
+ }
1641
+
1642
+ // Pane-free ENGINE routes (`/api/cli/engines`, `/api/cli/searches/*`): engine
1643
+ // discovery, search and every per-result fetch answered off the CLI session
1644
+ // itself instead of being relayed through the task-pane webview — the one
1645
+ // transport that a backgrounded PowerPoint is free to suspend mid-run.
1646
+ // Advertised by newer add-ins only; older ones keep the pane relay, which stays
1647
+ // as the compat path (see the direct-route block further down).
1648
+ export async function backendSupportsEngineRoutes(backendURL) {
1649
+ const caps = await backendCapabilities(backendURL);
1650
+ return caps?.engineRoutes === true;
1651
+ }
1652
+
1653
+ // Whether a backend honours a delegated Microsoft Graph token on the pane-free
1654
+ // engine routes — the CLI's own identity for SharePoint-backed content
1655
+ // (`slidemind login`, src/bridge/graph-auth.mjs). Gates BOTH the attach and the
1656
+ // remediation text: naming `slidemind login` on an add-in that ignores the token
1657
+ // would send a user through a browser flow that changes nothing.
1658
+ export async function backendSupportsGraphDelegated(backendURL) {
1659
+ const caps = await backendCapabilities(backendURL);
1660
+ return caps?.graphDelegated === true;
1661
+ }
1662
+
1287
1663
  // The add-in's hard version floor: /api/cli/capabilities advertises the oldest
1288
1664
  // CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
1289
1665
  // mid-rollout skew otherwise surfaces as confusing route/contract errors deep
@@ -1479,6 +1855,9 @@ export async function renderPptx({
1479
1855
  deckAssets = null,
1480
1856
  nativeSlides = null,
1481
1857
  etag = null,
1858
+ sourceGuids = null,
1859
+ conversions = null,
1860
+ reconcile = null,
1482
1861
  backendURL = null,
1483
1862
  onProgress = null,
1484
1863
  }) {
@@ -1509,6 +1888,30 @@ export async function renderPptx({
1509
1888
  // server splices them in by numeric-aware sorted filename (contract #7).
1510
1889
  if (hasNative) body.nativeSlides = nativeSlides;
1511
1890
  if (etag) body.etag = etag;
1891
+ // Deck-embedded source: known slide GUIDs (jsx path → sm-<uuid>) so the
1892
+ // server's tag bake keeps identities stable; minted ones ride back as
1893
+ // `sourceGuids` on the response.
1894
+ if (sourceGuids && Object.keys(sourceGuids).length > 0) {
1895
+ body.sourceGuids = sourceGuids;
1896
+ }
1897
+ // Reverse-converted slides (`slidemind adopt`): identity + fidelity evidence
1898
+ // per converted jsx file, so the server can gate the first replacement of a
1899
+ // converted slide against what it originally converted, plus the verbatim
1900
+ // source package whose preserved natives (chart / OLE / SmartArt) it splices
1901
+ // back into the rendered slide. Keyed exactly like `slides`. Additive: a deck
1902
+ // with no conversions sends the same body it always did.
1903
+ if (conversions && Object.keys(conversions).length > 0) {
1904
+ body.conversions = conversions;
1905
+ }
1906
+ // Manual-edit reconcile: for every slide the user edited in PowerPoint since
1907
+ // the last build, the baked baseline projection and the current one, keyed
1908
+ // exactly like `slides`. The server diffs them, folds text edits into the jsx
1909
+ // it renders, and reports the rest — the CLI owns no diff source. Additive: a
1910
+ // deck with no pending edits sends the same body it always did, and the
1911
+ // response's `reconcile` echo is what proves the backend understood it.
1912
+ if (reconcile && Object.keys(reconcile).length > 0) {
1913
+ body.reconcile = reconcile;
1914
+ }
1512
1915
  // The brand surface has no presId to label the render with — pass the deck
1513
1916
  // slug so job records/temp dirs stay readable (server-side sanitized).
1514
1917
  if (brandId && deckName) body.deckName = deckName;
@@ -1574,6 +1977,22 @@ export async function translateDeck({
1574
1977
  return res.json(); // { ok, base64, slideCount, warnings, skippedSlides }
1575
1978
  }
1576
1979
 
1980
+ // A 404 the RELAY emitted about the presId itself (the window is no longer in
1981
+ // the registry — a pane reconnect mints a new presId for the same window)
1982
+ // rather than one a ROUTE emitted about something else it was asked for (a
1983
+ // stale `unknown_search`, an unimplemented route). Only the former is worth
1984
+ // re-targeting: the same window is probably back under a new id.
1985
+ //
1986
+ // Read the PARSED code, never `err.code`: bridgeFetch falls that back to the
1987
+ // human error string, and the relay's vanished-presId 404 carries prose only
1988
+ // ("No PowerPoint window registered for presId='X'") — so testing `err.code`
1989
+ // saw a truthy, unmatched value and never retargeted anything.
1990
+ function isMissingPresentationError(err) {
1991
+ if (err?.status !== 404) return false;
1992
+ const code = err?.body?.code ?? null;
1993
+ return !code || code === "unknown_presentation" || code === "presentation_not_found";
1994
+ }
1995
+
1577
1996
  // Run a pane-relayed engine op; on `pane_suspended` (the server already waited
1578
1997
  // ~25s for a reconnect) OR `pane_timeout` (the pane looked connected but never
1579
1998
  // acked within the server's 60s window — the server marks it suspended on the
@@ -1582,22 +2001,346 @@ export async function translateDeck({
1582
2001
  // presId churn too). Engine ops are idempotent reads, and the one mutation
1583
2002
  // (live insert) rides the SAME operationId through the retry so the pane-side
1584
2003
  // dedup makes a re-send safe.
1585
- async function engineOpWithPaneWait(presId, backendURL, attempt) {
2004
+ //
2005
+ // `fileName`/`brandId` identify the WINDOW so the wait can re-match it after a
2006
+ // presId churn (see waitForPaneReady). `retargetOnMissing` additionally treats a
2007
+ // vanished presId as recoverable — opt-in, because for most engine ops a 404 is
2008
+ // the search record talking, not the window: it re-lists the registry, re-matches
2009
+ // by presFP and retries ONCE on a short budget before the caller surfaces the
2010
+ // wake-PowerPoint blocker.
2011
+ async function engineOpWithPaneWait(
2012
+ presId,
2013
+ backendURL,
2014
+ attempt,
2015
+ {
2016
+ fileName = null,
2017
+ brandId = null,
2018
+ otherPresIds = null,
2019
+ retargetOnMissing = false,
2020
+ } = {},
2021
+ ) {
1586
2022
  try {
1587
2023
  return await attempt(presId, backendURL);
1588
2024
  } catch (err) {
1589
- if (err?.code !== "pane_suspended" && err?.code !== "pane_timeout") {
1590
- throw err;
1591
- }
2025
+ const suspended =
2026
+ err?.code === "pane_suspended" || err?.code === "pane_timeout";
2027
+ const churned = retargetOnMissing && isMissingPresentationError(err);
2028
+ if (!suspended && !churned) throw err;
1592
2029
  const live = await waitForPaneReady(presId, {
1593
2030
  backendURL,
1594
- onWait: paneWaitNotice,
2031
+ fileName,
2032
+ brandId,
2033
+ otherPresIds,
2034
+ // A churned presId needs a re-list, not a user: the window is already
2035
+ // there under a new id, so poll briefly instead of holding the full
2036
+ // click-the-pane budget. A suspended pane keeps that budget.
2037
+ timeoutMs: churned && !suspended ? envInt("SLIDEMIND_BRIDGE_PANE_RETARGET_TIMEOUT_MS", 15000) : null,
2038
+ onWait: churned && !suspended ? paneRetargetNotice : paneWaitNotice,
1595
2039
  });
1596
2040
  if (!live) throw err;
1597
2041
  return attempt(live.presId, live.backendURL ?? backendURL);
1598
2042
  }
1599
2043
  }
1600
2044
 
2045
+ // ---------------------------------------------------------------------------
2046
+ // Pane-free engine routes (the DIRECT path)
2047
+ // ---------------------------------------------------------------------------
2048
+ //
2049
+ // Newer add-ins serve the whole engine surface off the CLI session:
2050
+ // GET /api/cli/engines catalog (same path,
2051
+ // answered pane-free)
2052
+ // POST /api/cli/searches → { searchId, results }
2053
+ // GET /api/cli/searches/:id/results/:sel/image stream
2054
+ // GET /api/cli/searches/:id/results/:sel/thumbnail stream
2055
+ // GET /api/cli/searches/:id/results/:sel/slide stream (one-slide .pptx)
2056
+ // GET /api/cli/searches/:id/results/:sel/source-presentation
2057
+ //
2058
+ // The searchId is SERVER-owned (per user, short TTL) rather than bound to a
2059
+ // window's pane, and a request carries only that searchId plus the selection
2060
+ // index — the server resolves the source config it stored at search time, so no
2061
+ // client ever names a file, site or drive.
2062
+ //
2063
+ // How a function picks its path: a `presId` means "relay through THAT window"
2064
+ // (the compat path old add-ins need and live insert always needs); NO presId
2065
+ // plus an explicit `backendURL`/`brandId` means "run this pane-free". Callers
2066
+ // resolve the backend once with resolveEngineBackend(), which returns null when
2067
+ // nothing paired advertises the routes — that null is the signal to fall back
2068
+ // to the pane relay, never a failure.
2069
+
2070
+ function searchRoute(searchId, suffix = "") {
2071
+ return `/api/cli/searches/${encodeURIComponent(String(searchId))}${suffix}`;
2072
+ }
2073
+
2074
+ // A searchId is a number on the pane relay (the pane mints it per window) and an
2075
+ // opaque server-minted id on the direct routes — accept either, reject anything
2076
+ // else before a call goes near the network.
2077
+ function assertSearchId(fn, searchId) {
2078
+ const ok =
2079
+ (typeof searchId === "number" && Number.isFinite(searchId)) ||
2080
+ (typeof searchId === "string" && searchId.trim().length > 0);
2081
+ if (!ok) throw new Error(`${fn}: searchId is required`);
2082
+ }
2083
+
2084
+ // A large asset legitimately takes longer than an engine op: the server is
2085
+ // fetching from SharePoint/blob AND piping. Separate budget so raising it
2086
+ // doesn't loosen every other bridge call.
2087
+ function downloadTimeoutMs() {
2088
+ return (
2089
+ parseInt(process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000", 10) ||
2090
+ 600000
2091
+ );
2092
+ }
2093
+
2094
+ function resultRoute(searchId, selection, leaf, params = null) {
2095
+ const qs = params && [...params.keys()].length > 0 ? `?${params}` : "";
2096
+ return `${searchRoute(searchId, `/results/${encodeURIComponent(String(selection))}/${leaf}`)}${qs}`;
2097
+ }
2098
+
2099
+ // Per-process memo of which backend serves a brand's engine routes, so a
2100
+ // multi-environment session pays the brand probe at most once per design.
2101
+ const engineBackendForBrand = new Map();
2102
+
2103
+ // A searchId is opaque (server-minted, hex) on the direct routes and numeric on
2104
+ // the pane relay. The distinction routes: an opaque id names a record on the
2105
+ // backend that minted it, and the pane can NEVER serve it — its handler only
2106
+ // understands its own per-window numeric ids.
2107
+ export function isOpaqueSearchId(searchId) {
2108
+ return (
2109
+ typeof searchId === "string" &&
2110
+ searchId.trim() !== "" &&
2111
+ !/^\d+$/.test(searchId.trim())
2112
+ );
2113
+ }
2114
+
2115
+ // Which paired backend minted this server-owned search record. Search records
2116
+ // are oid-scoped, so a foreign environment answers the same 404 as an expired
2117
+ // id — probing every engine-routes candidate with the record's own GET is both
2118
+ // exact and cheap (2h TTL, at most a couple of environments). Returns null when
2119
+ // no candidate holds it: expired, or minted by an environment no longer in
2120
+ // scope. Never falls back to the pane — see isOpaqueSearchId.
2121
+ //
2122
+ // One failure class is NOT routing information: a 401 means the session itself
2123
+ // is dead (revoked in the add-in's Connect CLI panel, or aged past its hard
2124
+ // lifetime). Swallowing it reports a revoked session as "that search expired",
2125
+ // and the user then re-runs a search that 401s the same way. So per-candidate
2126
+ // failures are collected and a session-invalid one is rethrown instead.
2127
+ export async function resolveSearchBackend(searchId) {
2128
+ if (!isOpaqueSearchId(searchId)) return null;
2129
+ const failures = [];
2130
+ for (const s of sessionsInScope()) {
2131
+ // A revoked session 401s the capability probe too, and that answer is
2132
+ // indistinguishable from "old add-in" unless the failure is kept.
2133
+ const probe = await backendCapabilitiesResult(s.backendURL);
2134
+ if (probe.error && isSessionInvalidError(probe.error)) {
2135
+ failures.push(probe.error);
2136
+ continue;
2137
+ }
2138
+ if (probe.caps?.engineRoutes !== true) continue;
2139
+ try {
2140
+ await bridgeFetch(searchRoute(searchId), {}, s.backendURL);
2141
+ return s.backendURL;
2142
+ } catch (err) {
2143
+ // 404 (not this environment's record / expired) or unreachable — try the
2144
+ // next candidate; the distinction doesn't matter to routing.
2145
+ failures.push(err);
2146
+ }
2147
+ }
2148
+ // The two failures that are NOT the record talking: a dead session, and a
2149
+ // missing Microsoft 365 sign-in (the record re-read is an engine route, so
2150
+ // it refuses with the rest of corpus search). Reported as "that search
2151
+ // expired", both send the user to re-run a search that fails the same way —
2152
+ // so they outrank the expired-record answer.
2153
+ const blocking = failures.find(
2154
+ (e) => isSessionInvalidError(e) || e?.code === "login_required",
2155
+ );
2156
+ if (blocking) throw blocking;
2157
+ return null;
2158
+ }
2159
+
2160
+ // Which paired backend runs the pane-free engine routes for this call. An
2161
+ // explicit backendURL is honoured as-is (the caller already resolved a window or
2162
+ // a deck). Otherwise: every session in scope that advertises the routes is a
2163
+ // candidate — with ONE connected environment (the normal case) that IS the
2164
+ // answer, and no probe is worth paying for it. With several connected, the
2165
+ // DECK's bound design decides, never an open window's origin. presFP/brandId is
2166
+ // content-derived, so the SAME design onboarded in two environments carries the
2167
+ // SAME id — candidates are therefore probed FIRST, and only when none serves
2168
+ // the design does the caller fall back to the pane relay (the design lives on
2169
+ // an older add-in, and searching a different corpus would fold ITS slides into
2170
+ // this deck). Returns null for that fallback and when nothing paired serves the
2171
+ // routes at all.
2172
+ export async function resolveEngineBackend({ backendURL = null, brandId = null } = {}) {
2173
+ if (backendURL) {
2174
+ return (await backendSupportsEngineRoutes(backendURL)) ? backendURL : null;
2175
+ }
2176
+ const sessions = sessionsInScope();
2177
+ const candidates = [];
2178
+ for (const s of sessions) {
2179
+ if (await backendSupportsEngineRoutes(s.backendURL)) candidates.push(s.backendURL);
2180
+ }
2181
+ if (candidates.length === 0) return null;
2182
+ // Exactly one CONNECTED environment: there is nothing else the deck could
2183
+ // belong to. Note the test is on the sessions, not on the candidates — during
2184
+ // a rollout only one of several environments serves the routes, and taking it
2185
+ // regardless would have searched the wrong corpus.
2186
+ if (sessions.length === 1) return candidates[0];
2187
+ if (brandId) {
2188
+ // The memo only stands while that environment is still a candidate.
2189
+ const memo = engineBackendForBrand.get(brandId);
2190
+ if (memo) return candidates.includes(memo) ? memo : null;
2191
+ // Candidates first: an environment that serves both the design AND the
2192
+ // engine routes wins outright, even when an older environment also has the
2193
+ // (content-identical) design.
2194
+ for (const be of candidates) {
2195
+ try {
2196
+ await getDesignBundleForBrand(brandId, { backendURL: be });
2197
+ engineBackendForBrand.set(brandId, be);
2198
+ return be;
2199
+ } catch {
2200
+ // This candidate doesn't serve the design — try the next.
2201
+ }
2202
+ }
2203
+ try {
2204
+ const { backendURL: be } = await getDesignBundleForBrandAnyBackend(brandId);
2205
+ engineBackendForBrand.set(brandId, be);
2206
+ // Only a non-candidate can answer here: the design's environment is an
2207
+ // older add-in with no engine routes. null sends the caller to that
2208
+ // environment's pane relay instead of a different corpus.
2209
+ return null;
2210
+ } catch {
2211
+ // No backend owns the design (unknown brand, old add-in) — fall through
2212
+ // rather than guessing an environment.
2213
+ }
2214
+ }
2215
+ if (candidates.length === 1) return candidates[0];
2216
+ const err = new Error(
2217
+ `Several connected environments run engine search (${candidates
2218
+ .map((u) => deriveEnvLabel(u))
2219
+ .join(", ")}). Scope the command with --backend <env>, point it at a ` +
2220
+ "bound deck (--deck) so the deck's design picks one, or name an open " +
2221
+ "window with --to <presId> to relay through it.",
2222
+ );
2223
+ err.code = "engine_backend_ambiguous";
2224
+ throw err;
2225
+ }
2226
+
2227
+ // Which connected environments answer the pane-free engine routes — the
2228
+ // "backend authoring ready" half of the readiness picture (`doctor`, `ready`),
2229
+ // reported as ENV LABELS so no URL can reach a printed line. Pass
2230
+ // connectionStatus()'s `backends` to skip environments that are already known
2231
+ // unreachable; without it every session in scope is probed.
2232
+ export async function engineReachability({ backends = null } = {}) {
2233
+ const urls = Array.isArray(backends)
2234
+ ? backends.filter((b) => b?.ok && b.backendURL).map((b) => b.backendURL)
2235
+ : sessionsInScope().map((s) => s.backendURL);
2236
+ const envs = [];
2237
+ for (const url of urls) {
2238
+ if (await backendSupportsEngineRoutes(url)) envs.push(deriveEnvLabel(url));
2239
+ }
2240
+ return { available: envs.length > 0, envs };
2241
+ }
2242
+
2243
+ // Resolve + verify the backend for a direct engine call. Throws a typed
2244
+ // `engine_routes_unavailable` when the caller asked for the pane-free path on a
2245
+ // backend that doesn't serve it — a programming error by then, since callers go
2246
+ // through resolveEngineBackend first, but it must never surface as a bare 404.
2247
+ async function directEngineBackend(fn, { backendURL = null, brandId = null }) {
2248
+ const be = backendURL ?? (await resolveEngineBackend({ brandId }));
2249
+ if (be && (await backendSupportsEngineRoutes(be))) return be;
2250
+ const err = new Error(
2251
+ `${fn}: no connected slidemind add-in serves pane-free engine routes` +
2252
+ `${be ? ` ('${deriveEnvLabel(be)}' is too old)` : ""}. ` +
2253
+ "Open PowerPoint with the slidemind pane so the search can be relayed, or update the add-in.",
2254
+ );
2255
+ err.code = "engine_routes_unavailable";
2256
+ throw err;
2257
+ }
2258
+
2259
+ // The user's delegated tokens for a backend, or nulls. Attached ONLY to the
2260
+ // pane-free engine ops: they are the user's own Entra identity, and render,
2261
+ // bundle, whoami and the pane relay have no business carrying them (the pane
2262
+ // signs its own requests with a browser token that is strictly fresher).
2263
+ //
2264
+ // When nothing is stored, the sign-in RUNS HERE — browser opens, the command
2265
+ // blocks until the human finishes (graph-auth.ensureSignedIn, once per
2266
+ // process). So login is not a separate ritual: the first engine command of a
2267
+ // fresh machine walks the user through it, and every later one rides the
2268
+ // stored refresh token silently. Null only when the add-in doesn't honour the
2269
+ // tokens, or the sign-in was refused/abandoned — the backend then answers its
2270
+ // typed `login_required`, which the blocker below renders.
2271
+ async function engineGraphToken(backendURL) {
2272
+ if (!backendURL) return null;
2273
+ if (!(await backendSupportsGraphDelegated(backendURL))) return null;
2274
+ let token = await getGraphToken(backendURL);
2275
+ if (!token && (await ensureSignedIn(backendURL))) {
2276
+ token = await getGraphToken(backendURL);
2277
+ }
2278
+ return token;
2279
+ }
2280
+
2281
+ // The SharePoint-resource sibling (same sign-in, same refresh token): the
2282
+ // backend resolves `sg:` site-group membership from the PAIR, and on a
2283
+ // SharePoint-configured deployment refuses corpus search without it.
2284
+ async function engineSpToken(backendURL) {
2285
+ if (!backendURL) return null;
2286
+ if (!(await backendSupportsGraphDelegated(backendURL))) return null;
2287
+ let token = await getSharePointToken(backendURL);
2288
+ if (!token && (await ensureSignedIn(backendURL))) {
2289
+ token = await getSharePointToken(backendURL);
2290
+ }
2291
+ return token;
2292
+ }
2293
+
2294
+ // Both delegated tokens as body fields, for the POST routes. Empty when there
2295
+ // is nothing to attach, so an older backend sees the body it always did.
2296
+ async function delegatedBodyFields(backendURL) {
2297
+ const [graphToken, spToken] = await Promise.all([
2298
+ engineGraphToken(backendURL),
2299
+ engineSpToken(backendURL),
2300
+ ]);
2301
+ return {
2302
+ ...(graphToken ? { graphApiToken: graphToken } : {}),
2303
+ ...(spToken ? { spApiToken: spToken } : {}),
2304
+ };
2305
+ }
2306
+
2307
+ // The byte routes are GETs, so the delegated tokens ride headers instead of
2308
+ // body fields. Empty (not absent-valued) when there is no token, so the request
2309
+ // headers are unchanged for everyone who never signed in.
2310
+ async function graphHeaders(backendURL) {
2311
+ const [graphToken, spToken] = await Promise.all([
2312
+ engineGraphToken(backendURL),
2313
+ engineSpToken(backendURL),
2314
+ ]);
2315
+ return {
2316
+ ...(graphToken ? { "x-slidemind-graph-token": graphToken } : {}),
2317
+ ...(spToken ? { "x-slidemind-sp-token": spToken } : {}),
2318
+ };
2319
+ }
2320
+
2321
+ // Corpus search runs as the USER: the pane-free engine routes resolve the
2322
+ // caller's group membership live from the delegated tokens `slidemind login`
2323
+ // minted, and answer `401 { code: "login_required", action: "…" }` without
2324
+ // them. Shape it like the connection ladder's rungs — `{ code, actor, message }`
2325
+ // — so the CLI can print ONE actionable line and an agent knows it cannot clear
2326
+ // this one itself (the sign-in needs a browser and a human). Returns null for
2327
+ // any other error.
2328
+ export function loginRequiredBlocker(err) {
2329
+ if (err?.code !== "login_required") return null;
2330
+ const action =
2331
+ typeof err?.body?.action === "string" && err.body.action.trim()
2332
+ ? err.body.action.trim()
2333
+ : "run `slidemind login` to sign in to Microsoft 365";
2334
+ return {
2335
+ code: "login_required",
2336
+ actor: "user",
2337
+ message:
2338
+ `slidemind needs your Microsoft 365 sign-in before it can search the corpus — ${action}. ` +
2339
+ "Everything else (render, bundle, imagery already downloaded) keeps working.",
2340
+ };
2341
+ }
2342
+
2343
+
1601
2344
  // Brand-engine search through the local bridge. New add-ins run this
1602
2345
  // server-side with the relayed user token; older add-ins may fall back to a
1603
2346
  // task-pane proxy. Returns a searchId + numbered results (image thumbnails, or
@@ -1609,8 +2352,12 @@ async function engineOpWithPaneWait(presId, backendURL, attempt) {
1609
2352
  // an unfiltered search over the whole corpus rather than one engine's saved
1610
2353
  // folder filter. A scope has no subtype to derive its index from, so it needs an
1611
2354
  // explicit `resultType`; the add-in rejects a scope search without one.
2355
+ //
2356
+ // With NO presId and a resolved `backendURL`/`brandId` this runs pane-free
2357
+ // against `POST /api/cli/searches` (same body) and tags the answer with the
2358
+ // backend that owns the searchId.
1612
2359
  export async function engineSearch({
1613
- presId,
2360
+ presId = null,
1614
2361
  engineId,
1615
2362
  query,
1616
2363
  subtype = null,
@@ -1618,22 +2365,48 @@ export async function engineSearch({
1618
2365
  intent = null,
1619
2366
  top = null,
1620
2367
  backendURL = null,
2368
+ brandId = null,
1621
2369
  // When the chosen pane is suspended, wait CLIENT-side for THIS window to wake
1622
2370
  // (the single-window default). engineSearchAnyPane sets this false for all but
1623
2371
  // the last candidate so it can fail over to another live window instead.
1624
2372
  waitOnSuspend = true,
1625
2373
  timeoutMs = null,
1626
2374
  }) {
1627
- if (!presId) throw new Error("engineSearch: presId is required");
1628
2375
  if (!engineId) throw new Error("engineSearch: engineId is required");
1629
2376
  if (!query) throw new Error("engineSearch: query is required");
1630
- const be = backendURL || (await resolveBackendForPres(presId));
2377
+ const direct = !presId && Boolean(backendURL || brandId);
2378
+ if (!presId && !direct) throw new Error("engineSearch: presId is required");
1631
2379
  const body = { engineId, query };
1632
2380
  if (subtype) body.subtype = subtype;
1633
2381
  // Additive: omitted unless set, so the body an older add-in sees is unchanged.
1634
2382
  if (resultType) body.resultType = resultType;
1635
2383
  if (intent) body.intent = intent;
1636
2384
  if (typeof top === "number") body.top = top;
2385
+ if (direct) {
2386
+ const be = await directEngineBackend("engineSearch", { backendURL, brandId });
2387
+ // The user's own identity: the backend resolves the caller's group
2388
+ // membership from these tokens per request and refuses without them
2389
+ // (login_required). Search also seeds the speculative insert cache
2390
+ // server-side, which is why it carries the Graph token as well as the
2391
+ // byte GETs.
2392
+ const delegated = await delegatedBodyFields(be);
2393
+ const res = await bridgeFetch(
2394
+ "/api/cli/searches",
2395
+ {
2396
+ method: "POST",
2397
+ headers: { "content-type": "application/json" },
2398
+ body: JSON.stringify({ ...body, ...delegated }),
2399
+ timeoutMs: timeoutMs ?? engineTimeoutMs(),
2400
+ },
2401
+ be,
2402
+ );
2403
+ const data = (await res.json()) ?? {};
2404
+ // Tag the routing state (like the brand routes do) so every follow-up pick
2405
+ // hits the backend that owns the searchId. `env` is the printable half —
2406
+ // callers must never surface backendURL.
2407
+ return { ...data, backendURL: be, env: deriveEnvLabel(be) };
2408
+ }
2409
+ const be = backendURL || (await resolveBackendForPres(presId));
1637
2410
  const run = async (pid, b) => {
1638
2411
  const res = await bridgeFetch(
1639
2412
  `/api/cli/presentations/${encodeURIComponent(pid)}/engine/search`,
@@ -1731,6 +2504,11 @@ export async function engineSearchAnyPane({
1731
2504
  // Fetch a chosen engine result's bytes through the local bridge. For image
1732
2505
  // engines this returns SVG/PNG bytes (+ optional recoloured svgMarkup) WITHOUT
1733
2506
  // inserting onto a slide.
2507
+ //
2508
+ // Pane-only by design: the pane-free route table streams (see
2509
+ // engineDownloadToFile), and this base64-through-the-ack path only exists for
2510
+ // add-ins that predate streaming — which by definition predate the direct
2511
+ // routes too.
1734
2512
  export async function engineDownload({
1735
2513
  presId,
1736
2514
  searchId,
@@ -1739,9 +2517,7 @@ export async function engineDownload({
1739
2517
  backendURL = null,
1740
2518
  }) {
1741
2519
  if (!presId) throw new Error("engineDownload: presId is required");
1742
- if (typeof searchId !== "number") {
1743
- throw new Error("engineDownload: searchId (number) is required");
1744
- }
2520
+ assertSearchId("engineDownload", searchId);
1745
2521
  if (typeof selection !== "number") {
1746
2522
  throw new Error("engineDownload: selection (number) is required");
1747
2523
  }
@@ -1778,46 +2554,32 @@ export async function engineDownload({
1778
2554
  // byte stream: it is kilobytes and its recolour happens pane-side. Callers get
1779
2555
  // `{ svgMarkup }` in that case and `{ bytesWritten, mimeType }` otherwise, so they
1780
2556
  // can persist whichever arrived.
2557
+ //
2558
+ // With NO presId and a resolved `backendURL`/`brandId` this streams pane-free
2559
+ // from `GET /api/cli/searches/:searchId/results/:selection/image` — same answer
2560
+ // shape, no window involved.
1781
2561
  export async function engineDownloadToFile({
1782
- presId,
2562
+ presId = null,
1783
2563
  searchId,
1784
2564
  selection,
1785
2565
  destPath,
1786
2566
  iconColor = null,
1787
2567
  backendURL = null,
2568
+ brandId = null,
1788
2569
  onProgress = null,
1789
2570
  }) {
1790
- if (!presId) throw new Error("engineDownloadToFile: presId is required");
1791
- if (typeof searchId !== "number") {
1792
- throw new Error("engineDownloadToFile: searchId (number) is required");
2571
+ const direct = !presId && Boolean(backendURL || brandId);
2572
+ if (!presId && !direct) {
2573
+ throw new Error("engineDownloadToFile: presId is required");
1793
2574
  }
2575
+ assertSearchId("engineDownloadToFile", searchId);
1794
2576
  if (typeof selection !== "number") {
1795
2577
  throw new Error("engineDownloadToFile: selection (number) is required");
1796
2578
  }
1797
2579
  if (!destPath) throw new Error("engineDownloadToFile: destPath is required");
1798
- const be = backendURL || (await resolveBackendForPres(presId));
1799
- const body = { searchId, selection };
1800
- if (iconColor) body.iconColor = iconColor;
1801
- return engineOpWithPaneWait(presId, be, async (pid, b) => {
1802
- const res = await bridgeStreamToFile(
1803
- `/api/cli/presentations/${encodeURIComponent(pid)}/engine/download-stream`,
1804
- {
1805
- method: "POST",
1806
- headers: { "content-type": "application/json" },
1807
- body: JSON.stringify(body),
1808
- // A large image legitimately takes longer than an engine op: the server
1809
- // is fetching from SharePoint/blob AND piping. Separate budget so raising
1810
- // it doesn't loosen every other relay call.
1811
- timeoutMs:
1812
- parseInt(
1813
- process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
1814
- 10,
1815
- ) || 600000,
1816
- onProgress,
1817
- },
1818
- destPath,
1819
- b,
1820
- );
2580
+ // Both paths answer with the same two shapes (streamed bytes, or the inline
2581
+ // SVG as JSON), so the response shaping is authored once.
2582
+ const shape = (res) => {
1821
2583
  if (!res.streamed) {
1822
2584
  // The inline-SVG answer (kilobytes) arrives as JSON, not as a byte stream.
1823
2585
  const parsed = res.body ? JSON.parse(res.body) : {};
@@ -1839,6 +2601,46 @@ export async function engineDownloadToFile({
1839
2601
  bytesWritten: res.bytesWritten,
1840
2602
  ...(warnings ? { warnings } : {}),
1841
2603
  };
2604
+ };
2605
+ if (direct) {
2606
+ const b = await directEngineBackend("engineDownloadToFile", {
2607
+ backendURL,
2608
+ brandId,
2609
+ });
2610
+ // Only the recolour rides the query string — the RESOURCE is never
2611
+ // client-supplied on this path; the server resolves it from searchId+selection.
2612
+ const params = new URLSearchParams();
2613
+ if (iconColor) params.set("iconColor", iconColor);
2614
+ const res = await bridgeStreamToFile(
2615
+ resultRoute(searchId, selection, "image", params),
2616
+ {
2617
+ method: "GET",
2618
+ headers: await graphHeaders(b),
2619
+ timeoutMs: downloadTimeoutMs(),
2620
+ onProgress,
2621
+ },
2622
+ destPath,
2623
+ b,
2624
+ );
2625
+ return shape(res);
2626
+ }
2627
+ const be = backendURL || (await resolveBackendForPres(presId));
2628
+ const body = { searchId, selection };
2629
+ if (iconColor) body.iconColor = iconColor;
2630
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
2631
+ const res = await bridgeStreamToFile(
2632
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/download-stream`,
2633
+ {
2634
+ method: "POST",
2635
+ headers: { "content-type": "application/json" },
2636
+ body: JSON.stringify(body),
2637
+ timeoutMs: downloadTimeoutMs(),
2638
+ onProgress,
2639
+ },
2640
+ destPath,
2641
+ b,
2642
+ );
2643
+ return shape(res);
1842
2644
  });
1843
2645
  }
1844
2646
 
@@ -1847,22 +2649,52 @@ export async function engineDownloadToFile({
1847
2649
  // request, and the add-in server performs it and pipes the (base64-decoded) bytes
1848
2650
  // here. `extractSlide` puts the whole slide through the pane's Socket.IO ack as
1849
2651
  // base64, so it is capped at ~7 MB of real bytes — which real reused slides reach.
2652
+ //
2653
+ // With NO presId and a resolved `backendURL`/`brandId` this streams pane-free
2654
+ // from `GET /api/cli/searches/:searchId/results/:selection/slide`.
1850
2655
  export async function extractSlideToFile({
1851
- presId,
2656
+ presId = null,
1852
2657
  searchId,
1853
2658
  selection,
1854
2659
  destPath,
1855
2660
  backendURL = null,
2661
+ brandId = null,
1856
2662
  onProgress = null,
1857
2663
  }) {
1858
- if (!presId) throw new Error("extractSlideToFile: presId is required");
1859
- if (typeof searchId !== "number") {
1860
- throw new Error("extractSlideToFile: searchId (number) is required");
1861
- }
2664
+ const direct = !presId && Boolean(backendURL || brandId);
2665
+ if (!presId && !direct) throw new Error("extractSlideToFile: presId is required");
2666
+ assertSearchId("extractSlideToFile", searchId);
1862
2667
  if (typeof selection !== "number") {
1863
2668
  throw new Error("extractSlideToFile: selection (number) is required");
1864
2669
  }
1865
2670
  if (!destPath) throw new Error("extractSlideToFile: destPath is required");
2671
+ const shape = (res) => {
2672
+ const title = res.headers?.["x-slidemind-presentation-title"];
2673
+ return {
2674
+ ok: true,
2675
+ bytesWritten: res.bytesWritten,
2676
+ slideId: res.headers?.["x-slidemind-slide-id"] ?? undefined,
2677
+ presentationTitle: title ? decodeURIComponent(String(title)) : undefined,
2678
+ };
2679
+ };
2680
+ if (direct) {
2681
+ const b = await directEngineBackend("extractSlideToFile", {
2682
+ backendURL,
2683
+ brandId,
2684
+ });
2685
+ const res = await bridgeStreamToFile(
2686
+ resultRoute(searchId, selection, "slide"),
2687
+ {
2688
+ method: "GET",
2689
+ headers: await graphHeaders(b),
2690
+ timeoutMs: downloadTimeoutMs(),
2691
+ onProgress,
2692
+ },
2693
+ destPath,
2694
+ b,
2695
+ );
2696
+ return shape(res);
2697
+ }
1866
2698
  const be = backendURL || (await resolveBackendForPres(presId));
1867
2699
  return engineOpWithPaneWait(presId, be, async (pid, b) => {
1868
2700
  const res = await bridgeStreamToFile(
@@ -1871,29 +2703,21 @@ export async function extractSlideToFile({
1871
2703
  method: "POST",
1872
2704
  headers: { "content-type": "application/json" },
1873
2705
  body: JSON.stringify({ searchId, selection }),
1874
- timeoutMs:
1875
- parseInt(
1876
- process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
1877
- 10,
1878
- ) || 600000,
2706
+ timeoutMs: downloadTimeoutMs(),
1879
2707
  onProgress,
1880
2708
  },
1881
2709
  destPath,
1882
2710
  b,
1883
2711
  );
1884
- const title = res.headers?.["x-slidemind-presentation-title"];
1885
- return {
1886
- ok: true,
1887
- bytesWritten: res.bytesWritten,
1888
- slideId: res.headers?.["x-slidemind-slide-id"] ?? undefined,
1889
- presentationTitle: title ? decodeURIComponent(String(title)) : undefined,
1890
- };
2712
+ return shape(res);
1891
2713
  });
1892
2714
  }
1893
2715
 
1894
2716
  // Extract a chosen slide-search result as a standalone one-slide .pptx (base64),
1895
2717
  // WITHOUT inserting it anywhere — the "fold into a deck" path. Relayed through
1896
- // the pane like search/download.
2718
+ // the pane like search/download, and pane-only for the same reason as
2719
+ // engineDownload: the pane-free route streams (extractSlideToFile above), and
2720
+ // this capped ack path exists only for add-ins that predate it.
1897
2721
  export async function extractSlide({
1898
2722
  presId,
1899
2723
  searchId,
@@ -1901,9 +2725,7 @@ export async function extractSlide({
1901
2725
  backendURL = null,
1902
2726
  }) {
1903
2727
  if (!presId) throw new Error("extractSlide: presId is required");
1904
- if (typeof searchId !== "number") {
1905
- throw new Error("extractSlide: searchId (number) is required");
1906
- }
2728
+ assertSearchId("extractSlide", searchId);
1907
2729
  if (typeof selection !== "number") {
1908
2730
  throw new Error("extractSlide: selection (number) is required");
1909
2731
  }
@@ -1927,17 +2749,23 @@ export async function extractSlide({
1927
2749
 
1928
2750
  // Insert one or more chosen slide-search results live into the open
1929
2751
  // presentation (the "reuse → insert live" path). Relayed through the pane.
2752
+ //
2753
+ // `fileName`/`brandId` identify the target WINDOW (brandId == the deck's presFP).
2754
+ // They cost nothing on the happy path and are what lets the single retry find
2755
+ // the same window again after a pane reconnect churned its presId — the same
2756
+ // self-healing `push` does around its insert.
1930
2757
  export async function insertSlideLive({
1931
2758
  presId,
1932
2759
  searchId,
1933
2760
  selections,
1934
2761
  at = null,
1935
2762
  backendURL = null,
2763
+ fileName = null,
2764
+ brandId = null,
2765
+ otherPresIds = null,
1936
2766
  }) {
1937
2767
  if (!presId) throw new Error("insertSlideLive: presId is required");
1938
- if (typeof searchId !== "number") {
1939
- throw new Error("insertSlideLive: searchId (number) is required");
1940
- }
2768
+ assertSearchId("insertSlideLive", searchId);
1941
2769
  if (!Array.isArray(selections) || selections.length === 0) {
1942
2770
  throw new Error("insertSlideLive: selections (number[]) is required");
1943
2771
  }
@@ -1951,54 +2779,283 @@ export async function insertSlideLive({
1951
2779
  const operationId = `insert-${randomUUID()}`;
1952
2780
  const body = { searchId, selections, operationId };
1953
2781
  if (typeof at === "number") body.at = at;
2782
+ return engineOpWithPaneWait(
2783
+ presId,
2784
+ be,
2785
+ async (pid, b) => {
2786
+ const res = await bridgeFetch(
2787
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-insert`,
2788
+ {
2789
+ method: "POST",
2790
+ headers: { "content-type": "application/json" },
2791
+ body: JSON.stringify(body),
2792
+ timeoutMs:
2793
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
2794
+ 90000,
2795
+ },
2796
+ b,
2797
+ );
2798
+ // Tag the window the insert actually landed in: after a retarget it is no
2799
+ // longer the presId the caller passed, and the caller reports it.
2800
+ return { ...(await res.json()), presId: pid }; // { ok, insertedCount, insertedSlideIds, presId }
2801
+ },
2802
+ // A vanished presId is recoverable HERE (unlike the engine reads, whose
2803
+ // 404s are about the search record): the insert never landed, so re-listing
2804
+ // and retrying against the re-registered window is safe — and the same
2805
+ // operationId rides along, so even an ambiguous first attempt can't
2806
+ // double-insert.
2807
+ { fileName, brandId, otherPresIds, retargetOnMissing: true },
2808
+ );
2809
+ }
2810
+
2811
+ // Reuse chosen slide-search results live when the search record is the
2812
+ // BACKEND's rather than a pane's.
2813
+ //
2814
+ // The pane's slide-insert handler resolves a selection out of the in-browser
2815
+ // search context IT minted (a numeric id); a searchId from the pane-free search
2816
+ // is server-owned and opaque, so relaying it would answer `searchId_required`.
2817
+ // The two legs `add-slide --search` + `push` already run therefore do the job in
2818
+ // the other order: extract each pick pane-free, then hand the BYTES to the
2819
+ // window's own insert route. Live insert stays pane-bound (Office.js is the only
2820
+ // thing that can type into an open deck) — only the searchId resolution moves.
2821
+ export async function insertSlidesFromSearch({
2822
+ presId,
2823
+ searchId,
2824
+ selections,
2825
+ at = null,
2826
+ backendURL = null,
2827
+ engineBackendURL = null,
2828
+ fileName = null,
2829
+ brandId = null,
2830
+ otherPresIds = null,
2831
+ onProgress = null,
2832
+ }) {
2833
+ if (!presId) throw new Error("insertSlidesFromSearch: presId is required");
2834
+ assertSearchId("insertSlidesFromSearch", searchId);
2835
+ if (!Array.isArray(selections) || selections.length === 0) {
2836
+ throw new Error("insertSlidesFromSearch: selections (number[]) is required");
2837
+ }
2838
+ const be = backendURL || (await resolveBackendForPres(presId));
2839
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-reuse-"));
2840
+ try {
2841
+ let insertedCount = 0;
2842
+ let livePresId = presId;
2843
+ let liveBackendURL = be;
2844
+ const insertedSlideIds = [];
2845
+ for (const [i, selection] of selections.entries()) {
2846
+ const slidePath = path.join(dir, `pick-${selection}.pptx`);
2847
+ const extracted = await extractSlideToFile({
2848
+ searchId,
2849
+ selection,
2850
+ destPath: slidePath,
2851
+ backendURL: engineBackendURL,
2852
+ brandId,
2853
+ onProgress,
2854
+ });
2855
+ if (!extracted?.bytesWritten) {
2856
+ throw new Error(`selection ${selection} produced no slide bytes`);
2857
+ }
2858
+ // One insert per pick, each with its own dedup key, so a retry inside
2859
+ // engineOpWithPaneWait re-sends THAT pick and nothing else. `at` counts
2860
+ // forward so the picks land in the order they were named.
2861
+ const operationId = `insert-${randomUUID()}`;
2862
+ const base64 = fs.readFileSync(slidePath).toString("base64");
2863
+ const resp = await engineOpWithPaneWait(
2864
+ livePresId,
2865
+ liveBackendURL,
2866
+ async (pid, b) => {
2867
+ const ack = await insertSlides({
2868
+ presId: pid,
2869
+ base64,
2870
+ insertAt: typeof at === "number" ? at + i : null,
2871
+ sourceSlideIds: extracted.slideId ? [extracted.slideId] : null,
2872
+ operationId,
2873
+ backendURL: b,
2874
+ });
2875
+ return { ...ack, presId: pid, backendURL: b };
2876
+ },
2877
+ { fileName, brandId, otherPresIds, retargetOnMissing: true },
2878
+ );
2879
+ // A retarget moves every remaining pick to the window that answered, so
2880
+ // a multi-pick reuse cannot end up split across two presentations.
2881
+ livePresId = resp.presId ?? livePresId;
2882
+ liveBackendURL = resp.backendURL ?? liveBackendURL;
2883
+ const landed = Array.isArray(resp.insertedSlideIds)
2884
+ ? resp.insertedSlideIds
2885
+ : [];
2886
+ insertedCount += landed.length || 1;
2887
+ insertedSlideIds.push(...landed);
2888
+ fs.rmSync(slidePath, { force: true });
2889
+ }
2890
+ return { ok: true, insertedCount, insertedSlideIds, presId: livePresId };
2891
+ } finally {
2892
+ fs.rmSync(dir, { recursive: true, force: true });
2893
+ }
2894
+ }
2895
+
2896
+ // Fetch a chosen slide-search result's thumbnail/preview as a data URI for
2897
+ // on-demand viewing (the agent doesn't have to extract the whole .pptx just to
2898
+ // look at a slide). Relayed through the pane.
2899
+ //
2900
+ // With NO presId and a resolved `backendURL`/`brandId` this reads the pane-free
2901
+ // `GET …/results/:selection/thumbnail`, which STREAMS: a thumbnail is small, so
2902
+ // it is buffered back into the same `{ dataUri }` contract rather than forcing
2903
+ // every caller to grow a file path.
2904
+ export async function viewSlide({
2905
+ presId = null,
2906
+ searchId,
2907
+ selection,
2908
+ backendURL = null,
2909
+ brandId = null,
2910
+ }) {
2911
+ const direct = !presId && Boolean(backendURL || brandId);
2912
+ if (!presId && !direct) throw new Error("viewSlide: presId is required");
2913
+ assertSearchId("viewSlide", searchId);
2914
+ if (typeof selection !== "number") {
2915
+ throw new Error("viewSlide: selection (number) is required");
2916
+ }
2917
+ if (direct) {
2918
+ const b = await directEngineBackend("viewSlide", { backendURL, brandId });
2919
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-thumb-"));
2920
+ const tmp = path.join(dir, "thumbnail.bin");
2921
+ try {
2922
+ const res = await bridgeStreamToFile(
2923
+ resultRoute(searchId, selection, "thumbnail"),
2924
+ {
2925
+ method: "GET",
2926
+ headers: await graphHeaders(b),
2927
+ timeoutMs: engineTimeoutMs(),
2928
+ },
2929
+ tmp,
2930
+ b,
2931
+ );
2932
+ if (!res.streamed) {
2933
+ // An add-in that answers with an inline preview passes straight through.
2934
+ return res.body ? JSON.parse(res.body) : { ok: false };
2935
+ }
2936
+ const mimeType =
2937
+ String(res.headers?.["content-type"] ?? "").split(";")[0].trim() ||
2938
+ "image/png";
2939
+ return {
2940
+ ok: true,
2941
+ mimeType,
2942
+ dataUri: `data:${mimeType};base64,${fs.readFileSync(tmp).toString("base64")}`,
2943
+ };
2944
+ } finally {
2945
+ fs.rmSync(dir, { recursive: true, force: true });
2946
+ }
2947
+ }
2948
+ const be = backendURL || (await resolveBackendForPres(presId));
1954
2949
  return engineOpWithPaneWait(presId, be, async (pid, b) => {
1955
2950
  const res = await bridgeFetch(
1956
- `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-insert`,
2951
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-view`,
1957
2952
  {
1958
2953
  method: "POST",
1959
2954
  headers: { "content-type": "application/json" },
1960
- body: JSON.stringify(body),
2955
+ body: JSON.stringify({ searchId, selection }),
1961
2956
  timeoutMs:
1962
2957
  parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
1963
2958
  90000,
1964
2959
  },
1965
2960
  b,
1966
2961
  );
1967
- return res.json(); // { ok, insertedCount, insertedSlideIds }
2962
+ return res.json(); // { ok, dataUri, mimeType }
1968
2963
  });
1969
2964
  }
1970
2965
 
1971
- // Fetch a chosen slide-search result's thumbnail/preview as a data URI for
1972
- // on-demand viewing (the agent doesn't have to extract the whole .pptx just to
1973
- // look at a slide). Relayed through the pane.
1974
- export async function viewSlide({
1975
- presId,
2966
+ // Read the ORIGINAL deck a chosen slide-search result came from — NOT the open
2967
+ // window (that is getPresentationContent below). Answers "what else is in the
2968
+ // presentation this slide belongs to?": the surrounding narrative, the slides
2969
+ // either side, the argument the source deck was making.
2970
+ //
2971
+ // The add-in reads it out of the slide index rather than downloading the source
2972
+ // file, so this is fast and works the same for blob- and SharePoint-backed
2973
+ // results — but the text is the last SYNCED extraction, not the live file. The
2974
+ // response is Markdown, and the pane never carries it: the relay route resolves
2975
+ // the selection through the pane and performs the backend read itself.
2976
+ //
2977
+ // searchId is presentation-scoped on the pane path, so that path relays through
2978
+ // the window the search ran on, like viewSlide. Idempotent, so the
2979
+ // pane_suspended retry is safe. With NO presId and a resolved
2980
+ // `backendURL`/`brandId` it reads the pane-free
2981
+ // `GET …/results/:selection/source-presentation` instead (the searchId is
2982
+ // server-owned there, so no window is involved at all).
2983
+ export async function readSourcePresentation({
2984
+ presId = null,
1976
2985
  searchId,
1977
2986
  selection,
2987
+ fromSlide = null,
2988
+ toSlide = null,
1978
2989
  backendURL = null,
2990
+ brandId = null,
1979
2991
  }) {
1980
- if (!presId) throw new Error("viewSlide: presId is required");
1981
- if (typeof searchId !== "number") {
1982
- throw new Error("viewSlide: searchId (number) is required");
2992
+ const direct = !presId && Boolean(backendURL || brandId);
2993
+ if (!presId && !direct) {
2994
+ throw new Error("readSourcePresentation: presId is required");
1983
2995
  }
2996
+ assertSearchId("readSourcePresentation", searchId);
1984
2997
  if (typeof selection !== "number") {
1985
- throw new Error("viewSlide: selection (number) is required");
2998
+ throw new Error("readSourcePresentation: selection (number) is required");
2999
+ }
3000
+ // Both paths answer with Markdown plus the counts/truncation in headers.
3001
+ const shape = async (res) => ({
3002
+ ok: true,
3003
+ markdown: await res.text(),
3004
+ truncated: res.headers?.["x-slidemind-truncated"] === "1",
3005
+ slideCount: Number(res.headers?.["x-slidemind-slide-count"] ?? 0) || 0,
3006
+ totalSlideCount:
3007
+ Number(res.headers?.["x-slidemind-total-slide-count"] ?? 0) || 0,
3008
+ presentationTitle: res.headers?.["x-slidemind-presentation-title"]
3009
+ ? decodeURIComponent(String(res.headers["x-slidemind-presentation-title"]))
3010
+ : undefined,
3011
+ });
3012
+ if (direct) {
3013
+ // The direct route table carries source-presentation, so engineRoutes is the
3014
+ // capability that gates it here (sourcePresentation gates the pane route).
3015
+ const b = await directEngineBackend("readSourcePresentation", {
3016
+ backendURL,
3017
+ brandId,
3018
+ });
3019
+ const params = new URLSearchParams();
3020
+ if (typeof fromSlide === "number") params.set("fromSlide", String(fromSlide));
3021
+ if (typeof toSlide === "number") params.set("toSlide", String(toSlide));
3022
+ const res = await bridgeFetch(
3023
+ resultRoute(searchId, selection, "source-presentation", params),
3024
+ {
3025
+ method: "GET",
3026
+ headers: { accept: "text/markdown, text/plain" },
3027
+ timeoutMs: engineTimeoutMs(),
3028
+ },
3029
+ b,
3030
+ );
3031
+ return shape(res);
1986
3032
  }
1987
3033
  const be = backendURL || (await resolveBackendForPres(presId));
3034
+ if (!(await backendSupportsSourcePresentation(be))) {
3035
+ const err = new Error(
3036
+ "This slidemind add-in cannot read a result's source presentation yet. Update the add-in, then try again.",
3037
+ );
3038
+ err.code = "source_presentation_unsupported";
3039
+ throw err;
3040
+ }
3041
+ const body = { searchId, selection };
3042
+ if (typeof fromSlide === "number") body.fromSlide = fromSlide;
3043
+ if (typeof toSlide === "number") body.toSlide = toSlide;
1988
3044
  return engineOpWithPaneWait(presId, be, async (pid, b) => {
1989
3045
  const res = await bridgeFetch(
1990
- `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-view`,
3046
+ `/api/cli/presentations/${encodeURIComponent(pid)}/source-presentation`,
1991
3047
  {
1992
3048
  method: "POST",
1993
- headers: { "content-type": "application/json" },
1994
- body: JSON.stringify({ searchId, selection }),
1995
- timeoutMs:
1996
- parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
1997
- 90000,
3049
+ headers: {
3050
+ "content-type": "application/json",
3051
+ accept: "text/markdown, text/plain",
3052
+ },
3053
+ body: JSON.stringify(body),
3054
+ timeoutMs: engineTimeoutMs(),
1998
3055
  },
1999
3056
  b,
2000
3057
  );
2001
- return res.json(); // { ok, dataUri, mimeType }
3058
+ return shape(res);
2002
3059
  });
2003
3060
  }
2004
3061
 
@@ -2048,12 +3105,18 @@ export async function insertSlides({
2048
3105
  insertAt = null,
2049
3106
  sourceSlideIds = null,
2050
3107
  operationId = null,
3108
+ sourceTagsBySlide = null,
2051
3109
  backendURL = null,
2052
3110
  }) {
2053
3111
  if (!presId) throw new Error("insertSlides: presId is required");
2054
3112
  if (!base64) throw new Error("insertSlides: base64 is required");
2055
3113
  const be = backendURL || (await resolveBackendForPres(presId));
2056
3114
  const body = { base64, insertAt, sourceSlideIds, operationId };
3115
+ // Deck-embedded source payloads for the pane's post-insert tag write,
3116
+ // aligned with the inserted slides in document order.
3117
+ if (Array.isArray(sourceTagsBySlide) && sourceTagsBySlide.some(Boolean)) {
3118
+ body.sourceTagsBySlide = sourceTagsBySlide;
3119
+ }
2057
3120
  const res = await bridgeFetch(
2058
3121
  `/api/cli/presentations/${encodeURIComponent(presId)}/insert`,
2059
3122
  {
@@ -2069,21 +3132,65 @@ export async function insertSlides({
2069
3132
  return ensurePaneAckOk(await res.json().catch(() => ({})), "insert");
2070
3133
  }
2071
3134
 
3135
+ // Read-only sibling of the mutation ops: export the LIVE slides carrying the
3136
+ // given GUIDs, so a `--replace` push can see what the user changed on screen
3137
+ // BEFORE it wipes it (plan Phase C1). `guids` omitted ⇒ the whole deck.
3138
+ //
3139
+ // The ack's `tags` come from the Office.js tag API, NOT from the exported
3140
+ // bytes: exportAsBase64 strips custom tags (office-js #6104), so a slide's
3141
+ // drift BASELINE is recoverable only from `tags` — never from `base64`, which
3142
+ // carries the CURRENT state and nothing else. `deck.unmatched` lists the live
3143
+ // slides that are not in the requested set (what a whole-deck replace would
3144
+ // delete); `truncated` names GUIDs that matched but did not fit the relay
3145
+ // frame, to be re-requested.
3146
+ export async function exportSlides({
3147
+ presId,
3148
+ guids = null,
3149
+ presentationId = null,
3150
+ backendURL = null,
3151
+ }) {
3152
+ if (!presId) throw new Error("exportSlides: presId is required");
3153
+ const be = backendURL || (await resolveBackendForPres(presId));
3154
+ const body = {};
3155
+ if (Array.isArray(guids) && guids.length > 0) body.guids = guids;
3156
+ if (presentationId) body.presentationId = presentationId;
3157
+ const res = await bridgeFetch(
3158
+ `/api/cli/presentations/${encodeURIComponent(presId)}/export-slides`,
3159
+ {
3160
+ method: "POST",
3161
+ headers: { "content-type": "application/json" },
3162
+ body: JSON.stringify(body),
3163
+ // Same pane round trip as the mutations (a suspended pane has to wake),
3164
+ // and the payload is slide bytes.
3165
+ timeoutMs:
3166
+ parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
3167
+ 1805000,
3168
+ },
3169
+ be,
3170
+ );
3171
+ return ensurePaneAckOk(await res.json().catch(() => ({})), "export-slides");
3172
+ }
3173
+
2072
3174
  export async function replaceSlides({
2073
3175
  presId,
2074
3176
  base64,
2075
3177
  operationId = null,
3178
+ sourceTagsBySlide = null,
2076
3179
  backendURL = null,
2077
3180
  }) {
2078
3181
  if (!presId) throw new Error("replaceSlides: presId is required");
2079
3182
  if (!base64) throw new Error("replaceSlides: base64 is required");
2080
3183
  const be = backendURL || (await resolveBackendForPres(presId));
3184
+ const replaceBody = { base64, operationId };
3185
+ if (Array.isArray(sourceTagsBySlide) && sourceTagsBySlide.some(Boolean)) {
3186
+ replaceBody.sourceTagsBySlide = sourceTagsBySlide;
3187
+ }
2081
3188
  const res = await bridgeFetch(
2082
3189
  `/api/cli/presentations/${encodeURIComponent(presId)}/replace`,
2083
3190
  {
2084
3191
  method: "POST",
2085
3192
  headers: { "content-type": "application/json" },
2086
- body: JSON.stringify({ base64, operationId }),
3193
+ body: JSON.stringify(replaceBody),
2087
3194
  timeoutMs:
2088
3195
  parseInt(process.env.SLIDEMIND_BRIDGE_MUTATION_TIMEOUT_MS || "1805000", 10) ||
2089
3196
  1805000,
@@ -2242,7 +3349,7 @@ export function pickPresentationEntry(
2242
3349
  const list = registry
2243
3350
  .map(
2244
3351
  (p) =>
2245
- ` ${p.presId}\t${p.env ? `[${p.env}]\t` : ""}${p.title || p.fileName || "(untitled)"}` +
3352
+ ` ${p.presId}\t${p.env && isMultiEnv() ? `[${p.env}]\t` : ""}${p.title || p.fileName || "(untitled)"}` +
2246
3353
  (p.connected === false || p.suspendedAt ? "\t(suspended — PowerPoint in background)" : ""),
2247
3354
  )
2248
3355
  .join("\n");