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
@@ -39,6 +39,11 @@
39
39
  // ├── lint-engine.mjs (wire.lintEngine.source — the add-in's slide-lint engine)
40
40
  // ├── primitives.json (wire.lintEngine.primitives — closed component registry)
41
41
  // └── bundle.json ({ presFP, name, version, etag, lintEngineVersion, tokenRoleMap, … })
42
+ //
43
+ // A deck REFERENCES this directory; it never carries a copy. The HTML preview
44
+ // points at these paths by absolute file: URL, the review server serves them
45
+ // from here, and the server render resolves brand assets from the design
46
+ // itself — so re-materializing a bundle changes no file inside any deck.
42
47
 
43
48
  import fs from "node:fs";
44
49
  import os from "node:os";
@@ -53,14 +58,49 @@ import {
53
58
  listBrands,
54
59
  } from "./client.mjs";
55
60
  import { updateCliInstruction } from "../install-channel.mjs";
61
+ import { statePath } from "../lib/deck-paths.mjs";
56
62
 
57
63
  // Highest bundle wire-contract version this CLI understands. The add-in stamps
58
64
  // `schemaVersion` on every bundle; a higher value means the CLI is too old.
59
65
  const CLI_MAX_BUNDLE_SCHEMA = 1;
60
66
 
67
+ // The bundle cache normally lives under the user's cache dir. Agent sandboxes
68
+ // (codex `workspace-write`, probed 2026-09-19: ~/.cache and ~/.config are
69
+ // DENIED, the cwd is allowed) refuse writes there, and a session that cannot
70
+ // materialize the bundle cannot author at all — two sim sessions "fixed" this
71
+ // by rewriting the harness wrapper. So when the preferred root is not
72
+ // writable, the cache falls back to `<cwd>/.slidemind-cache/bundles`, which
73
+ // every sandbox allows. A cache is a cache: a second copy costs a download.
74
+ // ponytail: probed once per process; a root that turns unwritable mid-run
75
+ // fails the write like before.
76
+ // Memo keyed by the preferred root: tests (and users) switch XDG_CACHE_HOME
77
+ // within one process.
78
+ const cacheRootMemo = new Map();
61
79
  export function bundleCacheRoot() {
62
80
  const base = process.env.XDG_CACHE_HOME || path.join(os.homedir(), ".cache");
63
- return path.join(base, "slidemind", "bundles");
81
+ const preferred = path.join(base, "slidemind", "bundles");
82
+ if (cacheRootMemo.has(preferred)) return cacheRootMemo.get(preferred);
83
+ cacheRootMemo.set(preferred, preferred);
84
+ try {
85
+ fs.mkdirSync(preferred, { recursive: true });
86
+ const probe = path.join(preferred, `.probe.${process.pid}`);
87
+ fs.writeFileSync(probe, "");
88
+ fs.rmSync(probe, { force: true });
89
+ } catch (err) {
90
+ if (err?.code !== "EPERM" && err?.code !== "EACCES" && err?.code !== "EROFS") return preferred;
91
+ const fallback = path.join(process.cwd(), ".slidemind-cache", "bundles");
92
+ // Say it once per fallback dir, not once per command: an agent session
93
+ // runs sixty commands and read this banner 53 times (x30 audit).
94
+ const said = !fs.existsSync(fallback);
95
+ fs.mkdirSync(fallback, { recursive: true });
96
+ if (said) {
97
+ process.stderr.write(
98
+ `slidemind: bundle cache ${preferred} is not writable here (${err.code}) — using ${fallback} for this session\n`,
99
+ );
100
+ }
101
+ cacheRootMemo.set(preferred, fallback);
102
+ }
103
+ return cacheRootMemo.get(preferred);
64
104
  }
65
105
 
66
106
  function sanitizeKey(value) {
@@ -141,6 +181,21 @@ export function clearBundleCache() {
141
181
  // add-in's self-signed dev certificate on loopback hosts (the same posture the
142
182
  // bridge client uses). Azure blob SAS URLs are public HTTPS and need no special
143
183
  // handling beyond a normal GET.
184
+ // Every file in the cache lands via tmp + rename. The cache is shared by every
185
+ // concurrent slidemind process on the machine, and each deck command re-syncs
186
+ // it (refreshDeckBundle) — a plain writeFileSync truncates first, so a sibling
187
+ // process dynamic-importing lint-engine.mjs / capture-engine.mjs at that instant
188
+ // saw an empty module ("Cached lint engine is corrupt", "predates the lint
189
+ // engine"; four hits in one 4-session sim batch, 2026-09-18). rename is atomic
190
+ // on the same filesystem, so a reader sees the old bytes or the new, never half.
191
+ const TMP_SUFFIX_RE = /\.\d+\.sm-tmp$/;
192
+ export function writeFileAtomic(target, data, encoding) {
193
+ fs.mkdirSync(path.dirname(target), { recursive: true });
194
+ const tmp = `${target}.${process.pid}.sm-tmp`;
195
+ fs.writeFileSync(tmp, data, encoding);
196
+ fs.renameSync(tmp, target);
197
+ }
198
+
144
199
  function getBuffer(url, { redirects = 4 } = {}) {
145
200
  return new Promise((resolve, reject) => {
146
201
  let u;
@@ -187,8 +242,7 @@ function getBuffer(url, { redirects = 4 } = {}) {
187
242
 
188
243
  async function downloadInto(targetPath, url) {
189
244
  const buf = await getBuffer(url);
190
- fs.mkdirSync(path.dirname(targetPath), { recursive: true });
191
- fs.writeFileSync(targetPath, buf);
245
+ writeFileAtomic(targetPath, buf);
192
246
  }
193
247
 
194
248
  const REUSABLE_KIND_ORDER = [
@@ -309,9 +363,7 @@ export async function materializeBundle(
309
363
  // Everything this sync owns, as dir-relative paths; drives the prune pass.
310
364
  const expected = new Set(["bundle.json"]);
311
365
  const writeText = (rel, text) => {
312
- const target = path.join(dir, rel);
313
- fs.mkdirSync(path.dirname(target), { recursive: true });
314
- fs.writeFileSync(target, text, "utf8");
366
+ writeFileAtomic(path.join(dir, rel), text, "utf8");
315
367
  expected.add(rel);
316
368
  };
317
369
 
@@ -423,8 +475,10 @@ export async function materializeBundle(
423
475
  // vision pass lifted from the brand's own master and flagged `reusable`).
424
476
  // Each carries a `workspacePath` (e.g. `assets/master-<sha8>.<ext>`) that the
425
477
  // add-in's design persona references from slide JSX as `<img src="./assets/
426
- // <file>">`; land them at that exact path so copyDesignSystem ships them into
427
- // the deck next to the role-keyed brand assets. Content-addressed name →
478
+ // <file>">`; land them at that exact path inside the BUNDLE, next to the
479
+ // role-keyed brand assets — the preview rewrites such a reference to this
480
+ // directory and the render resolves it server-side, so no deck carries a
481
+ // copy. Content-addressed name →
428
482
  // write-once (a changed image gets a new sha8 path; the old one prunes out).
429
483
  // The catalogue of these (name/kind/description) rides in design.md's
430
484
  // `### Brand Reusable Images` block, so the local agent sees both halves.
@@ -447,7 +501,7 @@ export async function materializeBundle(
447
501
  // HTML builder rewrites to `./assets/…`, so the local preview shows real brand
448
502
  // chrome instead of bare layouts. Each carries a `workspacePath`
449
503
  // (`assets/chrome-<componentName>.<ext>`); land it next to the other assets so
450
- // copyDesignSystem ships it into the deck. Etag-keyed → re-fetch only on change.
504
+ // the preview reads it from here. Etag-keyed → re-fetch only on change.
451
505
  for (const chrome of wire.layoutBackgrounds ?? []) {
452
506
  if (!chrome?.url || typeof chrome.workspacePath !== "string") continue;
453
507
  const rel = path.join("assets", path.basename(chrome.workspacePath));
@@ -538,7 +592,7 @@ export async function materializeBundle(
538
592
  fetchedAt: new Date().toISOString(),
539
593
  incompleteAssets: warnings.length > 0,
540
594
  };
541
- fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf8");
595
+ writeFileAtomic(metaPath, JSON.stringify(meta, null, 2), "utf8");
542
596
 
543
597
  // Last, after every write AND the prune — the overlay must win over the wire.
544
598
  const overlaid = applyBundleOverlay(dir);
@@ -571,27 +625,29 @@ export function applyBundleOverlay(dir) {
571
625
  let copied = 0;
572
626
  try {
573
627
  if (!fs.statSync(overlay).isDirectory()) return 0;
574
- fs.cpSync(overlay, dir, {
575
- recursive: true,
576
- force: true,
577
- // Docs only (.md/.css): the bundle dir also holds lint-engine.mjs and
578
- // capture-engine.mjs, which the CLI dynamic-imports — an overlay must
579
- // never be able to swap executable code, and text files re-sync from the
580
- // wire on every materialize so a doc overlay can't persist past the
581
- // experiment (binaries would).
582
- filter: (src) => {
628
+ // Docs only (.md/.css): the bundle dir also holds lint-engine.mjs and
629
+ // capture-engine.mjs, which the CLI dynamic-imports — an overlay must
630
+ // never be able to swap executable code, and text files re-sync from the
631
+ // wire on every materialize so a doc overlay can't persist past the
632
+ // experiment (binaries would). Copied atomically like every other cache
633
+ // write: a sibling session reading create.md mid-copy must not get half.
634
+ const walk = (rel) => {
635
+ for (const entry of fs.readdirSync(path.join(overlay, rel), { withFileTypes: true })) {
636
+ const childRel = path.join(rel, entry.name);
583
637
  let st;
584
638
  try {
585
- st = fs.lstatSync(src);
639
+ st = fs.statSync(path.join(overlay, childRel));
586
640
  } catch {
587
- return false; // dangling symlink — skip, never fail the materialize
641
+ continue; // dangling symlink — skip, never fail the materialize
588
642
  }
589
- if (st.isDirectory()) return true;
590
- if (!/\.(md|css)$/i.test(src)) return false;
591
- copied += 1;
592
- return true;
593
- },
594
- });
643
+ if (st.isDirectory()) walk(childRel);
644
+ else if (/\.(md|css)$/i.test(entry.name)) {
645
+ writeFileAtomic(path.join(dir, childRel), fs.readFileSync(path.join(overlay, childRel)));
646
+ copied += 1;
647
+ }
648
+ }
649
+ };
650
+ walk("");
595
651
  } catch (err) {
596
652
  // The overlay is an experiment convenience — a bad overlay must not break
597
653
  // ready/build/push after an otherwise successful sync.
@@ -615,6 +671,16 @@ function pruneUnexpected(dir, expected) {
615
671
  if (fs.readdirSync(path.join(dir, childRel)).length === 0) {
616
672
  fs.rmdirSync(path.join(dir, childRel));
617
673
  }
674
+ } else if (TMP_SUFFIX_RE.test(entry.name)) {
675
+ // A sibling process's in-flight write — leave it, or its rename fails.
676
+ // One that outlived its process (a crash mid-write) is swept after 60s.
677
+ let stale = false;
678
+ try {
679
+ stale = Date.now() - fs.statSync(path.join(dir, childRel)).mtimeMs > 60_000;
680
+ } catch {
681
+ continue; // renamed away between readdir and stat — the happy path
682
+ }
683
+ if (stale) fs.rmSync(path.join(dir, childRel), { force: true });
618
684
  } else if (!expected.has(childRel)) {
619
685
  fs.rmSync(path.join(dir, childRel), { force: true });
620
686
  pruned += 1;
@@ -629,13 +695,14 @@ function pruneUnexpected(dir, expected) {
629
695
  // against — lets `render-pptx` pass the etag for skew detection, and lets the
630
696
  // local HTML/editor resolve the right materialized bundle.
631
697
  export function deckBundleRefPath(deckDir) {
632
- return path.join(deckDir, ".bundle-ref.json");
698
+ return statePath(deckDir, "bundle-ref.json");
633
699
  }
634
700
 
635
701
  export function writeDeckBundleRef(deckDir, { presFP, etag }) {
636
702
  fs.mkdirSync(deckDir, { recursive: true });
637
- fs.writeFileSync(
638
- deckBundleRefPath(deckDir),
703
+ const file = deckBundleRefPath(deckDir);
704
+ writeFileAtomic(
705
+ file,
639
706
  JSON.stringify(
640
707
  { presFP, etag: etag ?? null, updatedAt: new Date().toISOString() },
641
708
  null,
@@ -801,7 +868,9 @@ const IMAGE_ENGINE_KIND_RE = /^(image|icon|photo|illustration|vector)$/i;
801
868
  // True when an engine produces insertable image bytes. Gates on the engine's
802
869
  // real modality — `kind` for bundle engines.json entries, `subtype` for live
803
870
  // `GET /api/cli/engines` catalog entries — NOT a guess over its
804
- // label/description.
871
+ // label/description. Which of those image engines serves an icon and which a
872
+ // photo IS read from label and description (asset-fetch.mjs selectAssetEngine),
873
+ // since the catalog's subtype is "image" for both.
805
874
  export function isImageEngine(engine) {
806
875
  return IMAGE_ENGINE_KIND_RE.test(engine?.kind ?? engine?.subtype ?? "");
807
876
  }
@@ -15,12 +15,19 @@ import path from "node:path";
15
15
  import { pathToFileURL } from "node:url";
16
16
 
17
17
  import { convertedHostLayouts, loadConversions } from "../lib/conversions.mjs";
18
+ import { deckSlug } from "../lib/deck-paths.mjs";
19
+ import { selectSlideFiles } from "../lib/slide-selection.mjs";
18
20
 
19
21
  // Same launch profile as the add-in's chrome-launcher.ts, so the client browser
20
22
  // behaves identically to the server's during capture.
21
23
  const LAUNCH_ARGS = [
22
24
  "--no-sandbox",
23
25
  "--disable-gpu",
26
+ // Headless Chrome ties its frames to the display's vsync: with the Mac's
27
+ // display asleep, requestAnimationFrame and screenshots never fire and every
28
+ // render hangs at 0% CPU (the add-in's chrome-launcher carries the same pair).
29
+ "--disable-gpu-vsync",
30
+ "--disable-frame-rate-limit",
24
31
  "--hide-scrollbars",
25
32
  "--disable-features=AppShimRemoteCocoa,AppShimNewCloseBehavior,WebAppSystemMediaControls",
26
33
  "--disable-default-apps",
@@ -54,11 +61,10 @@ function readBundleMeta(bundleDir) {
54
61
  }
55
62
  }
56
63
 
57
- function readJsxDir(dir) {
64
+ function readJsxDir(dir, only = null) {
58
65
  const out = {};
59
66
  if (!fs.existsSync(dir)) return out;
60
- for (const name of fs.readdirSync(dir)) {
61
- if (!name.endsWith(".jsx")) continue;
67
+ for (const name of selectSlideFiles(fs.readdirSync(dir).filter(name => name.endsWith(".jsx")), only)) {
62
68
  out[name] = fs.readFileSync(path.join(dir, name), "utf8");
63
69
  }
64
70
  return out;
@@ -85,10 +91,10 @@ function readAssetsAsBase64(dir, out, prefix = "") {
85
91
  // Build the in-memory workspace the capture engine renders: the deck's slides +
86
92
  // the materialized bundle's layouts / css / assets / fonts / primitives.
87
93
  // Exported for tests (pure: filesystem in, plain JSON out).
88
- export function buildCaptureInput(deckDir, bundleDir) {
94
+ export function buildCaptureInput(deckDir, bundleDir, { only = null } = {}) {
89
95
  const meta = readBundleMeta(bundleDir);
90
96
 
91
- const slides = readJsxDir(path.join(deckDir, "slides"));
97
+ const slides = readJsxDir(path.join(deckDir, "slides"), only);
92
98
  const layouts = readJsxDir(path.join(bundleDir, "layouts"));
93
99
  // Converted slides (`slidemind adopt`) invoke a ConvertedHost layout the
94
100
  // server mints per source canvas — no bundle carries it, so without a
@@ -151,7 +157,7 @@ export function buildCaptureInput(deckDir, bundleDir) {
151
157
  assets,
152
158
  fonts,
153
159
  primitives,
154
- deckName: path.basename(deckDir),
160
+ deckName: deckSlug(deckDir),
155
161
  };
156
162
  }
157
163
 
@@ -243,16 +249,7 @@ export async function captureDeckLocally({ deckDir, bundleDir, onProgress, only
243
249
  );
244
250
  }
245
251
 
246
- const input = buildCaptureInput(deckDir, bundleDir);
247
- // `only`: capture a subset of the authored slides (a per-slide builder
248
- // re-checking its own file) — the engine renders each slide on its own, so
249
- // dropping the others from the input is exact, not an approximation.
250
- if (Array.isArray(only) && only.length > 0) {
251
- const keep = new Set(only);
252
- for (const name of Object.keys(input.slides)) {
253
- if (!keep.has(name)) delete input.slides[name];
254
- }
255
- }
252
+ const input = buildCaptureInput(deckDir, bundleDir, { only });
256
253
  return withBrowser((browser) => engine.captureDeck(browser, input, { onProgress }));
257
254
  }
258
255
 
@@ -289,7 +286,7 @@ export async function loadConnectorPreview(bundleDir) {
289
286
 
290
287
  /**
291
288
  * The measured draft checks (`draftChecks: { fill, typeScale, typeScaleTokens,
292
- * overlap }`) exported from the same artifact — the rules the add-in's own
289
+ * overlap, escape, chrome, wordFit, splitText, splitTextAdvice }`) exported from the same artifact — the rules the add-in's own
293
290
  * see_draft runs. view-draft calls them on its local capture so both harnesses
294
291
  * teach one contract. The CLI ships no draft-check rule of its own; null when
295
292
  * the bundle has no engine or the engine predates the export.
@@ -5,7 +5,7 @@ import { randomUUID } from "node:crypto";
5
5
  import path from "node:path";
6
6
  import { deriveEnvLabel } from "../config.mjs";
7
7
  import { resolveBackendForPres } from "./presentations.mjs";
8
- import { listTimeoutMs, engineTimeoutMs, withTransientRetry, sessionsInScope, sessionForBackend, bridgeFetch, backendCapabilities, backendSupportsBrandRoutes, backendSupportsConceptImages, backendSupportsImageGeneration, backendSupportsBrandsList, graphHeaders } from "./transport.mjs";
8
+ import { listTimeoutMs, engineTimeoutMs, withTransientRetry, sessionsInScope, sessionForBackend, bridgeFetch, backendCapabilities, backendSupportsBrandRoutes, backendSupportsConceptImages, backendSupportsConceptSpecs, backendSupportsConceptSpecJobs, backendSupportsImageGeneration, backendSupportsBrandsList, assertImageIntentSupported, graphHeaders } from "./transport.mjs";
9
9
 
10
10
  // GET the full design bundle (tokens, css, layouts, designRules, signed asset
11
11
  // URLs, engines) for the presentation's active corporate design.
@@ -93,9 +93,11 @@ export async function getDesignBundleForBrandAnyBackend(brandId) {
93
93
  export async function listBrands() {
94
94
  const out = [];
95
95
  for (const s of sessionsInScope()) {
96
- if (!(await backendSupportsBrandsList(s.backendURL))) continue;
97
96
  const env = deriveEnvLabel(s.backendURL);
98
97
  try {
98
+ // Inside the try: a probe that got no answer is "that environment
99
+ // contributes none" like any other failure, not a crash of the listing.
100
+ if (!(await backendSupportsBrandsList(s.backendURL))) continue;
99
101
  const res = await bridgeFetch(
100
102
  "/api/cli/brands",
101
103
  { timeoutMs: listTimeoutMs() },
@@ -136,9 +138,12 @@ export function conceptTimeoutMs() {
136
138
 
137
139
  export async function postConceptImages(
138
140
  brandId,
139
- { planMarkdown, slideNumbers, references, force },
141
+ { planMarkdown, slideNumbers, force, imageIntent = null },
140
142
  backendURL,
141
143
  ) {
144
+ // An envelope the add-in would ignore is worse than no envelope: it would
145
+ // generate against the wrong picture and say nothing. Probe, then send.
146
+ if (imageIntent) await assertImageIntentSupported(backendURL);
142
147
  const body = { planMarkdown };
143
148
  // `--force`: the server keeps a per-plan mockup cache; an unchanged plan
144
149
  // would otherwise hand back last time's images no matter what changed
@@ -147,12 +152,10 @@ export async function postConceptImages(
147
152
  if (Array.isArray(slideNumbers) && slideNumbers.length > 0) {
148
153
  body.slideNumbers = slideNumbers;
149
154
  }
150
- // The user's own images, inline: `{ role: "concept" | "style", image:
151
- // <data URI>, slides?: number[], name? }`. A concept replaces the generated
152
- // mockup for its slides; a style reference leads the generation.
153
- if (Array.isArray(references) && references.length > 0) {
154
- body.references = references;
155
- }
155
+ // The deck's approved image obligations, with the sources inline
156
+ // (src/bridge/image-intent-envelope.mjs). Omitted entirely when the deck has
157
+ // none — which is what keeps every existing request byte-identical.
158
+ if (imageIntent) body.imageIntent = imageIntent;
156
159
  const res = await bridgeFetch(
157
160
  `/api/cli/brands/${encodeURIComponent(brandId)}/concept-images`,
158
161
  {
@@ -170,9 +173,10 @@ export async function postConceptImages(
170
173
  }
171
174
 
172
175
  // Generate concept mockup images for a brand over the pane-free brand route.
173
- // Body `{ planMarkdown, slideNumbers?, references? }`; the optional `slideNumbers`
176
+ // Body `{ planMarkdown, slideNumbers?, imageIntent? }`; the optional `slideNumbers`
174
177
  // filters which storyboard beats are rendered (the CLI batches per-slide);
175
- // `references` carries the user's own concept/style images inline. Returns the
178
+ // `imageIntent` carries the user's own images inline with their approved uses.
179
+ // Returns the
176
180
  // server's `{ ok, images, slideCount }` augmented with the resolved
177
181
  // `backendURL`. When `backendURL` is given it posts directly; otherwise it
178
182
  // fans out — like getDesignBundleForBrandAnyBackend, presFP/brandId is globally
@@ -184,9 +188,9 @@ export async function generateConceptImagesForBrand(
184
188
  {
185
189
  planMarkdown,
186
190
  slideNumbers = null,
187
- references = null,
188
191
  backendURL = null,
189
192
  force = false,
193
+ imageIntent = null,
190
194
  } = {},
191
195
  ) {
192
196
  if (!brandId) {
@@ -198,7 +202,7 @@ export async function generateConceptImagesForBrand(
198
202
  if (backendURL) {
199
203
  return postConceptImages(
200
204
  brandId,
201
- { planMarkdown, slideNumbers, references, force },
205
+ { planMarkdown, slideNumbers, force, imageIntent },
202
206
  backendURL,
203
207
  );
204
208
  }
@@ -215,7 +219,7 @@ export async function generateConceptImagesForBrand(
215
219
  }
216
220
  return await postConceptImages(
217
221
  brandId,
218
- { planMarkdown, slideNumbers, references, force },
222
+ { planMarkdown, slideNumbers, force, imageIntent },
219
223
  s.backendURL,
220
224
  );
221
225
  } catch (err) {
@@ -241,18 +245,32 @@ export async function generateConceptImagesForBrand(
241
245
  // null when several environments qualify (developer machines only; each request
242
246
  // then fans out as before). Throws the typed `concept_route_unavailable` when
243
247
  // none does, so the caller degrades to "continue without".
248
+ //
249
+ // An environment whose probe got no answer is passed over, not fatal: it cannot
250
+ // generate right now whatever it would have said, and the request names the
251
+ // deck's design, so an environment that does not serve it refuses instead of
252
+ // drawing the wrong one. Only when NO environment answered with the route is
253
+ // that unanswered probe the result — `capability_unknown`, retryable, never
254
+ // "no concept route" (2026-10-11: a stopped `local` session aborted every
255
+ // `concepts` run before the deck's own `dev` was asked).
244
256
  export async function resolveConceptBackend() {
245
257
  const sessions = sessionsInScope();
246
258
  const capable = [];
247
259
  const failures = [];
260
+ const unanswered = [];
248
261
  for (const s of sessions) {
249
- if (await backendSupportsConceptImages(s.backendURL)) {
250
- capable.push(s.backendURL);
251
- } else {
252
- failures.push(`${deriveEnvLabel(s.backendURL)}: no concept-images route`);
262
+ try {
263
+ if (await backendSupportsConceptImages(s.backendURL)) {
264
+ capable.push(s.backendURL);
265
+ } else {
266
+ failures.push(`${deriveEnvLabel(s.backendURL)}: no concept-images route`);
267
+ }
268
+ } catch (err) {
269
+ unanswered.push(err);
253
270
  }
254
271
  }
255
272
  if (capable.length === 0) {
273
+ if (unanswered.length) throw unanswered[0];
256
274
  const detail = failures.join("; ");
257
275
  const err = new Error(
258
276
  "no paired backend generates concept mockups" +
@@ -264,8 +282,129 @@ export async function resolveConceptBackend() {
264
282
  return capable.length === 1 ? capable[0] : null;
265
283
  }
266
284
 
285
+ // Extract concept specs for a brand over the pane-free brand route. Body
286
+ // `{ slides: [{ slide, image, storyboard? }], slot: { w, h } }` → the server's
287
+ // `{ ok, specs: [{ slide, spec }], failures }`. Returns null when the backend
288
+ // predates the route, so `slidemind concepts` degrades to PNGs only.
289
+ export async function extractConceptSpecsForBrand(
290
+ brandId,
291
+ { slides, slot, backendURL, palette = null, onProgress = null, onSpec = null,
292
+ imageIntent = null,
293
+ schemaFormat = process.env.SLIDEMIND_SPEC_SCHEMA || "compact-v1" },
294
+ ) {
295
+ if (!brandId) throw new Error("extractConceptSpecsForBrand: brandId is required");
296
+ if (!backendURL) throw new Error("extractConceptSpecsForBrand: backendURL is required");
297
+ if (!["legacy", "compact-v1"].includes(schemaFormat)) throw new Error(`Unknown spec schema: ${schemaFormat}`);
298
+ const schemaOption = { schemaFormat };
299
+ const checkSchema = (entry) => {
300
+ if (schemaFormat === "compact-v1" && entry?.spec?.schemaFormat !== schemaFormat) {
301
+ throw new Error("Backend did not return compact-v1 specs; update the add-in backend, or explicitly select SLIDEMIND_SPEC_SCHEMA=legacy for compatibility.");
302
+ }
303
+ };
304
+ // Same gate as the concept call: extraction that drops the envelope would
305
+ // inventory the mockup's approximation of the user's picture and record it as
306
+ // the slide's elements. Refuse before the request, never degrade.
307
+ if (imageIntent) await assertImageIntentSupported(backendURL);
308
+ if (!(await backendSupportsConceptSpecs(backendURL))) return null;
309
+ // The same envelope the concept call carried, so extraction sees the sources
310
+ // the mockup was made from. Absent for a deck with no image uses, and both
311
+ // the job and the sync body below spread it in — one contract, two routes.
312
+ const intentOption = imageIntent ? { imageIntent } : {};
313
+ // The polled job when the add-in has it: one POST, then a GET every few
314
+ // seconds until `state: done`. A dense slide gets its eight minutes; a
315
+ // sync request died at the ingress's ~240 s (x27–x32: 1–2 specs per deck).
316
+ if (await backendSupportsConceptSpecJobs(backendURL)) {
317
+ const base = `/api/cli/brands/${encodeURIComponent(brandId)}/concept-spec-jobs`;
318
+ const created = await bridgeFetch(
319
+ base,
320
+ {
321
+ method: "POST",
322
+ headers: { "content-type": "application/json" },
323
+ body: JSON.stringify({ slides, slot, ...schemaOption, ...(palette?.length ? { palette } : {}), ...intentOption }),
324
+ // ~7 MB of inlined mockups, and the server checks the image intent
325
+ // before its 202: the 8 s list timeout aborted the upload before the job
326
+ // existed (x69). The server takes no idempotency key to reuse one by.
327
+ timeoutMs: Math.max(conceptTimeoutMs(), 120_000),
328
+ },
329
+ backendURL,
330
+ );
331
+ const job = (await created.json()) ?? {};
332
+ if (job.jobId) {
333
+ const started = Date.now();
334
+ const deadline = started + Math.max(conceptTimeoutMs(), 20 * 60_000);
335
+ const delivered = new Set();
336
+ let lastBeat = started;
337
+ let lastDone = -1;
338
+ while (Date.now() < deadline) {
339
+ await new Promise((r) => setTimeout(r, 5000));
340
+ let state;
341
+ try {
342
+ const res = await bridgeFetch(`${base}/${encodeURIComponent(job.jobId)}`, { timeoutMs: listTimeoutMs() }, backendURL);
343
+ state = (await res.json()) ?? {};
344
+ } catch (err) {
345
+ if (err?.status === 404) break; // the container restarted; the sync route below
346
+ continue;
347
+ }
348
+ // Each spec is handed over the moment it exists: a command the agent
349
+ // gives up on still leaves what was read (x33: two stalls, nothing
350
+ // written), and the next run asks only for the rest.
351
+ for (const entry of state.specs ?? []) {
352
+ checkSchema(entry);
353
+ const n = Number(entry?.slide);
354
+ if (delivered.has(n)) continue;
355
+ delivered.add(n);
356
+ if (typeof onSpec === "function") onSpec(entry);
357
+ }
358
+ const done = delivered.size + (state.failures?.length ?? 0);
359
+ // A line when the count moves, else one heartbeat every 30 s.
360
+ if (typeof onProgress === "function" && (done !== lastDone || Date.now() - lastBeat >= 30_000)) {
361
+ onProgress({ done, total: state.total ?? slides.length, elapsedMs: Date.now() - started });
362
+ lastBeat = Date.now();
363
+ lastDone = done;
364
+ }
365
+ if (state.state === "done") return { ok: true, specs: state.specs ?? [], failures: state.failures ?? [], streamed: true };
366
+ }
367
+ }
368
+ }
369
+ const res = await bridgeFetch(
370
+ `/api/cli/brands/${encodeURIComponent(brandId)}/concept-specs`,
371
+ {
372
+ method: "POST",
373
+ headers: { "content-type": "application/json" },
374
+ body: JSON.stringify({ slides, slot, ...schemaOption, ...(palette?.length ? { palette } : {}), ...intentOption }),
375
+ // The server budgets 300s per slide and runs a batch in parallel; give
376
+ // the round trip headroom over that so a slow inventory is a server
377
+ // failure the response names, never a client abort.
378
+ timeoutMs: Math.max(conceptTimeoutMs(), 330_000),
379
+ },
380
+ backendURL,
381
+ );
382
+ const result = (await res.json()) ?? {};
383
+ for (const entry of result.specs ?? []) checkSchema(entry);
384
+ return result;
385
+ }
386
+
267
387
  // gpt-image calls routinely take 60-120s; budget like the concept relay so a
268
388
  // typed server error wins over a client timeout.
389
+ // Shorten the words of texts that do not fit their room (copy-fit.mjs), one
390
+ // slide per call, over `/api/cli/brands/:brandId/copy-fit`: `{ items, context,
391
+ // plan }` → `[{ id, text }]`. Null when the backend has no such route: the
392
+ // slide keeps its words and the author shortens them.
393
+ export async function fitCopyForBrand(brandId, { items, context = "", plan = null, backendURL }) {
394
+ if (!brandId || !backendURL || (await backendCapabilities(backendURL))?.copyFitV1 !== true) return null;
395
+ const res = await bridgeFetch(
396
+ `/api/cli/brands/${encodeURIComponent(brandId)}/copy-fit`,
397
+ {
398
+ method: "POST",
399
+ headers: { "content-type": "application/json" },
400
+ body: JSON.stringify({ items: items.map(({ id, role, text, maxChars, keepPrefix }) => ({ id, role, text, maxChars, ...(keepPrefix ? { keepPrefix } : {}) })), context, plan }),
401
+ timeoutMs: 120_000,
402
+ },
403
+ backendURL,
404
+ );
405
+ return ((await res.json()) ?? {}).items ?? [];
406
+ }
407
+
269
408
  export function imagenTimeoutMs() {
270
409
  return (
271
410
  parseInt(process.env.SLIDEMIND_BRIDGE_IMAGEN_TIMEOUT_MS || "200000", 10) ||