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
@@ -17,6 +17,7 @@
17
17
  // cannot put a bin on PATH), so a Codex-plugin session always runs the npm CLI
18
18
  // and the npm remediation is already correct there.
19
19
 
20
+ import fs from "node:fs";
20
21
  import path from "node:path";
21
22
  import { fileURLToPath } from "node:url";
22
23
 
@@ -50,12 +51,68 @@ export function isClaudePluginInstall(root = PACKAGE_ROOT) {
50
51
  return false;
51
52
  }
52
53
 
54
+ // Which sandbox is confining this process, if any? Codex exports CODEX_SANDBOX
55
+ // (`seatbelt` on macOS, `landlock` on Linux) for every command it runs under
56
+ // the sandbox, and leaves it unset for a full-access run — so its presence is
57
+ // a reliable "my writes are confined to the workspace" signal. Returned as the
58
+ // name so a caller can word a message, not just a boolean.
59
+ export function sandboxName() {
60
+ const s = process.env.CODEX_SANDBOX;
61
+ return s && s !== "none" ? s : null;
62
+ }
63
+
64
+ // Can a global `npm install -g slidemind@latest` launched from THIS process
65
+ // actually replace the running binary? npm renames the package directory in
66
+ // place, so the node_modules that holds slidemind must be writable. Two things
67
+ // break that, and the fix for each is the same escalation:
68
+ // "sandbox" — a Codex sandbox confines writes to the workspace (and the
69
+ // config dir too, so the "we already tried" record can never
70
+ // persist and a doomed install would respawn every command)
71
+ // "permission" — a root-owned global prefix
72
+ // Returns the reason, or null when the install would work. `root` is overridable
73
+ // for tests, mirroring isClaudePluginInstall(root).
74
+ export function globalInstallBlock(root = PACKAGE_ROOT) {
75
+ const dir = path.dirname(path.resolve(root));
76
+ const probe = path.join(
77
+ dir,
78
+ `.slidemind-wp-${process.pid}-${Math.random().toString(36).slice(2)}`,
79
+ );
80
+ try {
81
+ fs.writeFileSync(probe, "");
82
+ fs.rmSync(probe, { force: true });
83
+ return null;
84
+ } catch {
85
+ return sandboxName() ? "sandbox" : "permission";
86
+ }
87
+ }
88
+
53
89
  // The one sentence every version-floor / too-old-CLI message ends with, so the
54
- // remediation always names the updater that actually replaces THIS binary.
55
- export function updateCliInstruction() {
56
- return isClaudePluginInstall()
57
- ? "This CLI came from the slidemind Claude Code plugin, so update it there: " +
58
- "`/plugin` → slidemind → update (a global `npm install -g slidemind@latest` " +
59
- "installs a separate copy and will NOT change the `slidemind` this session runs)."
60
- : "Run `slidemind update` (or `npm install -g slidemind@latest`), then retry.";
90
+ // remediation always names the updater that actually replaces THIS binary —
91
+ // and asks for escalated rights when the running process cannot write the
92
+ // global install itself (a Codex sandbox, or a root-owned prefix).
93
+ // `block` lets a caller that already resolved globalInstallBlock() pass it in
94
+ // (so the message matches the decision exactly); omitted, it self-detects — the
95
+ // shape the hard-block callers (connection.mjs, transport.mjs) use.
96
+ export function updateCliInstruction(block = globalInstallBlock()) {
97
+ if (isClaudePluginInstall()) {
98
+ return (
99
+ "This CLI came from the slidemind Claude Code plugin, so update it there: " +
100
+ "`/plugin` → slidemind → update (a global `npm install -g slidemind@latest` " +
101
+ "installs a separate copy and will NOT change the `slidemind` this session runs)."
102
+ );
103
+ }
104
+ if (block === "sandbox") {
105
+ return (
106
+ "This session is sandboxed and cannot replace the global install itself — " +
107
+ "run `slidemind update` with escalated permissions (approve running it " +
108
+ "outside the sandbox) to update the CLI, then retry."
109
+ );
110
+ }
111
+ if (block === "permission") {
112
+ return (
113
+ "The global install is not writable from here — run `slidemind update` " +
114
+ "with escalated permissions (e.g. `sudo`) to update the CLI, then retry."
115
+ );
116
+ }
117
+ return "Run `slidemind update` (or `npm install -g slidemind@latest`), then retry.";
61
118
  }
package/src/lib/adopt.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { stageWorkspace } from './workspace-transaction.mjs';
1
2
  // `slidemind adopt <file.pptx>` — reconstruct a local deck workspace from a
2
3
  // presentation's deck-embedded source tags (intern-slidemind
3
4
  // docs/deck-embedded-source-spec.md §6.2) and, where the add-in offers it,
@@ -26,11 +27,21 @@ import path from "node:path";
26
27
 
27
28
  import { unzip } from "../pptx/zip.mjs";
28
29
  import { slideOrderPaths } from "../pptx/ooxml.mjs";
29
- import { decodeSourceTags, mintSlideGuid, readSlideTags } from "../pptx/tags.mjs";
30
+ import {
31
+ TAG_GUID,
32
+ computeProjection,
33
+ decodeSourceTags,
34
+ mintSlideGuid,
35
+ readSlideTags,
36
+ withoutChartHashes,
37
+ } from "../pptx/tags.mjs";
30
38
  import { foldPickedSlides } from "../pptx/slide-swap.mjs";
31
39
  import { readDeckBundleRef, writeDeckBundleRef } from "../bridge/bundle.mjs";
40
+ import { IMAGE_INTENT_META_KEY, restoreImageIntentFromTags } from "./image-intent-tags.mjs";
41
+ import { PRESERVATION_META_KEY, restorePreservationFromTags } from "./preservation-tags.mjs";
32
42
  import { loadSlideGuids, saveSlideGuids, slideGuidEntry } from "./slide-guids.mjs";
33
43
  import { writeConversionRecord } from "./conversions.mjs";
44
+ import { deckSlug } from "./deck-paths.mjs";
34
45
 
35
46
  // Converted JSX needs a fresh baseline against the imported PPTX. Source tags
36
47
  // alone cannot recover the original conversion evidence or preserved natives.
@@ -39,6 +50,52 @@ export function needsConversion(decoded) {
39
50
  /^\s*\/\/\s*SLIDEMIND_DIALECT:\s*converted\b/m.test(decoded.jsx);
40
51
  }
41
52
 
53
+ /**
54
+ * May the embedded JSX be written back verbatim?
55
+ *
56
+ * `decodeSourceTags` proves the PAYLOAD is intact. That is a different question
57
+ * from whether it still describes the slide: a normal PowerPoint text, geometry
58
+ * or style edit leaves the payload perfectly valid and perfectly stale. The
59
+ * second half of the proof is the drift baseline the bake stored alongside it —
60
+ * the structural projection as it stood when the slide was written. Recompute
61
+ * that projection over the slide as it is NOW and the two must agree.
62
+ *
63
+ * A slide with no baseline cannot be proved either way, so it is not reused.
64
+ * Chart hashes drop from both sides for the reason stated in tags.mjs: this repo
65
+ * projects `chart: null`, and comparing that against a real hash would send
66
+ * every chart-bearing slide through the converter forever.
67
+ */
68
+ export function sourceMatchesSlide(files, slidePath, decoded) {
69
+ if (!decoded?.baseline) return false;
70
+ return (
71
+ JSON.stringify(withoutChartHashes(computeProjection(files, slidePath))) ===
72
+ JSON.stringify(withoutChartHashes(decoded.baseline))
73
+ );
74
+ }
75
+
76
+ /**
77
+ * What each slide of a package becomes, decided from the PACKAGE rather than
78
+ * from the source decoder alone:
79
+ *
80
+ * "source" the embedded JSX is intact AND proved to still describe the slide
81
+ * "convert" hydrate from the CURRENT content through the add-in's converter
82
+ * "native" keep the slide verbatim as a lossless one-slide .pptx
83
+ *
84
+ * `slides` (1-based play-order numbers) narrows what is HYDRATED — everything
85
+ * outside it stays native. Restoring a proved-current source costs no backend
86
+ * call, so it happens whether or not the slide was requested.
87
+ */
88
+ export function hydrationRoutes({ files, order, decodedByIndex }, { slides = null } = {}) {
89
+ const wanted = slides && slides !== "all" ? new Set(slides.map(Number)) : null;
90
+ return decodedByIndex.map((decoded, i) => {
91
+ if (decoded && !needsConversion(decoded) && sourceMatchesSlide(files, order[i], decoded)) {
92
+ return "source";
93
+ }
94
+ if (!wanted || wanted.has(i + 1)) return "convert";
95
+ return "native";
96
+ });
97
+ }
98
+
42
99
  export const LIVE_DECK_UNSUPPORTED =
43
100
  "conversion of live presentations requires the add-in path in v1 — " +
44
101
  "`slidemind adopt` takes a .pptx FILE. Save the open deck to disk and adopt " +
@@ -163,8 +220,25 @@ function convertedByNumber(converted) {
163
220
  return map;
164
221
  }
165
222
 
166
- export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source = null }) {
167
- const { buffer, order, decodedByIndex } = source ?? readSourceDeck(pptxPath);
223
+ export function adoptDeckFromPptx({
224
+ pptxPath,
225
+ deckDir,
226
+ converted = null,
227
+ source = null,
228
+ slides = null,
229
+ only = null,
230
+ // 1-based play order -> file stem, for a slide that must keep the name it
231
+ // already has in the deck (a folded reused slide converted in place).
232
+ stems = null,
233
+ }) {
234
+ const deck = source ?? readSourceDeck(pptxPath);
235
+ const { buffer, order, decodedByIndex } = deck;
236
+ const routes = hydrationRoutes(deck, { slides });
237
+ // `only` materializes a SUBSET of the package into the workspace and leaves
238
+ // every other slide file exactly where it is — the later-hydration path, where
239
+ // one slide is being brought forward while another carries pending edits.
240
+ const materialize = only ? new Set([...only].map(Number)) : null;
241
+ const wanted = (index) => !materialize || materialize.has(index + 1);
168
242
  const convertedSlides = convertedByNumber(converted);
169
243
 
170
244
  const slidesDir = path.join(deckDir, "slides");
@@ -179,15 +253,19 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
179
253
  const adopted = [];
180
254
  const convertedFiles = [];
181
255
  const native = [];
256
+ // Which workspace file materializes each slide of the package, by 1-based play
257
+ // order. The revision record keys its per-slide evidence on this — without it
258
+ // a rebase cannot tell which local file a remote change belongs to.
259
+ const fileByIndex = new Map();
182
260
  let bundle = null;
183
261
 
184
- // Fold every converted slide in one pass. The package supplies preserved
185
- // natives AND the original envelope (layouts, passive third-party tags,
186
- // relationships). Tagged converted JSX alone cannot restore either safely.
262
+ // Fold every slide that is NOT restored from a proved-current source in one
263
+ // pass. The package supplies preserved natives AND the original envelope
264
+ // (layouts, passive third-party tags, relationships). Tagged converted JSX
265
+ // alone cannot restore either safely, and an unhydrated slide IS its package.
187
266
  const picks = [];
188
- decodedByIndex.forEach((decoded, index) => {
189
- if (!needsConversion(decoded)) return;
190
- picks.push(index + 1);
267
+ routes.forEach((route, index) => {
268
+ if (route !== "source" && wanted(index)) picks.push(index + 1);
191
269
  });
192
270
  const foldByPick = new Map(
193
271
  (picks.length
@@ -220,6 +298,14 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
220
298
  // crops) land BEFORE the slide loop so a collision-renamed asset can be
221
299
  // rewritten in the converted jsx. Names arrive from an UNTRUSTED package,
222
300
  // so they pass the same escape guard as tag-embedded assets.
301
+ // Slides whose embedded source carried image obligations, restored in one
302
+ // pass after the loop so the deck ends with one registry write, not one per
303
+ // slide (src/lib/image-intent-tags.mjs).
304
+ const intentEntries = [];
305
+ // …and the obligations they carried (src/lib/preservation-tags.mjs), keyed by
306
+ // the identity THIS deck gave the slide — a duplicate gets a fresh GUID below,
307
+ // and its record has to follow that, not the one baked into the file.
308
+ const preservationEntries = [];
223
309
  const convAssetRenames = new Map();
224
310
  for (const [name, b64] of Object.entries(converted?.assets ?? {})) {
225
311
  const bytes = Buffer.from(String(b64), "base64");
@@ -228,12 +314,17 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
228
314
  }
229
315
 
230
316
  decodedByIndex.forEach((decoded, index) => {
231
- if (decoded && !needsConversion(decoded)) {
232
- const stem = stemFor(decoded.meta.name, index, taken);
317
+ if (!wanted(index)) return;
318
+ if (routes[index] === "source") {
319
+ const stem = stems?.get(index + 1) ?? stemFor(decoded.meta.name, index, taken);
233
320
  let jsx = decoded.jsx;
321
+ const renames = new Map();
234
322
  for (const asset of decoded.assets) {
235
323
  const placed = placeAsset(deckDir, asset.name, asset.bytes, asset.sha256);
236
- if (placed && placed !== asset.name) jsx = jsx.split(asset.name).join(placed);
324
+ if (placed && placed !== asset.name) {
325
+ renames.set(asset.name, placed);
326
+ jsx = jsx.split(asset.name).join(placed);
327
+ }
237
328
  }
238
329
  fs.writeFileSync(path.join(slidesDir, `${stem}.jsx`), jsx, "utf8");
239
330
  const owns = ownerIndexByGuid.get(decoded.guid)?.index === index;
@@ -241,8 +332,26 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
241
332
  owns && !seenGuids.has(decoded.guid) ? decoded.guid : mintSlideGuid();
242
333
  seenGuids.add(guid);
243
334
  guids[`slides/${stem}.jsx`] = slideGuidEntry(guid, jsx);
335
+ // The slide's image obligations, if it recorded any. Only this route
336
+ // restores them: its embedded source was proved to still describe the
337
+ // slide, so the picture it names is the picture that is there.
338
+ if (decoded.meta[IMAGE_INTENT_META_KEY]) {
339
+ intentEntries.push({
340
+ slideId: guid,
341
+ intent: decoded.meta[IMAGE_INTENT_META_KEY],
342
+ restoreUses: true,
343
+ renames,
344
+ });
345
+ }
346
+ // Its preservation record, on the same terms and for the same reason:
347
+ // this route, and only this route, proved the payload still describes the
348
+ // slide, so the elements it requires are the elements that are there.
349
+ if (decoded.meta[PRESERVATION_META_KEY]) {
350
+ preservationEntries.push({ slideKey: guid, entry: decoded.meta[PRESERVATION_META_KEY] });
351
+ }
244
352
  bundle ??= decoded.meta.bundle ?? null;
245
353
  adopted.push(`${stem}.jsx`);
354
+ fileByIndex.set(index + 1, { file: `${stem}.jsx`, guid, route: "source" });
246
355
  return;
247
356
  }
248
357
  const fold = foldByPick.get(index + 1);
@@ -250,11 +359,16 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
250
359
  if (conv) {
251
360
  // Deterministically converted: editable jsx in the converted dialect,
252
361
  // plus the identity/evidence record push threads back to the server.
253
- const stem = stemFor(decoded?.meta.name ?? conv.name ?? `${pad2(index + 1)}-converted`, index, taken);
362
+ const stem =
363
+ stems?.get(index + 1) ??
364
+ stemFor(decoded?.meta.name ?? conv.name ?? `${pad2(index + 1)}-converted`, index, taken);
254
365
  let jsx = conv.jsx;
255
366
  for (const [from, to] of convAssetRenames) jsx = jsx.split(from).join(to);
256
367
  fs.writeFileSync(path.join(slidesDir, `${stem}.jsx`), jsx, "utf8");
257
- const candidateGuid = decoded?.guid ?? conv.guid;
368
+ // The route sends no guid; a slide whose package carries one (a native
369
+ // slide that was already pushed) keeps it, as the native branch does.
370
+ const candidateGuid =
371
+ decoded?.guid ?? conv.guid ?? readSlideTags(deck.files, order[index])[TAG_GUID] ?? null;
258
372
  const owns = !decoded || ownerIndexByGuid.get(decoded.guid)?.index === index;
259
373
  const guid = owns && candidateGuid && !seenGuids.has(candidateGuid)
260
374
  ? candidateGuid : mintSlideGuid();
@@ -281,18 +395,34 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
281
395
  );
282
396
  bundle ??= decoded?.meta.bundle ?? null;
283
397
  convertedFiles.push(`${stem}.jsx`);
398
+ fileByIndex.set(index + 1, { file: `${stem}.jsx`, guid, route: "convert" });
284
399
  return;
285
400
  }
286
- // No decodable source and no conversion → keep the slide verbatim as a
287
- // native one-slide .pptx, spliced back by filename order on every future
288
- // render.
401
+ // Nothing hydrated this slide — either it was outside `--slides` or the
402
+ // converter declined it. It stays verbatim as a lossless one-slide .pptx,
403
+ // spliced back by filename order on every future render, carrying its own
404
+ // notes, comments, animations and embeddings.
289
405
  if (!fold) return;
290
406
  const stem = stemFor(`${pad2(index + 1)}-reused-adopted`, index, taken);
291
407
  fs.writeFileSync(path.join(slidesDir, `${stem}.pptx`), fold.bytes);
292
408
  native.push(`${stem}.pptx`);
409
+ // A native slide keeps the identity the PACKAGE gives it — read straight
410
+ // off the GUID tag, never through the source decoder, so a slide whose
411
+ // source tags were stripped still has one.
412
+ const guid = decoded?.guid ?? readSlideTags(deck.files, order[index])[TAG_GUID] ?? null;
413
+ fileByIndex.set(index + 1, { file: `${stem}.pptx`, guid, route: "native" });
293
414
  });
294
415
 
295
416
  if (Object.keys(guids).length > 0) saveSlideGuids(deckDir, guids);
417
+ // Image identity, after the GUIDs are persisted: an override is keyed by the
418
+ // slide's durable ID, so the ID has to exist first. A file that recorded none
419
+ // — a plain imported .pptx, or one rendered before this contract — reaches
420
+ // this with nothing to restore and gains no obligation (A27).
421
+ const intentNotes = restoreImageIntentFromTags(deckDir, intentEntries).notes;
422
+ // Same ordering rule, same additive contract: an entry already on disk stays,
423
+ // so a repeated or resumed adoption restores nothing a second time and an
424
+ // older file cannot resurrect a requirement this deck superseded.
425
+ intentNotes.push(...restorePreservationFromTags(deckDir, preservationEntries).notes);
296
426
  // A wholly foreign deck names no bundle in any tag, but conversion ran
297
427
  // against a design — bind to that one so the next push renders pane-free
298
428
  // (the etag self-heals on the first render).
@@ -313,7 +443,7 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
313
443
  if (!fs.existsSync(planPath)) {
314
444
  fs.writeFileSync(
315
445
  planPath,
316
- `---\nskill: create-slides\n---\n\n# ${path.basename(deckDir)}\n\nAdopted from ${path.basename(pptxPath)}.\n`,
446
+ `---\nskill: create-slides\n---\n\n# ${deckSlug(deckDir)}\n\nAdopted from ${path.basename(pptxPath)}.\n`,
317
447
  "utf8",
318
448
  );
319
449
  }
@@ -324,10 +454,17 @@ export function adoptDeckFromPptx({ pptxPath, deckDir, converted = null, source
324
454
  converted: convertedFiles,
325
455
  native,
326
456
  bundle,
457
+ routes,
458
+ // 1-based play order -> { file, guid, route }: which workspace file carries
459
+ // each slide of the adopted package.
460
+ slideFiles: fileByIndex,
327
461
  // Why a slide the server was asked to convert stayed native (addinOwned,
328
462
  // nativeOnlyEnvelope, a parse failure) — the agent has to be able to say so.
329
463
  skipped: Array.isArray(converted?.skipped) ? converted.skipped : [],
330
- warnings: Array.isArray(converted?.warnings) ? converted.warnings : [],
464
+ warnings: [
465
+ ...(Array.isArray(converted?.warnings) ? converted.warnings : []),
466
+ ...intentNotes,
467
+ ],
331
468
  };
332
469
  }
333
470
 
@@ -351,19 +488,23 @@ export async function convertUntaggedSlides({
351
488
  brandId = null,
352
489
  note = () => {},
353
490
  source = null,
491
+ slides: requested = null,
354
492
  }) {
355
- const { buffer, decodedByIndex } = source ?? readSourceDeck(pptxPath);
356
- const slides = [];
357
- decodedByIndex.forEach((decoded, i) => {
358
- if (needsConversion(decoded)) slides.push(i + 1);
359
- });
493
+ const deck = source ?? readSourceDeck(pptxPath);
494
+ const { buffer } = deck;
495
+ // Ask for exactly the slides that will be hydrated — no more. A slide whose
496
+ // embedded source is proved current needs no conversion, and one outside
497
+ // `--slides` is staying native, so uploading either wastes a round trip and
498
+ // (on a big deck) minutes of wall time.
499
+ const routes = hydrationRoutes(deck, { slides: requested });
500
+ const slides = routes.flatMap((route, i) => (route === "convert" ? [i + 1] : []));
360
501
  if (slides.length === 0) return null;
361
502
 
362
503
  const client = await import("../bridge/client.mjs");
363
504
  const brand =
364
505
  brandId ??
365
506
  readDeckBundleRef(deckDir)?.presFP ??
366
- decodedByIndex.find((d) => d?.meta?.bundle?.presFP)?.meta.bundle.presFP ??
507
+ deck.decodedByIndex.find((d) => d?.meta?.bundle?.presFP)?.meta.bundle.presFP ??
367
508
  null;
368
509
  let resolved = brand && brand !== "unknown" ? brand : null;
369
510
  let backendURL = null;
@@ -415,3 +556,183 @@ export async function convertUntaggedSlides({
415
556
  return null;
416
557
  }
417
558
  }
559
+
560
+ // ---------------------------------------------------------------------------
561
+ // update mode — adoption that can be run twice
562
+ // ---------------------------------------------------------------------------
563
+
564
+ /**
565
+ * Adopt a .pptx into a workspace, or bring an already-adopted workspace up to
566
+ * that file. One entry point, because the difference between the two is a
567
+ * question about state the caller should not have to answer.
568
+ *
569
+ * Five outcomes:
570
+ *
571
+ * "adopted" first adoption — slides materialized and the accepted base
572
+ * recorded, the whole thing journaled so a crash resolves to a
573
+ * complete generation rather than a half-built deck;
574
+ * "hydrated" the same file, but slides that were native are now requested as
575
+ * editable JSX. Only those are re-materialized; every other
576
+ * slide file — including one with pending local edits — is left
577
+ * exactly as it is, and the new material joins the BASELINE
578
+ * rather than reading as an edit;
579
+ * "unchanged" the same file, nothing new asked for. Nothing is written: not
580
+ * the slides, not the assets, not the GUIDs, not the adoption
581
+ * time;
582
+ * "rebased" a different file over a clean workspace — the remote changes
583
+ * apply and the accepted base advances;
584
+ * needs_reconcile (thrown) — a different file over a workspace with
585
+ * unpublished edits. The incoming snapshot is STAGED and nothing
586
+ * local is touched; `slidemind rebase` decides what happens.
587
+ */
588
+ export async function adoptWorkspace({
589
+ pptxPath,
590
+ deckDir,
591
+ brandId = null,
592
+ slides = null,
593
+ convert = true,
594
+ note = () => {},
595
+ owner = null,
596
+ }) {
597
+ const revisionLib = await import("./revision.mjs");
598
+ if (!owner) {
599
+ const held = revisionLib.acquireOwner(deckDir, { command: 'adopt' });
600
+ try { return await adoptWorkspace({ pptxPath, deckDir, brandId, slides, convert, note, owner: held.token }); }
601
+ finally { held.release(); }
602
+ }
603
+ const source = readSourceDeck(pptxPath);
604
+ const incoming = revisionLib.inventoryPackage(source.buffer);
605
+ const revision = revisionLib.readRevision(deckDir);
606
+
607
+ const runConvert = async (only) =>
608
+ convert
609
+ ? await convertUntaggedSlides({
610
+ pptxPath,
611
+ deckDir,
612
+ brandId,
613
+ note,
614
+ source,
615
+ slides: only,
616
+ })
617
+ : null;
618
+
619
+ // --- already adopted from THIS file ------------------------------------
620
+ if (revision && revision.base?.sha256 === incoming.sha256) {
621
+ const byIndex = new Map(
622
+ (revision.base.slides ?? []).map((s) => [s.index, s]),
623
+ );
624
+ const requested = slides === "all" ? [...byIndex.keys()] : slides ? slides.map(Number) : [];
625
+ const toHydrate = requested.filter((n) => {
626
+ const entry = byIndex.get(n);
627
+ return entry && (!entry.file || entry.file.endsWith(".pptx"));
628
+ });
629
+ if (toHydrate.length === 0) {
630
+ return { mode: "unchanged", revision, slideCount: incoming.slideCount };
631
+ }
632
+
633
+ // Withdraw the native materialization of exactly those slides, then build
634
+ // their JSX in its place. Nothing else in slides/ is read or written.
635
+ const dirty = revisionLib.computeDirty(deckDir, revision);
636
+ if (toHydrate.some((n) => dirty.dirtySlides.includes(byIndex.get(n)?.file))) {
637
+ const error = new Error("Cannot hydrate an edited native slide; preserve or reconcile its changes first.");
638
+ error.code = "local_dirty";
639
+ throw error;
640
+ }
641
+ const activeDir = deckDir;
642
+ const tx = stageWorkspace(activeDir);
643
+ try {
644
+ const deckDir = tx.workspace;
645
+ const converted = convert ? await convertUntaggedSlides({ pptxPath, deckDir, brandId, note, source, slides: toHydrate }) : null;
646
+ const replaced = [];
647
+ for (const n of toHydrate) {
648
+ const entry = byIndex.get(n);
649
+ if (!entry?.file) continue;
650
+ fs.rmSync(path.join(deckDir, "slides", entry.file), { force: true });
651
+ replaced.push(entry.file);
652
+ }
653
+ const result = adoptDeckFromPptx({
654
+ pptxPath,
655
+ deckDir,
656
+ converted,
657
+ source,
658
+ slides: toHydrate,
659
+ only: toHydrate,
660
+ });
661
+ const slideFiles = new Map(
662
+ (revision.base.slides ?? [])
663
+ .filter((s) => s.file)
664
+ .map((s) => [s.index, { file: s.file, guid: s.guid }]),
665
+ );
666
+ for (const [index, entry] of result.slideFiles) slideFiles.set(index, entry);
667
+ revisionLib.writeRevision(deckDir, {
668
+ ...revision,
669
+ base: {
670
+ ...revision.base,
671
+ slides: (revision.base.slides ?? []).map((s) => ({
672
+ ...s,
673
+ file: slideFiles.get(s.index)?.file ?? s.file ?? null,
674
+ })),
675
+ },
676
+ });
677
+ const hydrated = [...result.adopted, ...result.converted];
678
+ revisionLib.noteHydration(deckDir, hydrated, replaced);
679
+ tx.activate({ checkpoint: () => revisionLib.assertCheckpoint(activeDir, { owner, generation: revision.generation, digest: dirty.inputDigest }) });
680
+ return { mode: "hydrated", result, hydrated, revision: revisionLib.readRevision(activeDir) };
681
+ } finally { tx.dispose(); }
682
+ }
683
+
684
+ // --- a DIFFERENT file over an adopted workspace ------------------------
685
+ if (revision) {
686
+ const staged = revisionLib.stageIncoming(deckDir, {
687
+ bytes: source.buffer,
688
+ source: { kind: "file", path: path.resolve(pptxPath), name: path.basename(pptxPath) },
689
+ });
690
+ const dirty = revisionLib.computeDirty(deckDir, revision);
691
+ if (dirty.localDirty) {
692
+ const err = new Error(
693
+ `${deckSlug(deckDir)} has unpublished local changes (${dirty.dirtySlides.length} slide(s)), ` +
694
+ `so adopting a newer file would overwrite them. The incoming file is kept as ` +
695
+ `revision ${staged.revisionId}; nothing local was touched.\n` +
696
+ `Reconcile it: \`slidemind rebase ${deckSlug(deckDir)} --incoming ${staged.revisionId}\``,
697
+ );
698
+ err.code = "needs_reconcile";
699
+ err.revisionId = staged.revisionId;
700
+ err.dirtySlides = dirty.dirtySlides;
701
+ throw err;
702
+ }
703
+ const { applyRebase } = await import("./rebase.mjs");
704
+ const rebase = applyRebase(deckDir, staged.revisionId, { owner });
705
+ return { mode: "rebased", rebase, revisionId: staged.revisionId };
706
+ }
707
+
708
+ // --- first adoption ----------------------------------------------------
709
+ const journal = revisionLib.journalBegin(deckDir, {
710
+ op: "adopt",
711
+ detail: { file: path.basename(pptxPath), sha256: incoming.sha256 },
712
+ });
713
+ const converted = await runConvert(slides);
714
+ revisionLib.journalStep(deckDir, journal, "materialize");
715
+ const result = adoptDeckFromPptx({ pptxPath, deckDir, converted, source, slides });
716
+ revisionLib.journalStep(deckDir, journal, "record-base");
717
+ revisionLib.recordAcceptedBase(deckDir, {
718
+ bytes: source.buffer,
719
+ origin: { kind: "adopted", file: path.basename(pptxPath), path: path.resolve(pptxPath) },
720
+ });
721
+ // The per-slide file mapping: which workspace file carries which slide of the
722
+ // accepted package. A rebase without it cannot tell whose edit a remote
723
+ // change collides with.
724
+ const recorded = revisionLib.readRevision(deckDir);
725
+ revisionLib.writeRevision(deckDir, {
726
+ ...recorded,
727
+ base: {
728
+ ...recorded.base,
729
+ slides: recorded.base.slides.map((s) => ({
730
+ ...s,
731
+ file: result.slideFiles.get(s.index)?.file ?? null,
732
+ hydrated: result.routes[s.index - 1] !== "native",
733
+ })),
734
+ },
735
+ });
736
+ revisionLib.journalComplete(deckDir);
737
+ return { mode: "adopted", result, revision: revisionLib.readRevision(deckDir) };
738
+ }
@@ -0,0 +1,96 @@
1
+ // A concept spec is the reader's inventory of ONE mockup: the bytes it was read
2
+ // from, not "slide N". `concepts` records their SHA-256 in the spec
3
+ // (`imageSha256`), and every consumer here takes a spec only while the mockup
4
+ // on disk still hashes to it.
5
+ //
6
+ // x112 GTM: a run that found 3 of 4 mockups on disk regenerated all four, then
7
+ // reused the three specs already on disk because their slide numbers and image
8
+ // intent matched. Slides 1–3 were transcribed from pictures that no longer
9
+ // existed, and the deck's concepts disagreed with its JSX (forms 0.55). Bound to
10
+ // the bytes, a replaced mockup has no spec until it is read again, and a spec
11
+ // with no binding (written before this contract) is simply read again.
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ import { listSpecSlides, readSpec } from "../builders/concept-spec.mjs";
17
+
18
+ const pad = (n) => String(n).padStart(2, "0");
19
+
20
+ export function mockupFileFor(deckDir, slideNumber) {
21
+ return path.join(deckDir, "concepts", `concept-slide-${pad(slideNumber)}.png`);
22
+ }
23
+
24
+ /** SHA-256 (hex) of a mockup's bytes, or null when there is no mockup. */
25
+ export function imageDigest(bytesOrFile) {
26
+ let bytes = bytesOrFile;
27
+ if (typeof bytesOrFile === "string") {
28
+ try {
29
+ bytes = fs.readFileSync(bytesOrFile);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+ return crypto.createHash("sha256").update(bytes).digest("hex");
35
+ }
36
+
37
+ /**
38
+ * Where slide N's spec stands against its mockup on disk:
39
+ * `current` (read from exactly these bytes), `stale` (read from other bytes),
40
+ * `unbound` (no binding recorded), `no-mockup` (a spec with no picture beside
41
+ * it) or `absent` (no spec).
42
+ */
43
+ export function specState(deckDir, slideNumber) {
44
+ const spec = readSpec(deckDir, slideNumber);
45
+ if (!spec) return { status: "absent", spec: null };
46
+ const digest = imageDigest(mockupFileFor(deckDir, slideNumber));
47
+ if (!digest) return { status: "no-mockup", spec };
48
+ if (typeof spec.imageSha256 !== "string" || !spec.imageSha256) return { status: "unbound", spec };
49
+ return { status: spec.imageSha256 === digest ? "current" : "stale", spec };
50
+ }
51
+
52
+ /** Slide N's spec while it describes the mockup on disk, else null. */
53
+ export function currentSpec(deckDir, slideNumber) {
54
+ const { status, spec } = specState(deckDir, slideNumber);
55
+ return status === "current" ? spec : null;
56
+ }
57
+
58
+ /** The slides whose spec is current, and those whose spec on disk is not. */
59
+ export function specSlidesByState(deckDir) {
60
+ const current = [];
61
+ const outdated = [];
62
+ for (const n of listSpecSlides(deckDir)) {
63
+ (specState(deckDir, n).status === "current" ? current : outdated).push(n);
64
+ }
65
+ return { current, outdated };
66
+ }
67
+
68
+ /**
69
+ * Remove slide N's spec — the mapped one, the reader's raw one and the legacy
70
+ * file next to the PNG. Called in the same step a mockup is replaced, so no
71
+ * reader of the deck can pair the new picture with the old inventory.
72
+ */
73
+ export function dropSpec(deckDir, slideNumber) {
74
+ const n = pad(slideNumber);
75
+ for (const f of [
76
+ path.join(deckDir, "state", "concept-specs", `concept-slide-${n}.json`),
77
+ path.join(deckDir, "state", "reader-specs", `concept-slide-${n}.json`),
78
+ path.join(deckDir, "concepts", `concept-slide-${n}.spec.json`),
79
+ ]) {
80
+ fs.rmSync(f, { force: true });
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Replace slide N's mockup atomically (temp file + rename in the same
86
+ * directory) and drop its spec in the same step.
87
+ */
88
+ export function replaceMockup(deckDir, slideNumber, bytes) {
89
+ const file = mockupFileFor(deckDir, slideNumber);
90
+ fs.mkdirSync(path.dirname(file), { recursive: true });
91
+ const tmp = `${file}.${process.pid}.tmp`;
92
+ fs.writeFileSync(tmp, bytes);
93
+ dropSpec(deckDir, slideNumber);
94
+ fs.renameSync(tmp, file);
95
+ return file;
96
+ }