slidemind 0.1.2 → 0.1.4

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.
@@ -0,0 +1,388 @@
1
+ #!/usr/bin/env node
2
+ // slidemind review <deck> — the spatial preview-review gate, CLI-owned.
3
+ //
4
+ // Builds the HTML preview, serves it from a tiny local HTTP server with the
5
+ // comment overlay (review-overlay.js) injected, opens the browser, and BLOCKS
6
+ // until the user approves or submits spatially-anchored comments in the page.
7
+ // The browser POSTs the decision to /review, which unblocks this command — no
8
+ // copy-paste round-trip. The verdict is printed to stdout for the harness:
9
+ // - approve -> "DECISION: approved" (the agent then runs the export the user
10
+ // asked for: slidemind push / build-pptx).
11
+ // - comments -> each comment with its slides/<f>.jsx:line:col, the source line,
12
+ // the click position, and a readonly-layout note where relevant
13
+ // (the agent edits the cited files and re-runs review).
14
+ //
15
+ // Host-free: it reviews the HTML preview BEFORE any .pptx render, so it needs a
16
+ // previously-materialized bundle (like `slidemind build`) but no open host.
17
+ // Each round's comments persist to <deck>/review.json so the next run shows them
18
+ // as read-only "ghost" pins for 1:1 verification.
19
+
20
+ import fs from "node:fs";
21
+ import http from "node:http";
22
+ import path from "node:path";
23
+ import { fileURLToPath, pathToFileURL } from "node:url";
24
+
25
+ import { buildDeck, openInBrowser } from "./build.mjs";
26
+ import { resolveSkillForDeck } from "./resolve-skill.mjs";
27
+ import { resolveBrandAsset } from "../bridge/bundle.mjs";
28
+
29
+ const __filename = fileURLToPath(import.meta.url);
30
+ const __dirname = path.dirname(__filename);
31
+ const OVERLAY_PATH = path.join(__dirname, "review-overlay.js");
32
+ const REVIEW_STATE_FILE = "review.json";
33
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000;
34
+
35
+ const MIME = {
36
+ ".html": "text/html; charset=utf-8",
37
+ ".css": "text/css; charset=utf-8",
38
+ ".js": "text/javascript; charset=utf-8",
39
+ ".json": "application/json; charset=utf-8",
40
+ ".svg": "image/svg+xml",
41
+ ".png": "image/png",
42
+ ".jpg": "image/jpeg",
43
+ ".jpeg": "image/jpeg",
44
+ ".gif": "image/gif",
45
+ ".webp": "image/webp",
46
+ ".woff": "font/woff",
47
+ ".woff2": "font/woff2",
48
+ };
49
+
50
+ function clamp01(n) {
51
+ return n < 0 ? 0 : n > 1 ? 1 : n;
52
+ }
53
+ function pct(n) {
54
+ return Math.round(clamp01(n) * 100);
55
+ }
56
+ function safeInlineJson(data) {
57
+ return JSON.stringify(data).replace(/<\/script>/g, "<\\/script>");
58
+ }
59
+
60
+ // "slides/01-cover.jsx:12:5" -> { jsxPath, line, col }
61
+ export function parseLoc(loc) {
62
+ if (!loc) return null;
63
+ const m = String(loc).match(/^(.*):(\d+):(\d+)$/);
64
+ if (!m) return null;
65
+ return { jsxPath: m[1], line: Number(m[2]), col: Number(m[3]) };
66
+ }
67
+
68
+ // Read the cited source line so the harness sees exactly what to edit. Slides
69
+ // live in the deck; layouts live in the materialized bundle (best-effort).
70
+ function readSourceLine(deckDir, parsed) {
71
+ if (!parsed) return null;
72
+ let filePath = null;
73
+ if (parsed.jsxPath.startsWith("slides/")) {
74
+ filePath = path.join(deckDir, parsed.jsxPath);
75
+ } else if (parsed.jsxPath.startsWith("layouts/")) {
76
+ try {
77
+ const skill = resolveSkillForDeck(deckDir);
78
+ filePath = path.join(skill.layoutsDir, path.basename(parsed.jsxPath));
79
+ } catch {
80
+ filePath = null;
81
+ }
82
+ }
83
+ if (!filePath || !fs.existsSync(filePath)) return null;
84
+ try {
85
+ const lines = fs.readFileSync(filePath, "utf8").split("\n");
86
+ const line = (lines[parsed.line - 1] || "").trim();
87
+ return line || null;
88
+ } catch {
89
+ return null;
90
+ }
91
+ }
92
+
93
+ // Render the comment round for the harness — ports the add-in's
94
+ // formatPreviewCommentsForLlm, adapted to CLI commands.
95
+ export function formatCommentsForHarness(deckDir, comments) {
96
+ const n = comments.length;
97
+ const out = [];
98
+ out.push("DECISION: comments");
99
+ out.push(
100
+ `The user left ${n} comment${n === 1 ? "" : "s"} on the preview. Nothing was exported.`,
101
+ );
102
+ out.push("");
103
+ comments.forEach((c, i) => {
104
+ const region = c.resolvedTarget || null;
105
+ const parsed = region ? parseLoc(region.loc) : null;
106
+ const head = `[${i + 1}] ${c.jsxPath} — `;
107
+ if (region && parsed) {
108
+ const cls = region.className ? ` class="${region.className}"` : "";
109
+ out.push(`${head}element <${region.tag}${cls}> at ${region.loc}`);
110
+ const srcLine = readSourceLine(deckDir, parsed);
111
+ if (srcLine) out.push(` Source line ${parsed.line}: ${srcLine}`);
112
+ const b = region.bbox || { x: 0, y: 0, w: 0, h: 0 };
113
+ const exX = b.w > 0 ? pct((c.point.x - b.x) / b.w) : 50;
114
+ const exY = b.h > 0 ? pct((c.point.y - b.y) / b.h) : 50;
115
+ out.push(
116
+ ` Click position: ${exX}%/${exY}% within the element, ` +
117
+ `${pct(c.point.x)}%/${pct(c.point.y)}% of the slide.`,
118
+ );
119
+ if (parsed.jsxPath.startsWith("layouts/")) {
120
+ out.push(
121
+ ` Note: this element is rendered by the READONLY layout component — its ` +
122
+ `content comes from the props the slide passes. Apply the change in ` +
123
+ `${c.jsxPath} (the prop feeding this slot), not in the layout file.`,
124
+ );
125
+ }
126
+ } else {
127
+ out.push(
128
+ `${head}slide-level note at ${pct(c.point.x)}%/${pct(c.point.y)}% ` +
129
+ `of the slide (no specific element).`,
130
+ );
131
+ }
132
+ out.push(` Comment: ${c.comment}`);
133
+ out.push("");
134
+ });
135
+ out.push(
136
+ `Address every comment by editing the cited slides/*.jsx file(s) at the given ` +
137
+ `line, then re-run: slidemind review ${path.basename(deckDir)}`,
138
+ );
139
+ return out.join("\n") + "\n";
140
+ }
141
+
142
+ function injectReview(html, previousComments, overlayJs, logoMark = null) {
143
+ const cfg = safeInlineJson({
144
+ endpoint: "/review",
145
+ previousComments,
146
+ logoMark,
147
+ });
148
+ const overlay = overlayJs.replace(/<\/script>/g, "<\\/script>");
149
+ const block =
150
+ `<script>window.__SLIDEMIND_REVIEW__ = ${cfg};</script>\n` +
151
+ `<script>\n${overlay}\n</script>\n</body>`;
152
+ return html.includes("</body>") ? html.replace("</body>", block) : html + block;
153
+ }
154
+
155
+ export function loadReviewState(deckDir) {
156
+ const f = path.join(deckDir, REVIEW_STATE_FILE);
157
+ if (!fs.existsSync(f)) return { version: 1, comments: [] };
158
+ try {
159
+ const data = JSON.parse(fs.readFileSync(f, "utf8"));
160
+ return { version: 1, comments: Array.isArray(data.comments) ? data.comments : [] };
161
+ } catch {
162
+ return { version: 1, comments: [] };
163
+ }
164
+ }
165
+
166
+ function saveReviewState(deckDir, state) {
167
+ fs.writeFileSync(
168
+ path.join(deckDir, REVIEW_STATE_FILE),
169
+ JSON.stringify(state, null, 2) + "\n",
170
+ );
171
+ }
172
+
173
+ // Start the local review server. Returns { url, port, waitForDecision, close }.
174
+ // presentation.html must already exist in deckDir (build it first).
175
+ export async function createReviewServer({
176
+ deckDir,
177
+ previousComments = [],
178
+ overlayJs,
179
+ logoMark = null,
180
+ port = 0,
181
+ }) {
182
+ let resolveDecision;
183
+ const decisionPromise = new Promise((res) => {
184
+ resolveDecision = res;
185
+ });
186
+ const overlay =
187
+ overlayJs ?? fs.readFileSync(OVERLAY_PATH, "utf8");
188
+ const indexHtml = injectReview(
189
+ fs.readFileSync(path.join(deckDir, "presentation.html"), "utf8"),
190
+ previousComments,
191
+ overlay,
192
+ logoMark,
193
+ );
194
+ const deckRoot = path.resolve(deckDir);
195
+
196
+ const server = http.createServer((req, res) => {
197
+ const u = new URL(req.url, "http://127.0.0.1");
198
+ const pathname = decodeURIComponent(u.pathname);
199
+
200
+ if (req.method === "POST" && pathname === "/review") {
201
+ let body = "";
202
+ req.on("data", (chunk) => {
203
+ body += chunk;
204
+ if (body.length > 5e6) req.destroy();
205
+ });
206
+ req.on("end", () => {
207
+ let payload;
208
+ try {
209
+ payload = JSON.parse(body || "{}");
210
+ } catch {
211
+ payload = {};
212
+ }
213
+ res.writeHead(200, { "Content-Type": MIME[".json"] });
214
+ res.end(JSON.stringify({ ok: true }));
215
+ resolveDecision({
216
+ decision: payload.decision === "comments" ? "comments" : "approve",
217
+ comments: Array.isArray(payload.comments) ? payload.comments : [],
218
+ });
219
+ });
220
+ return;
221
+ }
222
+
223
+ if (req.method !== "GET") {
224
+ res.writeHead(405);
225
+ res.end();
226
+ return;
227
+ }
228
+
229
+ if (
230
+ pathname === "/" ||
231
+ pathname === "/index.html" ||
232
+ pathname === "/presentation.html"
233
+ ) {
234
+ res.writeHead(200, { "Content-Type": MIME[".html"] });
235
+ res.end(indexHtml);
236
+ return;
237
+ }
238
+
239
+ const normalized = path.resolve(deckRoot, pathname.replace(/^\/+/, ""));
240
+ if (normalized !== deckRoot && !normalized.startsWith(deckRoot + path.sep)) {
241
+ res.writeHead(403);
242
+ res.end("forbidden");
243
+ return;
244
+ }
245
+ fs.readFile(normalized, (err, data) => {
246
+ if (err) {
247
+ res.writeHead(404);
248
+ res.end("not found");
249
+ return;
250
+ }
251
+ const ext = path.extname(normalized).toLowerCase();
252
+ res.writeHead(200, {
253
+ "Content-Type": MIME[ext] || "application/octet-stream",
254
+ });
255
+ res.end(data);
256
+ });
257
+ });
258
+
259
+ await new Promise((resolve, reject) => {
260
+ server.once("error", reject);
261
+ server.listen(port, "127.0.0.1", resolve);
262
+ });
263
+ const addr = server.address();
264
+ const url = `http://127.0.0.1:${addr.port}/`;
265
+
266
+ return {
267
+ url,
268
+ port: addr.port,
269
+ waitForDecision({ timeoutMs } = {}) {
270
+ if (!timeoutMs) return decisionPromise;
271
+ return new Promise((resolve, reject) => {
272
+ const t = setTimeout(
273
+ () => reject(new Error("review timed out — no decision received")),
274
+ timeoutMs,
275
+ );
276
+ decisionPromise.then((d) => {
277
+ clearTimeout(t);
278
+ resolve(d);
279
+ });
280
+ });
281
+ },
282
+ close() {
283
+ try {
284
+ server.close();
285
+ } catch {
286
+ /* already closing */
287
+ }
288
+ },
289
+ };
290
+ }
291
+
292
+ export async function runReview({
293
+ deckDir,
294
+ open = true,
295
+ port = 0,
296
+ timeoutMs = DEFAULT_TIMEOUT_MS,
297
+ } = {}) {
298
+ await buildDeck(deckDir);
299
+ const prev = loadReviewState(deckDir);
300
+ // The review window's logo is the add-in's genuine per-brand orb: a PNG
301
+ // shipped on the bundle as a role-keyed asset (copied into the deck by
302
+ // buildDeck). The review UI is dark, so prefer the dark variant. When absent
303
+ // (brand predates the feature / bundle not re-materialized), the overlay shows
304
+ // a plain slide glyph — the CLI no longer re-derives an orb.
305
+ const mark = resolveBrandAsset(deckDir, ["logoMarkDark", "logoMark", "logoMarkLight"]);
306
+ const logoMark = mark ? "/assets/" + mark.filename : null;
307
+ const server = await createReviewServer({
308
+ deckDir,
309
+ previousComments: prev.comments || [],
310
+ logoMark,
311
+ port,
312
+ });
313
+ process.stderr.write(`Preview review ready at ${server.url}\n`);
314
+ if (open) {
315
+ openInBrowser(server.url);
316
+ process.stderr.write(
317
+ "Opened your browser — leave comments or approve, then return here.\n",
318
+ );
319
+ } else {
320
+ process.stderr.write("Open the URL above in your browser to review.\n");
321
+ }
322
+
323
+ let decision;
324
+ try {
325
+ decision = await server.waitForDecision({ timeoutMs });
326
+ } finally {
327
+ server.close();
328
+ }
329
+
330
+ saveReviewState(deckDir, {
331
+ version: 1,
332
+ decision: decision.decision,
333
+ comments: decision.comments,
334
+ at: new Date().toISOString(),
335
+ });
336
+
337
+ if (decision.decision === "approve") {
338
+ process.stdout.write(
339
+ "DECISION: approved\n" +
340
+ "The user approved the preview. Proceed to produce the output they asked " +
341
+ "for (slidemind push / slidemind build-pptx). One approval covers the task.\n",
342
+ );
343
+ } else {
344
+ process.stdout.write(formatCommentsForHarness(deckDir, decision.comments));
345
+ }
346
+ return decision;
347
+ }
348
+
349
+ function parseArgs(argv) {
350
+ const args = { deck: null, open: true, port: 0 };
351
+ for (let i = 0; i < argv.length; i++) {
352
+ const a = argv[i];
353
+ if (a === "--deck" && i + 1 < argv.length) {
354
+ args.deck = argv[++i];
355
+ } else if (a === "--no-open") {
356
+ args.open = false;
357
+ } else if (a === "--port" && i + 1 < argv.length) {
358
+ args.port = parseInt(argv[++i], 10) || 0;
359
+ } else if (a === "-h" || a === "--help") {
360
+ process.stdout.write(
361
+ "Usage: slidemind review <deck> [--no-open] [--port <n>]\n" +
362
+ " node src/builders/review.mjs --deck <deck> [--no-open] [--port <n>]\n",
363
+ );
364
+ process.exit(0);
365
+ } else {
366
+ process.stderr.write(`review: unexpected argument '${a}'\n`);
367
+ process.exit(2);
368
+ }
369
+ }
370
+ if (!args.deck) {
371
+ process.stderr.write("review: --deck is required\n");
372
+ process.exit(2);
373
+ }
374
+ return args;
375
+ }
376
+
377
+ async function main() {
378
+ const args = parseArgs(process.argv.slice(2));
379
+ const deckDir = path.resolve(args.deck);
380
+ await runReview({ deckDir, open: args.open, port: args.port });
381
+ }
382
+
383
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
384
+ main().catch((err) => {
385
+ process.stderr.write(`review: ${err.message || err}\n`);
386
+ process.exit(1);
387
+ });
388
+ }
@@ -0,0 +1,67 @@
1
+ // Shared numeric-prefix + filename logic for folding reused slide-search
2
+ // results into a deck as native one-slide .pptx (the "reuse → fold into a deck"
3
+ // path). Both `slidemind add-slide` (bin/slidemind.mjs) and the
4
+ // `slidemind_add_slide_to_deck` MCP tool (src/mcp/server.mjs) drive the SAME
5
+ // math, so it lives here as one pure, tested unit — the callers keep the bridge
6
+ // round-trip and the fs write.
7
+ //
8
+ // Play order over the CLI is filename order (see src/lib/slide-order.mjs): a
9
+ // reused slide is named `NN-reused-<shortid>.pptx`, zero-padded to width 2, so
10
+ // it interleaves with the deck's `NN-<topic>.jsx` slides by its numeric prefix.
11
+
12
+ // A short, filesystem-safe id derived from the source slide id when the bridge
13
+ // returns one, else a random fallback. Lowercase alphanumerics, ≤ 8 chars.
14
+ export function shortIdFor(slideId) {
15
+ const fromSource = slideId
16
+ ? String(slideId)
17
+ .replace(/[^a-z0-9]/gi, "")
18
+ .slice(0, 8)
19
+ .toLowerCase()
20
+ : "";
21
+ return fromSource || randomShortId();
22
+ }
23
+
24
+ export function randomShortId() {
25
+ return Math.random().toString(36).slice(2, 10);
26
+ }
27
+
28
+ // Compute the starting numeric prefix for a batch of reused slides.
29
+ // - `existingNames`: the current entries in slides/ (any extension).
30
+ // - `at`: optional explicit prefix the FIRST added slide should take. When
31
+ // null/undefined, append after the last existing numeric prefix
32
+ // (maxPrefix + 1). Existing files are NOT renumbered (kept simple — the
33
+ // zero-padded width-2 namespace is wide enough).
34
+ // Picks beyond the first follow at +1 each.
35
+ export function startingPrefix(existingNames, at) {
36
+ const prefixes = (existingNames ?? [])
37
+ .map((f) => {
38
+ const m = /^(\d+)/.exec(f);
39
+ return m ? parseInt(m[1], 10) : null;
40
+ })
41
+ .filter((n) => n !== null);
42
+ const maxPrefix = prefixes.length > 0 ? Math.max(...prefixes) : 0;
43
+ return typeof at === "number" ? at : maxPrefix + 1;
44
+ }
45
+
46
+ // Given the existing slides/ filenames, the picked results (each carrying an
47
+ // optional `slideId` from the bridge), and an optional `at`, return the target
48
+ // filename for EACH pick in order: `NN-reused-<shortid>.pptx` (zero-padded
49
+ // width 2), the first at `startingPrefix(...)`, each subsequent +1.
50
+ //
51
+ // `picks` is an array of objects shaped like the bridge's slide-extract result,
52
+ // e.g. `[{ selection, slideId }]`. Only `slideId` is read here (for the short
53
+ // id); pass the selection back yourself if you need it alongside the filename.
54
+ // Returns `[{ filename, prefix, shortId }]`, aligned 1:1 with `picks`.
55
+ export function reusedSlideTargets(existingNames, picks, at) {
56
+ let nextPrefix = startingPrefix(existingNames, at);
57
+ return (picks ?? []).map((pick) => {
58
+ const shortId = shortIdFor(pick?.slideId ?? null);
59
+ const prefix = nextPrefix;
60
+ nextPrefix += 1;
61
+ return {
62
+ prefix,
63
+ shortId,
64
+ filename: `${String(prefix).padStart(2, "0")}-reused-${shortId}.pptx`,
65
+ };
66
+ });
67
+ }
@@ -3,7 +3,10 @@
3
3
  // such channel, so decks encode order with zero-padded numeric prefixes
4
4
  // (01-cover.jsx, 02-agenda.jsx, …) — see skills/create-slides/SKILL.md
5
5
  // (command mapping). The sort is numeric-aware so unpadded prefixes still
6
- // order correctly (2-foo.jsx before 10-bar.jsx).
6
+ // order correctly (2-foo.jsx before 10-bar.jsx). Authored .jsx slides and
7
+ // reused native .pptx slides share one play-order namespace — the collator
8
+ // compares the whole filename, so a 02-…​.pptx interleaves with 01-/03-…​.jsx
9
+ // by its numeric prefix regardless of extension.
7
10
 
8
11
  const collator = new Intl.Collator("en", { numeric: true });
9
12