slidemind 0.3.13 → 0.3.14

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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +86 -21
  4. package/README.md +14 -17
  5. package/bin/slidemind.mjs +65 -4969
  6. package/package.json +1 -1
  7. package/skills/create-slides/SKILL.md +113 -74
  8. package/skills/search/SKILL.md +25 -7
  9. package/src/bridge/build-and-push.mjs +108 -15
  10. package/src/bridge/bundle.mjs +69 -0
  11. package/src/bridge/capture.mjs +19 -0
  12. package/src/bridge/client/brands.mjs +622 -0
  13. package/src/bridge/client/engine-routing.mjs +237 -0
  14. package/src/bridge/client/engine-search.mjs +310 -0
  15. package/src/bridge/client/engine-slides.mjs +435 -0
  16. package/src/bridge/client/pane-ops.mjs +335 -0
  17. package/src/bridge/client/presentations.mjs +587 -0
  18. package/src/bridge/client/render.mjs +317 -0
  19. package/src/bridge/client/transport.mjs +695 -0
  20. package/src/bridge/client.mjs +80 -3452
  21. package/src/bridge/config.mjs +1 -1
  22. package/src/bridge/engine-download.mjs +15 -2
  23. package/src/builders/build.mjs +11 -804
  24. package/src/builders/draft-check.mjs +376 -0
  25. package/src/builders/lint.mjs +38 -2
  26. package/src/builders/preview.html +799 -0
  27. package/src/builders/review.mjs +23 -4
  28. package/src/builders/visual-families.mjs +6 -8
  29. package/src/cli/common.mjs +179 -0
  30. package/src/cli/concepts.mjs +310 -0
  31. package/src/cli/connection.mjs +147 -0
  32. package/src/cli/deck.mjs +608 -0
  33. package/src/cli/designs.mjs +113 -0
  34. package/src/cli/diagnose.mjs +141 -0
  35. package/src/cli/download-bundle.mjs +144 -0
  36. package/src/cli/engine-helpers.mjs +429 -0
  37. package/src/cli/engine-results.mjs +320 -0
  38. package/src/cli/help.mjs +201 -0
  39. package/src/cli/install.mjs +257 -0
  40. package/src/cli/read.mjs +120 -0
  41. package/src/cli/ready.mjs +805 -0
  42. package/src/cli/search.mjs +608 -0
  43. package/src/cli/session.mjs +177 -0
  44. package/src/cli/setup.mjs +282 -0
  45. package/src/cli/slide-reuse.mjs +334 -0
  46. package/src/cli/status.mjs +118 -0
  47. package/src/cli/workspace.mjs +302 -0
  48. package/src/installers/codex.mjs +1 -1
  49. package/src/update-check.mjs +231 -0
@@ -0,0 +1,435 @@
1
+ // Slide extraction, live insert, view and source-presentation reads off a search.
2
+
3
+ import { randomUUID } from "node:crypto";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { assertSearchId, downloadTimeoutMs, resultRoute, directEngineBackend } from "./engine-routing.mjs";
8
+ import { insertSlides } from "./pane-ops.mjs";
9
+ import { resolveBackendForPres, engineOpWithPaneWait } from "./presentations.mjs";
10
+ import { engineTimeoutMs, bridgeFetch, bridgeStreamToFile, backendSupportsSourcePresentation, graphHeaders } from "./transport.mjs";
11
+
12
+ // Stream a chosen slide-search result to `destPath` as a native one-slide .pptx.
13
+ // The size-unbounded sibling of `extractSlide`: the pane ack carries only the
14
+ // request, and the add-in server performs it and pipes the (base64-decoded) bytes
15
+ // here. `extractSlide` puts the whole slide through the pane's Socket.IO ack as
16
+ // base64, so it is capped at ~7 MB of real bytes — which real reused slides reach.
17
+ //
18
+ // With NO presId and a resolved `backendURL`/`brandId` this streams pane-free
19
+ // from `GET /api/cli/searches/:searchId/results/:selection/slide`.
20
+ export async function extractSlideToFile({
21
+ presId = null,
22
+ searchId,
23
+ selection,
24
+ destPath,
25
+ backendURL = null,
26
+ brandId = null,
27
+ onProgress = null,
28
+ }) {
29
+ const direct = !presId && Boolean(backendURL || brandId);
30
+ if (!presId && !direct) throw new Error("extractSlideToFile: presId is required");
31
+ assertSearchId("extractSlideToFile", searchId);
32
+ if (typeof selection !== "number") {
33
+ throw new Error("extractSlideToFile: selection (number) is required");
34
+ }
35
+ if (!destPath) throw new Error("extractSlideToFile: destPath is required");
36
+ const shape = (res) => {
37
+ const title = res.headers?.["x-slidemind-presentation-title"];
38
+ return {
39
+ ok: true,
40
+ bytesWritten: res.bytesWritten,
41
+ slideId: res.headers?.["x-slidemind-slide-id"] ?? undefined,
42
+ presentationTitle: title ? decodeURIComponent(String(title)) : undefined,
43
+ };
44
+ };
45
+ if (direct) {
46
+ const b = await directEngineBackend("extractSlideToFile", {
47
+ backendURL,
48
+ brandId,
49
+ });
50
+ const res = await bridgeStreamToFile(
51
+ resultRoute(searchId, selection, "slide"),
52
+ {
53
+ method: "GET",
54
+ headers: await graphHeaders(b),
55
+ timeoutMs: downloadTimeoutMs(),
56
+ onProgress,
57
+ },
58
+ destPath,
59
+ b,
60
+ );
61
+ return shape(res);
62
+ }
63
+ const be = backendURL || (await resolveBackendForPres(presId));
64
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
65
+ const res = await bridgeStreamToFile(
66
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-extract-stream`,
67
+ {
68
+ method: "POST",
69
+ headers: { "content-type": "application/json" },
70
+ body: JSON.stringify({ searchId, selection }),
71
+ timeoutMs: downloadTimeoutMs(),
72
+ onProgress,
73
+ },
74
+ destPath,
75
+ b,
76
+ );
77
+ return shape(res);
78
+ });
79
+ }
80
+
81
+ // Extract a chosen slide-search result as a standalone one-slide .pptx (base64),
82
+ // WITHOUT inserting it anywhere — the "fold into a deck" path. Relayed through
83
+ // the pane like search/download, and pane-only for the same reason as
84
+ // engineDownload: the pane-free route streams (extractSlideToFile above), and
85
+ // this capped ack path exists only for add-ins that predate it.
86
+ export async function extractSlide({
87
+ presId,
88
+ searchId,
89
+ selection,
90
+ backendURL = null,
91
+ }) {
92
+ if (!presId) throw new Error("extractSlide: presId is required");
93
+ assertSearchId("extractSlide", searchId);
94
+ if (typeof selection !== "number") {
95
+ throw new Error("extractSlide: selection (number) is required");
96
+ }
97
+ const be = backendURL || (await resolveBackendForPres(presId));
98
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
99
+ const res = await bridgeFetch(
100
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-extract`,
101
+ {
102
+ method: "POST",
103
+ headers: { "content-type": "application/json" },
104
+ body: JSON.stringify({ searchId, selection }),
105
+ timeoutMs:
106
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
107
+ 90000,
108
+ },
109
+ b,
110
+ );
111
+ return res.json(); // { ok, base64, slideId?, presentationTitle? }
112
+ });
113
+ }
114
+
115
+ // Insert one or more chosen slide-search results live into the open
116
+ // presentation (the "reuse → insert live" path). Relayed through the pane.
117
+ //
118
+ // `fileName`/`brandId` identify the target WINDOW (brandId == the deck's presFP).
119
+ // They cost nothing on the happy path and are what lets the single retry find
120
+ // the same window again after a pane reconnect churned its presId — the same
121
+ // self-healing `push` does around its insert.
122
+ export async function insertSlideLive({
123
+ presId,
124
+ searchId,
125
+ selections,
126
+ at = null,
127
+ backendURL = null,
128
+ fileName = null,
129
+ brandId = null,
130
+ otherPresIds = null,
131
+ }) {
132
+ if (!presId) throw new Error("insertSlideLive: presId is required");
133
+ assertSearchId("insertSlideLive", searchId);
134
+ if (!Array.isArray(selections) || selections.length === 0) {
135
+ throw new Error("insertSlideLive: selections (number[]) is required");
136
+ }
137
+ const be = backendURL || (await resolveBackendForPres(presId));
138
+ // Live insert is a MUTATION, so it carries an operationId for the add-in's
139
+ // runMutationOnce dedup (the slide-insert handler keys an LRU on it). Generate
140
+ // it ONCE here, before engineOpWithPaneWait — the body is built once and
141
+ // reused across the wrapper's single pane_suspended retry, so the SAME id
142
+ // rides both attempts and a retry can never double-insert. Mirrors how
143
+ // buildAndPush makes one push operationId reused across attempts.
144
+ const operationId = `insert-${randomUUID()}`;
145
+ const body = { searchId, selections, operationId };
146
+ if (typeof at === "number") body.at = at;
147
+ return engineOpWithPaneWait(
148
+ presId,
149
+ be,
150
+ async (pid, b) => {
151
+ const res = await bridgeFetch(
152
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-insert`,
153
+ {
154
+ method: "POST",
155
+ headers: { "content-type": "application/json" },
156
+ body: JSON.stringify(body),
157
+ timeoutMs:
158
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
159
+ 90000,
160
+ },
161
+ b,
162
+ );
163
+ // Tag the window the insert actually landed in: after a retarget it is no
164
+ // longer the presId the caller passed, and the caller reports it.
165
+ return { ...(await res.json()), presId: pid }; // { ok, insertedCount, insertedSlideIds, presId }
166
+ },
167
+ // A vanished presId is recoverable HERE (unlike the engine reads, whose
168
+ // 404s are about the search record): the insert never landed, so re-listing
169
+ // and retrying against the re-registered window is safe — and the same
170
+ // operationId rides along, so even an ambiguous first attempt can't
171
+ // double-insert.
172
+ { fileName, brandId, otherPresIds, retargetOnMissing: true },
173
+ );
174
+ }
175
+
176
+ // Reuse chosen slide-search results live when the search record is the
177
+ // BACKEND's rather than a pane's.
178
+ //
179
+ // The pane's slide-insert handler resolves a selection out of the in-browser
180
+ // search context IT minted (a numeric id); a searchId from the pane-free search
181
+ // is server-owned and opaque, so relaying it would answer `searchId_required`.
182
+ // The two legs `add-slide --search` + `push` already run therefore do the job in
183
+ // the other order: extract each pick pane-free, then hand the BYTES to the
184
+ // window's own insert route. Live insert stays pane-bound (Office.js is the only
185
+ // thing that can type into an open deck) — only the searchId resolution moves.
186
+ export async function insertSlidesFromSearch({
187
+ presId,
188
+ searchId,
189
+ selections,
190
+ at = null,
191
+ backendURL = null,
192
+ engineBackendURL = null,
193
+ fileName = null,
194
+ brandId = null,
195
+ otherPresIds = null,
196
+ onProgress = null,
197
+ }) {
198
+ if (!presId) throw new Error("insertSlidesFromSearch: presId is required");
199
+ assertSearchId("insertSlidesFromSearch", searchId);
200
+ if (!Array.isArray(selections) || selections.length === 0) {
201
+ throw new Error("insertSlidesFromSearch: selections (number[]) is required");
202
+ }
203
+ const be = backendURL || (await resolveBackendForPres(presId));
204
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-reuse-"));
205
+ try {
206
+ let insertedCount = 0;
207
+ let livePresId = presId;
208
+ let liveBackendURL = be;
209
+ const insertedSlideIds = [];
210
+ for (const [i, selection] of selections.entries()) {
211
+ const slidePath = path.join(dir, `pick-${selection}.pptx`);
212
+ const extracted = await extractSlideToFile({
213
+ searchId,
214
+ selection,
215
+ destPath: slidePath,
216
+ backendURL: engineBackendURL,
217
+ brandId,
218
+ onProgress,
219
+ });
220
+ if (!extracted?.bytesWritten) {
221
+ throw new Error(`selection ${selection} produced no slide bytes`);
222
+ }
223
+ // One insert per pick, each with its own dedup key, so a retry inside
224
+ // engineOpWithPaneWait re-sends THAT pick and nothing else. `at` counts
225
+ // forward so the picks land in the order they were named.
226
+ const operationId = `insert-${randomUUID()}`;
227
+ const base64 = fs.readFileSync(slidePath).toString("base64");
228
+ const resp = await engineOpWithPaneWait(
229
+ livePresId,
230
+ liveBackendURL,
231
+ async (pid, b) => {
232
+ const ack = await insertSlides({
233
+ presId: pid,
234
+ base64,
235
+ insertAt: typeof at === "number" ? at + i : null,
236
+ sourceSlideIds: extracted.slideId ? [extracted.slideId] : null,
237
+ operationId,
238
+ backendURL: b,
239
+ });
240
+ return { ...ack, presId: pid, backendURL: b };
241
+ },
242
+ { fileName, brandId, otherPresIds, retargetOnMissing: true },
243
+ );
244
+ // A retarget moves every remaining pick to the window that answered, so
245
+ // a multi-pick reuse cannot end up split across two presentations.
246
+ livePresId = resp.presId ?? livePresId;
247
+ liveBackendURL = resp.backendURL ?? liveBackendURL;
248
+ const landed = Array.isArray(resp.insertedSlideIds)
249
+ ? resp.insertedSlideIds
250
+ : [];
251
+ insertedCount += landed.length || 1;
252
+ insertedSlideIds.push(...landed);
253
+ bestEffortRm(slidePath);
254
+ }
255
+ return { ok: true, insertedCount, insertedSlideIds, presId: livePresId };
256
+ } finally {
257
+ bestEffortRm(dir);
258
+ }
259
+ }
260
+
261
+ // Remove a temp file/dir that bytes were JUST streamed into. Windows may still
262
+ // hold a handle on it at cleanup time (antivirus scanning the fresh write), so
263
+ // plain rmSync races into EBUSY/ENOTEMPTY there — retry, and swallow what's
264
+ // left: cleanup failure must never mask an operation that succeeded.
265
+ export function bestEffortRm(p) {
266
+ try {
267
+ fs.rmSync(p, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
268
+ } catch {}
269
+ }
270
+
271
+ // Fetch a chosen slide-search result's thumbnail/preview as a data URI for
272
+ // on-demand viewing (the agent doesn't have to extract the whole .pptx just to
273
+ // look at a slide). Relayed through the pane.
274
+ //
275
+ // With NO presId and a resolved `backendURL`/`brandId` this reads the pane-free
276
+ // `GET …/results/:selection/thumbnail`, which STREAMS: a thumbnail is small, so
277
+ // it is buffered back into the same `{ dataUri }` contract rather than forcing
278
+ // every caller to grow a file path.
279
+ export async function viewSlide({
280
+ presId = null,
281
+ searchId,
282
+ selection,
283
+ backendURL = null,
284
+ brandId = null,
285
+ }) {
286
+ const direct = !presId && Boolean(backendURL || brandId);
287
+ if (!presId && !direct) throw new Error("viewSlide: presId is required");
288
+ assertSearchId("viewSlide", searchId);
289
+ if (typeof selection !== "number") {
290
+ throw new Error("viewSlide: selection (number) is required");
291
+ }
292
+ if (direct) {
293
+ const b = await directEngineBackend("viewSlide", { backendURL, brandId });
294
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-thumb-"));
295
+ const tmp = path.join(dir, "thumbnail.bin");
296
+ try {
297
+ const res = await bridgeStreamToFile(
298
+ resultRoute(searchId, selection, "thumbnail"),
299
+ {
300
+ method: "GET",
301
+ headers: await graphHeaders(b),
302
+ timeoutMs: engineTimeoutMs(),
303
+ },
304
+ tmp,
305
+ b,
306
+ );
307
+ if (!res.streamed) {
308
+ // An add-in that answers with an inline preview passes straight through.
309
+ return res.body ? JSON.parse(res.body) : { ok: false };
310
+ }
311
+ const mimeType =
312
+ String(res.headers?.["content-type"] ?? "").split(";")[0].trim() ||
313
+ "image/png";
314
+ return {
315
+ ok: true,
316
+ mimeType,
317
+ dataUri: `data:${mimeType};base64,${fs.readFileSync(tmp).toString("base64")}`,
318
+ };
319
+ } finally {
320
+ bestEffortRm(dir);
321
+ }
322
+ }
323
+ const be = backendURL || (await resolveBackendForPres(presId));
324
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
325
+ const res = await bridgeFetch(
326
+ `/api/cli/presentations/${encodeURIComponent(pid)}/engine/slide-view`,
327
+ {
328
+ method: "POST",
329
+ headers: { "content-type": "application/json" },
330
+ body: JSON.stringify({ searchId, selection }),
331
+ timeoutMs:
332
+ parseInt(process.env.SLIDEMIND_BRIDGE_ENGINE_TIMEOUT_MS || "90000", 10) ||
333
+ 90000,
334
+ },
335
+ b,
336
+ );
337
+ return res.json(); // { ok, dataUri, mimeType }
338
+ });
339
+ }
340
+
341
+ // Read the ORIGINAL deck a chosen slide-search result came from — NOT the open
342
+ // window (that is getPresentationContent below). Answers "what else is in the
343
+ // presentation this slide belongs to?": the surrounding narrative, the slides
344
+ // either side, the argument the source deck was making.
345
+ //
346
+ // The add-in reads it out of the slide index rather than downloading the source
347
+ // file, so this is fast and works the same for blob- and SharePoint-backed
348
+ // results — but the text is the last SYNCED extraction, not the live file. The
349
+ // response is Markdown, and the pane never carries it: the relay route resolves
350
+ // the selection through the pane and performs the backend read itself.
351
+ //
352
+ // searchId is presentation-scoped on the pane path, so that path relays through
353
+ // the window the search ran on, like viewSlide. Idempotent, so the
354
+ // pane_suspended retry is safe. With NO presId and a resolved
355
+ // `backendURL`/`brandId` it reads the pane-free
356
+ // `GET …/results/:selection/source-presentation` instead (the searchId is
357
+ // server-owned there, so no window is involved at all).
358
+ export async function readSourcePresentation({
359
+ presId = null,
360
+ searchId,
361
+ selection,
362
+ fromSlide = null,
363
+ toSlide = null,
364
+ backendURL = null,
365
+ brandId = null,
366
+ }) {
367
+ const direct = !presId && Boolean(backendURL || brandId);
368
+ if (!presId && !direct) {
369
+ throw new Error("readSourcePresentation: presId is required");
370
+ }
371
+ assertSearchId("readSourcePresentation", searchId);
372
+ if (typeof selection !== "number") {
373
+ throw new Error("readSourcePresentation: selection (number) is required");
374
+ }
375
+ // Both paths answer with Markdown plus the counts/truncation in headers.
376
+ const shape = async (res) => ({
377
+ ok: true,
378
+ markdown: await res.text(),
379
+ truncated: res.headers?.["x-slidemind-truncated"] === "1",
380
+ slideCount: Number(res.headers?.["x-slidemind-slide-count"] ?? 0) || 0,
381
+ totalSlideCount:
382
+ Number(res.headers?.["x-slidemind-total-slide-count"] ?? 0) || 0,
383
+ presentationTitle: res.headers?.["x-slidemind-presentation-title"]
384
+ ? decodeURIComponent(String(res.headers["x-slidemind-presentation-title"]))
385
+ : undefined,
386
+ });
387
+ if (direct) {
388
+ // The direct route table carries source-presentation, so engineRoutes is the
389
+ // capability that gates it here (sourcePresentation gates the pane route).
390
+ const b = await directEngineBackend("readSourcePresentation", {
391
+ backendURL,
392
+ brandId,
393
+ });
394
+ const params = new URLSearchParams();
395
+ if (typeof fromSlide === "number") params.set("fromSlide", String(fromSlide));
396
+ if (typeof toSlide === "number") params.set("toSlide", String(toSlide));
397
+ const res = await bridgeFetch(
398
+ resultRoute(searchId, selection, "source-presentation", params),
399
+ {
400
+ method: "GET",
401
+ headers: { accept: "text/markdown, text/plain" },
402
+ timeoutMs: engineTimeoutMs(),
403
+ },
404
+ b,
405
+ );
406
+ return shape(res);
407
+ }
408
+ const be = backendURL || (await resolveBackendForPres(presId));
409
+ if (!(await backendSupportsSourcePresentation(be))) {
410
+ const err = new Error(
411
+ "This slidemind add-in cannot read a result's source presentation yet. Update the add-in, then try again.",
412
+ );
413
+ err.code = "source_presentation_unsupported";
414
+ throw err;
415
+ }
416
+ const body = { searchId, selection };
417
+ if (typeof fromSlide === "number") body.fromSlide = fromSlide;
418
+ if (typeof toSlide === "number") body.toSlide = toSlide;
419
+ return engineOpWithPaneWait(presId, be, async (pid, b) => {
420
+ const res = await bridgeFetch(
421
+ `/api/cli/presentations/${encodeURIComponent(pid)}/source-presentation`,
422
+ {
423
+ method: "POST",
424
+ headers: {
425
+ "content-type": "application/json",
426
+ accept: "text/markdown, text/plain",
427
+ },
428
+ body: JSON.stringify(body),
429
+ timeoutMs: engineTimeoutMs(),
430
+ },
431
+ b,
432
+ );
433
+ return shape(res);
434
+ });
435
+ }