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
@@ -0,0 +1,465 @@
1
+ // The deck's image registry: immutable bytes, durably stored, identified by
2
+ // their own digest.
3
+ //
4
+ // <workspace>/assets/local/<assetId>.<ext> the copied source, never rewritten
5
+ // <workspace>/state/image-assets.json {version: 1, assets: [ImageAsset]}
6
+ //
7
+ // An entry says what bytes ARE (digest, sniffed MIME, intrinsic size, where
8
+ // they came from). It says nothing about what they are FOR — that is an
9
+ // AssetUse in the plan (src/lib/image-intent.mjs). Importing an image creates
10
+ // no obligation to place it anywhere; that separation is the whole point of
11
+ // having two records.
12
+ //
13
+ // Three rules the implementation exists to keep:
14
+ //
15
+ // * **The bytes decide.** The MIME type and the dimensions are read from the
16
+ // header, never from the extension or a caller's claim — the same stance
17
+ // `extFromBytes` already takes for engine downloads, and for the same
18
+ // reason: a mislabelled asset renders wrong or not at all.
19
+ // * **Bytes first, manifest last.** The copy is written and its digest
20
+ // verified BEFORE the manifest is swapped in. A kill in between leaves an
21
+ // unreferenced file (harmless, nothing reads it) — never a committed entry
22
+ // pointing at bytes that are not there (design decision 8).
23
+ // * **Entries are immutable.** The same bytes keep the identity they were
24
+ // first given, so a use bound to an assetId can never come to mean a
25
+ // different picture. A replacement is a new import with a new ID.
26
+
27
+ import { createHash, randomBytes } from "node:crypto";
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+
31
+ import { extFromBytes } from "../bridge/engine-download.mjs";
32
+ import { imageSizeFromBuffer, svgSizeFromMarkup } from "../bridge/image-dimensions.mjs";
33
+ import { statePath } from "./deck-paths.mjs";
34
+ import { exifOrientation, imageRenderPlan } from "./image-derivatives.mjs";
35
+ import {
36
+ IMAGE_INTENT_VERSION,
37
+ SUPPORTED_IMAGE_MIME_TYPES,
38
+ parseImageAsset,
39
+ parseImageAssetManifest,
40
+ } from "./image-intent.mjs";
41
+
42
+ export const MANIFEST_FILE = "image-assets.json";
43
+ /** Workspace-relative, POSIX, and the only shape `workspacePath` may take. */
44
+ export const LOCAL_ASSET_DIR = "assets/local";
45
+
46
+ // The same ceiling the add-in's image pipeline enforces (sharp's default pixel
47
+ // limit, 0x3FFF²). A header declaring more is refused HERE, before any of it is
48
+ // copied — `test/fixtures/image-intent/oversized-header.png` is 69 bytes
49
+ // claiming 60000x60000.
50
+ export const MAX_IMAGE_PIXELS = 268402689;
51
+
52
+ // Sniffed extension → the MIME the contract speaks. bmp/tiff are listed on
53
+ // purpose: `extFromBytes` recognises them, and naming them produces "this
54
+ // pipeline cannot ship a BMP" instead of "unrecognised file".
55
+ const EXT_MIME = {
56
+ png: "image/png",
57
+ jpg: "image/jpeg",
58
+ gif: "image/gif",
59
+ webp: "image/webp",
60
+ svg: "image/svg+xml",
61
+ bmp: "image/bmp",
62
+ tiff: "image/tiff",
63
+ };
64
+
65
+ // The recovery line for each code this module raises. The KEYS are drawn from
66
+ // the contract's `IMAGE_INTENT_ISSUE_CODES` — this module declares no code of
67
+ // its own, and `test/image-assets.test.mjs` fails if one appears here that the
68
+ // shared vocabulary does not have.
69
+ export const IMAGE_ASSET_ACTIONS = {
70
+ image_asset_unsupported: "Supply a source in a supported image format (PNG, JPEG, GIF, WebP or SVG).",
71
+ image_asset_too_large: "Supply an appropriately sized source; the original is never silently downsampled.",
72
+ image_asset_missing: "Restore or re-import the source file, then rebind anything that referenced it.",
73
+ image_asset_changed: "Re-import the new bytes as their own asset and update the use through the plan.",
74
+ image_intent_invalid: "Correct the named field and re-record the entry.",
75
+ };
76
+
77
+ /** A typed failure: `err.code` for machines, `err.issue` for the contract's issue list. */
78
+ export function imageAssetError(code, message, extra = {}) {
79
+ const err = new Error(message);
80
+ err.code = code;
81
+ const action = IMAGE_ASSET_ACTIONS[code] ?? IMAGE_ASSET_ACTIONS.image_intent_invalid;
82
+ err.issue = { code, message, action, ...extra };
83
+ return err;
84
+ }
85
+
86
+ const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // inspection — the bytes decide
90
+ // ---------------------------------------------------------------------------
91
+
92
+ let crcTable = null;
93
+ function crc32(bytes) {
94
+ if (!crcTable) {
95
+ crcTable = new Int32Array(256);
96
+ for (let n = 0; n < 256; n++) {
97
+ let c = n;
98
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
99
+ crcTable[n] = c;
100
+ }
101
+ }
102
+ let c = -1;
103
+ for (let i = 0; i < bytes.length; i++) c = crcTable[(c ^ bytes[i]) & 0xff] ^ (c >>> 8);
104
+ return (c ^ -1) >>> 0;
105
+ }
106
+
107
+ // A PNG signature is eight bytes anyone can write; the IHDR's CRC is what says
108
+ // the header is really a header. Without this check a file whose "header" is
109
+ // the word "nonsense" registers happily and then renders as nothing anywhere.
110
+ function pngHeaderIntact(bytes) {
111
+ if (bytes.length < 33) return false;
112
+ if (bytes.readUInt32BE(8) !== 13 || bytes.toString("latin1", 12, 16) !== "IHDR") return false;
113
+ return crc32(bytes.subarray(12, 29)) === bytes.readUInt32BE(29);
114
+ }
115
+
116
+ // SVG has no magic number — it is XML — so it is identified the way the rest of
117
+ // this CLI identifies it: the markup has to actually open an <svg> element.
118
+ function svgMarkup(bytes) {
119
+ if (bytes.length > 4 * 1024 * 1024) return null; // a text scan has to stay bounded
120
+ const text = bytes.toString("utf8");
121
+ return /<svg[\s>]/i.test(text) ? text : null;
122
+ }
123
+
124
+ /**
125
+ * What these bytes are: `{mimeType, ext, width, height}`. The size is the one a
126
+ * viewer sees: a JPEG whose EXIF turns it a quarter turn reports its sides
127
+ * swapped (the add-in's registry reads the same), because crops, `<img>` boxes
128
+ * and the concept's image room are all expressed in it.
129
+ *
130
+ * Throws `image_asset_unsupported` for anything the pipeline cannot decode or
131
+ * ship, and `image_asset_too_large` for a header declaring more pixels than the
132
+ * renderer accepts. Pure — no filesystem, no manifest.
133
+ */
134
+ export function inspectImageBytes(bytes, { displayName = "the file" } = {}) {
135
+ const unsupported = (why) =>
136
+ imageAssetError("image_asset_unsupported", `${displayName}: ${why}`);
137
+
138
+ if (!bytes || bytes.length === 0) throw unsupported("it is empty");
139
+
140
+ const markup = svgMarkup(bytes);
141
+ const ext = extFromBytes(bytes) ?? (markup ? "svg" : null);
142
+ const mimeType = ext ? (EXT_MIME[ext] ?? null) : null;
143
+ if (!mimeType) {
144
+ throw unsupported("its bytes are not a recognisable image (they decode as no supported format)");
145
+ }
146
+ if (!SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType)) {
147
+ throw unsupported(
148
+ `${mimeType} is not one of the formats this pipeline can ship (${SUPPORTED_IMAGE_MIME_TYPES.join(", ")})`,
149
+ );
150
+ }
151
+ if (mimeType === "image/png" && !pngHeaderIntact(bytes)) {
152
+ throw unsupported("its PNG header is corrupt (the IHDR checksum does not match)");
153
+ }
154
+
155
+ const size = mimeType === "image/svg+xml" ? svgSizeFromMarkup(markup) : imageSizeFromBuffer(bytes);
156
+ if (!size) throw unsupported("its intrinsic dimensions could not be read from the header");
157
+
158
+ // An SVG states its size in user units, which a viewBox may write as
159
+ // fractions; the contract wants positive integers.
160
+ const turned = mimeType === "image/jpeg" && (exifOrientation(bytes) ?? 1) >= 5;
161
+ const width = Math.max(1, Math.round(turned ? size.height : size.width));
162
+ const height = Math.max(1, Math.round(turned ? size.width : size.height));
163
+ if (width * height > MAX_IMAGE_PIXELS) {
164
+ throw imageAssetError(
165
+ "image_asset_too_large",
166
+ `${displayName}: ${width}x${height} is past the ${MAX_IMAGE_PIXELS}-pixel limit the renderer accepts`,
167
+ );
168
+ }
169
+ return { mimeType, ext, width, height };
170
+ }
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // the manifest
174
+ // ---------------------------------------------------------------------------
175
+
176
+ export function manifestPath(workspaceDir) {
177
+ return statePath(workspaceDir, MANIFEST_FILE);
178
+ }
179
+
180
+ const EMPTY_MANIFEST = () => ({ version: IMAGE_INTENT_VERSION, assets: [] });
181
+
182
+ /**
183
+ * The deck's registry. An absent file is an empty registry; a present one that
184
+ * does not parse is an error, never an empty one — silently starting over
185
+ * would orphan every use already bound to it.
186
+ */
187
+ export function readImageAssetManifest(workspaceDir) {
188
+ const file = manifestPath(workspaceDir);
189
+ let raw;
190
+ try {
191
+ raw = fs.readFileSync(file, "utf8");
192
+ } catch (err) {
193
+ if (err?.code === "ENOENT") return EMPTY_MANIFEST();
194
+ throw imageAssetError("image_asset_missing", `${file} could not be read: ${err.message}`);
195
+ }
196
+ let parsed;
197
+ try {
198
+ parsed = JSON.parse(raw);
199
+ } catch (err) {
200
+ throw imageAssetError("image_intent_invalid", `${file} is not valid JSON: ${err.message}`);
201
+ }
202
+ const result = parseImageAssetManifest(parsed);
203
+ if (!result.value) {
204
+ const first = result.issues[0];
205
+ throw imageAssetError(first?.code ?? "image_intent_invalid", `${file}: ${first?.message ?? "invalid manifest"}`);
206
+ }
207
+ return result.value;
208
+ }
209
+
210
+ /** Absolute path of an entry's stored bytes, from its portable workspace path. */
211
+ export function assetFilePath(workspaceDir, asset) {
212
+ return path.join(workspaceDir, ...asset.workspacePath.split("/"));
213
+ }
214
+
215
+ export function findAssetById(manifest, assetId) {
216
+ return manifest.assets.find((a) => a.assetId === assetId) ?? null;
217
+ }
218
+
219
+ /**
220
+ * An entry plus its verified bytes.
221
+ *
222
+ * `image_asset_missing` when the file is gone, `image_asset_changed` when what
223
+ * is on disk no longer hashes to the registered digest — the two failures the
224
+ * contract's error table distinguishes, because their repairs differ.
225
+ */
226
+ export function resolveImageAsset(workspaceDir, assetId, { verify = true } = {}) {
227
+ const manifest = readImageAssetManifest(workspaceDir);
228
+ const asset = findAssetById(manifest, assetId);
229
+ if (!asset) {
230
+ throw imageAssetError("image_asset_missing", `no image asset ${assetId} in this deck`, { assetId });
231
+ }
232
+ const file = assetFilePath(workspaceDir, asset);
233
+ let bytes;
234
+ try {
235
+ bytes = fs.readFileSync(file);
236
+ } catch {
237
+ throw imageAssetError("image_asset_missing", `${asset.displayName}: ${asset.workspacePath} is missing`, {
238
+ assetId,
239
+ });
240
+ }
241
+ if (verify && sha256(bytes) !== asset.sha256) {
242
+ throw imageAssetError(
243
+ "image_asset_changed",
244
+ `${asset.displayName}: ${asset.workspacePath} no longer matches the digest it was registered with`,
245
+ { assetId },
246
+ );
247
+ }
248
+ return { asset, file, bytes };
249
+ }
250
+
251
+ function publishManifest(workspaceDir, manifest) {
252
+ const file = manifestPath(workspaceDir);
253
+ fs.mkdirSync(path.dirname(file), { recursive: true });
254
+ // Write-then-rename, the same swap `revision.mjs` uses for the accepted base:
255
+ // a reader sees the old registry or the new one, never half of either.
256
+ const tmp = `${file}.${process.pid}.tmp`;
257
+ fs.writeFileSync(tmp, JSON.stringify(manifest, null, 2) + "\n");
258
+ fs.renameSync(tmp, file);
259
+ return file;
260
+ }
261
+
262
+ // Copy bytes to their workspace path and prove the copy landed. A verification
263
+ // failure removes the half-written file: nothing has been committed yet, so the
264
+ // deck is left exactly as it was.
265
+ function writeAssetBytes(workspaceDir, asset, bytes) {
266
+ const file = assetFilePath(workspaceDir, asset);
267
+ fs.mkdirSync(path.dirname(file), { recursive: true });
268
+ const tmp = `${file}.${process.pid}.tmp`;
269
+ const fd = fs.openSync(tmp, "w");
270
+ try {
271
+ fs.writeFileSync(fd, bytes);
272
+ fs.fsyncSync(fd);
273
+ } finally {
274
+ fs.closeSync(fd);
275
+ }
276
+ fs.renameSync(tmp, file);
277
+ if (sha256(fs.readFileSync(file)) !== asset.sha256) {
278
+ fs.rmSync(file, { force: true });
279
+ throw imageAssetError(
280
+ "image_asset_changed",
281
+ `${asset.displayName}: the copy written to ${asset.workspacePath} did not match the source digest; nothing was recorded`,
282
+ { assetId: asset.assetId },
283
+ );
284
+ }
285
+ return file;
286
+ }
287
+
288
+ function mintAssetId(manifest) {
289
+ for (;;) {
290
+ const id = `asset_${randomBytes(8).toString("hex")}`;
291
+ if (!findAssetById(manifest, id)) return id;
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Adopt registry entries whose identity was minted ELSEWHERE — the only way an
297
+ * assetId this deck did not allocate may enter its manifest.
298
+ *
299
+ * It exists for one caller: re-adopting a .pptx that slidemind rendered, whose
300
+ * slides carry their own image provenance (src/lib/image-intent-tags.mjs). The
301
+ * uses baked into that file name their assets by ID, so re-minting one would
302
+ * leave every obligation dangling — the identity has to survive the round trip
303
+ * or nothing does.
304
+ *
305
+ * Every entry is proved before it is committed: the record parses against the
306
+ * contract, and the bytes already on disk at its `workspacePath` hash to the
307
+ * digest it claims. An assetId this deck already has is only accepted when it
308
+ * names the same bytes; a different digest behind a known ID is refused, never
309
+ * overwritten (entries are immutable). Refusals come back as `rejected`, not as
310
+ * a throw: a partial restore reports what it could not take.
311
+ *
312
+ * @returns {{added: string[], reused: string[], rejected: Array<{assetId, reason}>}}
313
+ */
314
+ export function registerImageAssets(workspaceDir, entries) {
315
+ const manifest = readImageAssetManifest(workspaceDir);
316
+ const byId = new Map(manifest.assets.map((a) => [a.assetId, a]));
317
+ const added = [];
318
+ const reused = [];
319
+ const rejected = [];
320
+ const fresh = [];
321
+ for (const raw of entries ?? []) {
322
+ const parsed = parseImageAsset(raw);
323
+ if (!parsed.value) {
324
+ rejected.push({
325
+ assetId: raw?.assetId ?? null,
326
+ reason: parsed.issues[0]?.message ?? "invalid registry entry",
327
+ });
328
+ continue;
329
+ }
330
+ const entry = parsed.value;
331
+ const existing = byId.get(entry.assetId);
332
+ if (existing) {
333
+ if (existing.sha256 === entry.sha256) reused.push(entry.assetId);
334
+ else {
335
+ rejected.push({
336
+ assetId: entry.assetId,
337
+ reason: `this deck already registers ${entry.assetId} for different bytes; a registry entry is never rewritten`,
338
+ });
339
+ }
340
+ continue;
341
+ }
342
+ let bytes;
343
+ try {
344
+ bytes = fs.readFileSync(assetFilePath(workspaceDir, entry));
345
+ } catch {
346
+ rejected.push({ assetId: entry.assetId, reason: `${entry.workspacePath} is not in the deck` });
347
+ continue;
348
+ }
349
+ if (sha256(bytes) !== entry.sha256) {
350
+ rejected.push({
351
+ assetId: entry.assetId,
352
+ reason: `${entry.workspacePath} does not hash to the digest the record claims`,
353
+ });
354
+ continue;
355
+ }
356
+ byId.set(entry.assetId, entry);
357
+ fresh.push(entry);
358
+ added.push(entry.assetId);
359
+ }
360
+ if (fresh.length) {
361
+ publishManifest(workspaceDir, {
362
+ version: IMAGE_INTENT_VERSION,
363
+ assets: [...manifest.assets, ...fresh],
364
+ });
365
+ }
366
+ return { added, reused, rejected };
367
+ }
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // import
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /**
374
+ * Register a file's bytes with this deck.
375
+ *
376
+ * Filesystem paths only — acquiring bytes (an engine download, the harness's
377
+ * attachment access, the user's own browser) happens before this call, and this
378
+ * command never fetches. It infers no intent either: the returned entry obliges
379
+ * nothing until a plan gives it a use.
380
+ *
381
+ * Deduplicates by digest WITHIN the deck: re-importing the same bytes returns
382
+ * the entry they already have (`reused: true`), which is what makes a repeated
383
+ * flag or a re-run idempotent.
384
+ *
385
+ * @param {string} workspaceDir the deck workspace
386
+ * @param {string} sourcePath a readable file
387
+ * @param {{displayName?: string, provenance?: {kind: string, sourceId?: string}, fault?: Function}} [opts]
388
+ * `fault` is the interruption seam: it runs after the bytes are on disk and
389
+ * before the manifest is swapped, so a test can prove that order holds.
390
+ * (Same idiom as `stageWorkspace().activate({fault})`.)
391
+ * @returns {Promise<{asset: object, reused: boolean, plan: object}>}
392
+ */
393
+ export async function importImageAsset(
394
+ workspaceDir,
395
+ sourcePath,
396
+ { displayName = null, provenance = { kind: "local" }, fault = null } = {},
397
+ ) {
398
+ const file = path.resolve(sourcePath);
399
+ let bytes;
400
+ try {
401
+ bytes = fs.readFileSync(file);
402
+ } catch (err) {
403
+ if (err?.code === "EISDIR") {
404
+ throw imageAssetError("image_asset_unsupported", `${file} is a directory, not an image`);
405
+ }
406
+ throw imageAssetError("image_asset_missing", `no file to import at ${file}`);
407
+ }
408
+
409
+ const name = String(displayName ?? path.basename(file)).trim().slice(0, 200) || path.basename(file);
410
+ // Inspection first: an undecodable source is refused before anything is
411
+ // copied, let alone committed.
412
+ const probe = inspectImageBytes(bytes, { displayName: name });
413
+ const digest = sha256(bytes);
414
+
415
+ // The deck's own lock, so two imports cannot both read the registry, append
416
+ // to it and write it back. Re-enters under an existing SLIDEMIND_OWNER token.
417
+ const { acquireOwner } = await import("./revision.mjs");
418
+ const held = acquireOwner(workspaceDir, { command: "assets import" });
419
+ try {
420
+ const manifest = readImageAssetManifest(workspaceDir);
421
+
422
+ const existing = manifest.assets.find((a) => a.sha256 === digest);
423
+ if (existing) {
424
+ // Immutable: the first identity stands, display name and all. Only the
425
+ // stored copy is restored, in case it was deleted since.
426
+ if (!fs.existsSync(assetFilePath(workspaceDir, existing))) {
427
+ writeAssetBytes(workspaceDir, existing, bytes);
428
+ }
429
+ return { asset: existing, reused: true, plan: imageRenderPlan(existing, bytes) };
430
+ }
431
+
432
+ const assetId = mintAssetId(manifest);
433
+ const entry = {
434
+ assetId,
435
+ sha256: digest,
436
+ mimeType: probe.mimeType,
437
+ width: probe.width,
438
+ height: probe.height,
439
+ byteLength: bytes.length,
440
+ displayName: name,
441
+ workspacePath: `${LOCAL_ASSET_DIR}/${assetId}.${probe.ext}`,
442
+ provenance: {
443
+ kind: provenance?.kind ?? "local",
444
+ ...(provenance?.sourceId ? { sourceId: String(provenance.sourceId) } : {}),
445
+ },
446
+ };
447
+ // Our own record goes through the contract's parser: one validator, so the
448
+ // registry can never hold something the plan side would reject.
449
+ const parsed = parseImageAsset(entry);
450
+ if (!parsed.value) {
451
+ const first = parsed.issues[0];
452
+ throw imageAssetError(first?.code ?? "image_intent_invalid", `${name}: ${first?.message ?? "invalid entry"}`);
453
+ }
454
+
455
+ writeAssetBytes(workspaceDir, parsed.value, bytes);
456
+ if (fault) fault(parsed.value);
457
+ publishManifest(workspaceDir, {
458
+ version: IMAGE_INTENT_VERSION,
459
+ assets: [...manifest.assets, parsed.value],
460
+ });
461
+ return { asset: parsed.value, reused: false, plan: imageRenderPlan(parsed.value, bytes) };
462
+ } finally {
463
+ held.release();
464
+ }
465
+ }