slidemind 0.1.8 → 0.1.9

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.
@@ -24,7 +24,10 @@
24
24
  // On a comments round the browser tab does NOT close: it polls
25
25
  // /__review/heartbeat and reloads itself once the next run binds the same port
26
26
  // (matched by a service marker + a fresh round nonce), so a fresh tab isn't
27
- // opened every round.
27
+ // opened every round. Reuse is granted only to a VISIBLE waiting tab — if the
28
+ // tab is buried in a background window (or none is polling), the browser is
29
+ // ALWAYS opened and the stale tab retires itself; the review must never sit
30
+ // invisible while the user thinks nothing happened.
28
31
 
29
32
  import fs from "node:fs";
30
33
  import http from "node:http";
@@ -42,7 +45,9 @@ import {
42
45
  listenWithFallback,
43
46
  preferredGatePort,
44
47
  serveAndOpen as gateServeAndOpen,
48
+ pendingBrowserLines,
45
49
  GRACE_MS,
50
+ OPEN_FAILED_GRACE_MS,
46
51
  POLL_MS,
47
52
  POLL_TTL_MS,
48
53
  } from "./gate-server.mjs";
@@ -141,6 +146,17 @@ export function formatCommentsForHarness(deckDir, comments) {
141
146
  // Imperative, stateful tail (mirrors plan.mjs's formatPlanFeedbackForHarness):
142
147
  // after a comments round the user's tab waits for the NEXT review run — an
143
148
  // agent that ends its turn here deadlocks the session.
149
+ //
150
+ // The recovery pointer sits in the TAIL deliberately: when a harness capture
151
+ // truncates long output it usually drops the TOP, so the pointer to the full
152
+ // persisted round is what survives.
153
+ out.push(
154
+ `This round (all ${n} comment${n === 1 ? "" : "s"}) is also saved verbatim in ` +
155
+ `${path.join(deckDir, REVIEW_STATE_FILE)} — if this output looks truncated or ` +
156
+ `you can't see every numbered comment above, read that file for the full list ` +
157
+ `instead of guessing or asking the user to re-type anything.`,
158
+ );
159
+ out.push("");
144
160
  out.push("The user's browser tab is still open, waiting for the next round. They are");
145
161
  out.push("waiting on YOU now — do both steps in this same turn:");
146
162
  out.push(" 1. Edit the cited slides/*.jsx file(s) at the given lines.");
@@ -154,12 +170,13 @@ export function formatCommentsForHarness(deckDir, comments) {
154
170
  // Render the no-answer outcome for the harness — same contract as plan.mjs's
155
171
  // formatPlanPendingForHarness: an expected state with resume instructions, not
156
172
  // a failure and never an implicit approval.
157
- export function formatReviewPendingForHarness(deckDir, { timeoutMs } = {}) {
173
+ export function formatReviewPendingForHarness(deckDir, { timeoutMs, pageLoaded, url } = {}) {
158
174
  const mins = Math.max(1, Math.round((timeoutMs ?? DEFAULT_TIMEOUT_MS) / 60000));
159
175
  const base = path.basename(deckDir);
160
176
  return [
161
177
  "DECISION: pending",
162
178
  `No decision yet — the gate waited ${mins} minutes and the user hasn't answered.`,
179
+ ...pendingBrowserLines({ pageLoaded, url }),
163
180
  "This is NOT an approval and NOT a rejection. Do not push or export anything.",
164
181
  "Close out your turn gracefully:",
165
182
  " 1. Tell the user the preview is still waiting for their review in the browser.",
@@ -279,7 +296,12 @@ export async function createReviewServer({
279
296
  // The `service` marker is the shape guard: a waiting tab only reloads for
280
297
  // a genuine slidemind review round, never because a foreign app grabbed
281
298
  // the same port. The `round` nonce distinguishes a newer round from self.
282
- hb.respond(res);
299
+ // `visible`/`round` from the poll drive the reuse-vs-fresh-tab decision:
300
+ // only a VISIBLE waiting tab may claim reuse (see gate-server.mjs).
301
+ hb.respond(res, {
302
+ visible: u.searchParams.get("visible") === "1",
303
+ tabRound: u.searchParams.get("round"),
304
+ });
283
305
  return;
284
306
  }
285
307
 
@@ -288,6 +310,10 @@ export async function createReviewServer({
288
310
  pathname === "/index.html" ||
289
311
  pathname === "/presentation.html"
290
312
  ) {
313
+ // A browser fetched the gate page — the end-to-end proof the review
314
+ // actually opened in front of the user (see gate-server's page-load
315
+ // tracker; serveAndOpen and the pending contract report from it).
316
+ hb.notePageLoad();
291
317
  res.writeHead(200, { "Content-Type": MIME[".html"] });
292
318
  res.end(indexHtml);
293
319
  return;
@@ -315,6 +341,15 @@ export async function createReviewServer({
315
341
  waitForHeartbeat(ms) {
316
342
  return hb.waitForHeartbeat(ms);
317
343
  },
344
+ markOpenedFresh() {
345
+ hb.markFresh();
346
+ },
347
+ waitForPageLoad(ms) {
348
+ return hb.waitForPageLoad(ms);
349
+ },
350
+ pageLoaded() {
351
+ return hb.pageLoaded();
352
+ },
318
353
  waitForDecision(opts = {}) {
319
354
  return gate.waitForDecision(opts);
320
355
  },
@@ -331,18 +366,25 @@ export async function createReviewServer({
331
366
  // Print the review URL, then decide whether to open a browser. If a tab left
332
367
  // waiting from a previous comments round pings /__review/heartbeat within the
333
368
  // grace window, reuse it (it reloads itself) instead of opening a duplicate.
334
- // Returns true if an existing tab was reused. The testable seam for runReview —
335
- // a thin wrapper over the shared gate primitive that supplies this gate's
336
- // browser opener; the preview-gate message strings are gate-server's defaults.
369
+ // Returns gate-server's { reused, launched, confirmed, url } status. The
370
+ // testable seam for runReview — a thin wrapper over the shared gate primitive
371
+ // that supplies this gate's browser opener (quiet: the gate's BROWSER: line is
372
+ // the single source of open/failed messaging); the preview-gate message nouns
373
+ // are gate-server's defaults.
337
374
  export function serveAndOpen(server, opts = {}) {
338
- return gateServeAndOpen(server, { open: openInBrowser, ...opts });
375
+ return gateServeAndOpen(server, {
376
+ open: (url) => openInBrowser(url, { quiet: true }),
377
+ ...opts,
378
+ });
339
379
  }
340
380
 
341
381
  export async function runReview({
342
382
  deckDir,
343
383
  port = null,
344
384
  timeoutMs = DEFAULT_TIMEOUT_MS,
345
- open = openInBrowser,
385
+ // quiet: serveAndOpen's BROWSER: line owns all open/failed messaging.
386
+ open = (url) => openInBrowser(url, { quiet: true }),
387
+ openFailedGraceMs = OPEN_FAILED_GRACE_MS,
346
388
  } = {}) {
347
389
  await buildDeck(deckDir);
348
390
  const prev = loadReviewState(deckDir);
@@ -362,7 +404,7 @@ export async function runReview({
362
404
  });
363
405
  // A waiting tab can only exist when the previous round ended in comments — so
364
406
  // first runs / post-approve runs open the browser immediately (graceMs 0).
365
- await serveAndOpen(server, {
407
+ const browser = await serveAndOpen(server, {
366
408
  open,
367
409
  graceMs: prev.decision === "comments" ? GRACE_MS : 0,
368
410
  waitLine:
@@ -373,12 +415,32 @@ export async function runReview({
373
415
 
374
416
  let decision;
375
417
  try {
418
+ if (browser.launched === false) {
419
+ // The opener chain failed outright: the user can only reach the gate via
420
+ // the printed URL. Give a manually-opened tab a short window, then bail
421
+ // out with the pending contract (carrying the failed-open status + link)
422
+ // instead of blocking the agent for the full timeout on an invisible gate.
423
+ const loaded = await server.waitForPageLoad(openFailedGraceMs);
424
+ if (!loaded) {
425
+ const err = new Error("browser never opened — gate unseen");
426
+ err.code = "GATE_TIMEOUT";
427
+ throw err;
428
+ }
429
+ }
376
430
  decision = await server.waitForDecision({ timeoutMs });
377
431
  } catch (err) {
378
432
  if (err && err.code === "GATE_TIMEOUT") {
379
433
  // Expected outcome, not an error: exit 0 with the pending contract so the
380
434
  // agent wraps up cleanly instead of reading a scary non-zero failure.
381
- process.stdout.write(formatReviewPendingForHarness(deckDir, { timeoutMs }));
435
+ // pageLoaded is re-checked NOW (not serveAndOpen's early snapshot) so a
436
+ // tab that opened late still counts as "the user saw the gate".
437
+ process.stdout.write(
438
+ formatReviewPendingForHarness(deckDir, {
439
+ timeoutMs,
440
+ pageLoaded: server.pageLoaded(),
441
+ url: server.url,
442
+ }),
443
+ );
382
444
  return null;
383
445
  }
384
446
  throw err;
@@ -0,0 +1,118 @@
1
+ // Storyboard-markdown: segment a plan.md into alternating markdown / storyboard
2
+ // runs, splice an edited storyboard fence back in, and pull the red-thread beats
3
+ // out of the `## Red thread` section. Ported 1:1 from the add-in's
4
+ // src/ui/src/pages/plan-review/storyboard-markdown.ts (TS types dropped; regexes
5
+ // and control flow identical). Ships into the plan gate's browser bundle in one
6
+ // shared IIFE with storyboard-schema and the other plan modules, so module-scope
7
+ // helpers use an `sm` prefix to stay collision-free; exported names match the
8
+ // add-in exactly. The import below is a single line on purpose — the browser
9
+ // bundler strips lines matching `import … from "…";`.
10
+ import { parseStoryboardCanvas, serializeStoryboardCanvas, STORYBOARD_CANVAS_LANG } from "./storyboard-schema.mjs";
11
+
12
+ const SM_FENCE_RE = /```([^\n`]*)\n([\s\S]*?)```/g;
13
+ const SM_RED_THREAD_HEADING_RE = /^##\s+Red thread\s*$/im;
14
+ const SM_NEXT_H2_RE = /^##\s+/im;
15
+
16
+ function smCountLines(text) {
17
+ if (!text) return 0;
18
+ return text.split("\n").length - 1;
19
+ }
20
+
21
+ function smNormalizeFenceLang(raw) {
22
+ return raw.trim().split(/\s+/)[0] ?? "";
23
+ }
24
+
25
+ function smCleanRedThreadLine(line) {
26
+ return line
27
+ .replace(/^\s*(?:[-*]|\d+[.)])\s+/, "")
28
+ .replace(/^\s*Slide\s+\d+\s*:\s*/i, "")
29
+ .replace(/\s+/g, " ")
30
+ .trim();
31
+ }
32
+
33
+ function smSplitRedThreadBody(body) {
34
+ const lines = body.split("\n").map(smCleanRedThreadLine).filter(Boolean);
35
+ if (lines.length !== 1) return lines;
36
+ const [line] = lines;
37
+ if (!line) return [];
38
+ return line
39
+ .split(/\s+(?:->|→|=>|↦)\s+/)
40
+ .map(smCleanRedThreadLine)
41
+ .filter(Boolean);
42
+ }
43
+
44
+ /** Absolute bounds of the `## Red thread` section body (after the heading). */
45
+ export function findRedThreadSection(markdown) {
46
+ const heading = SM_RED_THREAD_HEADING_RE.exec(markdown);
47
+ if (!heading) return null;
48
+ const bodyStart = (heading.index ?? 0) + heading[0].length;
49
+ const rest = markdown.slice(bodyStart);
50
+ const nextHeading = SM_NEXT_H2_RE.exec(rest);
51
+ return {
52
+ bodyStart,
53
+ bodyEnd: bodyStart + (nextHeading?.index ?? rest.length),
54
+ };
55
+ }
56
+
57
+ export function extractRedThreadMessages(markdown) {
58
+ const section = findRedThreadSection(markdown);
59
+ if (!section) return [];
60
+ return smSplitRedThreadBody(markdown.slice(section.bodyStart, section.bodyEnd));
61
+ }
62
+
63
+ export function splitPlanMarkdownIntoSegments(markdown) {
64
+ const segments = [];
65
+ let cursor = 0;
66
+ let lineOffset = 0;
67
+
68
+ for (const match of markdown.matchAll(SM_FENCE_RE)) {
69
+ const full = match[0] ?? "";
70
+ const matchStart = match.index ?? 0;
71
+ const matchEnd = matchStart + full.length;
72
+ const lang = smNormalizeFenceLang(match[1] ?? "");
73
+ const body = match[2] ?? "";
74
+
75
+ if (lang !== STORYBOARD_CANVAS_LANG) continue;
76
+
77
+ if (matchStart > cursor) {
78
+ const text = markdown.slice(cursor, matchStart);
79
+ segments.push({ type: "markdown", text, lineOffset });
80
+ lineOffset += smCountLines(text);
81
+ }
82
+
83
+ const parsed = parseStoryboardCanvas(body.trim());
84
+ segments.push({
85
+ type: "storyboard",
86
+ raw: body,
87
+ data: parsed.data,
88
+ error: parsed.error,
89
+ warnings: parsed.warnings,
90
+ start: matchStart,
91
+ end: matchEnd,
92
+ lineOffset,
93
+ });
94
+ lineOffset += smCountLines(full);
95
+ cursor = matchEnd;
96
+ }
97
+
98
+ if (cursor < markdown.length || segments.length === 0) {
99
+ segments.push({
100
+ type: "markdown",
101
+ text: markdown.slice(cursor),
102
+ lineOffset,
103
+ });
104
+ }
105
+
106
+ return segments;
107
+ }
108
+
109
+ export function replaceStoryboardSegment(markdown, segment, nextData) {
110
+ const nextBlock = [
111
+ `\`\`\`${STORYBOARD_CANVAS_LANG}`,
112
+ serializeStoryboardCanvas(nextData),
113
+ "```",
114
+ ].join("\n");
115
+ return `${markdown.slice(0, segment.start)}${nextBlock}${markdown.slice(
116
+ segment.end,
117
+ )}`;
118
+ }
@@ -0,0 +1,180 @@
1
+ // Storyboard-canvas schema: the defensive parser/serializer for the JSON block a
2
+ // plan.md carries inside each ```storyboard-canvas``` fence. Ported 1:1 from the
3
+ // add-in's src/ui/src/pages/plan-review/storyboard-schema.ts (TS types dropped;
4
+ // every constant, regex, clamp and branch identical). This module also ships into
5
+ // the plan gate's browser bundle, concatenated with the other plan modules into a
6
+ // single IIFE — so module-scope helpers use an `sb`/`SB_` prefix to stay unique
7
+ // across that shared scope, while the exported API names match the add-in exactly.
8
+
9
+ export const STORYBOARD_CANVAS_LANG = "storyboard-canvas";
10
+ export const STORYBOARD_WIDTH = 1600;
11
+ export const STORYBOARD_HEIGHT = 900;
12
+
13
+ export const BODY_SAFE_AREA = {
14
+ x: 80,
15
+ y: 110,
16
+ w: 1440,
17
+ h: 670,
18
+ };
19
+
20
+ export const MIN_BLOCK_WIDTH = 180;
21
+ export const MIN_BLOCK_HEIGHT = 96;
22
+ export const MAX_BLOCKS = 8;
23
+ const SB_MAX_ID_LENGTH = 64;
24
+ export const MAX_DESIGN_ELEMENT_LENGTH = 56;
25
+ export const MAX_CONTENT_LENGTH = 28;
26
+ const SB_MAX_KEY_MESSAGE_LENGTH = 180;
27
+ const SB_MAX_VISUAL_FAMILY_LENGTH = 64;
28
+ const SB_MAX_DEPTH_NOTES = 6;
29
+ const SB_MAX_DEPTH_NOTE_LENGTH = 220;
30
+
31
+ function sbIsRecord(value) {
32
+ return value !== null && typeof value === "object" && !Array.isArray(value);
33
+ }
34
+
35
+ function sbAsTrimmedString(value, maxLength) {
36
+ if (typeof value !== "string") return "";
37
+ return value.trim().slice(0, maxLength);
38
+ }
39
+
40
+ function sbAsFiniteNumber(value, fallback) {
41
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
42
+ }
43
+
44
+ function sbClamp(value, min, max) {
45
+ return Math.min(Math.max(value, min), max);
46
+ }
47
+
48
+ function sbNormalizeBlock(value, index, warnings) {
49
+ if (!sbIsRecord(value)) {
50
+ warnings.push(`Block ${index + 1} is not an object and was ignored.`);
51
+ return null;
52
+ }
53
+
54
+ const id = sbAsTrimmedString(value.id, SB_MAX_ID_LENGTH) || `block-${index + 1}`;
55
+ const designElement = sbAsTrimmedString(
56
+ value.designElement,
57
+ MAX_DESIGN_ELEMENT_LENGTH,
58
+ );
59
+ const content = sbAsTrimmedString(value.content, MAX_CONTENT_LENGTH);
60
+ if (!designElement || !content) {
61
+ warnings.push(
62
+ `Block "${id}" needs both designElement and content and was ignored.`,
63
+ );
64
+ return null;
65
+ }
66
+
67
+ const minW = MIN_BLOCK_WIDTH;
68
+ const minH = MIN_BLOCK_HEIGHT;
69
+ const maxW = BODY_SAFE_AREA.w;
70
+ const maxH = BODY_SAFE_AREA.h;
71
+ const w = sbClamp(sbAsFiniteNumber(value.w, 360), minW, maxW);
72
+ const h = sbClamp(sbAsFiniteNumber(value.h, 150), minH, maxH);
73
+ const x = sbClamp(
74
+ sbAsFiniteNumber(value.x, BODY_SAFE_AREA.x),
75
+ BODY_SAFE_AREA.x,
76
+ BODY_SAFE_AREA.x + BODY_SAFE_AREA.w - w,
77
+ );
78
+ const y = sbClamp(
79
+ sbAsFiniteNumber(value.y, BODY_SAFE_AREA.y),
80
+ BODY_SAFE_AREA.y,
81
+ BODY_SAFE_AREA.y + BODY_SAFE_AREA.h - h,
82
+ );
83
+ const emphasis =
84
+ value.emphasis === "primary" ||
85
+ value.emphasis === "secondary" ||
86
+ value.emphasis === "tertiary"
87
+ ? value.emphasis
88
+ : undefined;
89
+
90
+ return {
91
+ id,
92
+ x,
93
+ y,
94
+ w,
95
+ h,
96
+ ...(emphasis ? { emphasis } : {}),
97
+ designElement,
98
+ content,
99
+ };
100
+ }
101
+
102
+ export function parseStoryboardCanvas(raw) {
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(raw);
106
+ } catch {
107
+ return {
108
+ data: null,
109
+ warnings: [],
110
+ error: "Storyboard JSON could not be parsed.",
111
+ };
112
+ }
113
+
114
+ if (!sbIsRecord(parsed)) {
115
+ return {
116
+ data: null,
117
+ warnings: [],
118
+ error: "Storyboard data must be a JSON object.",
119
+ };
120
+ }
121
+
122
+ const warnings = [];
123
+ if (parsed.version !== 1) {
124
+ warnings.push("Storyboard version was normalized to 1.");
125
+ }
126
+
127
+ const slide = Math.max(1, Math.round(sbAsFiniteNumber(parsed.slide, 1)));
128
+ const rawBlocks = Array.isArray(parsed.blocks) ? parsed.blocks : [];
129
+ if (!Array.isArray(parsed.blocks)) {
130
+ warnings.push("Storyboard blocks must be an array.");
131
+ }
132
+
133
+ const blocks = rawBlocks
134
+ .slice(0, MAX_BLOCKS)
135
+ .map((block, index) => sbNormalizeBlock(block, index, warnings))
136
+ .filter((block) => block !== null);
137
+
138
+ if (rawBlocks.length > MAX_BLOCKS) {
139
+ warnings.push(`Only the first ${MAX_BLOCKS} storyboard blocks are shown.`);
140
+ }
141
+
142
+ if (blocks.length === 0) {
143
+ return {
144
+ data: null,
145
+ warnings,
146
+ error: "Storyboard needs at least one valid block.",
147
+ };
148
+ }
149
+
150
+ const keyMessage = sbAsTrimmedString(parsed.keyMessage, SB_MAX_KEY_MESSAGE_LENGTH);
151
+ const visualFamily = sbAsTrimmedString(
152
+ parsed.visualFamily,
153
+ SB_MAX_VISUAL_FAMILY_LENGTH,
154
+ );
155
+ const rawContentNotes = Array.isArray(parsed.contentNotes)
156
+ ? parsed.contentNotes
157
+ : parsed.depthNotes;
158
+ const contentNotes = Array.isArray(rawContentNotes)
159
+ ? rawContentNotes
160
+ .slice(0, SB_MAX_DEPTH_NOTES)
161
+ .map((note) => sbAsTrimmedString(note, SB_MAX_DEPTH_NOTE_LENGTH))
162
+ .filter(Boolean)
163
+ : undefined;
164
+
165
+ return {
166
+ data: {
167
+ version: 1,
168
+ slide,
169
+ blocks,
170
+ ...(visualFamily ? { visualFamily } : {}),
171
+ ...(keyMessage ? { keyMessage } : {}),
172
+ ...(contentNotes?.length ? { contentNotes } : {}),
173
+ },
174
+ warnings,
175
+ };
176
+ }
177
+
178
+ export function serializeStoryboardCanvas(data) {
179
+ return JSON.stringify(data, null, 2);
180
+ }
@@ -0,0 +1,32 @@
1
+ import { splitPlanMarkdownIntoSegments } from "./storyboard-markdown.mjs";
2
+
3
+ /**
4
+ * Split a per-slide concept batch into the dependency waves expected by the
5
+ * add-in: independent slides and family anchors first, family variants second.
6
+ */
7
+ export function conceptGenerationWaves(planMarkdown, slideCount) {
8
+ const groups = new Map();
9
+ for (const segment of splitPlanMarkdownIntoSegments(planMarkdown)) {
10
+ if (segment.type !== "storyboard" || !segment.data) continue;
11
+ const family = segment.data.visualFamily?.trim();
12
+ const slide = segment.data.slide;
13
+ if (!family || !Number.isFinite(slide)) continue;
14
+ groups.set(family, [...(groups.get(family) ?? []), slide]);
15
+ }
16
+
17
+ const variants = new Set();
18
+ for (const unsorted of groups.values()) {
19
+ const members = [...new Set(unsorted)].sort((a, b) => a - b);
20
+ if (members.length < 2) continue;
21
+ for (const slide of members.slice(1)) variants.add(slide);
22
+ }
23
+
24
+ const remaining = Array.from(
25
+ { length: Math.max(0, slideCount - 1) },
26
+ (_, index) => index + 2,
27
+ );
28
+ return {
29
+ firstWave: remaining.filter((slide) => !variants.has(slide)),
30
+ variants: remaining.filter((slide) => variants.has(slide)),
31
+ };
32
+ }