slidemind 0.3.20 → 0.4.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.
Files changed (187) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +592 -16
  4. package/LICENSE +4 -0
  5. package/README.md +203 -8
  6. package/THIRD-PARTY-NOTICES.md +201 -0
  7. package/bin/slidemind.mjs +50 -2
  8. package/package.json +5 -3
  9. package/skills/create-slides/SKILL.md +484 -886
  10. package/skills/search/SKILL.md +46 -23
  11. package/src/bridge/build-and-push.mjs +548 -73
  12. package/src/bridge/bundle.mjs +100 -31
  13. package/src/bridge/capture.mjs +14 -17
  14. package/src/bridge/client/brands.mjs +157 -18
  15. package/src/bridge/client/engine-routing.mjs +48 -13
  16. package/src/bridge/client/engine-search.mjs +24 -1
  17. package/src/bridge/client/render.mjs +27 -1
  18. package/src/bridge/client/transport.mjs +499 -23
  19. package/src/bridge/client.mjs +10 -0
  20. package/src/bridge/config.mjs +67 -6
  21. package/src/bridge/engine-download.mjs +0 -10
  22. package/src/bridge/graph-auth.mjs +55 -3
  23. package/src/bridge/image-intent-envelope.mjs +239 -0
  24. package/src/bridge/package-preview.mjs +67 -0
  25. package/src/bridge/sharepoint.mjs +914 -0
  26. package/src/builders/asset-fetch.mjs +203 -0
  27. package/src/builders/block-dividers.mjs +83 -0
  28. package/src/builders/build.mjs +171 -87
  29. package/src/builders/card-height.mjs +294 -0
  30. package/src/builders/chart-hole.mjs +183 -0
  31. package/src/builders/chart-jsx.mjs +410 -0
  32. package/src/builders/chart-labels.mjs +326 -0
  33. package/src/builders/concept-geometry.mjs +370 -0
  34. package/src/builders/concept-intent.mjs +200 -0
  35. package/src/builders/concept-layout.mjs +79 -0
  36. package/src/builders/concept-lines.mjs +509 -0
  37. package/src/builders/concept-lists.mjs +270 -0
  38. package/src/builders/concept-marks.mjs +84 -0
  39. package/src/builders/concept-relationships.mjs +12 -0
  40. package/src/builders/concept-spec.mjs +645 -0
  41. package/src/builders/concept-text.mjs +140 -0
  42. package/src/builders/concept-waves.mjs +57 -0
  43. package/src/builders/copy-fit.mjs +139 -0
  44. package/src/builders/design-tokens.mjs +347 -0
  45. package/src/builders/draft-check.mjs +552 -340
  46. package/src/builders/draft-lines.mjs +369 -0
  47. package/src/builders/edit-route.mjs +29 -0
  48. package/src/builders/figure-sketch.mjs +375 -0
  49. package/src/builders/fold-sets.mjs +639 -0
  50. package/src/builders/gate-server.mjs +9 -4
  51. package/src/builders/gate-session.js +273 -12
  52. package/src/builders/gate-shell.js +1494 -0
  53. package/src/builders/image-intent-check.mjs +308 -0
  54. package/src/builders/image-use-reconcile.mjs +107 -0
  55. package/src/builders/kit-helpers.mjs +121 -0
  56. package/src/builders/layout-names.mjs +32 -0
  57. package/src/builders/legacy-reference-uses.mjs +179 -0
  58. package/src/builders/line-fit.mjs +224 -0
  59. package/src/builders/line-native.mjs +895 -0
  60. package/src/builders/line-relations.mjs +230 -0
  61. package/src/builders/lint-fix.mjs +117 -9
  62. package/src/builders/lint.mjs +25 -20
  63. package/src/builders/list-jsx.mjs +88 -0
  64. package/src/builders/placement.mjs +133 -0
  65. package/src/builders/plan-checks.mjs +135 -0
  66. package/src/builders/plan-comments.mjs +19 -0
  67. package/src/builders/plan-handlers.mjs +95 -0
  68. package/src/builders/plan-markdown-edit.mjs +3 -3
  69. package/src/builders/plan-overlay.js +1530 -1608
  70. package/src/builders/plan.mjs +689 -186
  71. package/src/builders/preset-definitions.json +1 -0
  72. package/src/builders/preset-eval.mjs +244 -0
  73. package/src/builders/preview.html +51 -120
  74. package/src/builders/red-thread-backbone.mjs +135 -36
  75. package/src/builders/repair.mjs +205 -0
  76. package/src/builders/repeat-groups.mjs +540 -0
  77. package/src/builders/resolve-skill.mjs +5 -4
  78. package/src/builders/review-overlay.js +296 -293
  79. package/src/builders/review.mjs +305 -84
  80. package/src/builders/rule-shift.mjs +96 -0
  81. package/src/builders/seat.mjs +354 -0
  82. package/src/builders/settle-driver.mjs +236 -0
  83. package/src/builders/settle.mjs +1448 -0
  84. package/src/builders/slide-prep.mjs +67 -0
  85. package/src/builders/spec-assets.mjs +236 -0
  86. package/src/builders/spec-match.mjs +56 -0
  87. package/src/builders/stack.mjs +413 -0
  88. package/src/builders/storyboard-markdown.mjs +70 -27
  89. package/src/builders/storyboard-schema.mjs +54 -114
  90. package/src/builders/text-blocks.mjs +212 -0
  91. package/src/builders/text-card-fit.mjs +552 -0
  92. package/src/builders/text-stack.mjs +276 -0
  93. package/src/builders/transcribe.mjs +1069 -0
  94. package/src/cli/assets.mjs +164 -0
  95. package/src/cli/common.mjs +173 -12
  96. package/src/cli/concept-spec-support.mjs +76 -0
  97. package/src/cli/concepts.mjs +1002 -103
  98. package/src/cli/deck.mjs +401 -65
  99. package/src/cli/diagnose.mjs +4 -2
  100. package/src/cli/engine-helpers.mjs +17 -10
  101. package/src/cli/engine-results.mjs +76 -3
  102. package/src/cli/help.mjs +232 -28
  103. package/src/cli/install.mjs +9 -0
  104. package/src/cli/maintenance.mjs +104 -0
  105. package/src/cli/read.mjs +2 -1
  106. package/src/cli/ready.mjs +6 -3
  107. package/src/cli/record.mjs +522 -0
  108. package/src/cli/remote.mjs +818 -0
  109. package/src/cli/revision.mjs +686 -0
  110. package/src/cli/search.mjs +30 -11
  111. package/src/cli/session.mjs +56 -13
  112. package/src/cli/setup.mjs +46 -2
  113. package/src/cli/status.mjs +142 -33
  114. package/src/cli/tasks.mjs +246 -0
  115. package/src/cli/transcribe.mjs +854 -0
  116. package/src/cli/workspace-ui.mjs +260 -0
  117. package/src/cli/workspace.mjs +212 -34
  118. package/src/install-channel.mjs +64 -7
  119. package/src/lib/adopt.mjs +346 -25
  120. package/src/lib/concept-spec-binding.mjs +96 -0
  121. package/src/lib/conversions.mjs +70 -6
  122. package/src/lib/convert-folded.mjs +107 -0
  123. package/src/lib/deck-paths.mjs +418 -0
  124. package/src/lib/durable-ids.mjs +47 -0
  125. package/src/lib/edit-operations.mjs +316 -0
  126. package/src/lib/edit-state.mjs +1244 -0
  127. package/src/lib/edit-transitions.mjs +120 -0
  128. package/src/lib/extensions.mjs +501 -0
  129. package/src/lib/image-assets.mjs +465 -0
  130. package/src/lib/image-derivatives.mjs +234 -0
  131. package/src/lib/image-intent-fingerprint.mjs +70 -0
  132. package/src/lib/image-intent-tags.mjs +176 -0
  133. package/src/lib/image-intent.mjs +802 -0
  134. package/src/lib/image-use-overrides.mjs +279 -0
  135. package/src/lib/inline-components.mjs +613 -0
  136. package/src/lib/lint-converted.mjs +68 -4
  137. package/src/lib/merge-json.mjs +163 -0
  138. package/src/lib/overwrite-guard.mjs +3 -3
  139. package/src/lib/presentation-context.mjs +531 -0
  140. package/src/lib/preservation-tags.mjs +157 -0
  141. package/src/lib/preservation-verdict.mjs +1342 -0
  142. package/src/lib/publish.mjs +1557 -0
  143. package/src/lib/rebase.mjs +945 -0
  144. package/src/lib/reconcile-ack.mjs +4 -6
  145. package/src/lib/reconcile.mjs +62 -24
  146. package/src/lib/record-client.mjs +175 -0
  147. package/src/lib/record-state.mjs +151 -0
  148. package/src/lib/remote.mjs +661 -0
  149. package/src/lib/revision.mjs +1210 -0
  150. package/src/lib/slide-guids.mjs +4 -6
  151. package/src/lib/slide-preservation.mjs +423 -0
  152. package/src/lib/slide-selection.mjs +9 -0
  153. package/src/lib/workspace-transaction.mjs +117 -0
  154. package/src/pptx/content-digest.mjs +334 -0
  155. package/src/pptx/deck-assemble.mjs +1278 -0
  156. package/src/pptx/fill/engine.mjs +2 -3
  157. package/src/pptx/fill/freeform.mjs +6 -3
  158. package/src/pptx/ooxml.mjs +20 -0
  159. package/src/pptx/package-evidence.mjs +383 -0
  160. package/src/pptx/tags.mjs +163 -11
  161. package/src/update-check.mjs +22 -4
  162. package/src/workspace/adapters.mjs +222 -0
  163. package/src/workspace/auth.mjs +256 -0
  164. package/src/workspace/autostart.mjs +204 -0
  165. package/src/workspace/daemon.mjs +360 -0
  166. package/src/workspace/discovery.mjs +180 -0
  167. package/src/workspace/edit.mjs +167 -0
  168. package/src/workspace/events.mjs +52 -0
  169. package/src/workspace/gate-client.mjs +290 -0
  170. package/src/workspace/handback.mjs +73 -0
  171. package/src/workspace/lifecycle.mjs +635 -0
  172. package/src/workspace/operations.mjs +200 -0
  173. package/src/workspace/pick-folder.mjs +152 -0
  174. package/src/workspace/registry.mjs +359 -0
  175. package/src/workspace/remote-links.mjs +397 -0
  176. package/src/workspace/remote-status.mjs +359 -0
  177. package/src/workspace/routes.mjs +1331 -0
  178. package/src/workspace/server.mjs +159 -0
  179. package/src/workspace/sync.mjs +1119 -0
  180. package/src/workspace/task-actions.mjs +215 -0
  181. package/src/workspace/tasks.mjs +507 -0
  182. package/src/workspace/ui/app.js +1886 -0
  183. package/src/workspace/ui/index.html +141 -0
  184. package/src/workspace/ui/plan-bridge.js +120 -0
  185. package/src/workspace/ui/sharepoint.svg +59 -0
  186. package/src/workspace/ui/styles.css +423 -0
  187. package/src/builders/visual-families.mjs +0 -46
@@ -8,7 +8,7 @@ import https from "node:https";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { deriveEnvLabel, envPhrase, getSessionFor, readSessions } from "../config.mjs";
11
- import { ensureSignedIn, getGraphToken, getSharePointToken } from "../graph-auth.mjs";
11
+ import { ensureSignedIn, getGraphToken, getSharePointToken, tokenRefreshFailure } from "../graph-auth.mjs";
12
12
  import { updateCliInstruction } from "../../install-channel.mjs";
13
13
 
14
14
  // This CLI's own version, for the add-in's minCliVersion floor (see
@@ -184,8 +184,7 @@ export function isTransientBridgeError(err) {
184
184
  return /relay unreachable/i.test(String(err.message || ""));
185
185
  }
186
186
 
187
- export async function withTransientRetry(fn, isRetryable = isTransientBridgeError) {
188
- const delays = transientRetryDelays();
187
+ export async function withTransientRetry(fn, isRetryable = isTransientBridgeError, delays = transientRetryDelays()) {
189
188
  for (let attempt = 0; ; attempt += 1) {
190
189
  try {
191
190
  return await fn();
@@ -283,6 +282,7 @@ export async function bridgeFetch(pathPart, init = {}, backendURL = null) {
283
282
  (detail ? `: ${detail}` : ""),
284
283
  );
285
284
  err.status = response.status;
285
+ err.headers = response.headers ?? {};
286
286
  const code = machineCode ?? humanError;
287
287
  if (code) err.code = code;
288
288
  if (parsed) err.body = parsed;
@@ -499,6 +499,7 @@ export async function bridgeStreamToFile(pathPart, init, destPath, backendURL =
499
499
  (detail ? `: ${detail}` : ""),
500
500
  );
501
501
  err.status = response.status;
502
+ err.headers = response.headers ?? {};
502
503
  const code = machineCode ?? humanError;
503
504
  if (code) err.code = code;
504
505
  if (parsed) err.body = parsed;
@@ -516,40 +517,376 @@ export function sleep(ms) {
516
517
  return new Promise((resolve) => setTimeout(resolve, ms));
517
518
  }
518
519
 
519
- // One capability probe per backend per process. Keyed by the RESOLVED backend
520
- // URL (callers always resolve before probing), so multiple paired backends on
521
- // mixed versions each get their own answer.
520
+ // One capability probe per backend, cached for CAPABILITIES_TTL_MS. Keyed by
521
+ // the RESOLVED backend URL (callers always resolve before probing), so
522
+ // multiple paired backends on mixed versions each get their own answer.
523
+ //
524
+ // Entries are `{probe, at}`: a short-lived CLI never notices the TTL, the
525
+ // long-lived workspace daemon does — a deployment can flip a policy flag
526
+ // while it runs. A queued remote MUTATION passes `refresh: true` and bypasses
527
+ // the cache outright; batch reads may take the cached answer.
528
+ export const CAPABILITIES_TTL_MS = 60_000;
522
529
  export const backendCapabilitiesCache = new Map();
523
530
 
524
- // The probe result, failure included. Every ordinary caller wants the graceful
525
- // `{}` (an older backend simply 404s this path), but a caller that is DIAGNOSING
526
- // has to see a 401 — "this session is revoked" must never be reported as "this
527
- // add-in is too old". Kept as one cached promise so the distinction costs no
528
- // extra round-trip.
529
- export function backendCapabilitiesResult(backendURL) {
530
- let probe = backendCapabilitiesCache.get(backendURL);
531
- if (!probe) {
532
- probe = (async () => {
531
+ // A probe that did not get an answer is tried again before it is reported:
532
+ // `CAPABILITY_PROBE_ATTEMPTS` requests in all, spaced by the server's
533
+ // `Retry-After` when it sent one (it does on 429) and by this backoff otherwise.
534
+ export const CAPABILITY_PROBE_ATTEMPTS = 3;
535
+ export const CAPABILITY_PROBE_BACKOFF_MS = [1_000, 2_000];
536
+ export const CAPABILITY_PROBE_MAX_DELAY_MS = 30_000;
537
+
538
+ // Seconds or an HTTP date, as `Retry-After` allows; null when absent/unusable.
539
+ function retryAfterDelayMs(err, now = Date.now()) {
540
+ const value = err?.headers?.["retry-after"];
541
+ if (value == null || value === "") return null;
542
+ const seconds = Number(value);
543
+ if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
544
+ const date = Date.parse(String(value));
545
+ return Number.isFinite(date) ? Math.max(0, date - now) : null;
546
+ }
547
+
548
+ // What one failed probe request says about the backend's capabilities:
549
+ // absent 404 — an add-in from before /api/cli/capabilities. That IS an
550
+ // answer: no capabilities, and asking again will not change it.
551
+ // unknown anything else — a timeout, a 5xx, a 429, an unreachable relay, a
552
+ // revoked session. Nothing was learned about the capabilities.
553
+ // Of the unknowns, two are not worth a second request: a 401 (the session is
554
+ // dead; the error already names the remediation) and the add-in's version floor
555
+ // (the answer is "update the CLI", however often it is asked).
556
+ export function probeOutcome(err) {
557
+ if (err?.status === 404) return "absent";
558
+ return "unknown";
559
+ }
560
+
561
+ function probeRetryable(err) {
562
+ if (probeOutcome(err) !== "unknown") return false;
563
+ if (isSessionInvalidError(err)) return false;
564
+ if (err?.code === "cli_update_required") return false;
565
+ return true;
566
+ }
567
+
568
+ // The probe result, failure included, as `{ status, caps, error, attempts }`:
569
+ // answered `caps` is what the add-in advertises (`error` null)
570
+ // absent the add-in predates the route (`caps` {}, `error` the 404):
571
+ // no capabilities — sync render, no version floor, pane relay
572
+ // unknown the probe FAILED to get an answer (`caps` null, `error` the last
573
+ // failure). It is not an answer and is never read as one: a
574
+ // capability that could not be discovered is neither present nor
575
+ // absent. Callers that can act on the distinction read `status`;
576
+ // everyone else goes through `backendCapabilities`, which refuses
577
+ // with a typed, retryable error instead of handing back `{}`.
578
+ //
579
+ // Only ANSWERS are cached (answered and absent — both are facts about the
580
+ // deployment, and good for CAPABILITIES_TTL_MS). An unknown is retried here,
581
+ // shared with every caller that asks while it is in flight, and then dropped:
582
+ // caching it would turn one slow second into sixty seconds of every optional
583
+ // feature "missing" and every routing decision taking the wrong path (x103:
584
+ // one timed-out probe sent the icon fetch to the pane relay of a closed
585
+ // PowerPoint, and reported "No PowerPoint window" for it).
586
+ export function backendCapabilitiesResult(
587
+ backendURL,
588
+ {
589
+ refresh = false,
590
+ maxAgeMs = CAPABILITIES_TTL_MS,
591
+ attempts = CAPABILITY_PROBE_ATTEMPTS,
592
+ sleep: wait = sleep,
593
+ } = {},
594
+ ) {
595
+ const cached = backendCapabilitiesCache.get(backendURL);
596
+ if (cached && !refresh && Date.now() - cached.at < maxAgeMs) return cached.probe;
597
+ const probe = (async () => {
598
+ let error = null;
599
+ for (let attempt = 1; ; attempt++) {
533
600
  try {
534
601
  const res = await bridgeFetch(
535
602
  "/api/cli/capabilities",
536
603
  { timeoutMs: listTimeoutMs() },
537
604
  backendURL,
538
605
  );
539
- return { caps: (await res.json()) ?? {}, error: null };
606
+ return { status: "answered", caps: (await res.json()) ?? {}, error: null, attempts: attempt };
540
607
  } catch (err) {
541
- // Older backends 404 this path (with a non-JSON body) — no
542
- // capabilities: sync render, no version floor.
543
- return { caps: {}, error: err };
608
+ error = err;
609
+ if (probeOutcome(err) === "absent") {
610
+ return { status: "absent", caps: {}, error: err, attempts: attempt };
611
+ }
612
+ const delay = retryAfterDelayMs(err) ?? CAPABILITY_PROBE_BACKOFF_MS[attempt - 1] ?? null;
613
+ if (!probeRetryable(err) || attempt >= attempts || delay === null || delay > CAPABILITY_PROBE_MAX_DELAY_MS) {
614
+ return { status: "unknown", caps: null, error: err, attempts: attempt };
615
+ }
616
+ await wait(delay);
544
617
  }
545
- })();
546
- backendCapabilitiesCache.set(backendURL, probe);
547
- }
618
+ }
619
+ })();
620
+ const entry = { probe, at: Date.now() };
621
+ backendCapabilitiesCache.set(backendURL, entry);
622
+ probe.then(
623
+ (result) => {
624
+ // Drop an unknown, but only this one: a fresher probe may have replaced it.
625
+ if (result.status === "unknown" && backendCapabilitiesCache.get(backendURL) === entry) {
626
+ backendCapabilitiesCache.delete(backendURL);
627
+ }
628
+ },
629
+ () => {},
630
+ );
548
631
  return probe;
549
632
  }
550
633
 
634
+ export function refreshBackendCapabilities(backendURL) {
635
+ return backendCapabilitiesResult(backendURL, { refresh: true });
636
+ }
637
+
638
+ // The error every capability reader raises for an `unknown` probe: typed,
639
+ // retryable, and carrying the real cause — the status, headers (Retry-After)
640
+ // and message of the request that failed — so a caller's retry ladder treats
641
+ // it like the transport failure it is. A dead session keeps its own error:
642
+ // "reconnect from the add-in" is the remediation, not "retry".
643
+ export function capabilityUnknownError(backendURL, probe) {
644
+ const cause = probe?.error;
645
+ if (isSessionInvalidError(cause)) return cause;
646
+ if (cause?.code === "cli_update_required") return cause;
647
+ const attempts = probe?.attempts ?? 1;
648
+ const err = new Error(
649
+ `this slidemind add-in's capabilities could not be read` +
650
+ `${envPhrase(deriveEnvLabel(backendURL), "on")}` +
651
+ `${cause?.message ? ` (${cause.message})` : ""}` +
652
+ `${attempts > 1 ? ` after ${attempts} attempts` : ""}. ` +
653
+ "Nothing was decided from it: a capability that could not be discovered is neither present nor " +
654
+ "absent. Retry once the backend answers.",
655
+ );
656
+ err.code = "capability_unknown";
657
+ err.retryable = true;
658
+ err.cause = cause ?? null;
659
+ err.attempts = attempts;
660
+ if (cause?.status) err.status = cause.status;
661
+ if (cause?.headers) err.headers = cause.headers;
662
+ const retryAfter = retryAfterDelayMs(cause);
663
+ if (retryAfter !== null) err.retryAfterMs = retryAfter;
664
+ return err;
665
+ }
666
+
667
+ // The shared-record protocol a backend speaks (contracts.md §6), as a verdict
668
+ // the sync queue can act on without re-deriving it:
669
+ // supported `sharedRecords:{protocolVersion:1,kinds:[…]}` names `kind`, or
670
+ // is `["*"]` (any kind: the name is the client's namespace);
671
+ // no `kind` yet — a destination without a record — any kind will do
672
+ // unsupported the backend answered and does not advertise it (older add-in:
673
+ // the probe 404s; or it lists other kinds)
674
+ // unknown the probe FAILED for another reason (revoked session, relay
675
+ // down) — keep local data, block automated remote mutation;
676
+ // failure to discover permissions is not permission to write.
677
+ // `sharepointPublish` (deployment write policy) rides along as a separate
678
+ // condition; it is never folded into `status`.
679
+ export async function sharedRecordsSupport(
680
+ backendURL,
681
+ { kind = null, protocolVersion = 1, refresh = false } = {},
682
+ ) {
683
+ const { status: probe, caps, error } = await backendCapabilitiesResult(backendURL, { refresh });
684
+ const sr = caps?.sharedRecords;
685
+ const advertised =
686
+ sr && typeof sr === "object" && sr.protocolVersion === protocolVersion &&
687
+ Array.isArray(sr.kinds) && (sr.kinds.includes("*") || (kind ? sr.kinds.includes(kind) : sr.kinds.length > 0));
688
+ let status = "unsupported";
689
+ if (advertised) status = "supported";
690
+ else if (probe === "unknown") status = "unknown";
691
+ return {
692
+ status,
693
+ code: status === "unknown" ? "capability_unknown" : null,
694
+ sharedRecords: sr && typeof sr === "object" ? sr : null,
695
+ sharepointPublish: caps?.sharepointPublish === true,
696
+ error,
697
+ };
698
+ }
699
+
700
+ // The capabilities a backend advertises — `{}` for an add-in that predates the
701
+ // route — or a thrown `capability_unknown` when the probe got no answer. Every
702
+ // `backendSupports*` reader and every raw `caps.<flag>` check goes through
703
+ // here, so a probe failure can never be read as `false`: the one place an
704
+ // absent flag and a failed request used to look the same.
551
705
  export async function backendCapabilities(backendURL) {
552
- return (await backendCapabilitiesResult(backendURL)).caps;
706
+ const probe = await backendCapabilitiesResult(backendURL);
707
+ if (probe.status === "unknown") throw capabilityUnknownError(backendURL, probe);
708
+ return probe.caps;
709
+ }
710
+
711
+ // ---------------------------------------------------------------------------
712
+ // The edit-safeguard contract (openspec `safeguard-slide-editing`, task 8.3)
713
+ // ---------------------------------------------------------------------------
714
+ //
715
+ // Every OTHER capability on this page is an OPTIONAL feature: absent, the CLI
716
+ // falls back (the pane relay instead of the pane-free engine routes) and keeps
717
+ // working. The edit safeguards are the opposite kind of thing. They are what
718
+ // stops an edit from silently dropping the element it was not asked to touch,
719
+ // so when an operation depends on one, an add-in that does not enforce it is a
720
+ // REFUSAL, never a fallback. The rule the whole change turns on applies here
721
+ // too: `unknown` is not `passed`, and a check that is absent is not a check
722
+ // that was satisfied.
723
+ //
724
+ // Two numbers are negotiated, and they are the ones the records already carry —
725
+ // no fourth version is minted here:
726
+ // `records` EDIT_OPERATION_VERSION / SLIDE_PRESERVATION_VERSION (one line:
727
+ // the operation and preservation sidecars are written together)
728
+ // `verdict` PRESERVATION_VERDICT_VERSION
729
+ // Both must EQUAL this CLI's. A backend behind us cannot honour a record it
730
+ // does not parse; a backend ahead of us may have changed what a field means,
731
+ // and guessing compatibility in that direction is how a safeguard becomes
732
+ // decorative. Neither direction is a version-FLOOR matter: `minCliVersion` is a
733
+ // hard bar for every command and must never exceed what npm has published,
734
+ // while this refuses exactly the operations that need the contract.
735
+ export const EDIT_SAFEGUARD_CAP_KEY = "editSafeguards";
736
+
737
+ /**
738
+ * The named safeguards a command can require. One vocabulary, shared with the
739
+ * add-in's advertisement — an operation asks for what it depends on, and the
740
+ * backend lists what it actually enforces.
741
+ *
742
+ * preservation-verdict the verdict is enforced at final delivery, so a
743
+ * render that lost a required element is refused
744
+ * operation-scope the backend honours the operation record's scope
745
+ * (targeted edit ≠ licence to rewrite the slide)
746
+ * bounded-repair repair runs inside the declared budget, or not at all
747
+ */
748
+ export const EDIT_SAFEGUARDS = Object.freeze([
749
+ "preservation-verdict",
750
+ "operation-scope",
751
+ "bounded-repair",
752
+ ]);
753
+
754
+ function contractVersions() {
755
+ // Imported lazily: transport.mjs is on the hot path of every command, and the
756
+ // record contracts pull in the whole validator graph.
757
+ return import("../../lib/edit-operations.mjs").then(async (ops) => ({
758
+ records: ops.EDIT_OPERATION_VERSION,
759
+ verdict: (await import("../../lib/preservation-verdict.mjs")).PRESERVATION_VERDICT_VERSION,
760
+ }));
761
+ }
762
+
763
+ const versionOf = (value) => (Number.isInteger(value) ? value : null);
764
+
765
+ /**
766
+ * Does `backendURL` enforce the safeguards `need` names?
767
+ *
768
+ * Four outcomes, three statuses — the precision lives in `code` so the shape
769
+ * matches {@link sharedRecordsSupport}, the other versioned capability:
770
+ *
771
+ * supported it advertises our contract and enforces all of `need`
772
+ * unsupported/`…unsupported` it answered, and does not (older add-in, or a
773
+ * deployment that enforces only some of them)
774
+ * unsupported/`…contract_ahead` it speaks a NEWER contract than this CLI
775
+ * unsupported/`…contract_behind` it speaks an OLDER one
776
+ * unknown/`…unknown` the probe failed for any reason but a 404 —
777
+ * a revoked session, a relay that is down.
778
+ * Discovering nothing is not discovering that
779
+ * the check is unnecessary.
780
+ *
781
+ * `need` defaults to EVERY safeguard: a caller that forgets to say what it
782
+ * depends on gets the strictest answer, not a free pass. A name this CLI does
783
+ * not know is refused for the same reason — the backend cannot be advertising
784
+ * something neither side has a definition of.
785
+ */
786
+ export async function editSafeguardSupport(
787
+ backendURL,
788
+ { need = EDIT_SAFEGUARDS, refresh = false } = {},
789
+ ) {
790
+ const wanted = (Array.isArray(need) ? need : [need]).filter(Boolean);
791
+ const ours = await contractVersions();
792
+ const base = { contract: null, ours, enforces: [], missing: wanted, error: null };
793
+
794
+ const unknownNames = wanted.filter((n) => !EDIT_SAFEGUARDS.includes(n));
795
+ if (unknownNames.length > 0) {
796
+ return { ...base, status: "unsupported", code: "safeguards_unsupported", missing: unknownNames };
797
+ }
798
+
799
+ const { status: probe, caps, error } = await backendCapabilitiesResult(backendURL, { refresh });
800
+ if (probe === "unknown") {
801
+ return { ...base, status: "unknown", code: "safeguards_unknown", error };
802
+ }
803
+
804
+ const advertised = caps?.[EDIT_SAFEGUARD_CAP_KEY];
805
+ if (!advertised || typeof advertised !== "object") {
806
+ return { ...base, status: "unsupported", code: "safeguards_unsupported", error };
807
+ }
808
+
809
+ const contract = {
810
+ records: versionOf(advertised.records),
811
+ verdict: versionOf(advertised.verdict),
812
+ };
813
+ // A malformed advertisement is an add-in that does not speak this contract,
814
+ // not one to be met halfway.
815
+ if (contract.records === null || contract.verdict === null) {
816
+ return { ...base, status: "unsupported", code: "safeguards_unsupported", contract, error };
817
+ }
818
+ if (contract.records > ours.records || contract.verdict > ours.verdict) {
819
+ return { ...base, status: "unsupported", code: "safeguards_contract_ahead", contract, error };
820
+ }
821
+ if (contract.records < ours.records || contract.verdict < ours.verdict) {
822
+ return { ...base, status: "unsupported", code: "safeguards_contract_behind", contract, error };
823
+ }
824
+
825
+ const enforces = Array.isArray(advertised.enforces)
826
+ ? advertised.enforces.filter((n) => typeof n === "string")
827
+ : [];
828
+ const missing = wanted.filter((n) => !enforces.includes(n));
829
+ if (missing.length > 0) {
830
+ return { ...base, status: "unsupported", code: "safeguards_unsupported", contract, enforces, missing, error };
831
+ }
832
+ return { ...base, status: "supported", code: null, contract, enforces, missing: [], error };
833
+ }
834
+
835
+ function safeguardError(verdict, { need }) {
836
+ const names = need.join(", ");
837
+ const ours = `records=${verdict.ours.records} verdict=${verdict.ours.verdict}`;
838
+ const theirs = verdict.contract
839
+ ? `records=${verdict.contract.records} verdict=${verdict.contract.verdict}`
840
+ : "no contract at all";
841
+ const nothing = "Nothing was written.";
842
+ let message;
843
+ switch (verdict.code) {
844
+ case "safeguards_unknown":
845
+ message =
846
+ `this add-in's capabilities could not be read` +
847
+ `${verdict.error?.message ? ` (${verdict.error.message})` : ""}, so whether it enforces ` +
848
+ `${names} is unknown. ${nothing} A check that could not be discovered is not a check ` +
849
+ `that passed — retry once the backend answers.`;
850
+ break;
851
+ case "safeguards_contract_ahead":
852
+ message =
853
+ `this add-in speaks a NEWER edit-safeguard contract (${theirs}) than this CLI (${ours}), ` +
854
+ `so ${names} cannot be requested safely. ${nothing} ${updateCliInstruction()}`;
855
+ break;
856
+ case "safeguards_contract_behind":
857
+ message =
858
+ `this add-in speaks an OLDER edit-safeguard contract (${theirs}) than this CLI (${ours}), ` +
859
+ `so it cannot honour ${names}. ${nothing} Wait for the deployment to update the slidemind ` +
860
+ `add-in — the safeguard is not something to work around.`;
861
+ break;
862
+ default:
863
+ message =
864
+ `this slidemind add-in does not enforce ${verdict.missing.join(", ") || names}, which this ` +
865
+ `operation depends on. ${nothing} Update the slidemind add-in to a build that advertises ` +
866
+ `\`${EDIT_SAFEGUARD_CAP_KEY}\`, or run an operation that does not require it.`;
867
+ }
868
+ const err = new Error(message);
869
+ err.code = verdict.code ?? "safeguards_unsupported";
870
+ err.safeguards = { need, missing: verdict.missing, contract: verdict.contract, ours: verdict.ours };
871
+ return err;
872
+ }
873
+
874
+ /**
875
+ * Refuse unless `backendURL` enforces every safeguard in `need`.
876
+ *
877
+ * `refresh` defaults to TRUE: this gate runs immediately before an operation
878
+ * that depends on the answer, and the 60s capability cache may predate a
879
+ * deployment that rolled back. The same rule the remote-mutation path follows —
880
+ * a write re-probes, a batch read may take the cached answer.
881
+ */
882
+ export async function assertEditSafeguards(
883
+ backendURL,
884
+ { need = EDIT_SAFEGUARDS, refresh = true } = {},
885
+ ) {
886
+ const wanted = (Array.isArray(need) ? need : [need]).filter(Boolean);
887
+ const verdict = await editSafeguardSupport(backendURL, { need: wanted, refresh });
888
+ if (verdict.status === "supported") return verdict;
889
+ throw safeguardError(verdict, { need: wanted });
553
890
  }
554
891
 
555
892
  export async function backendSupportsRenderJobs(backendURL) {
@@ -571,11 +908,119 @@ export async function backendSupportsBrandRoutes(backendURL) {
571
908
  // pane-free `/api/cli/brands/:brandId/concept-images` route (see
572
909
  // generateConceptImagesForBrand). Advertised by newer add-ins only; older ones
573
910
  // omit the flag and the caller degrades to "continue without".
911
+ // Whether a backend exports the native line directives (`data-connector` with
912
+ // `data-prst`/sites, `data-sm-line`, `data-sm-line-group` — the nativeLinesV1
913
+ // contract). The CLI writes ONLY that vocabulary, so an add-in that answered
914
+ // without the flag is a refusal, never a fallback to older markup.
915
+ // supported / unsupported (answered, no flag) / unknown (probe failed, not a 404)
916
+ export async function nativeLinesSupport(backendURL) {
917
+ if (!backendURL) return "unknown";
918
+ const { status, caps } = await backendCapabilitiesResult(backendURL, { refresh: true });
919
+ if (caps?.nativeLinesV1 === true) return "supported";
920
+ return status === "unknown" ? "unknown" : "unsupported";
921
+ }
922
+
574
923
  export async function backendSupportsConceptImages(backendURL) {
575
924
  const caps = await backendCapabilities(backendURL);
576
925
  return caps?.conceptImages === true;
577
926
  }
578
927
 
928
+ // Whether a backend extracts concept specs (a vision inventory of a mockup)
929
+ // over `/api/cli/brands/:brandId/concept-specs`. Older add-ins omit the flag
930
+ // and `slidemind concepts` writes PNGs only.
931
+ export async function backendSupportsConceptSpecs(backendURL) {
932
+ const caps = await backendCapabilities(backendURL);
933
+ return caps?.conceptSpecs === true;
934
+ }
935
+
936
+ // The same question as a verdict a command can print, naming WHICH add-in
937
+ // answered: a warning that only says "this add-in" leaves a user with several
938
+ // paired environments guessing which one lags (the 2026-10-06 proposal sim ran
939
+ // against prod while dev already read specs).
940
+ // supported / unsupported (answered without the flag) / unknown (no backend
941
+ // to ask, or the probe failed for a reason other than a 404)
942
+ export async function conceptSpecSupport(backendURL) {
943
+ if (!backendURL) return { status: "unknown", env: null, backendURL: null };
944
+ const probe = await backendCapabilitiesResult(backendURL);
945
+ let status = "unsupported";
946
+ if (probe.caps?.conceptSpecs === true) status = "supported";
947
+ else if (probe.status === "unknown") status = "unknown";
948
+ return { status, env: deriveEnvLabel(backendURL), backendURL };
949
+ }
950
+
951
+ // Whether a backend runs concept-spec extraction as a polled job
952
+ // (`/api/cli/brands/:brandId/concept-spec-jobs`): no ingress timeout, an
953
+ // 8-minute per-slide budget server-side. Older add-ins answer the sync route.
954
+ export async function backendSupportsConceptSpecJobs(backendURL) {
955
+ const caps = await backendCapabilities(backendURL);
956
+ return caps?.conceptSpecJobs === true;
957
+ }
958
+
959
+ // ---------------------------------------------------------------------------
960
+ // The image-intent contract (openspec `preserve-image-intent-across-generation`)
961
+ // ---------------------------------------------------------------------------
962
+ //
963
+ // `imageIntentV1` says the add-in understands the `imageIntent` envelope on the
964
+ // concept, spec, render and translate routes AND returns an
965
+ // `imageIntentValidation` verdict for it. Like the edit safeguards next door,
966
+ // and unlike every optional capability above it, this is a REFUSAL rather than
967
+ // a fallback: an add-in that ignores the envelope would generate and render a
968
+ // deck whose approved original never reached a slide, and report success.
969
+ //
970
+ // It is required only where an envelope actually travels. A deck whose plan
971
+ // records no image uses builds no envelope, so nothing about a legacy deck
972
+ // changes — no probe, no gate, the same request bytes as before.
973
+ export const IMAGE_INTENT_CAP_KEY = "imageIntentV1";
974
+
975
+ const IMAGE_INTENT_NOTHING =
976
+ "Nothing was generated and nothing was written — the deck's plan, its registry and its imported " +
977
+ "sources are exactly as they were.";
978
+
979
+ /**
980
+ * Does `backendURL` speak the image-intent contract?
981
+ *
982
+ * supported it advertises `imageIntentV1`
983
+ * unsupported it answered, and does not (an older add-in deployment)
984
+ * unknown the probe failed for any reason but a 404 — discovering
985
+ * nothing is not discovering that the check is unnecessary
986
+ */
987
+ export async function imageIntentSupport(backendURL, { refresh = false } = {}) {
988
+ const { status, caps, error } = await backendCapabilitiesResult(backendURL, { refresh });
989
+ if (caps?.[IMAGE_INTENT_CAP_KEY] === true) {
990
+ return { status: "supported", code: null, error: null };
991
+ }
992
+ if (status === "unknown") {
993
+ return { status: "unknown", code: "image_intent_unsupported", error };
994
+ }
995
+ return { status: "unsupported", code: "image_intent_unsupported", error };
996
+ }
997
+
998
+ /**
999
+ * Refuse, before the request, unless `backendURL` speaks the contract.
1000
+ *
1001
+ * `refresh` defaults to TRUE for the same reason the safeguard gate re-probes:
1002
+ * this runs immediately before the one operation that depends on the answer,
1003
+ * and the 60s capability cache may predate a rolled-back deployment.
1004
+ */
1005
+ export async function assertImageIntentSupported(backendURL, { refresh = true } = {}) {
1006
+ const verdict = await imageIntentSupport(backendURL, { refresh });
1007
+ if (verdict.status === "supported") return verdict;
1008
+ const err = new Error(
1009
+ verdict.status === "unknown"
1010
+ ? `this slidemind add-in's capabilities could not be read` +
1011
+ `${verdict.error?.message ? ` (${verdict.error.message})` : ""}, so whether it preserves this ` +
1012
+ `deck's approved image uses is unknown. ${IMAGE_INTENT_NOTHING} A capability that could not be ` +
1013
+ `discovered is not one that is present — retry once the backend answers.`
1014
+ : `this slidemind add-in does not support image intent (\`${IMAGE_INTENT_CAP_KEY}\`), which this ` +
1015
+ `deck's approved image uses depend on: it would drop the envelope, place no original and report ` +
1016
+ `success. ${IMAGE_INTENT_NOTHING} Wait for the deployment to update the slidemind add-in, or ` +
1017
+ `work in a deck whose plan records no image uses — those are unaffected.`,
1018
+ );
1019
+ err.code = "image_intent_unsupported";
1020
+ err.imageIntent = { capability: IMAGE_INTENT_CAP_KEY, status: verdict.status };
1021
+ throw err;
1022
+ }
1023
+
579
1024
  // Whether a backend generates regular images over the pane-free
580
1025
  // `/api/cli/brands/:brandId/images` route (see generateImageForBrand).
581
1026
  // Advertised by newer add-ins only.
@@ -623,6 +1068,18 @@ export async function backendSupportsGraphDelegated(backendURL) {
623
1068
  return caps?.graphDelegated === true;
624
1069
  }
625
1070
 
1071
+ // Whether the deployment's POLICY allows the CLI to publish back to SharePoint
1072
+ // (`slidemind publish`). A policy flag and nothing more: it does NOT prove the
1073
+ // user's token carries AllSites.Write, that admin consent was granted, or that
1074
+ // this item is writable. Those three are found at REQUEST time — a
1075
+ // `consent_required` challenge, a 403 on the write itself — never inferred from
1076
+ // a capability literal. Gating the command on it only keeps a deployment that
1077
+ // has switched the feature off from being asked in the first place.
1078
+ export async function backendSupportsSharePointPublish(backendURL) {
1079
+ const caps = await backendCapabilities(backendURL);
1080
+ return caps?.sharepointPublish === true;
1081
+ }
1082
+
626
1083
  // The add-in's hard version floor: /api/cli/capabilities advertises the oldest
627
1084
  // CLI it still speaks (`minCliVersion`). An older CLI must refuse to proceed —
628
1085
  // mid-rollout skew otherwise surfaces as confusing route/contract errors deep
@@ -654,9 +1111,27 @@ export async function engineGraphToken(backendURL, { signIn = true } = {}) {
654
1111
  if (!token && signIn && (await ensureSignedIn(backendURL))) {
655
1112
  token = await getGraphToken(backendURL);
656
1113
  }
1114
+ if (!token) assertTokenRefreshed(backendURL, "graph");
657
1115
  return token;
658
1116
  }
659
1117
 
1118
+ // A stored sign-in whose token could not be refreshed just now is an outage to
1119
+ // retry, not a missing sign-in: nothing is sent without the token, because the
1120
+ // backend would answer `login_required` and the agent would send the user to
1121
+ // `slidemind login` for nothing (graph-auth.tokenRefreshFailure).
1122
+ function assertTokenRefreshed(backendURL, resource) {
1123
+ const cause = tokenRefreshFailure(backendURL, resource);
1124
+ if (!cause) return;
1125
+ const err = new Error(
1126
+ `the Microsoft 365 sign-in${envPhrase(deriveEnvLabel(backendURL), "for")} is intact, but its ` +
1127
+ `${resource === "sharepoint" ? "SharePoint" : "Graph"} token could not be refreshed just now ` +
1128
+ `(${cause}). Nothing was sent; retry the command.`,
1129
+ );
1130
+ err.code = "token_refresh_failed";
1131
+ err.retryable = true;
1132
+ throw err;
1133
+ }
1134
+
660
1135
  // The SharePoint-resource sibling (same sign-in, same refresh token): the
661
1136
  // backend resolves `sg:` site-group membership from the PAIR, and on a
662
1137
  // SharePoint-configured deployment refuses corpus search without it.
@@ -667,6 +1142,7 @@ export async function engineSpToken(backendURL, { signIn = true } = {}) {
667
1142
  if (!token && signIn && (await ensureSignedIn(backendURL))) {
668
1143
  token = await getSharePointToken(backendURL);
669
1144
  }
1145
+ if (!token) assertTokenRefreshed(backendURL, "sharepoint");
670
1146
  return token;
671
1147
  }
672
1148
 
@@ -15,6 +15,8 @@ export {
15
15
  getDesignBundleForBrandAnyBackend,
16
16
  listBrands,
17
17
  generateConceptImagesForBrand,
18
+ extractConceptSpecsForBrand,
19
+ fitCopyForBrand,
18
20
  resolveConceptBackend,
19
21
  generateImageForBrand,
20
22
  backendSupportsConvertPptx,
@@ -33,6 +35,7 @@ export {
33
35
  engineSearchAnyPane,
34
36
  engineDownload,
35
37
  engineDownloadToFile,
38
+ pickAssets,
36
39
  } from "./client/engine-search.mjs";
37
40
  export {
38
41
  extractSlideToFile,
@@ -79,10 +82,17 @@ export {
79
82
  isSessionInvalidError,
80
83
  backendSupportsBrandRoutes,
81
84
  backendSupportsConceptImages,
85
+ conceptSpecSupport,
86
+ nativeLinesSupport,
82
87
  backendSupportsImageGeneration,
83
88
  backendSupportsBrandsList,
84
89
  backendSupportsSourcePresentation,
85
90
  backendSupportsEngineRoutes,
86
91
  backendSupportsGraphDelegated,
92
+ backendSupportsSharePointPublish,
87
93
  assertCliVersionSupported,
94
+ EDIT_SAFEGUARDS,
95
+ EDIT_SAFEGUARD_CAP_KEY,
96
+ editSafeguardSupport,
97
+ assertEditSafeguards,
88
98
  } from "./client/transport.mjs";