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,429 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { envTag } from "../bridge/config.mjs";
6
+ import { fail, loadBridgeModule, SRC_DIR } from "./common.mjs";
7
+
8
+ // Turn the add-in's deliberately terse search failures into something actionable.
9
+ // It withholds detail on purpose: the raw message used to carry the built OData
10
+ // filter and physical index names, and "Engine not found" doubles as a permission
11
+ // denial so engine existence stays undisclosed. So the CLI can't diagnose — it can
12
+ // only name the next move.
13
+ export function searchFailureHint(error, engineId) {
14
+ const raw = error ?? "unknown error";
15
+ if (/engine not found/i.test(raw)) {
16
+ return (
17
+ `${raw} — '${engineId}' is unknown or not visible to you. ` +
18
+ `Re-run 'slidemind ls engines' (ids can be retired) and pick from the live list, ` +
19
+ `or search 'scope:all' to skip engines entirely.`
20
+ );
21
+ }
22
+ if (/^search failed$/i.test(raw)) {
23
+ return (
24
+ `${raw} — the add-in withholds the reason (it can leak index internals); ` +
25
+ `the detail is in the add-in's logs. Retry once, then try 'scope:all'.`
26
+ );
27
+ }
28
+ return raw;
29
+ }
30
+
31
+ // Zero results is now a routine, ambiguous outcome: the add-in answers "success,
32
+ // nothing" for a corpus that isn't connected, one that is still embedding, and a
33
+ // folder filter that matches nothing — all indistinguishable on the wire. Say that
34
+ // instead of letting an empty list read as "this content does not exist".
35
+ export function writeEmptySearchDiagnostic(engineId, bundleMod) {
36
+ const isScope = bundleMod.isSearchScopeId(engineId);
37
+ const lines = [
38
+ ` No results — this does NOT mean the content doesn't exist. Any of these looks identical here:`,
39
+ ` · nothing is connected yet, so there is no corpus to search`,
40
+ ` · the corpus is still indexing (content appears progressively)`,
41
+ ];
42
+ if (!isScope) {
43
+ lines.push(
44
+ ` · this engine is a saved FOLDER FILTER and the content sits outside it`,
45
+ );
46
+ }
47
+ lines.push(` Next:`);
48
+ if (!isScope) {
49
+ lines.push(
50
+ ` · widen to the unfiltered corpus: slidemind search "<query>" --engine ${bundleMod.SCOPE_ALL} --result-type slide`,
51
+ );
52
+ }
53
+ lines.push(
54
+ ` · re-word the query (content search matches phrasing, so a synonym can decide a hit)`,
55
+ ` · slidemind ls engines — see every engine and scope you can reach`,
56
+ );
57
+ if (isScope) {
58
+ lines.push(
59
+ ` · if the unfiltered corpus is also empty, report it as "not found" and name what you searched — don't author a replacement as if reuse had been tried`,
60
+ );
61
+ }
62
+ process.stdout.write(`${lines.join("\n")}\n`);
63
+ }
64
+
65
+ // Write the search's numbered collage to a file and tell the agent to look at it.
66
+ //
67
+ // The add-in composes ONE bounded 800x600 image showing every candidate with its
68
+ // selection number burned in — the same picture the in-app agent sees. That is the
69
+ // intended way to choose a visual result: one look at one file, rather than N
70
+ // per-result previews (which is also why the relay ack carries no per-result bytes
71
+ // — a corpus image is multi-megabyte and N of them can't cross the relay).
72
+ //
73
+ // Best-effort by design: a missing/…unparseable grid must never fail a search that
74
+ // otherwise returned usable results. Content-intent searches have no grid at all —
75
+ // their signal is the printed text.
76
+ export function writeSearchGrid(gridDataUri, searchId, resolveExt) {
77
+ if (typeof gridDataUri !== "string" || !gridDataUri.startsWith("data:")) {
78
+ return null;
79
+ }
80
+ try {
81
+ const m = /^data:([^;]+);base64,(.+)$/.exec(gridDataUri);
82
+ if (!m) return null;
83
+ const bytes = Buffer.from(m[2], "base64");
84
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "slidemind-grid-"));
85
+ const target = path.join(dir, `grid-${searchId}.${resolveExt(m[1], bytes)}`);
86
+ fs.writeFileSync(target, bytes);
87
+ return target;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ // Where an engine op runs. Newer add-ins serve the engine surface off the CLI
94
+ // session itself (`/api/cli/engines`, `/api/cli/searches/*`), so discovery,
95
+ // search, download and extraction need no PowerPoint window at all; older ones
96
+ // relay every one of them through a task-pane webview, which is what
97
+ // requirePaneHost gates. The DIRECT path is preferred whenever it exists and the
98
+ // pane path stays as the compat fallback, so both add-in generations work.
99
+ //
100
+ // `--to <presId>` stops being a REQUIREMENT — the searchId is server-owned
101
+ // rather than bound to a window, so nothing has to be targeted — but it is still
102
+ // honoured, and naming a window means "relay through THAT pane". That is the
103
+ // escape hatch for the two things the pane-free routes refuse (`asset_needs_host`
104
+ // for SharePoint-hosted bytes, `recolor_needs_host` for an icon tint), both of
105
+ // which the pane can do; ignoring --to made their own remediation unreachable.
106
+ // `brandId` is the deck's bound design — the only thing that routes an engine op
107
+ // in a multi-environment session when no window is involved.
108
+ // No connected environment holds a server-owned search record. Authored once
109
+ // because every consumer of an opaque searchId reaches the same dead end and
110
+ // the same way out — the record's own environment is the only thing that can
111
+ // serve it, so a re-search is the fix regardless of who asked.
112
+ export function expiredSearchMessage(searchId) {
113
+ return (
114
+ `search ${searchId} was not found on any connected environment — server-side ` +
115
+ "search records expire after ~2 hours. Re-run 'slidemind search' and pick again."
116
+ );
117
+ }
118
+
119
+ export async function resolveEngineRouting(client, { to = null, brandId = null, pick = "one", searchId = null } = {}) {
120
+ // An opaque (server-minted) searchId pins the route by itself: the record
121
+ // lives on the environment that minted it, and the pane relay can never
122
+ // serve it — its handler only knows its own per-window numeric ids. So the
123
+ // record's origin outranks the deck's design AND --to, and when no connected
124
+ // environment holds the record the only honest answer is "search again",
125
+ // not a pane fallback that is guaranteed to 4xx.
126
+ if (client.isOpaqueSearchId?.(searchId)) {
127
+ if (to) {
128
+ // Agents thread `--to` from a window-bound habit; on a server-held
129
+ // record it is meaningless, not wrong — say so once and carry on.
130
+ process.stderr.write(
131
+ ` note: --to ${to} ignored — searchId ${searchId} is held by the add-in, not by a window.\n`,
132
+ );
133
+ }
134
+ let origin = null;
135
+ try {
136
+ origin = await client.resolveSearchBackend(searchId);
137
+ } catch (err) {
138
+ // The probe distinguishes "no environment holds this record" from the
139
+ // failures that only LOOK like it — a dead session, a missing sign-in —
140
+ // and hands the latter back for their own remediation.
141
+ await failOnEngineBlocker(client, err, { to });
142
+ }
143
+ if (origin) {
144
+ return { direct: true, backendURL: origin, presId: null, registry: null, entry: null };
145
+ }
146
+ fail(expiredSearchMessage(searchId));
147
+ }
148
+ let backendURL = null;
149
+ if (!to) {
150
+ backendURL = await client.resolveEngineBackend({ brandId });
151
+ }
152
+ if (backendURL) {
153
+ return { direct: true, backendURL, presId: null, registry: null, entry: null };
154
+ }
155
+ const registry = await client.requirePaneHost();
156
+ const entry =
157
+ pick === "any"
158
+ ? client.pickAnyPresentationEntry(registry, to)
159
+ : client.pickPresentationEntry(registry, to);
160
+ return {
161
+ direct: false,
162
+ backendURL: entry.backendURL ?? null,
163
+ presId: entry.presId,
164
+ registry,
165
+ entry,
166
+ };
167
+ }
168
+
169
+ // The design a deck is bound to (presFP == brandId), or null when it isn't bound
170
+ // / isn't a deck. Routing state only — never printed.
171
+ export async function deckBrandId(deckDir) {
172
+ if (!deckDir) return null;
173
+ const bundleMod = await import(
174
+ pathToFileURL(path.join(SRC_DIR, "bridge", "bundle.mjs")).href
175
+ );
176
+ return bundleMod.readDeckBundleRef(deckDir)?.presFP ?? null;
177
+ }
178
+
179
+ // A pane-ready window, or null — never a throw, and never a wait. Used by the
180
+ // login_required paths below, where an open pane is an OPTION rather than a
181
+ // precondition. `to` is honoured only when it names a window that is actually
182
+ // registered; a stale --to must not cost the caller its fallback.
183
+ export async function openPaneEntry(client, to = null) {
184
+ const registry = (await client.paneReadyPresentations?.()) ?? [];
185
+ if (registry.length === 0) return null;
186
+ const explicit = registry.some((p) => p.presId === to) ? to : null;
187
+ try {
188
+ return { registry, entry: client.pickAnyPresentationEntry(registry, explicit) };
189
+ } catch {
190
+ // Several windows and no way to choose — no automatic fallback from here.
191
+ return null;
192
+ }
193
+ }
194
+
195
+ // The ONE rendering of a corpus-identity refusal: the pane-free routes run as
196
+ // the USER and answer `login_required` without the delegated tokens `slidemind
197
+ // login` mints (the CLI attempts that sign-in itself before ever sending a
198
+ // token-less request, so reaching this means the browser step was refused or
199
+ // abandoned). The refusal backs the PANE-FREE path only: an open task pane
200
+ // signs every request with a live browser token, so when a pane-ready window
201
+ // is already open, that window can serve a fresh search right now — say so.
202
+ // Returns the message, or null for any other failure.
203
+ export async function loginRequiredMessage(client, err, { to = null } = {}) {
204
+ const blocker = client.loginRequiredBlocker?.(err);
205
+ if (!blocker) return null;
206
+ const pane = await openPaneEntry(client, to);
207
+ if (!pane) return blocker.message;
208
+ const env = envTag(pane.entry.env);
209
+ return (
210
+ `${blocker.message} A slidemind pane is already open${env} and signs each request live, so a fresh ` +
211
+ `search through that window works now: slidemind search "<query>" --to ${pane.entry.presId}.`
212
+ );
213
+ }
214
+
215
+ // Engine-route failures the USER has to clear get one actionable line instead of
216
+ // a relay error. `login_required` means the corpus routes want the user's own
217
+ // Microsoft 365 sign-in. Only corpus search stops; render and bundle don't.
218
+ // Anything else is re-thrown for the top-level handler.
219
+ export async function failOnEngineBlocker(client, err, { to = null } = {}) {
220
+ const message = await loginRequiredMessage(client, err, { to });
221
+ if (message) fail(message);
222
+ throw err;
223
+ }
224
+
225
+ // A login_required refusal that an OPEN PANE can simply route around. Only an
226
+ // operation that can be replayed pane-side qualifies — creating a NEW search,
227
+ // whose id the pane mints itself. A record-bound op on an opaque searchId
228
+ // cannot: the pane's handler only understands its own per-window numeric ids,
229
+ // so those get loginRequiredMessage's extended blocker instead. Returns
230
+ // { registry, entry } or null.
231
+ export async function paneRelayForLoginRequired(client, err, { to = null } = {}) {
232
+ if (!client.loginRequiredBlocker?.(err)) return null;
233
+ return openPaneEntry(client, to);
234
+ }
235
+
236
+ // The pane-free routes refuse exactly one thing outright: bytes that live in
237
+ // SharePoint (`asset_needs_host`, which needs a delegated token only a browser
238
+ // can mint). An open pane has one, so the remediation is the RELAY — name it,
239
+ // rather than echoing a 409 whose "retry" would take the identical route.
240
+ // (Icon tints are baked locally — the CLI never asks the pane-free route for
241
+ // one.) Returns null for anything else.
242
+ export function describeHostOnlyEngineFailure(err) {
243
+ const code = err?.code ?? err?.body?.code ?? null;
244
+ if (code !== "asset_needs_host") return null;
245
+ const detail =
246
+ typeof err?.body?.error === "string" ? err.body.error : (err?.message ?? "");
247
+ return (
248
+ `${detail} Run 'slidemind search "<query>" --to <presId>' against an open window and ` +
249
+ "re-pick, then repeat this download with the same --to — that relays it through the pane."
250
+ );
251
+ }
252
+
253
+ // The other half of the same boundary: a SharePoint-backed SLIDE. The extract
254
+ // route fetches it with the caller's identity, so without a delegated token the
255
+ // add-in falls back to a managed identity that is not configured for the corpus
256
+ // site and the fetch dies inside the provider. That route answers a plain 400
257
+ // with no discrete code, so the provider text is the only signal there is.
258
+ export function isSharePointProviderFailure(err) {
259
+ const text = `${err?.body?.error ?? ""} ${err?.message ?? ""}`;
260
+ return /sharepoint provider/i.test(text);
261
+ }
262
+
263
+ // `slidemind login` as the WINDOW-FREE way past both — named only on a backend
264
+ // that honours the delegated token and only while no sign-in is stored, so the
265
+ // sentence never appears where following it would change nothing. Null
266
+ // otherwise, which keeps every pre-existing message byte-identical.
267
+ export async function graphLoginRemediation(client, backendURL) {
268
+ if (!backendURL) return null;
269
+ try {
270
+ if (!(await client.backendSupportsGraphDelegated?.(backendURL))) return null;
271
+ const graph = await loadBridgeModule("graph-auth");
272
+ if (graph.hasGraphSession(backendURL)) return null;
273
+ } catch {
274
+ // A capability probe that fails is not a reason to withhold the download's
275
+ // real error — just say nothing extra.
276
+ return null;
277
+ }
278
+ return (
279
+ "Or run 'slidemind login' once: it signs this CLI in to Microsoft 365 with your own " +
280
+ "identity, and SharePoint-backed content then downloads with no window at all."
281
+ );
282
+ }
283
+
284
+ // The user-facing text for an engine byte fetch that failed for want of an
285
+ // identity — either refusal shape. Returns null when the failure is something
286
+ // else entirely (the caller keeps its own message).
287
+ export async function describeEngineByteFailure(client, err, { backendURL = null } = {}) {
288
+ const paneAdvice = describeHostOnlyEngineFailure(err);
289
+ if (!paneAdvice && !isSharePointProviderFailure(err)) return null;
290
+ const login = await graphLoginRemediation(client, backendURL);
291
+ if (!login) return paneAdvice;
292
+ return paneAdvice ? `${paneAdvice} ${login}` : `${err.message} ${login}`;
293
+ }
294
+
295
+ // A searchId is a number when the pane minted it (the relay path) and an opaque
296
+ // server-minted id on the pane-free engine routes, where the search record lives
297
+ // on the backend. Accept both so a pick round-trips whichever search produced it.
298
+ export function parseSearchIdArg(raw, flag = "--search") {
299
+ if (!raw || raw.startsWith("-")) fail(`${flag} expects a searchId, got '${raw}'`);
300
+ return /^\d+$/.test(raw) ? parseInt(raw, 10) : raw;
301
+ }
302
+
303
+ export function parsePicks(raw, flag = "--pick") {
304
+ if (!raw) fail(`${flag} expects a comma-separated list of selection numbers`);
305
+ // Tolerate a trailing/empty segment ("1," or "1,,2") by dropping blank parts
306
+ // rather than turning them into NaN that fails the whole call; dedupe so the
307
+ // same selection isn't extracted twice.
308
+ const segments = raw
309
+ .split(",")
310
+ .map((s) => s.trim())
311
+ .filter((s) => s.length > 0);
312
+ if (segments.length === 0) {
313
+ fail(`${flag} expects a comma-separated list of selection numbers`);
314
+ }
315
+ const picks = [...new Set(segments.map((s) => parseInt(s, 10)))];
316
+ if (picks.some((n) => !Number.isFinite(n) || n <= 0)) {
317
+ fail(`${flag} expects positive selection numbers, got '${raw}'`);
318
+ }
319
+ return picks;
320
+ }
321
+
322
+ /**
323
+ * Turn a live-insert failure into the action that actually fixes it. The client
324
+ * has already re-listed the registry and retried ONCE against the live window
325
+ * (a pane reconnect churns the presId), so a pane error that reaches here is one
326
+ * only the user can clear — and the way around it is the window-free fold.
327
+ *
328
+ * A login_required refusal is NOT among them: it is authored once, in
329
+ * loginRequiredMessage, which the caller consults first.
330
+ */
331
+
332
+ export function describeInsertSlideFailure(err, searchId) {
333
+ const code = err?.code ?? err?.body?.code ?? null;
334
+ if (code === "unknown_search") {
335
+ return `searchId ${searchId} is no longer available. Run 'slidemind search' again, then re-pick.`;
336
+ }
337
+ if (code === "searchId_required") {
338
+ // The pane was handed an id it cannot resolve — its search context is
339
+ // per-window and numeric, and a server-minted id was never in it.
340
+ return (
341
+ `searchId ${searchId} belongs to the add-in, not to that PowerPoint window. ` +
342
+ `Re-run 'slidemind search' (the CLI picks the reuse route from where the search ran), then re-pick.`
343
+ );
344
+ }
345
+ const paneAsleep =
346
+ code === "pane_suspended" ||
347
+ code === "pane_timeout" ||
348
+ // A relay 404 about the presId itself carries prose and no machine code,
349
+ // and bridgeFetch falls `code` back to that prose — so read the PARSED one.
350
+ (err?.status === 404 &&
351
+ (!err?.body?.code || err.body.code === "unknown_presentation"));
352
+ if (paneAsleep) {
353
+ return (
354
+ "the slidemind task pane in that PowerPoint window is not responding (already retried once). " +
355
+ "Foreground PowerPoint and click the slidemind pane to wake it, then re-run — or reuse the same " +
356
+ `result with no window at all: slidemind add-slide --search ${searchId} --pick <n> --deck <deck>.`
357
+ );
358
+ }
359
+ return `insert-slide failed: ${err?.message ?? "unknown error"}`;
360
+ }
361
+
362
+ // One line per written slide, plus the machine-readable twin. The searchId is
363
+ // echoed on purpose: an agent interrupted between picks can resume from the JSON
364
+ // without re-running the search (the record lives on the backend now).
365
+ export function writeAddSlideResult({ asJson, searchId, deckDir, deck, written, slides }) {
366
+ if (asJson) {
367
+ process.stdout.write(
368
+ `${JSON.stringify(
369
+ {
370
+ ok: true,
371
+ searchId,
372
+ deck: path.relative(process.cwd(), deckDir),
373
+ slides,
374
+ },
375
+ null,
376
+ 2,
377
+ )}\n`,
378
+ );
379
+ return;
380
+ }
381
+ process.stdout.write(
382
+ `Added ${written.length} reused slide(s) to ${path.relative(process.cwd(), deckDir)}:\n` +
383
+ written.map((w) => ` ${w}`).join("\n") +
384
+ `\nThey are native .pptx in slides/ — re-run 'slidemind build-pptx ${deck}' or 'slidemind push ${deck}' to render the deck with them spliced in.\n`,
385
+ );
386
+ }
387
+
388
+ // Map a `--icon-color` value onto the `--pick` list. Accepts either one colour
389
+ // (applied to every pick) or exactly one per pick (positional); an empty
390
+ // segment means "no colour for this pick". Returns an array aligned to picks.
391
+ export function parseIconColors(raw, pickCount) {
392
+ if (!raw) return new Array(pickCount).fill(null);
393
+ const colors = raw
394
+ .split(",")
395
+ .map((s) => s.trim())
396
+ .map((s) => (s.length ? s : null));
397
+ for (const c of colors) {
398
+ if (c !== null && !/^#[0-9a-fA-F]{3,8}$/.test(c)) {
399
+ fail(`--icon-color expects hex colours like #0A7E8C, got '${c}'`);
400
+ }
401
+ }
402
+ if (colors.length === 1) return new Array(pickCount).fill(colors[0]);
403
+ if (colors.length !== pickCount) {
404
+ fail(
405
+ `--icon-color takes one colour (applied to all) or exactly ${pickCount} (one per --pick); got ${colors.length}`,
406
+ );
407
+ }
408
+ return colors;
409
+ }
410
+
411
+ export function describeReadSourceFailure(err, searchId, pick) {
412
+ const code = err?.code ?? err?.body?.code ?? null;
413
+ switch (code) {
414
+ case "unknown_search":
415
+ return `searchId ${searchId} is no longer available on that window. Run 'slidemind search' again, then re-pick.`;
416
+ case "wrong_result_type":
417
+ return `result ${pick} has no source presentation — only slide search results come from a deck (image and collection results do not).`;
418
+ case "access_denied":
419
+ return "you do not have access to that result's source presentation.";
420
+ case "no_indexed_content":
421
+ return "that presentation has no indexed slides yet. It may still be syncing — try again later.";
422
+ case "invalid_source":
423
+ return `result ${pick} carries no source file reference. Re-run the search, or pick a different result.`;
424
+ case "source_presentation_unsupported":
425
+ return err.message;
426
+ default:
427
+ return `read-source failed: ${err?.message ?? "unknown error"}`;
428
+ }
429
+ }