slidemind 0.3.19 → 0.4.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.
Files changed (186) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +591 -15
  4. package/README.md +202 -7
  5. package/bin/slidemind.mjs +50 -2
  6. package/package.json +2 -2
  7. package/skills/create-slides/SKILL.md +484 -886
  8. package/skills/search/SKILL.md +46 -23
  9. package/src/bridge/build-and-push.mjs +548 -73
  10. package/src/bridge/bundle.mjs +100 -31
  11. package/src/bridge/capture.mjs +14 -17
  12. package/src/bridge/client/brands.mjs +157 -18
  13. package/src/bridge/client/engine-routing.mjs +48 -13
  14. package/src/bridge/client/engine-search.mjs +24 -1
  15. package/src/bridge/client/engine-slides.mjs +1 -1
  16. package/src/bridge/client/render.mjs +27 -1
  17. package/src/bridge/client/transport.mjs +499 -23
  18. package/src/bridge/client.mjs +10 -0
  19. package/src/bridge/config.mjs +67 -6
  20. package/src/bridge/engine-download.mjs +0 -10
  21. package/src/bridge/graph-auth.mjs +55 -3
  22. package/src/bridge/image-intent-envelope.mjs +239 -0
  23. package/src/bridge/package-preview.mjs +67 -0
  24. package/src/bridge/sharepoint.mjs +914 -0
  25. package/src/builders/asset-fetch.mjs +203 -0
  26. package/src/builders/block-dividers.mjs +83 -0
  27. package/src/builders/build.mjs +171 -87
  28. package/src/builders/card-height.mjs +294 -0
  29. package/src/builders/chart-hole.mjs +183 -0
  30. package/src/builders/chart-jsx.mjs +410 -0
  31. package/src/builders/chart-labels.mjs +326 -0
  32. package/src/builders/concept-geometry.mjs +370 -0
  33. package/src/builders/concept-intent.mjs +200 -0
  34. package/src/builders/concept-layout.mjs +79 -0
  35. package/src/builders/concept-lines.mjs +509 -0
  36. package/src/builders/concept-lists.mjs +270 -0
  37. package/src/builders/concept-marks.mjs +84 -0
  38. package/src/builders/concept-relationships.mjs +12 -0
  39. package/src/builders/concept-spec.mjs +645 -0
  40. package/src/builders/concept-text.mjs +140 -0
  41. package/src/builders/concept-waves.mjs +57 -0
  42. package/src/builders/copy-fit.mjs +139 -0
  43. package/src/builders/design-tokens.mjs +347 -0
  44. package/src/builders/draft-check.mjs +552 -340
  45. package/src/builders/draft-lines.mjs +369 -0
  46. package/src/builders/edit-route.mjs +29 -0
  47. package/src/builders/figure-sketch.mjs +375 -0
  48. package/src/builders/fold-sets.mjs +639 -0
  49. package/src/builders/gate-server.mjs +9 -4
  50. package/src/builders/gate-session.js +273 -12
  51. package/src/builders/gate-shell.js +1494 -0
  52. package/src/builders/image-intent-check.mjs +308 -0
  53. package/src/builders/image-use-reconcile.mjs +107 -0
  54. package/src/builders/kit-helpers.mjs +121 -0
  55. package/src/builders/layout-names.mjs +32 -0
  56. package/src/builders/legacy-reference-uses.mjs +179 -0
  57. package/src/builders/line-fit.mjs +224 -0
  58. package/src/builders/line-native.mjs +895 -0
  59. package/src/builders/line-relations.mjs +230 -0
  60. package/src/builders/lint-fix.mjs +117 -9
  61. package/src/builders/lint.mjs +25 -20
  62. package/src/builders/list-jsx.mjs +88 -0
  63. package/src/builders/placement.mjs +133 -0
  64. package/src/builders/plan-checks.mjs +135 -0
  65. package/src/builders/plan-comments.mjs +19 -0
  66. package/src/builders/plan-handlers.mjs +95 -0
  67. package/src/builders/plan-markdown-edit.mjs +3 -3
  68. package/src/builders/plan-overlay.js +1530 -1608
  69. package/src/builders/plan.mjs +689 -186
  70. package/src/builders/preset-definitions.json +1 -0
  71. package/src/builders/preset-eval.mjs +244 -0
  72. package/src/builders/preview.html +51 -120
  73. package/src/builders/red-thread-backbone.mjs +135 -36
  74. package/src/builders/repair.mjs +205 -0
  75. package/src/builders/repeat-groups.mjs +540 -0
  76. package/src/builders/resolve-skill.mjs +5 -4
  77. package/src/builders/review-overlay.js +296 -293
  78. package/src/builders/review.mjs +305 -84
  79. package/src/builders/rule-shift.mjs +96 -0
  80. package/src/builders/seat.mjs +354 -0
  81. package/src/builders/settle-driver.mjs +236 -0
  82. package/src/builders/settle.mjs +1448 -0
  83. package/src/builders/slide-prep.mjs +67 -0
  84. package/src/builders/spec-assets.mjs +236 -0
  85. package/src/builders/spec-match.mjs +56 -0
  86. package/src/builders/stack.mjs +413 -0
  87. package/src/builders/storyboard-markdown.mjs +70 -27
  88. package/src/builders/storyboard-schema.mjs +54 -114
  89. package/src/builders/text-blocks.mjs +212 -0
  90. package/src/builders/text-card-fit.mjs +552 -0
  91. package/src/builders/text-stack.mjs +276 -0
  92. package/src/builders/transcribe.mjs +1069 -0
  93. package/src/cli/assets.mjs +164 -0
  94. package/src/cli/common.mjs +173 -12
  95. package/src/cli/concept-spec-support.mjs +76 -0
  96. package/src/cli/concepts.mjs +1002 -103
  97. package/src/cli/deck.mjs +401 -65
  98. package/src/cli/diagnose.mjs +4 -2
  99. package/src/cli/engine-helpers.mjs +17 -10
  100. package/src/cli/engine-results.mjs +76 -3
  101. package/src/cli/help.mjs +232 -28
  102. package/src/cli/install.mjs +9 -0
  103. package/src/cli/maintenance.mjs +104 -0
  104. package/src/cli/read.mjs +2 -1
  105. package/src/cli/ready.mjs +6 -3
  106. package/src/cli/record.mjs +522 -0
  107. package/src/cli/remote.mjs +818 -0
  108. package/src/cli/revision.mjs +686 -0
  109. package/src/cli/search.mjs +30 -11
  110. package/src/cli/session.mjs +56 -13
  111. package/src/cli/setup.mjs +46 -2
  112. package/src/cli/status.mjs +142 -33
  113. package/src/cli/tasks.mjs +246 -0
  114. package/src/cli/transcribe.mjs +854 -0
  115. package/src/cli/workspace-ui.mjs +260 -0
  116. package/src/cli/workspace.mjs +212 -34
  117. package/src/install-channel.mjs +64 -7
  118. package/src/lib/adopt.mjs +346 -25
  119. package/src/lib/concept-spec-binding.mjs +96 -0
  120. package/src/lib/conversions.mjs +70 -6
  121. package/src/lib/convert-folded.mjs +107 -0
  122. package/src/lib/deck-paths.mjs +418 -0
  123. package/src/lib/durable-ids.mjs +47 -0
  124. package/src/lib/edit-operations.mjs +316 -0
  125. package/src/lib/edit-state.mjs +1244 -0
  126. package/src/lib/edit-transitions.mjs +120 -0
  127. package/src/lib/extensions.mjs +501 -0
  128. package/src/lib/image-assets.mjs +465 -0
  129. package/src/lib/image-derivatives.mjs +234 -0
  130. package/src/lib/image-intent-fingerprint.mjs +70 -0
  131. package/src/lib/image-intent-tags.mjs +176 -0
  132. package/src/lib/image-intent.mjs +802 -0
  133. package/src/lib/image-use-overrides.mjs +279 -0
  134. package/src/lib/inline-components.mjs +613 -0
  135. package/src/lib/lint-converted.mjs +68 -4
  136. package/src/lib/merge-json.mjs +163 -0
  137. package/src/lib/overwrite-guard.mjs +3 -3
  138. package/src/lib/presentation-context.mjs +531 -0
  139. package/src/lib/preservation-tags.mjs +157 -0
  140. package/src/lib/preservation-verdict.mjs +1342 -0
  141. package/src/lib/publish.mjs +1557 -0
  142. package/src/lib/rebase.mjs +945 -0
  143. package/src/lib/reconcile-ack.mjs +4 -6
  144. package/src/lib/reconcile.mjs +62 -24
  145. package/src/lib/record-client.mjs +175 -0
  146. package/src/lib/record-state.mjs +151 -0
  147. package/src/lib/remote.mjs +661 -0
  148. package/src/lib/revision.mjs +1210 -0
  149. package/src/lib/slide-guids.mjs +4 -6
  150. package/src/lib/slide-preservation.mjs +423 -0
  151. package/src/lib/slide-selection.mjs +9 -0
  152. package/src/lib/workspace-transaction.mjs +117 -0
  153. package/src/pptx/content-digest.mjs +334 -0
  154. package/src/pptx/deck-assemble.mjs +1278 -0
  155. package/src/pptx/fill/engine.mjs +2 -3
  156. package/src/pptx/fill/freeform.mjs +6 -3
  157. package/src/pptx/ooxml.mjs +20 -0
  158. package/src/pptx/package-evidence.mjs +383 -0
  159. package/src/pptx/tags.mjs +163 -11
  160. package/src/update-check.mjs +22 -4
  161. package/src/workspace/adapters.mjs +222 -0
  162. package/src/workspace/auth.mjs +256 -0
  163. package/src/workspace/autostart.mjs +204 -0
  164. package/src/workspace/daemon.mjs +360 -0
  165. package/src/workspace/discovery.mjs +180 -0
  166. package/src/workspace/edit.mjs +167 -0
  167. package/src/workspace/events.mjs +52 -0
  168. package/src/workspace/gate-client.mjs +290 -0
  169. package/src/workspace/handback.mjs +73 -0
  170. package/src/workspace/lifecycle.mjs +635 -0
  171. package/src/workspace/operations.mjs +200 -0
  172. package/src/workspace/pick-folder.mjs +152 -0
  173. package/src/workspace/registry.mjs +359 -0
  174. package/src/workspace/remote-links.mjs +397 -0
  175. package/src/workspace/remote-status.mjs +359 -0
  176. package/src/workspace/routes.mjs +1331 -0
  177. package/src/workspace/server.mjs +159 -0
  178. package/src/workspace/sync.mjs +1119 -0
  179. package/src/workspace/task-actions.mjs +215 -0
  180. package/src/workspace/tasks.mjs +507 -0
  181. package/src/workspace/ui/app.js +1886 -0
  182. package/src/workspace/ui/index.html +141 -0
  183. package/src/workspace/ui/plan-bridge.js +120 -0
  184. package/src/workspace/ui/sharepoint.svg +59 -0
  185. package/src/workspace/ui/styles.css +423 -0
  186. package/src/builders/visual-families.mjs +0 -46
@@ -19,7 +19,8 @@
19
19
  // credential with a different subject: the CLI session is minted by the add-in
20
20
  // for whoever clicked Connect CLI, the Graph token belongs to whoever completed
21
21
  // the browser sign-in. Written only when present, so a machine that never ran
22
- // `login` keeps a byte-identical file.
22
+ // `login` keeps a byte-identical file. `msGraphUsername` names that account (as
23
+ // the id_token spelled it) so `whoami` can report it without a network probe.
23
24
  //
24
25
  // Stored under XDG_CONFIG_HOME (config, not cache/state) so it persists and is
25
26
  // clearly user-scoped. 0600 — it holds bearer credentials.
@@ -131,10 +132,45 @@ function readRaw() {
131
132
  try {
132
133
  return JSON.parse(raw);
133
134
  } catch {
135
+ // Two CLI processes writing at once (a token refresh in each of two sim
136
+ // sessions, 2026-09-19) left the shorter write followed by the tail of the
137
+ // longer one: a complete object plus trailing bytes. The first complete
138
+ // top-level object is the store; anything after it is the collision.
139
+ const first = firstJsonObject(raw);
140
+ if (first) return first;
134
141
  return null; // malformed JSON
135
142
  }
136
143
  }
137
144
 
145
+ // The first complete `{…}` at the start of `raw`, parsed — or null.
146
+ export function firstJsonObject(raw) {
147
+ const start = raw.indexOf("{");
148
+ if (start < 0) return null;
149
+ let depth = 0;
150
+ let inString = false;
151
+ for (let i = start; i < raw.length; i++) {
152
+ const ch = raw[i];
153
+ if (inString) {
154
+ if (ch === "\\") i++;
155
+ else if (ch === '"') inString = false;
156
+ continue;
157
+ }
158
+ if (ch === '"') inString = true;
159
+ else if (ch === "{") depth++;
160
+ else if (ch === "}") {
161
+ depth--;
162
+ if (depth === 0) {
163
+ try {
164
+ return JSON.parse(raw.slice(start, i + 1));
165
+ } catch {
166
+ return null;
167
+ }
168
+ }
169
+ }
170
+ }
171
+ return null;
172
+ }
173
+
138
174
  // Every credential this file hands out is registered for output scrubbing the
139
175
  // moment it is read, so a token spliced into an error message downstream — a
140
176
  // server echoing the request back, a response body used as an error detail —
@@ -164,6 +200,9 @@ export function readSessions() {
164
200
  ...(typeof s.msGraphRefreshToken === "string" && s.msGraphRefreshToken
165
201
  ? { msGraphRefreshToken: s.msGraphRefreshToken }
166
202
  : {}),
203
+ ...(typeof s.msGraphUsername === "string" && s.msGraphUsername
204
+ ? { msGraphUsername: s.msGraphUsername }
205
+ : {}),
167
206
  }));
168
207
  }
169
208
  // v1: { backendURL, sessionToken }
@@ -202,6 +241,7 @@ function writeAll(sessions, defaultBackend) {
202
241
  label: s.label || deriveEnvLabel(s.backendURL),
203
242
  ...(s.oid ? { oid: s.oid } : {}),
204
243
  ...(s.msGraphRefreshToken ? { msGraphRefreshToken: s.msGraphRefreshToken } : {}),
244
+ ...(s.msGraphUsername ? { msGraphUsername: s.msGraphUsername } : {}),
205
245
  })),
206
246
  };
207
247
  const def =
@@ -209,9 +249,12 @@ function writeAll(sessions, defaultBackend) {
209
249
  ? defaultBackend
210
250
  : sessions[0]?.backendURL;
211
251
  if (def) payload.default = def;
212
- fs.writeFileSync(sessionConfigPath(), `${JSON.stringify(payload, null, 2)}\n`, {
213
- mode: 0o600,
214
- });
252
+ // Atomic: write beside the store and rename over it, so a second process
253
+ // writing at the same moment can never interleave bytes with this one.
254
+ const target = sessionConfigPath();
255
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
256
+ fs.writeFileSync(tmp, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
257
+ fs.renameSync(tmp, target);
215
258
  }
216
259
 
217
260
  // Add or replace a session (keyed by backendURL). Becomes the default if none
@@ -248,6 +291,11 @@ export function upsertSession({ backendURL, sessionToken, label = null, oid = nu
248
291
  ...(samePerson && previous?.msGraphRefreshToken
249
292
  ? { msGraphRefreshToken: previous.msGraphRefreshToken }
250
293
  : {}),
294
+ // The name travels with the token it describes — never on its own, or a
295
+ // re-pair by someone else would keep printing the previous user's account.
296
+ ...(samePerson && previous?.msGraphRefreshToken && previous?.msGraphUsername
297
+ ? { msGraphUsername: previous.msGraphUsername }
298
+ : {}),
251
299
  });
252
300
  const def = getDefaultBackend() || backendURL;
253
301
  writeAll(sessions, def);
@@ -314,6 +362,11 @@ export function getSessionFor(backendURL) {
314
362
  // Never during tests: `node --test` sets NODE_TEST_CONTEXT, and a test writing
315
363
  // into the developer's real login keychain would outlive the test's isolated
316
364
  // XDG_CONFIG_HOME. SLIDEMIND_NO_KEYCHAIN is the explicit opt-out.
365
+ //
366
+ // Never under a redirected HOME either (batch/bisect harnesses isolate runs with
367
+ // HOME=<run>/.home): `security` resolves the login keychain under $HOME, finds
368
+ // none, and macOS raises a modal "Keychain Not Found" dialog that blocks the
369
+ // write until someone clicks it. Such runs keep the token in their own file.
317
370
  const KEYCHAIN_MARKER = "@keychain";
318
371
  const KEYCHAIN_SERVICE = "slidemind-msgraph";
319
372
 
@@ -321,7 +374,8 @@ function keychainUsable() {
321
374
  return (
322
375
  process.platform === "darwin" &&
323
376
  !process.env.NODE_TEST_CONTEXT &&
324
- !process.env.SLIDEMIND_NO_KEYCHAIN
377
+ !process.env.SLIDEMIND_NO_KEYCHAIN &&
378
+ path.resolve(os.homedir()) === os.userInfo().homedir
325
379
  );
326
380
  }
327
381
 
@@ -385,11 +439,17 @@ export function getGraphRefreshToken(backendURL) {
385
439
  // Every refresh returns a NEW refresh token, so this is called on each rotation.
386
440
  // Returns false when that backend is no longer paired — a session removed
387
441
  // mid-refresh must not resurrect itself as a token-only record.
388
- export function setGraphRefreshToken(backendURL, refreshToken) {
442
+ //
443
+ // `username` is the signed-in account's name, which only the SIGN-IN knows (it
444
+ // reads it off the id_token). A rotation passes nothing and leaves the stored
445
+ // name alone; clearing the token clears the name with it, so nothing can report
446
+ // an account this machine no longer holds a credential for.
447
+ export function setGraphRefreshToken(backendURL, refreshToken, { username = null } = {}) {
389
448
  const sessions = readSessions();
390
449
  const record = sessions.find((s) => s.backendURL === backendURL);
391
450
  if (!record) return false;
392
451
  if (refreshToken) {
452
+ if (username) record.msGraphUsername = username;
393
453
  record.msGraphRefreshToken = refreshToken;
394
454
  if (keychainUsable()) {
395
455
  try {
@@ -406,6 +466,7 @@ export function setGraphRefreshToken(backendURL, refreshToken) {
406
466
  keychainDelete(backendURL);
407
467
  }
408
468
  delete record.msGraphRefreshToken;
469
+ delete record.msGraphUsername;
409
470
  }
410
471
  writeAll(sessions, getDefaultBackend());
411
472
  return true;
@@ -239,16 +239,6 @@ async function streamOneResult({
239
239
  markup = local.markup;
240
240
  recolorRefused = null;
241
241
  }
242
- } else if (!iconColor && describeRecolor(markup, res.mimeType).mode === "mono") {
243
- // No colour asked for and the glyph is a single fixed paint: make it
244
- // TINTABLE instead of shipping it black. With `currentColor` the slide's
245
- // own `color: var(--token)` on the <img> drives the fill in the preview
246
- // and in the .pptx alike, so one file serves a white glyph on an accent
247
- // medallion and an accent glyph on white. Every sim builder that baked
248
- // one hex at download time later lost either the glyph (blue on blue)
249
- // or the medallion fill; a baked colour stays available via --icon-color.
250
- const tintable = applyLocalRecolor(markup, "currentColor");
251
- if (tintable.applied) markup = tintable.markup;
252
242
  }
253
243
  const filename = `engine-${searchId}-${selection}.svg`;
254
244
  const target = path.join(assetsDir, filename);
@@ -108,6 +108,13 @@ function tokenEndpoint(tenantId) {
108
108
  return `${authorityBase()}/${encodeURIComponent(tenantId)}/oauth2/v2.0/token`;
109
109
  }
110
110
 
111
+ // A failure that says nothing about the sign-in: the service could not be
112
+ // reached or was down for a moment. Only these leave a refreshFailures entry.
113
+ const OUTAGE_STATUS = new Set([408, 429]);
114
+ function unreachable(message) {
115
+ return Object.assign(new Error(message), { transient: true });
116
+ }
117
+
111
118
  // One /env/config read per backend per process — the app registration doesn't
112
119
  // change under a running command.
113
120
  const appRegistrationCache = new Map();
@@ -122,10 +129,13 @@ async function discoverAppRegistration(backendURL) {
122
129
  try {
123
130
  res = await httpJson("GET", `${backendURL}/env/config`);
124
131
  } catch (err) {
125
- throw new Error(
132
+ throw unreachable(
126
133
  `could not reach slidemind${envPhrase(env, "on")} to start sign-in: ${err?.message || err}`,
127
134
  );
128
135
  }
136
+ if (OUTAGE_STATUS.has(res.status) || res.status >= 500) {
137
+ throw unreachable(`slidemind${envPhrase(env, "on")} answered HTTP ${res.status} for its sign-in details`);
138
+ }
129
139
  const clientId = typeof res.json?.CLIENT_ID === "string" ? res.json.CLIENT_ID : "";
130
140
  const tenantId = typeof res.json?.TENANT_ID === "string" ? res.json.TENANT_ID : "";
131
141
  if (res.status !== 200 || !clientId || !tenantId) {
@@ -453,7 +463,7 @@ async function requestToken(tenantId, form) {
453
463
  try {
454
464
  res = await httpJson("POST", tokenEndpoint(tenantId), { form });
455
465
  } catch (err) {
456
- throw new Error(`could not reach Microsoft sign-in: ${err?.message || err}`);
466
+ throw unreachable(`could not reach Microsoft sign-in: ${err?.message || err}`);
457
467
  }
458
468
  if (res.status === 200 && res.json?.access_token) {
459
469
  // Every mint and every refresh lands here, before any caller can act on the
@@ -470,6 +480,8 @@ async function requestToken(tenantId, form) {
470
480
  const err = new Error(String(detail).split("\n")[0]);
471
481
  err.oauth = true;
472
482
  err.code = res.json?.error ?? null;
483
+ // Entra down or throttling: the credential was never judged.
484
+ err.transient = OUTAGE_STATUS.has(res.status) || res.status >= 500;
473
485
  throw err;
474
486
  }
475
487
 
@@ -708,7 +720,11 @@ export async function loginGraph({
708
720
  session: paired,
709
721
  env: deriveEnvLabel(backendURL),
710
722
  });
711
- if (!setGraphRefreshToken(backendURL, tokens.refresh_token)) {
723
+ if (
724
+ !setGraphRefreshToken(backendURL, tokens.refresh_token, {
725
+ username: accountName(claims),
726
+ })
727
+ ) {
712
728
  throw new Error(
713
729
  `slidemind is not connected to '${deriveEnvLabel(backendURL)}'.`,
714
730
  );
@@ -751,6 +767,24 @@ const cacheKey = (backendURL, resource) => `${backendURL}${resource}`;
751
767
  // the next redeems it.
752
768
  const refreshChain = new Map();
753
769
 
770
+ // Why the last refresh of a backend + resource came back empty for a reason
771
+ // that is NOT the credential (Entra or this backend unreachable, a timeout, an
772
+ // HTTP 408/429/5xx — see `unreachable`).
773
+ // The sign-in is intact then, and a request sent without its token is refused
774
+ // as `login_required`, which sends the user to sign in again for nothing (x108:
775
+ // "run `slidemind login`" mid-run, and the next search answered).
776
+ const refreshFailures = new Map(); // cacheKey → message
777
+
778
+ /**
779
+ * The cause of the last failed refresh of this backend's `resource` token while
780
+ * its sign-in is still stored, or null. Callers refuse to send a request without
781
+ * the token then, and report a retryable failure instead of a missing sign-in.
782
+ */
783
+ export function tokenRefreshFailure(backendURL, resource) {
784
+ if (!hasGraphSession(backendURL)) return null;
785
+ return refreshFailures.get(cacheKey(backendURL, resource)) ?? null;
786
+ }
787
+
754
788
  function withRefreshLock(backendURL, fn) {
755
789
  const tail = refreshChain.get(backendURL) ?? Promise.resolve();
756
790
  const next = tail.then(fn, fn);
@@ -781,6 +815,21 @@ export async function getGraphToken(backendURL) {
781
815
  return resourceToken(backendURL, "graph");
782
816
  }
783
817
 
818
+ /**
819
+ * This deployment's SharePoint origin (`https://tenant.sharepoint.com`), or null
820
+ * when it has none configured. The SAME value the SharePoint token is minted
821
+ * for, which is what makes it the only origin the direct adapter may send that
822
+ * token to — the CLI hardcodes no tenant, exactly as the connect doctrine says.
823
+ */
824
+ export async function sharePointResource(backendURL) {
825
+ if (!backendURL) return null;
826
+ try {
827
+ return (await discoverAppRegistration(backendURL)).sharePointResource;
828
+ } catch {
829
+ return null;
830
+ }
831
+ }
832
+
784
833
  /**
785
834
  * A usable SharePoint-resource access token for one backend, or null.
786
835
  *
@@ -858,9 +907,12 @@ async function refreshResourceToken(backendURL, resource, storedRefreshToken) {
858
907
  "corpus search and SharePoint content need the user to run `slidemind login` again.\n",
859
908
  );
860
909
  }
910
+ } else if (err?.transient) {
911
+ refreshFailures.set(cacheKey(backendURL, resource), err.message);
861
912
  }
862
913
  return null;
863
914
  }
915
+ refreshFailures.delete(cacheKey(backendURL, resource));
864
916
  // Rotation: AAD issues a new refresh token on every refresh and the old one
865
917
  // is spent. Persisting it is what keeps the ~90-day window sliding.
866
918
  if (tokens.refresh_token) setGraphRefreshToken(backendURL, tokens.refresh_token);
@@ -0,0 +1,239 @@
1
+ // The `imageIntent` request envelope — the deck's approved image obligations,
2
+ // projected onto the wire (implementation guide §4).
3
+ //
4
+ // Two shapes, one builder:
5
+ //
6
+ // * MODEL endpoints (concept images, concept specs, spec jobs) carry the
7
+ // bytes INLINE as a data URI. The add-in's model call has no access to this
8
+ // machine, so a descriptor without bytes would be an instruction to imagine
9
+ // the picture.
10
+ // * RENDER / TRANSLATE carry `workspacePath` instead. Those requests already
11
+ // ship every deck asset as base64 in `deckAssets` (src/bridge/build-and-
12
+ // push.mjs → gatherDeckAssets, keyed `assets/<rel>`), and an image asset
13
+ // lives at `assets/local/<assetId>.<ext>` — which IS its workspacePath. So
14
+ // the envelope points into the byte map the request already has rather than
15
+ // sending every screenshot twice.
16
+ //
17
+ // Everything is built from what is PERSISTED — the deck's registry
18
+ // (state/image-assets.json), the plan's storyboard fences, and the targeted-edit
19
+ // overrides — never from whatever a command happens to be holding. A use that
20
+ // only exists in a command's arguments is a use the user has not approved.
21
+ //
22
+ // The envelope is returned through the contract's own `parseImageIntentEnvelope`,
23
+ // so a request can never carry a record the receiving validator would reject,
24
+ // and a deck with no image uses builds NO envelope at all: `null`, so the call
25
+ // serializes exactly as it did before this feature existed.
26
+
27
+ import fs from "node:fs";
28
+ import path from "node:path";
29
+
30
+ import { splitPlanMarkdownIntoSegments } from "../builders/storyboard-markdown.mjs";
31
+ import {
32
+ imageAssetError,
33
+ readImageAssetManifest,
34
+ resolveImageAsset,
35
+ } from "../lib/image-assets.mjs";
36
+ import {
37
+ IMAGE_INTENT_VERSION,
38
+ parseImageIntentEnvelope,
39
+ selectImageIntent,
40
+ } from "../lib/image-intent.mjs";
41
+ import { imageIntentFingerprint } from "../lib/image-intent-fingerprint.mjs";
42
+ import { readImageUseOverrides, slideIdentities } from "../lib/image-use-overrides.mjs";
43
+
44
+ // The ceiling a SINGLE source may carry into one request. It is the add-in's
45
+ // own `MAX_IMAGE_ASSET_BYTES` (RT/image-assets.ts), which is the ceiling
46
+ // `engine_add` and `generate_image` already enforce server-side — reused rather
47
+ // than re-invented, so an asset this CLI accepts is one the route accepts. Over
48
+ // it, the answer is the contract's `image_asset_too_large`: produce a declared
49
+ // derivative or supply a smaller source. The final source is never silently
50
+ // downsampled and the asset list is never silently shortened.
51
+ export const IMAGE_INTENT_MAX_ASSET_BYTES = 8 * 1024 * 1024;
52
+
53
+ // The encoded-body target a batched request aims to stay under. Same number,
54
+ // different job: it is what the per-slide batching in `slidemind concepts`
55
+ // already assumed ("four slides keep each body under ~8 MB") now that an
56
+ // envelope's inline sources ride along with the mockup PNGs.
57
+ export const IMAGE_INTENT_REQUEST_BYTE_BUDGET = 8 * 1024 * 1024;
58
+
59
+ const mb = (n) => (n / (1024 * 1024)).toFixed(1);
60
+
61
+ function readPlanMarkdown(deckDir) {
62
+ try {
63
+ return fs.readFileSync(path.join(deckDir, "plan.md"), "utf8");
64
+ } catch {
65
+ return "";
66
+ }
67
+ }
68
+
69
+ /** The uses each storyboard fence records: `Map<slide, AssetUse[]>`. */
70
+ export function planStoryboardUses(planMarkdown) {
71
+ const bySlide = new Map();
72
+ for (const segment of splitPlanMarkdownIntoSegments(planMarkdown ?? "")) {
73
+ if (segment.type !== "storyboard" || !segment.data?.slide) continue;
74
+ bySlide.set(segment.data.slide, segment.data.assetUses ?? []);
75
+ }
76
+ return bySlide;
77
+ }
78
+
79
+ /**
80
+ * The uses that actually apply to each requested slide: its targeted-edit
81
+ * override if it has one, otherwise the plan's.
82
+ *
83
+ * This is `effectiveSlideUses` (src/lib/image-use-overrides.mjs) for a whole
84
+ * slide set — composed from the same two primitives rather than called per
85
+ * slide, because the slide-identity half rescans and re-hashes every slide file
86
+ * on each call and a 20-slide deck would pay for it twenty times.
87
+ *
88
+ * @returns {Map<number, Array>}
89
+ */
90
+ export function effectiveUsesForSlides(deckDir, slides, { planMarkdown = null } = {}) {
91
+ const planned = planStoryboardUses(planMarkdown ?? readPlanMarkdown(deckDir));
92
+ const wanted = normalizeSlides(slides);
93
+ const out = new Map();
94
+ if (!wanted.length) return out;
95
+
96
+ // Only pay for the override machinery when the deck has overrides at all —
97
+ // which, for a planned deck that was never edited slide-by-slide, is never.
98
+ const overrides = readImageUseOverrides(deckDir);
99
+ const idBySlide = new Map();
100
+ if (overrides.slides.length) {
101
+ for (const entry of slideIdentities(deckDir)) idBySlide.set(entry.slide, entry.slideId);
102
+ }
103
+ for (const slide of wanted) {
104
+ const { uses } = selectImageIntent({
105
+ slideId: idBySlide.get(slide) ?? null,
106
+ storyboardUses: planned.get(slide) ?? [],
107
+ overrides,
108
+ });
109
+ out.set(slide, uses);
110
+ }
111
+ return out;
112
+ }
113
+
114
+ function normalizeSlides(slides) {
115
+ return [...new Set(slides ?? [])]
116
+ .filter((n) => Number.isInteger(n) && n > 0)
117
+ .sort((a, b) => a - b);
118
+ }
119
+
120
+ // One asset's transport descriptor, with its bytes proved: `resolveImageAsset`
121
+ // raises `image_asset_missing` when the copy is gone and `image_asset_changed`
122
+ // when it no longer hashes to what the registry recorded — the two failures the
123
+ // contract distinguishes because their repairs differ.
124
+ function assetDescriptor(deckDir, assetId, { inline, maxAssetBytes }) {
125
+ const { asset, bytes } = resolveImageAsset(deckDir, assetId);
126
+ if (bytes.length > maxAssetBytes) {
127
+ throw imageAssetError(
128
+ "image_asset_too_large",
129
+ `${asset.displayName}: ${mb(bytes.length)} MB is past the ${mb(maxAssetBytes)} MB a single image may carry into one request`,
130
+ { assetId },
131
+ );
132
+ }
133
+ const descriptor = {
134
+ assetId: asset.assetId,
135
+ sha256: asset.sha256,
136
+ mimeType: asset.mimeType,
137
+ width: asset.width,
138
+ height: asset.height,
139
+ byteLength: bytes.length,
140
+ displayName: asset.displayName,
141
+ };
142
+ return inline
143
+ ? { ...descriptor, image: `data:${asset.mimeType};base64,${bytes.toString("base64")}` }
144
+ : { ...descriptor, workspacePath: asset.workspacePath };
145
+ }
146
+
147
+ /**
148
+ * The `imageIntent` envelope for one request, or **null** when none of the
149
+ * requested slides carries an image use.
150
+ *
151
+ * Null is the load-bearing case: it is what keeps every existing call — every
152
+ * deck that has no image intent, which today is all of them — serializing byte
153
+ * for byte as it did before.
154
+ *
155
+ * @param {string} deckDir the deck workspace
156
+ * @param {{slides: number[], planMarkdown?: string|null, inline?: boolean,
157
+ * maxAssetBytes?: number}} opts
158
+ * `inline: true` (the default) carries data URIs for the model endpoints;
159
+ * `inline: false` carries `workspacePath`, for render/translate, whose
160
+ * `deckAssets` byte map already holds those exact bytes under that key.
161
+ */
162
+ export function buildImageIntentEnvelope(
163
+ deckDir,
164
+ { slides, planMarkdown = null, inline = true, maxAssetBytes = IMAGE_INTENT_MAX_ASSET_BYTES } = {},
165
+ ) {
166
+ const uses = effectiveUsesForSlides(deckDir, slides, { planMarkdown });
167
+ const slidesOut = [];
168
+ const assetIds = new Set();
169
+ for (const slide of normalizeSlides([...uses.keys()])) {
170
+ const list = uses.get(slide) ?? [];
171
+ if (!list.length) continue;
172
+ slidesOut.push({ slide, uses: list });
173
+ for (const use of list) assetIds.add(use.assetId);
174
+ }
175
+ if (!slidesOut.length) return null;
176
+
177
+ const envelope = {
178
+ version: IMAGE_INTENT_VERSION,
179
+ // Only the assets the REQUESTED slides use, in a deterministic order so two
180
+ // identical requests serialize identically.
181
+ assets: [...assetIds]
182
+ .sort()
183
+ .map((assetId) => assetDescriptor(deckDir, assetId, { inline, maxAssetBytes })),
184
+ slides: slidesOut,
185
+ };
186
+
187
+ // One validator on both sides of the wire: whatever this builds is run
188
+ // through the same parser the receiver uses, so a drift here fails here.
189
+ const parsed = parseImageIntentEnvelope(envelope);
190
+ if (!parsed.value) {
191
+ const first = parsed.issues[0];
192
+ throw imageAssetError(
193
+ first?.code ?? "image_intent_invalid",
194
+ `image intent envelope: ${first?.message ?? "invalid"}`,
195
+ first?.assetId ? { assetId: first.assetId } : {},
196
+ );
197
+ }
198
+ return parsed.value;
199
+ }
200
+
201
+ /**
202
+ * The canonical intent fingerprint of each requested slide — `null` for a slide
203
+ * with no uses, so a legacy no-intent slide has nothing to record and nothing
204
+ * to invalidate (design decision 6).
205
+ *
206
+ * Reads the registry, never the bytes: an entry is immutable, so its recorded
207
+ * digest IS the source identity. Whether the copy on disk still matches is a
208
+ * different question, answered where the bytes are actually needed.
209
+ *
210
+ * @returns {Map<number, string|null>}
211
+ */
212
+ export function slideIntentFingerprints(deckDir, slides, { planMarkdown = null, context = null } = {}) {
213
+ const uses = effectiveUsesForSlides(deckDir, slides, { planMarkdown });
214
+ const out = new Map();
215
+ if (![...uses.values()].some((list) => list.length)) {
216
+ for (const slide of uses.keys()) out.set(slide, null);
217
+ return out;
218
+ }
219
+ const manifest = readImageAssetManifest(deckDir);
220
+ const byId = new Map(manifest.assets.map((asset) => [asset.assetId, asset]));
221
+ for (const [slide, list] of uses) {
222
+ if (!list.length) {
223
+ out.set(slide, null);
224
+ continue;
225
+ }
226
+ const assets = [...new Set(list.map((use) => use.assetId))]
227
+ .map((id) => byId.get(id))
228
+ .filter(Boolean);
229
+ out.set(
230
+ slide,
231
+ imageIntentFingerprint({
232
+ uses: list,
233
+ assets,
234
+ context: { ...(context ?? {}), slide },
235
+ }),
236
+ );
237
+ }
238
+ return out;
239
+ }
@@ -0,0 +1,67 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { previewPath } from '../lib/deck-paths.mjs';
5
+ import { slideOrderPaths } from '../pptx/ooxml.mjs';
6
+ import { unzip } from '../pptx/zip.mjs';
7
+ import { backendCapabilities, bridgeFetch, withTransientRetry } from './client/transport.mjs';
8
+ import { multipartBody } from './client/brands.mjs';
9
+
10
+ const digest = bytes => createHash('sha256').update(bytes).digest('hex');
11
+
12
+ export function validatePackagePreview(bytes, result) {
13
+ const count = slideOrderPaths(unzip(bytes)).length;
14
+ if (!count || result?.packageSha256 !== digest(bytes) || result?.renderer !== 'libreoffice')
15
+ throw new Error('Package preview does not identify the exported PPTX bytes and renderer.');
16
+ const slides = [...(result.images ?? [])].sort((a,b)=>a.slideNumber-b.slideNumber);
17
+ if (slides.length !== count || slides.some((s,i)=>s.slideNumber!==i+1))
18
+ throw new Error('Package preview is incomplete or contains duplicate/out-of-order slide identities.');
19
+ return slides.map(s => {
20
+ const png = Buffer.from(s.base64 ?? '', 'base64');
21
+ if (!png.subarray(0,8).equals(Buffer.from([137,80,78,71,13,10,26,10])))
22
+ throw new Error(`Package preview slide ${s.slideNumber} is not a PNG.`);
23
+ return { slideNumber:s.slideNumber, png };
24
+ });
25
+ }
26
+
27
+ // The exact final bytes, including native/reused slides and assembly changes.
28
+ // A browser capture is NEVER reported as a render of this package.
29
+ // The backend renders at most a few packages at once and answers 429
30
+ // preview_busy beyond that. A render holds its slot for tens of seconds, so a
31
+ // busy answer waits long enough for one to finish instead of giving up.
32
+ const BUSY_DELAYS_MS = [10000, 20000, 40000];
33
+ const isBusy = err => err?.status === 429 && err?.code === 'preview_busy';
34
+
35
+ export async function writePackagePreview({deckDir, bytes, target, onProgress, request, busyDelays = BUSY_DELAYS_MS}) {
36
+ const packageSha256 = digest(bytes);
37
+ const unavailable = note => ({ kind:'pptx', renderer:null, status:'unavailable', packageSha256, dir:null, files:[], note });
38
+ if (!target?.brandId || !target.backendURL) return unavailable('Exported PPTX visual verification unavailable: no brand render target.');
39
+ try {
40
+ if (!request) {
41
+ const caps = await backendCapabilities(target.backendURL);
42
+ if (!caps.pptxPreview) return unavailable('Exported PPTX visual verification unavailable: this backend does not provide package previews. Browser previews verify source only.');
43
+ request = async () => {
44
+ const upload = multipartBody({}, {name:'deck', fileName:'deck.pptx', bytes, contentType:'application/vnd.openxmlformats-officedocument.presentationml.presentation'});
45
+ const response = await bridgeFetch(`/api/cli/brands/${encodeURIComponent(target.brandId)}/preview-pptx`,{method:'POST',headers:{'content-type':upload.contentType},body:upload.body,timeoutMs:200000},target.backendURL);
46
+ return response.json();
47
+ };
48
+ }
49
+ onProgress?.({stage:'verifying exported PPTX',done:0,total:1});
50
+ const result = await withTransientRetry(() => request(bytes, target), isBusy, busyDelays);
51
+ const slides = validatePackagePreview(bytes,result);
52
+ // Content-addressed directory: a failed later build cannot leave old PNGs
53
+ // looking like successful verification of new bytes.
54
+ const dir = path.join(previewPath(deckDir,'package'),packageSha256);
55
+ fs.mkdirSync(dir,{recursive:true});
56
+ const files = slides.map(({slideNumber,png})=>{
57
+ const file=path.join(dir,`${String(slideNumber).padStart(2,'0')}.png`);
58
+ fs.writeFileSync(file,png);return file;
59
+ });
60
+ const preview={kind:'pptx',renderer:result.renderer,status:'rendered',packageSha256,dir,files,
61
+ note:'Rendered from the exported PPTX with LibreOffice. Inspect every slide; this is not a PowerPoint pixel-parity or visual-quality verdict.'};
62
+ fs.writeFileSync(path.join(dir,'manifest.json'),JSON.stringify(preview,null,2)+'\n');
63
+ return preview;
64
+ } catch (err) {
65
+ return unavailable(`Exported PPTX visual verification unavailable: ${err instanceof Error ? err.message : String(err)}. Browser previews verify source only.`);
66
+ }
67
+ }