slidemind 0.1.9 → 0.2.1

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.
@@ -17,6 +17,7 @@ import {
17
17
  getSessionFor,
18
18
  readSessions,
19
19
  } from "./config.mjs";
20
+ import { updateCliInstruction } from "../install-channel.mjs";
20
21
 
21
22
  // This CLI's own version, for the add-in's minCliVersion floor (see
22
23
  // assertCliVersionSupported). Read lazily from the package manifest so the
@@ -51,6 +52,41 @@ export function compareCliVersions(a, b) {
51
52
  return 0;
52
53
  }
53
54
 
55
+ // The one error every version-floor check raises, so the probe path
56
+ // (assertCliVersionSupported) and the per-response header path are
57
+ // indistinguishable to callers and to the skills that relay the text.
58
+ function cliUpdateRequiredError(min) {
59
+ const err = new Error(
60
+ `slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
61
+ `supports (${min}). ${updateCliInstruction()}`,
62
+ );
63
+ err.code = "cli_update_required";
64
+ return err;
65
+ }
66
+
67
+ // `doctor` and `ready` exist to EXPLAIN a broken connection, version skew
68
+ // included — they render it as a structured blocker with an `actor` and a
69
+ // nextAction. Throwing the raw error out of bridgeFetch would break the one
70
+ // command that diagnoses the problem, so they opt out of the header check
71
+ // (their own ladder still reports cli_update_required, terminally).
72
+ let diagnosticMode = false;
73
+ export function setDiagnosticMode(on) {
74
+ diagnosticMode = on !== false;
75
+ }
76
+
77
+ // Every add-in response carries the version floor in `x-slidemind-min-cli`.
78
+ // Checking it here covers routes no capability probe guards — the pane ops
79
+ // (search/read/insert) and pairing itself — for zero extra round-trips, and it
80
+ // fires BEFORE the status handling below so a stale CLI hitting a route that
81
+ // does not exist yet reads "run slidemind update" instead of a bare 404.
82
+ function enforceVersionFloor(response) {
83
+ if (diagnosticMode) return;
84
+ const min = response?.headers?.["x-slidemind-min-cli"];
85
+ if (typeof min !== "string" || !min) return;
86
+ if (compareCliVersions(cliVersion(), min) >= 0) return;
87
+ throw cliUpdateRequiredError(min);
88
+ }
89
+
54
90
  function isLoopbackHostname(hostname) {
55
91
  return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1";
56
92
  }
@@ -199,6 +235,7 @@ async function bridgeFetch(pathPart, init = {}, backendURL = null) {
199
235
  }
200
236
  throw err;
201
237
  }
238
+ enforceVersionFloor(response);
202
239
  if (response.status === 401) {
203
240
  throw new Error(
204
241
  "slidemind session is invalid or expired. Re-pair: open the add-in, " +
@@ -267,6 +304,7 @@ function nodeRequest(urlString, init = {}) {
267
304
  resolve({
268
305
  status: res.statusCode ?? 0,
269
306
  statusText: res.statusMessage ?? "",
307
+ headers: res.headers,
270
308
  body: Buffer.concat(chunks).toString("utf8"),
271
309
  });
272
310
  });
@@ -290,6 +328,163 @@ function nodeRequest(urlString, init = {}) {
290
328
  });
291
329
  }
292
330
 
331
+ // A streaming sibling of `nodeRequest`, for responses too large to hold in
332
+ // memory. `nodeRequest` concatenates the whole body and decodes it as UTF-8 —
333
+ // fine for JSON, but it would both balloon memory and CORRUPT binary. This pipes
334
+ // a success response straight to `destPath` and never materializes it.
335
+ //
336
+ // Error and JSON bodies are still buffered: both are small by construction (an
337
+ // error message, or the inline SVG the download route answers with), and the
338
+ // caller needs them whole to act on.
339
+ function nodeRequestToFile(urlString, init = {}, destPath) {
340
+ const url = new URL(urlString);
341
+ const isHttps = url.protocol === "https:";
342
+ const transport = isHttps ? https : http;
343
+ const body = init.body ?? null;
344
+ const headers = { ...(init.headers ?? {}) };
345
+ if (
346
+ body &&
347
+ headers["content-length"] === undefined &&
348
+ headers["Content-Length"] === undefined
349
+ ) {
350
+ headers["content-length"] = Buffer.byteLength(body);
351
+ }
352
+ const timeoutMs =
353
+ init.timeoutMs ??
354
+ (parseInt(process.env.SLIDEMIND_BRIDGE_TIMEOUT_MS || "35000", 10) || 35000);
355
+
356
+ return new Promise((resolve, reject) => {
357
+ const req = transport.request(
358
+ url,
359
+ {
360
+ method: init.method ?? "GET",
361
+ headers,
362
+ rejectUnauthorized: isHttps
363
+ ? !isLoopbackHostname(url.hostname)
364
+ : undefined,
365
+ },
366
+ (res) => {
367
+ const status = res.statusCode ?? 0;
368
+ const contentType = String(res.headers["content-type"] ?? "");
369
+ const buffered = status < 200 || status >= 300 || contentType.includes("application/json");
370
+ if (buffered) {
371
+ const chunks = [];
372
+ res.on("data", (c) => chunks.push(Buffer.from(c)));
373
+ res.on("end", () =>
374
+ resolve({
375
+ status,
376
+ statusText: res.statusMessage ?? "",
377
+ headers: res.headers,
378
+ body: Buffer.concat(chunks).toString("utf8"),
379
+ streamed: false,
380
+ }),
381
+ );
382
+ res.on("error", reject);
383
+ return;
384
+ }
385
+ const out = fs.createWriteStream(destPath);
386
+ let bytesWritten = 0;
387
+ const total = Number.parseInt(
388
+ String(res.headers["content-length"] ?? ""),
389
+ 10,
390
+ );
391
+ res.on("data", (c) => {
392
+ bytesWritten += c.length;
393
+ init.onProgress?.(bytesWritten, Number.isFinite(total) ? total : null);
394
+ });
395
+ res.pipe(out);
396
+ // A stream that dies mid-flight must reject, not resolve with a truncated
397
+ // file — the caller deletes the partial and surfaces the failure.
398
+ res.on("error", (err) => {
399
+ out.destroy();
400
+ reject(err);
401
+ });
402
+ out.on("error", reject);
403
+ out.on("finish", () =>
404
+ resolve({
405
+ status,
406
+ statusText: res.statusMessage ?? "",
407
+ headers: res.headers,
408
+ bytesWritten,
409
+ streamed: true,
410
+ }),
411
+ );
412
+ },
413
+ );
414
+ req.setTimeout(timeoutMs, () => {
415
+ const err = new Error(
416
+ `slidemind bridge request timed out after ${timeoutMs}ms`,
417
+ );
418
+ err.code = "bridge_timeout";
419
+ req.destroy(err);
420
+ });
421
+ req.on("error", reject);
422
+ if (body) req.write(body);
423
+ req.end();
424
+ });
425
+ }
426
+
427
+ // `bridgeFetch` for a response streamed to disk. Shares the session lookup,
428
+ // auth header and non-2xx error mapping so a streaming call fails exactly like
429
+ // every other bridge call.
430
+ async function bridgeStreamToFile(pathPart, init, destPath, backendURL = null) {
431
+ const session = sessionForBackend(backendURL);
432
+ const url = `${session.backendURL}${pathPart}`;
433
+ const headers = {
434
+ accept: "application/octet-stream, application/json",
435
+ ...init.headers,
436
+ authorization: `Bearer ${session.sessionToken}`,
437
+ };
438
+ let response;
439
+ try {
440
+ response = await nodeRequestToFile(url, { ...init, headers }, destPath);
441
+ } catch (err) {
442
+ if (
443
+ err &&
444
+ /ECONNREFUSED|ECONNRESET|ENOTFOUND|self-signed|fetch failed/i.test(
445
+ String(err.message || err),
446
+ )
447
+ ) {
448
+ throw new Error(
449
+ `slidemind relay unreachable (${deriveEnvLabel(session.backendURL)}). Check your connection; ` +
450
+ "if it persists, re-pair with `slidemind pair`.",
451
+ );
452
+ }
453
+ throw err;
454
+ }
455
+ enforceVersionFloor(response);
456
+ if (response.status === 401) {
457
+ throw new Error(
458
+ "slidemind session is invalid or expired. Re-pair: open the add-in, " +
459
+ 'click "Connect CLI", and run the shown `slidemind pair …`.',
460
+ );
461
+ }
462
+ if (response.status < 200 || response.status >= 300) {
463
+ let parsed = null;
464
+ try {
465
+ parsed = response.body ? JSON.parse(response.body) : null;
466
+ } catch {
467
+ /* non-JSON error body */
468
+ }
469
+ const machineCode =
470
+ parsed && typeof parsed.code === "string" ? parsed.code : null;
471
+ const humanError =
472
+ parsed && typeof parsed.error === "string" ? parsed.error : null;
473
+ const detail =
474
+ humanError ?? (response.body ? response.body.slice(0, 400) : "");
475
+ const err = new Error(
476
+ `slidemind bridge ${response.status} ${response.statusText} for ${pathPart}` +
477
+ (detail ? `: ${detail}` : ""),
478
+ );
479
+ err.status = response.status;
480
+ const code = machineCode ?? humanError;
481
+ if (code) err.code = code;
482
+ if (parsed) err.body = parsed;
483
+ throw err;
484
+ }
485
+ return response;
486
+ }
487
+
293
488
  // List one backend's open presentations, tagging each entry with its origin
294
489
  // (backendURL + a short env label) so the merged registry can be routed and
295
490
  // displayed per environment. Transient blips retry per-backend.
@@ -879,6 +1074,82 @@ export async function generateConceptImagesForBrand(
879
1074
  throw err;
880
1075
  }
881
1076
 
1077
+ // gpt-image calls routinely take 60-120s; budget like the concept relay so a
1078
+ // typed server error wins over a client timeout.
1079
+ function imagenTimeoutMs() {
1080
+ return (
1081
+ parseInt(process.env.SLIDEMIND_BRIDGE_IMAGEN_TIMEOUT_MS || "200000", 10) ||
1082
+ 200000
1083
+ );
1084
+ }
1085
+
1086
+ async function postGenerateImage(brandId, body, backendURL) {
1087
+ const res = await bridgeFetch(
1088
+ `/api/cli/brands/${encodeURIComponent(brandId)}/images`,
1089
+ {
1090
+ method: "POST",
1091
+ headers: { "content-type": "application/json" },
1092
+ body: JSON.stringify(body),
1093
+ timeoutMs: imagenTimeoutMs(),
1094
+ },
1095
+ backendURL,
1096
+ );
1097
+ const data = (await res.json()) ?? {};
1098
+ return { ...data, backendURL };
1099
+ }
1100
+
1101
+ // Generate ONE regular image over the pane-free brand route
1102
+ // (`slidemind imagen`). Body `{ prompt, size?, quality?, brandStyle? }`;
1103
+ // returns the server's `{ ok, image, styleReferences }` augmented with the
1104
+ // resolved `backendURL`. Fan-out and degradation mirror
1105
+ // generateConceptImagesForBrand: throws a typed `imagen_route_unavailable`
1106
+ // when no paired backend serves the route (older add-ins, unknown brand,
1107
+ // missing grant) so callers can print the one actionable fix.
1108
+ export async function generateImageForBrand(
1109
+ brandId,
1110
+ { prompt, size = null, quality = null, brandStyle = false, backendURL = null } = {},
1111
+ ) {
1112
+ if (!brandId) {
1113
+ throw new Error("generateImageForBrand: brandId is required");
1114
+ }
1115
+ if (typeof prompt !== "string" || !prompt.trim()) {
1116
+ throw new Error("generateImageForBrand: prompt is required");
1117
+ }
1118
+ const body = { prompt };
1119
+ if (size) body.size = size;
1120
+ if (quality) body.quality = quality;
1121
+ if (brandStyle) body.brandStyle = true;
1122
+ if (backendURL) {
1123
+ return postGenerateImage(brandId, body, backendURL);
1124
+ }
1125
+ const sessions = sessionsInScope();
1126
+ const failures = [];
1127
+ for (const s of sessions) {
1128
+ try {
1129
+ if (!(await backendSupportsImageGeneration(s.backendURL))) {
1130
+ failures.push({
1131
+ env: deriveEnvLabel(s.backendURL),
1132
+ reason: "no image-generation route (update the add-in)",
1133
+ });
1134
+ continue;
1135
+ }
1136
+ return await postGenerateImage(brandId, body, s.backendURL);
1137
+ } catch (err) {
1138
+ failures.push({
1139
+ env: deriveEnvLabel(s.backendURL),
1140
+ reason: err instanceof Error ? err.message : String(err),
1141
+ });
1142
+ }
1143
+ }
1144
+ const detail = failures.map((f) => `${f.env}: ${f.reason}`).join("; ");
1145
+ const err = new Error(
1146
+ `no paired backend generates images for design '${brandId}'` +
1147
+ `${detail ? ` (${detail})` : ""}.`,
1148
+ );
1149
+ err.code = "imagen_route_unavailable";
1150
+ throw err;
1151
+ }
1152
+
882
1153
  // List the LIVE engine catalog (NOT the bundle's curated set) for the paired
883
1154
  // backend(s). `GET /api/cli/engines` is backend-scoped, not presId-scoped, so
884
1155
  // when several environments are paired this fans out and tags each engine with
@@ -1004,6 +1275,14 @@ export async function backendSupportsConceptImages(backendURL) {
1004
1275
  return caps?.conceptImages === true;
1005
1276
  }
1006
1277
 
1278
+ // Whether a backend generates regular images over the pane-free
1279
+ // `/api/cli/brands/:brandId/images` route (see generateImageForBrand).
1280
+ // Advertised by newer add-ins only.
1281
+ export async function backendSupportsImageGeneration(backendURL) {
1282
+ const caps = await backendCapabilities(backendURL);
1283
+ return caps?.imageGeneration === true;
1284
+ }
1285
+
1007
1286
  // The add-in's hard version floor: /api/cli/capabilities advertises the oldest
1008
1287
  // CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
1009
1288
  // mid-rollout skew otherwise surfaces as confusing route/contract errors deep
@@ -1013,13 +1292,7 @@ export async function assertCliVersionSupported(backendURL) {
1013
1292
  const caps = await backendCapabilities(backendURL);
1014
1293
  const min = typeof caps?.minCliVersion === "string" ? caps.minCliVersion : null;
1015
1294
  if (!min || compareCliVersions(cliVersion(), min) >= 0) return;
1016
- const err = new Error(
1017
- `slidemind CLI ${cliVersion()} is older than the minimum this add-in ` +
1018
- `supports (${min}). Run \`slidemind update\` (or ` +
1019
- "`npm install -g slidemind@latest`), then retry.",
1020
- );
1021
- err.code = "cli_update_required";
1022
- throw err;
1295
+ throw cliUpdateRequiredError(min);
1023
1296
  }
1024
1297
 
1025
1298
  // Rebuild a job failure into the exact error shape bridgeFetch throws for the
@@ -1330,11 +1603,17 @@ async function engineOpWithPaneWait(presId, backendURL, attempt) {
1330
1603
  // slide results carrying per-slide text + metadata). `subtype`/`intent`/`top`
1331
1604
  // are passed through for slide engines (image engines ignore intent); omit them
1332
1605
  // and the server uses its defaults (back-compat with older add-ins).
1606
+ //
1607
+ // `engineId` may also be a SCOPE sentinel (`scope:all` / `scope:sharepoint`) —
1608
+ // an unfiltered search over the whole corpus rather than one engine's saved
1609
+ // folder filter. A scope has no subtype to derive its index from, so it needs an
1610
+ // explicit `resultType`; the add-in rejects a scope search without one.
1333
1611
  export async function engineSearch({
1334
1612
  presId,
1335
1613
  engineId,
1336
1614
  query,
1337
1615
  subtype = null,
1616
+ resultType = null,
1338
1617
  intent = null,
1339
1618
  top = null,
1340
1619
  backendURL = null,
@@ -1350,6 +1629,8 @@ export async function engineSearch({
1350
1629
  const be = backendURL || (await resolveBackendForPres(presId));
1351
1630
  const body = { engineId, query };
1352
1631
  if (subtype) body.subtype = subtype;
1632
+ // Additive: omitted unless set, so the body an older add-in sees is unchanged.
1633
+ if (resultType) body.resultType = resultType;
1353
1634
  if (intent) body.intent = intent;
1354
1635
  if (typeof top === "number") body.top = top;
1355
1636
  const run = async (pid, b) => {
@@ -1384,6 +1665,7 @@ export async function engineSearchAnyPane({
1384
1665
  engineId,
1385
1666
  query,
1386
1667
  subtype = null,
1668
+ resultType = null,
1387
1669
  intent = null,
1388
1670
  top = null,
1389
1671
  }) {
@@ -1417,6 +1699,7 @@ export async function engineSearchAnyPane({
1417
1699
  engineId,
1418
1700
  query,
1419
1701
  subtype,
1702
+ resultType,
1420
1703
  intent,
1421
1704
  top,
1422
1705
  backendURL: cand.backendURL ?? primaryBackend,
@@ -1483,6 +1766,130 @@ export async function engineDownload({
1483
1766
  });
1484
1767
  }
1485
1768
 
1769
+ // Stream a chosen engine result's bytes to `destPath`. The size-unbounded
1770
+ // download: the pane's Socket.IO ack carries only a signed source reference, and
1771
+ // the add-in server fetches it and pipes the body here — so nothing buffers the
1772
+ // whole image and there is no base64 4/3 inflation. `engineDownload` above sends
1773
+ // the bytes through the pane ack instead and is therefore capped by the relay's
1774
+ // 10 MB frame limit; prefer this for anything that might be large.
1775
+ //
1776
+ // An SVG result comes back as JSON (`{ ok, mimeType, svgMarkup }`) rather than a
1777
+ // byte stream: it is kilobytes and its recolour happens pane-side. Callers get
1778
+ // `{ svgMarkup }` in that case and `{ bytesWritten, mimeType }` otherwise, so they
1779
+ // can persist whichever arrived.
1780
+ export async function engineDownloadToFile({
1781
+ presId,
1782
+ searchId,
1783
+ selection,
1784
+ destPath,
1785
+ iconColor = null,
1786
+ backendURL = null,
1787
+ onProgress = null,
1788
+ }) {
1789
+ if (!presId) throw new Error("engineDownloadToFile: presId is required");
1790
+ if (typeof searchId !== "number") {
1791
+ throw new Error("engineDownloadToFile: searchId (number) is required");
1792
+ }
1793
+ if (typeof selection !== "number") {
1794
+ throw new Error("engineDownloadToFile: selection (number) is required");
1795
+ }
1796
+ if (!destPath) throw new Error("engineDownloadToFile: destPath is required");
1797
+ const be = backendURL || (await resolveBackendForPres(presId));
1798
+ const body = { searchId, selection };
1799
+ if (iconColor) body.iconColor = iconColor;
1800
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
1801
+ const res = await bridgeStreamToFile(
1802
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/download-stream`,
1803
+ {
1804
+ method: "POST",
1805
+ headers: { "content-type": "application/json" },
1806
+ body: JSON.stringify(body),
1807
+ // A large image legitimately takes longer than an engine op: the server
1808
+ // is fetching from SharePoint/blob AND piping. Separate budget so raising
1809
+ // it doesn't loosen every other relay call.
1810
+ timeoutMs:
1811
+ parseInt(
1812
+ process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
1813
+ 10,
1814
+ ) || 600000,
1815
+ onProgress,
1816
+ },
1817
+ destPath,
1818
+ b,
1819
+ );
1820
+ if (!res.streamed) {
1821
+ // The inline-SVG answer (kilobytes) arrives as JSON, not as a byte stream.
1822
+ const parsed = res.body ? JSON.parse(res.body) : {};
1823
+ return { ok: true, streamed: false, ...parsed };
1824
+ }
1825
+ const warningsHeader = res.headers?.["x-slidemind-warnings"];
1826
+ let warnings;
1827
+ if (warningsHeader) {
1828
+ try {
1829
+ warnings = JSON.parse(warningsHeader);
1830
+ } catch {
1831
+ // A malformed header must not fail a download that otherwise succeeded.
1832
+ }
1833
+ }
1834
+ return {
1835
+ ok: true,
1836
+ streamed: true,
1837
+ mimeType: String(res.headers?.["content-type"] ?? "") || null,
1838
+ bytesWritten: res.bytesWritten,
1839
+ ...(warnings ? { warnings } : {}),
1840
+ };
1841
+ });
1842
+ }
1843
+
1844
+ // Stream a chosen slide-search result to `destPath` as a native one-slide .pptx.
1845
+ // The size-unbounded sibling of `extractSlide`: the pane ack carries only the
1846
+ // request, and the add-in server performs it and pipes the (base64-decoded) bytes
1847
+ // here. `extractSlide` puts the whole slide through the pane's Socket.IO ack as
1848
+ // base64, so it is capped at ~7 MB of real bytes — which real reused slides reach.
1849
+ export async function extractSlideToFile({
1850
+ presId,
1851
+ searchId,
1852
+ selection,
1853
+ destPath,
1854
+ backendURL = null,
1855
+ onProgress = null,
1856
+ }) {
1857
+ if (!presId) throw new Error("extractSlideToFile: presId is required");
1858
+ if (typeof searchId !== "number") {
1859
+ throw new Error("extractSlideToFile: searchId (number) is required");
1860
+ }
1861
+ if (typeof selection !== "number") {
1862
+ throw new Error("extractSlideToFile: selection (number) is required");
1863
+ }
1864
+ if (!destPath) throw new Error("extractSlideToFile: destPath is required");
1865
+ const be = backendURL || (await resolveBackendForPres(presId));
1866
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
1867
+ const res = await bridgeStreamToFile(
1868
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-extract-stream`,
1869
+ {
1870
+ method: "POST",
1871
+ headers: { "content-type": "application/json" },
1872
+ body: JSON.stringify({ searchId, selection }),
1873
+ timeoutMs:
1874
+ parseInt(
1875
+ process.env.SLIDEMIND_BRIDGE_DOWNLOAD_TIMEOUT_MS || "600000",
1876
+ 10,
1877
+ ) || 600000,
1878
+ onProgress,
1879
+ },
1880
+ destPath,
1881
+ b,
1882
+ );
1883
+ const title = res.headers?.["x-slidemind-presentation-title"];
1884
+ return {
1885
+ ok: true,
1886
+ bytesWritten: res.bytesWritten,
1887
+ slideId: res.headers?.["x-slidemind-slide-id"] ?? undefined,
1888
+ presentationTitle: title ? decodeURIComponent(String(title)) : undefined,
1889
+ };
1890
+ });
1891
+ }
1892
+
1486
1893
  // Extract a chosen slide-search result as a standalone one-slide .pptx (base64),
1487
1894
  // WITHOUT inserting it anywhere — the "fold into a deck" path. Relayed through
1488
1895
  // the pane like search/download.