slidemind 0.3.13 → 0.3.14

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 (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/AGENTS.md +86 -21
  4. package/README.md +14 -17
  5. package/bin/slidemind.mjs +65 -4969
  6. package/package.json +1 -1
  7. package/skills/create-slides/SKILL.md +113 -74
  8. package/skills/search/SKILL.md +25 -7
  9. package/src/bridge/build-and-push.mjs +108 -15
  10. package/src/bridge/bundle.mjs +69 -0
  11. package/src/bridge/capture.mjs +19 -0
  12. package/src/bridge/client/brands.mjs +622 -0
  13. package/src/bridge/client/engine-routing.mjs +237 -0
  14. package/src/bridge/client/engine-search.mjs +310 -0
  15. package/src/bridge/client/engine-slides.mjs +435 -0
  16. package/src/bridge/client/pane-ops.mjs +335 -0
  17. package/src/bridge/client/presentations.mjs +587 -0
  18. package/src/bridge/client/render.mjs +317 -0
  19. package/src/bridge/client/transport.mjs +695 -0
  20. package/src/bridge/client.mjs +80 -3452
  21. package/src/bridge/config.mjs +1 -1
  22. package/src/bridge/engine-download.mjs +15 -2
  23. package/src/builders/build.mjs +11 -804
  24. package/src/builders/draft-check.mjs +376 -0
  25. package/src/builders/lint.mjs +38 -2
  26. package/src/builders/preview.html +799 -0
  27. package/src/builders/review.mjs +23 -4
  28. package/src/builders/visual-families.mjs +6 -8
  29. package/src/cli/common.mjs +179 -0
  30. package/src/cli/concepts.mjs +310 -0
  31. package/src/cli/connection.mjs +147 -0
  32. package/src/cli/deck.mjs +608 -0
  33. package/src/cli/designs.mjs +113 -0
  34. package/src/cli/diagnose.mjs +141 -0
  35. package/src/cli/download-bundle.mjs +144 -0
  36. package/src/cli/engine-helpers.mjs +429 -0
  37. package/src/cli/engine-results.mjs +320 -0
  38. package/src/cli/help.mjs +201 -0
  39. package/src/cli/install.mjs +257 -0
  40. package/src/cli/read.mjs +120 -0
  41. package/src/cli/ready.mjs +805 -0
  42. package/src/cli/search.mjs +608 -0
  43. package/src/cli/session.mjs +177 -0
  44. package/src/cli/setup.mjs +282 -0
  45. package/src/cli/slide-reuse.mjs +334 -0
  46. package/src/cli/status.mjs +118 -0
  47. package/src/cli/workspace.mjs +302 -0
  48. package/src/installers/codex.mjs +1 -1
  49. package/src/update-check.mjs +231 -0
@@ -0,0 +1,376 @@
1
+ // Draft-check mechanics — the CLI-owned half of `slidemind view-draft`.
2
+ //
3
+ // The measured rules (fill bands, type scale, text overlap) are NOT here: they
4
+ // live in the add-in and reach the CLI as the capture engine's `draftChecks`
5
+ // export (`capture.mjs` → `loadDraftChecks`), so in-app see_draft and
6
+ // view-draft run one rule set with one wording. What stays CLI-side is what
7
+ // only exists in the CLI's world: the asset-plan.md inventory form, the
8
+ // .draft-preview record and the gates it arms, and the 422 translate parser.
9
+ // Images are never overlaid: a magenta proportion grid on the concept and the
10
+ // render dominated both and hid whether the slide was built well.
11
+
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+
16
+ export const ASSET_PLAN_FILE = "asset-plan.md";
17
+ export const DRAFT_CHECK_FILE = path.join(".draft-preview", "draft-check.json");
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Inventory — asset-plan.md as a form the build reads (port of inventory.ts).
21
+ // ---------------------------------------------------------------------------
22
+
23
+ const SLIDE_HEADING_RE = /^## Slide (\d+)\b/;
24
+ const SLIDE_FILE_NUMBER_RE = /^(\d{1,3})[-_.]/;
25
+ const SECTION_RE = /^(regions|organisms|connectors|icons):/;
26
+ const FETCHED_ASSET_RE = /^(engine|gen)-/;
27
+ // "none", "(none)", "n/a", "no connectors …", "none authorable …" — an
28
+ // explicit empty row, never a listed organism/connector.
29
+ const NONE_RE = /^\(?\s*(none|n\/a|no\b)/i;
30
+
31
+ const pad2 = (n) => String(n).padStart(2, "0");
32
+
33
+ /** Seed the inventory form once (never overwrites a filled one). `images`: [{slideNumber,label}]. */
34
+ export function seedAssetPlan(deckDir, images) {
35
+ const file = path.join(deckDir, ASSET_PLAN_FILE);
36
+ if (!images?.length || fs.existsSync(file)) return false;
37
+ const slides = [...images]
38
+ .sort((a, b) => a.slideNumber - b.slideNumber)
39
+ .map((img) =>
40
+ [
41
+ `## Slide ${pad2(img.slideNumber)} — ${img.label ?? `Slide ${img.slideNumber}`}`,
42
+ `jsx: slides/${pad2(img.slideNumber)}-<name>.jsx`,
43
+ "regions:",
44
+ "- ",
45
+ "organisms:",
46
+ "- ",
47
+ "connectors:",
48
+ "- ",
49
+ "icons:",
50
+ "- ",
51
+ "",
52
+ ].join("\n"),
53
+ );
54
+ fs.writeFileSync(
55
+ file,
56
+ [
57
+ "# Asset plan — concept inventory",
58
+ "",
59
+ "Fill this BEFORE writing any slide, from `concepts/concept-slide-NN.png` (the BODY region only —",
60
+ "chrome goes through layout props). One bullet per line; the",
61
+ "draft check counts `connectors:` against the JSX and every fetched asset against the slides.",
62
+ "",
63
+ "- regions: split of the content slot in percentages of its width/height, e.g. `hero left x0–60 y0–100`, `rail right x65–100`, `callout bottom y85–100`.",
64
+ "- organisms: every card, figure, badge, chip row, callout, loop, satellite — region, share, alignment.",
65
+ "- connectors: `from → to, straight|elbow|curved, arrow end|both|none, solid|dashed` — one per arrow/line in the mockup.",
66
+ "- icons: subject, prominence, engine query; append the fetched `assets/<file>` when sourced, or `(no result)`.",
67
+ "",
68
+ "After `slidemind view-draft`, under each slide add `draft-check:` followed by ONE tick line per",
69
+ "organism row: `- [x] <organism>` when it is in the render at the concept's position and share,",
70
+ "`- [ ] <organism> — <what differs>` when it is not. `review` refuses to open while a slide has",
71
+ "fewer ticks than organisms or any `[ ]` left — fix the miss in the JSX, re-render, re-tick.",
72
+ "",
73
+ ...slides,
74
+ ].join("\n"),
75
+ );
76
+ return true;
77
+ }
78
+
79
+ function parseAssetPlan(text) {
80
+ const out = new Map();
81
+ let current = null;
82
+ let section = "";
83
+ for (const raw of text.split("\n")) {
84
+ const line = raw.trim();
85
+ const heading = SLIDE_HEADING_RE.exec(line);
86
+ if (heading) {
87
+ current = { filled: false, connectors: 0, organisms: 0, icons: 0, iconsResolved: 0, verdict: null, ticked: 0, unticked: 0 };
88
+ out.set(Number(heading[1]), current);
89
+ section = "";
90
+ continue;
91
+ }
92
+ if (!current) continue;
93
+ if (/^draft-check:/i.test(line)) {
94
+ current.verdict = line.slice("draft-check:".length).trim();
95
+ section = "draft-check";
96
+ continue;
97
+ }
98
+ if (section === "draft-check") {
99
+ const tick = /^-\s*\[( |x|X)\]/.exec(line);
100
+ if (tick) {
101
+ if (tick[1] === " ") current.unticked += 1;
102
+ else current.ticked += 1;
103
+ continue;
104
+ }
105
+ }
106
+ const sec = SECTION_RE.exec(line);
107
+ if (sec) {
108
+ section = sec[1];
109
+ // Inline form (`connectors: a -> b, …; c -> d, …`) counts like bullets —
110
+ // agents write both shapes; only the seeded form uses bullets.
111
+ const inline = line.slice(sec[0].length).trim();
112
+ if (inline && !NONE_RE.test(inline)) {
113
+ if (section === "organisms" || section === "regions") current.filled = true;
114
+ if (section === "connectors") {
115
+ current.connectors += inline.split(";").filter((x) => x.trim()).length;
116
+ }
117
+ }
118
+ continue;
119
+ }
120
+ if (!line.startsWith("-")) continue;
121
+ const body = line.slice(1).trim();
122
+ if (!body || NONE_RE.test(body)) continue;
123
+ if (section === "organisms" || section === "regions") current.filled = true;
124
+ if (section === "organisms") current.organisms += 1;
125
+ if (section === "connectors") current.connectors += 1;
126
+ if (section === "icons") {
127
+ current.icons += 1;
128
+ // Resolved = a fetched file appended, or an explicit "(no result)".
129
+ if (/assets\/|\(no result\)/i.test(body)) current.iconsResolved += 1;
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * The asset plan as stage facts: per slide whether the inventory is filled,
137
+ * how many icon rows exist and how many are resolved. null without a plan.
138
+ */
139
+ export function assetPlanSummary(deckDir) {
140
+ const planFile = path.join(deckDir, ASSET_PLAN_FILE);
141
+ if (!fs.existsSync(planFile)) return null;
142
+ let plan;
143
+ try {
144
+ plan = parseAssetPlan(fs.readFileSync(planFile, "utf8"));
145
+ } catch {
146
+ return null;
147
+ }
148
+ const out = { slides: plan.size, unfilled: 0, icons: 0, iconsResolved: 0 };
149
+ for (const inv of plan.values()) {
150
+ if (!inv.filled) out.unfilled += 1;
151
+ out.icons += inv.icons;
152
+ out.iconsResolved += inv.iconsResolved;
153
+ }
154
+ return out;
155
+ }
156
+
157
+ function countConnectors(jsx) {
158
+ const attached = (jsx.match(/data-connector\b/g) ?? []).length;
159
+ const markers = (jsx.match(/marker-(?:end|start)\s*=/g) ?? []).length;
160
+ return attached + markers;
161
+ }
162
+
163
+ /**
164
+ * `[inventory:warn]` lines for the deck: per authored slide (inventory empty,
165
+ * connectors listed > drawn) plus fetched assets no slide references. Empty
166
+ * when the deck has no asset-plan.md.
167
+ */
168
+ export function inventoryWarnings(deckDir, jsxFiles = null) {
169
+ const planFile = path.join(deckDir, ASSET_PLAN_FILE);
170
+ if (!fs.existsSync(planFile)) return [];
171
+ let plan;
172
+ try {
173
+ plan = parseAssetPlan(fs.readFileSync(planFile, "utf8"));
174
+ } catch {
175
+ return [];
176
+ }
177
+ if (plan.size === 0) return [];
178
+ const slidesDir = path.join(deckDir, "slides");
179
+ const allJsx = fs.existsSync(slidesDir)
180
+ ? fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx")).sort()
181
+ : [];
182
+ const files = jsxFiles ?? allJsx;
183
+ const lines = [];
184
+ for (const file of files) {
185
+ const num = SLIDE_FILE_NUMBER_RE.exec(file);
186
+ if (!num) continue;
187
+ const inv = plan.get(Number(num[1]));
188
+ if (!inv) continue;
189
+ const jsxPath = `slides/${file}`;
190
+ if (!inv.filled) {
191
+ lines.push(
192
+ `[inventory:warn] ${jsxPath} — asset-plan.md has no regions/organisms rows for this slide. Inventory its concept mockup (every organism, its region and share) and rebuild from that list, not from memory.`,
193
+ );
194
+ continue;
195
+ }
196
+ let jsx = "";
197
+ try {
198
+ jsx = fs.readFileSync(path.join(slidesDir, file), "utf8");
199
+ } catch {
200
+ continue;
201
+ }
202
+ const drawn = countConnectors(jsx);
203
+ if (inv.connectors > drawn) {
204
+ lines.push(
205
+ `[inventory:warn] ${jsxPath} — asset-plan.md lists ${inv.connectors} connector${inv.connectors === 1 ? "" : "s"} for this slide, the JSX draws ${drawn} (data-connector / marker-end). Draw the missing ones per workflow/authoring.md → Arrow contract; a mockup connector is never dropped.`,
206
+ );
207
+ }
208
+ }
209
+ // Fetched assets nobody references: the mockup staged them, the deck lost them.
210
+ const referenced = allJsx
211
+ .map((f) => {
212
+ try {
213
+ return fs.readFileSync(path.join(slidesDir, f), "utf8");
214
+ } catch {
215
+ return "";
216
+ }
217
+ })
218
+ .join("\n");
219
+ const assetsDir = path.join(deckDir, "assets");
220
+ const assets = fs.existsSync(assetsDir) ? fs.readdirSync(assetsDir).sort() : [];
221
+ const planText = fs.readFileSync(planFile, "utf8");
222
+ for (const file of assets) {
223
+ if (!FETCHED_ASSET_RE.test(file)) continue;
224
+ if (referenced.includes(file)) continue;
225
+ if (new RegExp(`${file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[^\\n]*\\(unused\\)`).test(planText)) continue;
226
+ lines.push(
227
+ `[inventory:warn] assets/${file} was fetched for this deck but no slide references it — place it where the mockup stages that icon (\`<img src="./assets/${file}">\`), or mark it \`(unused)\` in asset-plan.md.`,
228
+ );
229
+ }
230
+ return lines;
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // The draft-check record — what `review` refuses on.
235
+ // ---------------------------------------------------------------------------
236
+
237
+ export function hashFile(file) {
238
+ return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
239
+ }
240
+
241
+ /** Persist the measured defects per slide, keyed by the jsx content hash. */
242
+ export function saveDraftCheck(deckDir, slides) {
243
+ const file = path.join(deckDir, DRAFT_CHECK_FILE);
244
+ fs.mkdirSync(path.dirname(file), { recursive: true });
245
+ const record = { version: 1, at: new Date().toISOString(), slides: {} };
246
+ for (const s of slides) record.slides[s.fileName] = { hash: s.hash, defects: s.defects };
247
+ fs.writeFileSync(file, JSON.stringify(record, null, 2) + "\n");
248
+ return record;
249
+ }
250
+
251
+ export function loadDraftCheck(deckDir) {
252
+ try {
253
+ return JSON.parse(fs.readFileSync(path.join(deckDir, DRAFT_CHECK_FILE), "utf8"));
254
+ } catch {
255
+ return null;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Standing measured defects: the last view-draft flagged them and the jsx has
261
+ * not changed since. null when nothing stands (no record, all fixed, or the
262
+ * slide was edited after the check — the next view-draft re-measures it).
263
+ */
264
+ export function draftCheckBlocker(deckDir) {
265
+ const record = loadDraftCheck(deckDir);
266
+ if (!record?.slides) return null;
267
+ const standing = [];
268
+ for (const [fileName, entry] of Object.entries(record.slides)) {
269
+ if (!entry?.defects?.length) continue;
270
+ const file = path.join(deckDir, "slides", fileName);
271
+ if (!fs.existsSync(file)) continue;
272
+ if (hashFile(file) !== entry.hash) continue;
273
+ standing.push(...entry.defects);
274
+ }
275
+ if (standing.length === 0) return null;
276
+ return (
277
+ `the last draft check measured ${standing.length} defect${standing.length === 1 ? "" : "s"} that still stand ` +
278
+ `(the slide files are unchanged since). Fix them in the JSX, re-run \`slidemind view-draft\`, then \`review\`:\n` +
279
+ standing.map((l) => ` ${l}`).join("\n")
280
+ );
281
+ }
282
+
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // The self-check verdict — an inventory, not an opinion.
286
+ //
287
+ // Every `draft-check:` verdict on disk across 181 judged sessions reads
288
+ // "matches" (15 of 15), including slides the judge scored 0.71 with eight
289
+ // organisms missing. A verdict is free; a tick per organism is not — the
290
+ // agent has to find each one in the render or say what differs.
291
+ // ---------------------------------------------------------------------------
292
+
293
+ /**
294
+ * Concept-seeded slides whose asset-plan.md section lists organisms but whose
295
+ * `draft-check:` is missing, ticks fewer organisms than listed, or leaves a
296
+ * `[ ]` standing. null when every such slide is fully ticked.
297
+ */
298
+ export function verdictBlocker(deckDir) {
299
+ const planFile = path.join(deckDir, ASSET_PLAN_FILE);
300
+ if (!fs.existsSync(planFile)) return null;
301
+ let plan;
302
+ try {
303
+ plan = parseAssetPlan(fs.readFileSync(planFile, "utf8"));
304
+ } catch {
305
+ return null;
306
+ }
307
+ const slidesDir = path.join(deckDir, "slides");
308
+ const conceptsDir = path.join(deckDir, "concepts");
309
+ const files = fs.existsSync(slidesDir)
310
+ ? fs.readdirSync(slidesDir).filter((f) => f.endsWith(".jsx")).sort()
311
+ : [];
312
+ const problems = [];
313
+ for (const file of files) {
314
+ const num = SLIDE_FILE_NUMBER_RE.exec(file);
315
+ if (!num) continue;
316
+ const n = Number(num[1]);
317
+ const inv = plan.get(n);
318
+ if (!inv || inv.organisms === 0) continue;
319
+ if (!fs.existsSync(path.join(conceptsDir, `concept-slide-${pad2(n)}.png`))) continue;
320
+ const ticks = inv.ticked + inv.unticked;
321
+ if (inv.verdict === null) {
322
+ problems.push(`slides/${file}: no draft-check verdict — asset-plan.md lists ${inv.organisms} organisms; open the concept and the render together and tick each one`);
323
+ } else if (inv.unticked > 0) {
324
+ problems.push(`slides/${file}: ${inv.unticked} organism${inv.unticked === 1 ? "" : "s"} ticked [ ] (differs) — fix in the JSX, re-run view-draft, re-tick`);
325
+ } else if (ticks < inv.organisms) {
326
+ problems.push(`slides/${file}: asset-plan.md lists ${inv.organisms} organisms, the verdict ticks ${ticks} — one \`- [x]\`/\`- [ ]\` line per organism`);
327
+ }
328
+ }
329
+ if (problems.length === 0) return null;
330
+ return (
331
+ "the self-check is incomplete — a verdict is one tick per organism, not one word:\n" +
332
+ problems.map((l) => ` ${l}`).join("\n")
333
+ );
334
+ }
335
+
336
+ // ---------------------------------------------------------------------------
337
+ // Translate-gate errors — the server's slot-fit / diagram-geometry /
338
+ // connector-endpoints verdicts, folded into the draft check so a builder
339
+ // learns them on ITS slide before the deck-wide export, not five build-pptx
340
+ // rounds later (x18: 17 of 39 minutes were that loop).
341
+ // ---------------------------------------------------------------------------
342
+
343
+ /**
344
+ * Split a translate 422 message into per-slide lines. The server writes
345
+ * `<file>.jsx: <rule>: <summary>` headers followed by ` [<rule>:error] <stem> — …`
346
+ * lines; both are kept, keyed by the jsx file name. Unattributed lines land
347
+ * under "" so nothing is lost.
348
+ */
349
+ export function parseTranslateErrors(message) {
350
+ const out = new Map();
351
+ const add = (file, line) => {
352
+ if (!out.has(file)) out.set(file, []);
353
+ out.get(file).push(line);
354
+ };
355
+ const text = String(message ?? "").replace(/^[^\n]*\/translate:\s*/, "");
356
+ let current = "";
357
+ for (const raw of text.split("\n")) {
358
+ const line = raw.trimEnd();
359
+ if (!line.trim()) continue;
360
+ const head = /^\s*(\S+\.jsx):\s+([a-z-]+):\s+(.*)$/.exec(line);
361
+ if (head) {
362
+ current = head[1];
363
+ add(current, `[${head[2]}:error] ${head[1]} — ${head[3]}`);
364
+ continue;
365
+ }
366
+ const tagged = /^\s*\[([a-z-]+):error\]\s+(\S+)\s+—/.exec(line);
367
+ if (tagged) {
368
+ const stem = tagged[2];
369
+ const file = [...out.keys()].find((f) => f.replace(/\.jsx$/, "") === stem) ?? (current || stem + ".jsx");
370
+ add(file, line.trim());
371
+ continue;
372
+ }
373
+ add(current, line.trim());
374
+ }
375
+ return out;
376
+ }
@@ -97,7 +97,8 @@ export async function lintSlidesOrThrow({ deckDir, slideFiles, layoutSources })
97
97
  primitives,
98
98
  });
99
99
 
100
- for (const line of result.lines) {
100
+ const lines = collapseLintLines(result.lines);
101
+ for (const line of lines) {
101
102
  process.stderr.write(`${line}\n`);
102
103
  }
103
104
  if (!result.ok) {
@@ -106,7 +107,42 @@ export async function lintSlidesOrThrow({ deckDir, slideFiles, layoutSources })
106
107
  (result.warnings
107
108
  ? `, ${result.warnings} warning${result.warnings === 1 ? "" : "s"}`
108
109
  : "");
109
- throw new Error(`${summary}\n${result.lines.join("\n")}`);
110
+ throw new Error(`${summary}\n${lines.join("\n")}`);
110
111
  }
111
112
  return result;
112
113
  }
114
+
115
+ // The engine's lines, as an agent should read them: exact duplicates dropped,
116
+ // and the one class it cannot act on — a connector ref whose id is built with
117
+ // a template (`${…}`) and may well resolve at render time — folded into ONE
118
+ // note per file. Verbatim, a 6-node loop printed twelve identical paragraphs
119
+ // (one per data-from/data-to) that buried the single real warning under them.
120
+ // The rule itself stays the add-in's; only the CLI's rendering changes.
121
+ const DYNAMIC_ID_RE =
122
+ /^(\s*)\[lint:warn\] (\S+?):\d+ — `(data-(?:from|to)="[^"]*")` on a data-connector does not resolve against any static id on this slide, but the slide builds ids dynamically/;
123
+
124
+ export function collapseLintLines(lines) {
125
+ const out = [];
126
+ const seen = new Set();
127
+ const dynamic = new Map(); // file → { indent, refs: [] }
128
+ for (const line of lines) {
129
+ if (seen.has(line)) continue;
130
+ seen.add(line);
131
+ const m = DYNAMIC_ID_RE.exec(line);
132
+ if (m) {
133
+ const entry = dynamic.get(m[2]) ?? { file: m[2], indent: m[1], refs: [] };
134
+ entry.refs.push(m[3].replace(/^data-(?:from|to)=/, ""));
135
+ if (!dynamic.has(m[2])) {
136
+ dynamic.set(m[2], entry);
137
+ out.push(entry); // placeholder keeps the file's position in the listing
138
+ }
139
+ continue;
140
+ }
141
+ out.push(line);
142
+ }
143
+ return out.map((item) =>
144
+ typeof item === "string"
145
+ ? item
146
+ : `${item.indent}[lint:note] ${item.file} — ${item.refs.length} connector ref${item.refs.length === 1 ? "" : "s"} (${[...new Set(item.refs)].slice(0, 4).join(", ")}${item.refs.length > 4 ? ", …" : ""}) use ids the slide builds with a template — they resolve at render time; check that each connector lands on its node in the draft render.`,
147
+ );
148
+ }