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
@@ -0,0 +1,531 @@
1
+ // `SLIDEMIND_CONTEXT_V1` — the portable context a .pptx carries about itself.
2
+ //
3
+ // A colleague opens a link on a machine that has never seen this deck. Nothing
4
+ // local can tell them what it is, which design it was built on, which business
5
+ // rules apply to it, or which external documents its content was approved
6
+ // against. The presentation-level tag is the only thing that travels WITH the
7
+ // file, so that is where those four facts live:
8
+ //
9
+ // { v, deckId, documentInstance, brand, intent, extensions, operationId }
10
+ //
11
+ // deckId LOGICAL identity — survives a copy, and is REMINTED when a
12
+ // copy becomes its own document (see the fork rule below).
13
+ // documentInstance PHYSICAL identity — provider:tenant:driveId:itemId of the
14
+ // item these bytes were published to. A cold open compares
15
+ // it with the item the user's own link resolved to; a
16
+ // mismatch means this is a copy, and a copy may not mutate
17
+ // the original's context.
18
+ // brand { presFP, etag } — which corporate design to materialize.
19
+ // intent bounded, human-scale purpose for an ORDINARY deck.
20
+ // extensions [{ id, protocolVersion, dataVersion, required, refs }] —
21
+ // which installed extension owns this deck's business rules
22
+ // and which exact artifacts its content was approved against.
23
+ // operationId the publish that wrote these bytes, so an unknown outcome
24
+ // can be resolved by reading the file back.
25
+ //
26
+ // Three rules this module enforces rather than documents:
27
+ //
28
+ // 1. **A ref is portable or it is not a ref.** Storage identity plus a content
29
+ // hash, never a local path and never a command. A deck that could name an
30
+ // executable would be a deck that could run one.
31
+ // 2. **A mutable ref pins an exact version or it is not paired.** An eTag is a
32
+ // precondition value, not a content address — it says "unchanged since",
33
+ // never "this content". A mutable artifact without a retrievable versionId
34
+ // needs an immutable snapshot ref beside it.
35
+ // 3. **Overflow is explicit.** The payload is capped; a deck that needs more
36
+ // context points at an immutable context ARTIFACT instead of being
37
+ // truncated into something that parses but means less than it says.
38
+
39
+ import { createHash } from "node:crypto";
40
+
41
+ import { readText } from "../pptx/zip.mjs";
42
+ import { relsFor, relsPathFor, resolvePartPath, decodeXmlEntities, TAG_ELEMENT, tagAttr, tagElements } from "../pptx/ooxml.mjs";
43
+
44
+ export const CONTEXT_VERSION = 1;
45
+ export const TAG_CONTEXT_META = "SLIDEMIND_CONTEXT_V1";
46
+ export const TAG_CONTEXT_CHUNK = "SLIDEMIND_CONTEXT_";
47
+
48
+ // What actually rides in the tag values, so this is the number PowerPoint sees.
49
+ // A deck past it publishes an immutable context artifact and points at it.
50
+ export const CONTEXT_MAX_ENCODED_BYTES = 32 * 1024;
51
+
52
+ // One tag value stays well under any host's attribute limit; the same size the
53
+ // source-tag encoder uses, for the same reason.
54
+ const CHUNK_BYTES = 12288;
55
+
56
+ const TAGS_REL_TYPE =
57
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/tags";
58
+ const TAGS_CONTENT_TYPE =
59
+ "application/vnd.openxmlformats-officedocument.presentationml.tags+xml";
60
+ const TAG_XML_NS =
61
+ 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" ' +
62
+ 'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" ' +
63
+ 'xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"';
64
+
65
+ const PRESENTATION_PART = "ppt/presentation.xml";
66
+ const enc = (s) => new TextEncoder().encode(s);
67
+ const sha256 = (data) =>
68
+ createHash("sha256")
69
+ .update(typeof data === "string" ? Buffer.from(data, "utf8") : Buffer.from(data))
70
+ .digest("hex");
71
+
72
+ function xmlEsc(s) {
73
+ return String(s).replace(/[<>&"']/g, (c) =>
74
+ c === "<" ? "&lt;" : c === ">" ? "&gt;" : c === "&" ? "&amp;" : c === '"' ? "&quot;" : "&apos;",
75
+ );
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // portable refs
80
+ // ---------------------------------------------------------------------------
81
+
82
+ export const MUTABILITY = ["immutable", "mutable"];
83
+
84
+ // Nothing outside this set may appear on a ref. The list is a WHITELIST rather
85
+ // than a blacklist of dangerous keys on purpose: the next dangerous key is the
86
+ // one nobody thought to ban.
87
+ const REF_KEYS = new Set([
88
+ "artifactId",
89
+ "purpose",
90
+ "required",
91
+ "mutability",
92
+ "provider",
93
+ "tenant",
94
+ "host",
95
+ "driveId",
96
+ "itemId",
97
+ "versionId",
98
+ "sha256",
99
+ "bytes",
100
+ "contentType",
101
+ "snapshotOf",
102
+ ]);
103
+
104
+ /**
105
+ * Why a ref cannot travel, or null.
106
+ *
107
+ * The three refusals that matter: a local path (it means nothing on the machine
108
+ * that opens the file), an executable/command key (a deck must never be able to
109
+ * say what to run), and a mutable ref with no exact version and no immutable
110
+ * snapshot beside it (it would pair the deck with "whatever that file says
111
+ * today", which is not what anyone approved).
112
+ */
113
+ export function refProblem(ref, { snapshots = new Set() } = {}) {
114
+ if (!ref || typeof ref !== "object") return "a ref must be an object";
115
+ for (const key of Object.keys(ref)) {
116
+ if (!REF_KEYS.has(key)) return `\`${key}\` is not a portable ref field`;
117
+ }
118
+ for (const required of ["artifactId", "purpose", "mutability", "sha256"]) {
119
+ if (!ref[required]) return `a ref needs ${required}`;
120
+ }
121
+ if (!MUTABILITY.includes(ref.mutability)) {
122
+ return `mutability must be immutable or mutable (got ${ref.mutability})`;
123
+ }
124
+ if (!/^[0-9a-f]{64}$/.test(String(ref.sha256))) return "sha256 must be a hex digest";
125
+ for (const key of ["provider", "tenant", "host", "driveId", "itemId", "versionId"]) {
126
+ const value = ref[key];
127
+ if (value === undefined || value === null) continue;
128
+ if (typeof value !== "string") return `${key} must be a string`;
129
+ // A Windows drive letter, a POSIX absolute path, a relative walk, or a
130
+ // file: URL — every shape a local path arrives in.
131
+ if (/^[a-zA-Z]:[\\/]/.test(value) || value.startsWith("/") || value.startsWith("\\")) {
132
+ return `${key} looks like a local path; portable refs carry storage identity only`;
133
+ }
134
+ if (value.includes("..")) return `${key} must not contain a path traversal`;
135
+ if (/^file:/i.test(value)) return `${key} must not be a file: URL`;
136
+ }
137
+ if (ref.mutability === "mutable" && !ref.versionId && !snapshots.has(ref.artifactId)) {
138
+ return (
139
+ "a mutable ref must pin an exact versionId, or be paired with an immutable " +
140
+ "snapshot ref whose snapshotOf names it"
141
+ );
142
+ }
143
+ return null;
144
+ }
145
+
146
+ /** Validate a whole ref list together — snapshots are resolved across it. */
147
+ export function validateRefs(refs) {
148
+ const list = Array.isArray(refs) ? refs : [];
149
+ const snapshots = new Set(
150
+ list.filter((r) => r?.mutability === "immutable" && r?.snapshotOf).map((r) => r.snapshotOf),
151
+ );
152
+ const errors = [];
153
+ const seen = new Set();
154
+ for (const ref of list) {
155
+ const why = refProblem(ref, { snapshots });
156
+ if (why) errors.push(`${ref?.artifactId ?? "(unnamed)"}: ${why}`);
157
+ if (ref?.artifactId) {
158
+ if (seen.has(ref.artifactId)) errors.push(`${ref.artifactId}: declared twice`);
159
+ seen.add(ref.artifactId);
160
+ }
161
+ }
162
+ return errors;
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // the context record
167
+ // ---------------------------------------------------------------------------
168
+
169
+ // Ordinary-deck intent is BOUNDED: it is a handful of human sentences about who
170
+ // the deck is for and what it has to do, not a place to park a plan. The caps
171
+ // are what keep the tag inside its budget without truncating anything silently —
172
+ // over the cap is an error the caller resolves, never a quiet trim.
173
+ const INTENT_FIELDS = {
174
+ audience: { max: 400 },
175
+ objective: { max: 400 },
176
+ constraints: { max: 1200, list: true },
177
+ decisions: { max: 1200, list: true },
178
+ roles: { max: 400, list: true },
179
+ };
180
+
181
+ export function intentProblems(intent) {
182
+ if (intent === null || intent === undefined) return [];
183
+ if (typeof intent !== "object" || Array.isArray(intent)) return ["intent must be an object"];
184
+ const errors = [];
185
+ for (const [key, value] of Object.entries(intent)) {
186
+ const spec = INTENT_FIELDS[key];
187
+ if (!spec) {
188
+ errors.push(`\`${key}\` is not a bounded intent field (${Object.keys(INTENT_FIELDS).join(", ")})`);
189
+ continue;
190
+ }
191
+ const text = spec.list
192
+ ? (Array.isArray(value) ? value : [value]).join("\n")
193
+ : String(value ?? "");
194
+ if (spec.list && !Array.isArray(value)) errors.push(`${key} must be an array`);
195
+ if (Buffer.byteLength(text, "utf8") > spec.max) {
196
+ errors.push(`${key} is longer than ${spec.max} bytes — put it in a context artifact instead`);
197
+ }
198
+ }
199
+ return errors;
200
+ }
201
+
202
+ /**
203
+ * Check a whole context record. Returns the list of problems; empty means it may
204
+ * be encoded. Nothing here mutates or trims — a context that does not fit is a
205
+ * decision for the caller (publish an overflow artifact), not a silent edit.
206
+ */
207
+ export function contextProblems(context) {
208
+ const errors = [];
209
+ if (!context || typeof context !== "object") return ["context must be an object"];
210
+ if (context.v !== CONTEXT_VERSION) errors.push(`v must be ${CONTEXT_VERSION}`);
211
+ if (!context.deckId) errors.push("deckId is required");
212
+ errors.push(...intentProblems(context.intent));
213
+ for (const ext of context.extensions ?? []) {
214
+ if (!ext?.id) {
215
+ errors.push("an extension declaration needs an id");
216
+ continue;
217
+ }
218
+ if (ext.protocolVersion !== 1) {
219
+ errors.push(`${ext.id}: protocolVersion must be 1 in this schema`);
220
+ }
221
+ if (ext.command || ext.exec || ext.path) {
222
+ // The one rule a deck must never be able to bend: metadata SELECTS an
223
+ // installed extension by id, it never supplies what to run.
224
+ errors.push(`${ext.id}: a context may name an extension, never a command to run`);
225
+ }
226
+ errors.push(...validateRefs(ext.refs).map((e) => `${ext.id}: ${e}`));
227
+ }
228
+ if (context.overflowRef) {
229
+ errors.push(...validateRefs([context.overflowRef]).map((e) => `overflowRef: ${e}`));
230
+ }
231
+ return errors;
232
+ }
233
+
234
+ /** The canonical JSON a digest is taken over — key order must not change a digest. */
235
+ export function canonicalContext(context) {
236
+ const sortKeys = (value) => {
237
+ if (Array.isArray(value)) return value.map(sortKeys);
238
+ if (value && typeof value === "object") {
239
+ return Object.fromEntries(
240
+ Object.keys(value)
241
+ .sort()
242
+ .map((k) => [k, sortKeys(value[k])]),
243
+ );
244
+ }
245
+ return value;
246
+ };
247
+ return JSON.stringify(sortKeys(context));
248
+ }
249
+
250
+ /** The digest an extension's evidence binds to. */
251
+ export function contextDigest(context) {
252
+ return sha256(canonicalContext(context));
253
+ }
254
+
255
+ /**
256
+ * Encode a context into its tag map, or throw with the reason.
257
+ *
258
+ * `context_too_large` is thrown rather than truncated: the caller's answer is an
259
+ * immutable context artifact plus an `overflowRef`, and a half-written intent
260
+ * that still parses is exactly the silent loss this whole change exists to stop.
261
+ */
262
+ export function encodeContextTags(context) {
263
+ const problems = contextProblems(context);
264
+ if (problems.length) {
265
+ const e = new Error(`this presentation context cannot be written:\n ${problems.join("\n ")}`);
266
+ e.code = "context_invalid";
267
+ e.problems = problems;
268
+ throw e;
269
+ }
270
+ const json = canonicalContext(context);
271
+ const payload = Buffer.from(json, "utf8").toString("base64");
272
+ if (Buffer.byteLength(payload, "utf8") > CONTEXT_MAX_ENCODED_BYTES) {
273
+ const e = new Error(
274
+ `this presentation context encodes to ${Buffer.byteLength(payload, "utf8")} bytes, over the ` +
275
+ `${CONTEXT_MAX_ENCODED_BYTES}-byte tag budget. Publish the detail as an immutable context ` +
276
+ `artifact and point at it with overflowRef.`,
277
+ );
278
+ e.code = "context_too_large";
279
+ e.encodedBytes = Buffer.byteLength(payload, "utf8");
280
+ throw e;
281
+ }
282
+ const chunks = [];
283
+ for (let i = 0; i < payload.length; i += CHUNK_BYTES) {
284
+ chunks.push(payload.slice(i, i + CHUNK_BYTES));
285
+ }
286
+ const tags = {
287
+ [TAG_CONTEXT_META]: JSON.stringify({
288
+ v: CONTEXT_VERSION,
289
+ chunks: chunks.length,
290
+ sha256: sha256(json),
291
+ bytes: Buffer.byteLength(json, "utf8"),
292
+ }),
293
+ };
294
+ chunks.forEach((c, i) => {
295
+ tags[`${TAG_CONTEXT_CHUNK}${i}`] = c;
296
+ });
297
+ return tags;
298
+ }
299
+
300
+ /**
301
+ * Decode a context out of a tag map.
302
+ *
303
+ * Returns `{ context, digest }`, or null when there is no context at all, or
304
+ * `{ corrupt: <why> }` when there is one and it does not hold together. The
305
+ * three answers are deliberately distinct: "no context" is a normal ordinary
306
+ * deck, "corrupt context" is a deck whose business gates cannot be assumed gone.
307
+ */
308
+ export function decodeContextTags(tags) {
309
+ const raw = tags?.[TAG_CONTEXT_META];
310
+ if (!raw) return null;
311
+ let meta;
312
+ try {
313
+ meta = JSON.parse(raw);
314
+ } catch {
315
+ return { corrupt: "the context header is not readable JSON" };
316
+ }
317
+ if (meta?.v !== CONTEXT_VERSION) {
318
+ return { corrupt: `context schema v${meta?.v} is not readable by this CLI (expected v${CONTEXT_VERSION})` };
319
+ }
320
+ const parts = [];
321
+ for (let i = 0; i < Number(meta.chunks ?? 0); i++) {
322
+ const part = tags[`${TAG_CONTEXT_CHUNK}${i}`];
323
+ if (part === undefined) return { corrupt: `context chunk ${i} is missing` };
324
+ parts.push(part);
325
+ }
326
+ let json;
327
+ try {
328
+ json = Buffer.from(parts.join(""), "base64").toString("utf8");
329
+ } catch {
330
+ return { corrupt: "the context payload is not decodable" };
331
+ }
332
+ if (sha256(json) !== meta.sha256) {
333
+ return { corrupt: "the context payload does not match its recorded digest" };
334
+ }
335
+ let context;
336
+ try {
337
+ context = JSON.parse(json);
338
+ } catch {
339
+ return { corrupt: "the context payload is not readable JSON" };
340
+ }
341
+ const problems = contextProblems(context);
342
+ if (problems.length) return { corrupt: problems.join("; "), context };
343
+ return { context, digest: sha256(json) };
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // package side
348
+ // ---------------------------------------------------------------------------
349
+
350
+ /** Every tag reachable from `ppt/presentation.xml`'s own custDataLst. */
351
+ export function readPresentationTags(files) {
352
+ const xml = readText(files, PRESENTATION_PART);
353
+ if (!xml) return {};
354
+ const tagRels = {};
355
+ for (const rel of relsFor(files, PRESENTATION_PART)) {
356
+ if (rel.id && rel.type === TAGS_REL_TYPE) {
357
+ tagRels[rel.id] = resolvePartPath("ppt", rel.target);
358
+ }
359
+ }
360
+ const out = {};
361
+ for (const cust of xml.matchAll(/<p:custDataLst\b[\s\S]*?<\/p:custDataLst>/g)) {
362
+ for (const ref of cust[0].matchAll(/<p:tags\b[^>]*\br:id="([^"]+)"/g)) {
363
+ const partPath = tagRels[ref[1]];
364
+ const tagXml = partPath ? readText(files, partPath) : null;
365
+ if (!tagXml) continue;
366
+ for (const t of tagElements(tagXml)) {
367
+ const name = tagAttr(t[1], "name");
368
+ const val = tagAttr(t[1], "val");
369
+ if (name) out[decodeXmlEntities(name)] = decodeXmlEntities(val ?? "");
370
+ }
371
+ }
372
+ }
373
+ return out;
374
+ }
375
+
376
+ /** The context a package carries, in the three-answer shape of {@link decodeContextTags}. */
377
+ export function readPresentationContext(files) {
378
+ return decodeContextTags(readPresentationTags(files));
379
+ }
380
+
381
+ function nextTagIndex(files) {
382
+ let max = 0;
383
+ for (const name of Object.keys(files)) {
384
+ const m = name.match(/^ppt\/tags\/tag(\d+)\.xml$/);
385
+ if (m) max = Math.max(max, Number(m[1]));
386
+ }
387
+ return max + 1;
388
+ }
389
+
390
+ function nextRid(relsXml) {
391
+ let max = 0;
392
+ for (const m of relsXml.matchAll(/\bId="rId(\d+)"/g)) max = Math.max(max, Number(m[1]));
393
+ return max + 1;
394
+ }
395
+
396
+ // Drop the tag part this module previously wrote, with its relationship, its
397
+ // content-type override and its custDataLst reference. Republishing without
398
+ // this would stack a second context on every save, and the reader would join
399
+ // two headers into one unreadable payload.
400
+ function removeExistingContext(files) {
401
+ const xml = readText(files, PRESENTATION_PART);
402
+ if (!xml) return { xml: null, changed: false };
403
+ const relsPath = relsPathFor(PRESENTATION_PART);
404
+ let relsXml = readText(files, relsPath);
405
+ if (!relsXml) return { xml, changed: false };
406
+ let nextXml = xml;
407
+ let changed = false;
408
+ for (const rel of relsFor(files, PRESENTATION_PART)) {
409
+ if (rel.type !== TAGS_REL_TYPE || !rel.id) continue;
410
+ const partPath = resolvePartPath("ppt", rel.target);
411
+ const tagXml = readText(files, partPath);
412
+ if (!tagXml || ![...tagElements(tagXml)].some((t) => tagAttr(t[1], "name") === TAG_CONTEXT_META)) continue;
413
+ // A part that also holds another add-in's tags is shared: strip only ours
414
+ // and keep the part, its relationship and its reference in place.
415
+ const withoutOurs = tagXml.replace(new RegExp(TAG_ELEMENT, "g"), (tag) => (/\bname=["']SLIDEMIND_CONTEXT/.test(tag) ? "" : tag));
416
+ if (/<p:tag\b/.test(withoutOurs)) {
417
+ files[partPath] = enc(withoutOurs);
418
+ changed = true;
419
+ continue;
420
+ }
421
+ delete files[partPath];
422
+ relsXml = relsXml.replace(
423
+ new RegExp(`<Relationship\\b[^>]*\\bId="${rel.id}"[^>]*/>`, "g"),
424
+ "",
425
+ );
426
+ nextXml = nextXml.replace(
427
+ new RegExp(`<p:tags\\b[^>]*\\br:id="${rel.id}"\\s*/>`, "g"),
428
+ "",
429
+ );
430
+ const ct = readText(files, "[Content_Types].xml");
431
+ if (ct) {
432
+ files["[Content_Types].xml"] = enc(
433
+ ct.replace(new RegExp(`<Override\\b[^>]*PartName="/${partPath}"[^>]*/>`, "g"), ""),
434
+ );
435
+ }
436
+ changed = true;
437
+ }
438
+ if (changed) {
439
+ // An emptied list is removed rather than left behind: PowerPoint accepts an
440
+ // empty p:custDataLst, but leaving one on every save is litter nobody reads.
441
+ nextXml = nextXml.replace(/<p:custDataLst>\s*<\/p:custDataLst>/g, "");
442
+ files[relsPath] = enc(relsXml);
443
+ }
444
+ return { xml: nextXml, changed };
445
+ }
446
+
447
+ // CT_Presentation fixes the child order, and PowerPoint repairs a file that
448
+ // breaks it. custDataLst sits after photoAlbum and before kinsoku — so the
449
+ // insertion point is the first of the four elements that may follow it.
450
+ const AFTER_CUST_DATA = ["p:kinsoku", "p:defaultTextStyle", "p:modifyVerifier", "p:extLst"];
451
+
452
+ /**
453
+ * Write a context into a package IN PLACE, replacing any context this module
454
+ * wrote before. Additive to everything else: another add-in's presentation tags,
455
+ * the slide-level source tags and every other part are untouched.
456
+ */
457
+ export function bakePresentationContext(files, context) {
458
+ const tags = encodeContextTags(context);
459
+ const { xml: stripped } = removeExistingContext(files);
460
+ const xml = stripped ?? readText(files, PRESENTATION_PART);
461
+ if (!xml) return false;
462
+
463
+ // CT_CustomerDataList allows ONE p:tags. A presentation that already carries
464
+ // one (a pack's master may bring think-cell's) gets our tags merged into
465
+ // that part; a second p:tags element is a PowerPoint repair prompt
466
+ // (observed 2026-09-17: PowerPoint kept the other add-in's and dropped ours).
467
+ const shared = xml.match(/<p:custDataLst\b[^>]*>[\s\S]*?<p:tags\b[^>]*\br:id="([^"]+)"/);
468
+ if (shared) {
469
+ const rel = relsFor(files, PRESENTATION_PART).find((r) => r.id === shared[1] && r.type === TAGS_REL_TYPE);
470
+ const partPath = rel ? resolvePartPath("ppt", rel.target) : null;
471
+ const tagXml = partPath ? readText(files, partPath) : null;
472
+ if (tagXml && tagXml.includes("</p:tagLst>")) {
473
+ const ours = Object.entries(tags)
474
+ .map(([name, val]) => `<p:tag name="${xmlEsc(name)}" val="${xmlEsc(val)}"/>`)
475
+ .join("");
476
+ files[partPath] = enc(tagXml.replace("</p:tagLst>", `${ours}</p:tagLst>`));
477
+ files[PRESENTATION_PART] = enc(xml);
478
+ return true;
479
+ }
480
+ }
481
+
482
+ const tagIndex = nextTagIndex(files);
483
+ const partName = `ppt/tags/tag${tagIndex}.xml`;
484
+ files[partName] = enc(
485
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n<p:tagLst ${TAG_XML_NS}>` +
486
+ Object.entries(tags)
487
+ .map(([name, val]) => `<p:tag name="${xmlEsc(name)}" val="${xmlEsc(val)}"/>`)
488
+ .join("") +
489
+ `</p:tagLst>`,
490
+ );
491
+
492
+ const contentTypes = readText(files, "[Content_Types].xml") ?? "";
493
+ if (contentTypes && !contentTypes.includes(`PartName="/${partName}"`)) {
494
+ files["[Content_Types].xml"] = enc(
495
+ contentTypes.replace(
496
+ "</Types>",
497
+ `<Override PartName="/${partName}" ContentType="${TAGS_CONTENT_TYPE}"/></Types>`,
498
+ ),
499
+ );
500
+ }
501
+
502
+ const relsPath = relsPathFor(PRESENTATION_PART);
503
+ const relsXml =
504
+ readText(files, relsPath) ??
505
+ `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\r\n<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>`;
506
+ const rId = `rId${nextRid(relsXml)}`;
507
+ files[relsPath] = enc(
508
+ relsXml.replace(
509
+ "</Relationships>",
510
+ `<Relationship Id="${rId}" Type="${TAGS_REL_TYPE}" Target="tags/tag${tagIndex}.xml"/></Relationships>`,
511
+ ),
512
+ );
513
+
514
+ const existing = xml.match(/<p:custDataLst\b[^>]*?(\/?)>/);
515
+ let nextXml;
516
+ if (existing && existing[1] === "/") {
517
+ nextXml = xml.replace(
518
+ existing[0],
519
+ `${existing[0].replace(/\/>$/, ">")}<p:tags r:id="${rId}"/></p:custDataLst>`,
520
+ );
521
+ } else if (existing) {
522
+ nextXml = xml.replace(existing[0], `${existing[0]}<p:tags r:id="${rId}"/>`);
523
+ } else {
524
+ const block = `<p:custDataLst><p:tags r:id="${rId}"/></p:custDataLst>`;
525
+ const anchor = AFTER_CUST_DATA.map((tag) => xml.indexOf(`<${tag}`)).filter((i) => i >= 0);
526
+ const at = anchor.length ? Math.min(...anchor) : xml.lastIndexOf("</p:presentation>");
527
+ nextXml = at >= 0 ? xml.slice(0, at) + block + xml.slice(at) : xml;
528
+ }
529
+ files[PRESENTATION_PART] = enc(nextXml);
530
+ return true;
531
+ }
@@ -0,0 +1,157 @@
1
+ // Preservation provenance through the deck-embedded source tags — what a slide
2
+ // must still contain, baked into the .pptx it was rendered into and read back
3
+ // out when that file is adopted again (openspec `safeguard-slide-editing`,
4
+ // task 5.7 / E19–E20).
5
+ //
6
+ // Why it rides the slide's own tags rather than only `state/`: the .pptx is the
7
+ // only thing that travels. A rendered deck is mailed, opened, saved and handed
8
+ // back, and `slidemind adopt` rebuilds a workspace from it alone. The slide's
9
+ // jsx, assets, identity, drift baseline and image obligations all survive that
10
+ // round trip already (src/lib/image-intent-tags.mjs is the same shape, one
11
+ // contract over). An obligation that did not would come back as a slide nobody
12
+ // could prove had kept its connector, its native object or its chart.
13
+ //
14
+ // Two halves, both narrow:
15
+ //
16
+ // BAKE `preservationTagMeta` returns the slide's entry out of
17
+ // `state/slide-preservation.json`, keyed by the slide's own GUID.
18
+ // RESTORE `restorePreservationFromTags` puts entries back for the slides
19
+ // adopt restored from their own proved-current source.
20
+ //
21
+ // Four rules the restore exists to keep — the deletion ones are the subtle
22
+ // half, and they run in both directions:
23
+ //
24
+ // * **A file with no `preservation` carries no obligations.** A plain
25
+ // imported .pptx, or one rendered before this contract, is legacy and stays
26
+ // legacy; `migrateEditState` seeds it from CURRENT evidence instead. An
27
+ // obligation is never manufactured from a shape that happens to be there.
28
+ // * **Only a slide restored from its own proved-current source restores.**
29
+ // A slide that had to be re-converted is rebuilt content — adopt itself
30
+ // judged the embedded payload stale (`sourceMatchesSlide`) — so the element
31
+ // the record names may be the one the user just deleted in PowerPoint.
32
+ // A stale-but-valid payload never wins over current truth, here as
33
+ // everywhere else in adoption.
34
+ // * **An entry already on disk is never overwritten.** The workspace's record
35
+ // is the current statement of what is owed; the file's is history. That is
36
+ // what stops an OLDER .pptx from resurrecting a requirement this deck
37
+ // superseded — and what makes the restore idempotent, so a resumed or
38
+ // repeated adopt re-seeds nothing.
39
+ // * **`superseded` travels with `active`.** An intentional deletion is part
40
+ // of the record, so a deck re-adopted into a FRESH workspace comes back
41
+ // with the obligation still ended rather than silently owed again.
42
+ //
43
+ // Evidence is deliberately NOT carried across: a re-adoption is a new base, and
44
+ // a workspace that has verified nothing must say `unknown` rather than inherit
45
+ // someone else's `passed` (guide §5 — missing evidence is distinct from a
46
+ // passed check, and any intervening change invalidates a verdict).
47
+
48
+ import {
49
+ SLIDE_PRESERVATION_VERSION,
50
+ validateSlidePreservation,
51
+ } from "./slide-preservation.mjs";
52
+ import { readSlidePreservation, writeSlidePreservation } from "./edit-state.mjs";
53
+
54
+ export const PRESERVATION_META_KEY = "preservation";
55
+
56
+ // ponytail: a flat size cap, not a chunked channel. SRC_META is one tag value
57
+ // and already carries the asset list; the source and the drift baseline get
58
+ // their own chunk streams (CHUNK_BYTES = 12288) precisely because they can be
59
+ // large. An entry is a few hundred bytes per requirement, so this only bites on
60
+ // a slide with ~50 of them — and the upgrade path is the same chunking the
61
+ // baseline uses (TAG_BASELINE_CHUNK), not a bigger number here.
62
+ const META_BUDGET = 8192;
63
+
64
+ /**
65
+ * The deck's preservation document for a whole bake, or null when it cannot be
66
+ * read. Read once and handed to {@link preservationTagMeta} per slide: the bake
67
+ * already avoids re-reading per-slide state, and an unreadable or future-schema
68
+ * record must not be the thing that fails a render — the commands that actually
69
+ * need it refuse on their own.
70
+ */
71
+ export function preservationDocForBake(deckDir) {
72
+ try {
73
+ return readSlidePreservation(deckDir);
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * The `SRC_META.preservation` payload for one slide, or null when the slide has
81
+ * no record — which keeps its tags byte-identical to what they were before this
82
+ * contract existed.
83
+ *
84
+ * @param {string} deckDir the deck workspace
85
+ * @param {string} slideKey the slide's durable identity (its GUID)
86
+ * @param {{ preservation?: object }} [opts] a pre-read document, so a whole-deck
87
+ * bake reads `state/slide-preservation.json` once instead of once per slide
88
+ */
89
+ export function preservationTagMeta(deckDir, slideKey, { preservation = null } = {}) {
90
+ if (!slideKey) return null;
91
+ const doc = preservation ?? preservationDocForBake(deckDir);
92
+ const entry = doc?.slides?.[slideKey];
93
+ if (!entry) return null;
94
+ if (JSON.stringify(entry).length > META_BUDGET) return null;
95
+ return entry;
96
+ }
97
+
98
+ /**
99
+ * Restore the obligations a set of adopted slides carried.
100
+ *
101
+ * @param {string} deckDir the deck being adopted into
102
+ * @param {Array<{slideKey: string|null, entry: object|null}>} entries one per
103
+ * slide adopted from its own proved-current source. `slideKey` is the GUID
104
+ * this adoption assigned, which is NOT the embedded one when the slide turned
105
+ * out to be a duplicate — the record follows the identity the slide has here.
106
+ * @returns {{slides: number, notes: string[]}}
107
+ */
108
+ export function restorePreservationFromTags(deckDir, entries) {
109
+ const notes = [];
110
+ const wanted = (entries ?? []).filter((e) => e?.entry && e.slideKey);
111
+ if (!wanted.length) return { slides: 0, notes };
112
+
113
+ let previous;
114
+ try {
115
+ previous = readSlidePreservation(deckDir);
116
+ } catch (err) {
117
+ notes.push(`recorded obligations were not restored: ${err?.message ?? err}`);
118
+ return { slides: 0, notes };
119
+ }
120
+
121
+ const slides = { ...previous.slides };
122
+ let restored = 0;
123
+ for (const { slideKey, entry } of wanted) {
124
+ if (slides[slideKey]) continue; // never overwrite a live record with history
125
+ if (entry.version !== SLIDE_PRESERVATION_VERSION) {
126
+ notes.push(
127
+ `a slide records preservation version ${JSON.stringify(entry.version ?? null)} — ` +
128
+ `this CLI reads version ${SLIDE_PRESERVATION_VERSION}; its obligations were not restored`,
129
+ );
130
+ continue;
131
+ }
132
+ // Bound to the identity this deck gave the slide, and to no verdict: the
133
+ // requirements come back, the claim that they were met does not.
134
+ const rebound = { ...entry, slideKey, evidence: "unknown" };
135
+ // The payload came out of an untrusted file — shape-check before it becomes
136
+ // this deck's statement of what is owed.
137
+ const { ok, errors } = validateSlidePreservation(rebound, { slideKey });
138
+ if (!ok) {
139
+ notes.push(
140
+ `slide ${slideKey}: its recorded obligations are not a valid preservation record ` +
141
+ `(${errors[0]?.path}: ${errors[0]?.message}) — they were not restored`,
142
+ );
143
+ continue;
144
+ }
145
+ slides[slideKey] = rebound;
146
+ restored += 1;
147
+ }
148
+ if (restored === 0) return { slides: 0, notes };
149
+
150
+ try {
151
+ writeSlidePreservation(deckDir, { version: SLIDE_PRESERVATION_VERSION, slides });
152
+ } catch (err) {
153
+ notes.push(`recorded obligations were not restored: ${err?.message ?? err}`);
154
+ return { slides: 0, notes };
155
+ }
156
+ return { slides: restored, notes };
157
+ }