slidemind 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,14 +8,27 @@
8
8
  // copy-paste round-trip. The verdict is printed to stdout for the harness:
9
9
  // - approve -> "DECISION: approved" (the agent then runs the export the user
10
10
  // asked for: slidemind push / build-pptx).
11
- // - comments -> each comment with its slides/<f>.jsx:line:col, the source line,
11
+ // - comments -> each pin with its slides/<f>.jsx:line:col, the source line,
12
12
  // the click position, and a readonly-layout note where relevant
13
13
  // (the agent edits the cited files and re-runs review).
14
14
  //
15
+ // ASYNC REVIEW (the drain loop, shared with the plan gate). Saving a pin sends
16
+ // it AT ONCE and the tab does not veil over: the preview stays fully browsable
17
+ // and pinnable in WORKING mode, and further pins queue in the tab's
18
+ // sessionStorage. When the agent re-runs this command the waiting tab reloads
19
+ // (heartbeat claim) and its boot script POSTs that backlog to /review/backlog.
20
+ // A non-empty backlog resolves the gate immediately with those pins (a "drain
21
+ // round": DECISION: comments prints and the command exits without ever
22
+ // parking), so several consecutive comment rounds are the normal shape of a
23
+ // review. An empty backlog never POSTs and the run parks and blocks as before.
24
+ // Approve is refused once a backlog arrived in this run — that render is
25
+ // already superseded by pins the agent has been told to fix.
26
+ //
15
27
  // Host-free: it reviews the HTML preview BEFORE any .pptx render, so it needs a
16
28
  // 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.
29
+ // Each round persists to <deck>/review.json (v2, one row per anchored pin) as
30
+ // the harness's recovery copy AND as the ledger seed a fresh tab renders the
31
+ // previous round's read-only "ghost" pins from.
19
32
  //
20
33
  // Port + tab-reuse contract: each run binds a deterministic per-deck port (a
21
34
  // hash of the deck path folded into a fixed high band, 40000..40999) so a tab
@@ -42,6 +55,10 @@ import {
42
55
  serveStaticFile,
43
56
  createDecisionGate,
44
57
  createHeartbeat,
58
+ bundleGateSession,
59
+ normalizeGateComments,
60
+ serveBacklogPost,
61
+ serveDecisionPost,
45
62
  listenWithFallback,
46
63
  preferredGatePort,
47
64
  serveAndOpen as gateServeAndOpen,
@@ -104,31 +121,67 @@ function readSourceLine(deckDir, parsed) {
104
121
  }
105
122
  }
106
123
 
124
+ // Normalize the browser's pin array into the persisted/printed v2 row shape.
125
+ // Every row keeps its spatial anchor (jsxPath + resolvedTarget.loc + the click
126
+ // position as FULL-PRECISION 0..1 fractions of the slide); the shared core adds
127
+ // identity (`id` dedupes a client retry, `round` says which render the pin was
128
+ // dropped on — a queued pin is delivered a round later, so the two can differ)
129
+ // and drops text-less rows.
130
+ //
131
+ // x/y are deliberately NOT quantized. Whole percent was ±6px of ghost-pin drift
132
+ // on a 1280px stage and collapsed every sub-1% offset; percent is a DISPLAY
133
+ // unit, applied once in formatCommentsForHarness.
134
+ export function normalizeReviewComments(raw) {
135
+ return normalizeGateComments(raw, (c) => ({
136
+ jsxPath: typeof c.jsxPath === "string" ? c.jsxPath : "",
137
+ resolvedTarget:
138
+ c.resolvedTarget && typeof c.resolvedTarget === "object" ? c.resolvedTarget : null,
139
+ x: clamp01(Number(c.x) || 0),
140
+ y: clamp01(Number(c.y) || 0),
141
+ // The status a delivered pin carries: the agent is about to work it. A
142
+ // later run serves these back as the ledger seed, where they stay "sent" —
143
+ // the agent has them, but nothing has answered them yet.
144
+ status: c.status === "queued" || c.status === "addressed" ? c.status : "sent",
145
+ }));
146
+ }
147
+
107
148
  // Render the comment round for the harness — ports the add-in's
108
149
  // formatPreviewCommentsForLlm, adapted to CLI commands.
109
- export function formatCommentsForHarness(deckDir, comments) {
110
- const n = comments.length;
150
+ export function formatCommentsForHarness(deckDir, comments, { drained = false } = {}) {
151
+ const rows = normalizeReviewComments(comments);
152
+ const n = rows.length;
111
153
  const out = [];
112
154
  out.push("DECISION: comments");
113
155
  out.push(
114
156
  `The user left ${n} comment${n === 1 ? "" : "s"} on the preview. Nothing was exported.`,
115
157
  );
158
+ if (drained) {
159
+ out.push(
160
+ "(These were queued while you were fixing and arrived with this run — that is " +
161
+ "why the gate returned immediately instead of waiting.)",
162
+ );
163
+ }
116
164
  out.push("");
117
- comments.forEach((c, i) => {
165
+ rows.forEach((c, i) => {
118
166
  const region = c.resolvedTarget || null;
119
167
  const parsed = region ? parseLoc(region.loc) : null;
120
- const head = `[${i + 1}] ${c.jsxPath} — `;
168
+ // The round tag is load-bearing: a pin queued during a fix names the render
169
+ // it was dropped on, which may be one behind the files you are looking at.
170
+ const head = `[${i + 1}, round ${c.round}] ${c.jsxPath} — `;
171
+ // Percent is the unit the AGENT reads; the stored anchor stays full precision.
172
+ const x = clamp01(c.x);
173
+ const y = clamp01(c.y);
121
174
  if (region && parsed) {
122
175
  const cls = region.className ? ` class="${region.className}"` : "";
123
176
  out.push(`${head}element <${region.tag}${cls}> at ${region.loc}`);
124
177
  const srcLine = readSourceLine(deckDir, parsed);
125
178
  if (srcLine) out.push(` Source line ${parsed.line}: ${srcLine}`);
126
179
  const b = region.bbox || { x: 0, y: 0, w: 0, h: 0 };
127
- const exX = b.w > 0 ? pct((c.point.x - b.x) / b.w) : 50;
128
- const exY = b.h > 0 ? pct((c.point.y - b.y) / b.h) : 50;
180
+ const exX = b.w > 0 ? pct((x - b.x) / b.w) : 50;
181
+ const exY = b.h > 0 ? pct((y - b.y) / b.h) : 50;
129
182
  out.push(
130
183
  ` Click position: ${exX}%/${exY}% within the element, ` +
131
- `${pct(c.point.x)}%/${pct(c.point.y)}% of the slide.`,
184
+ `${pct(x)}%/${pct(y)}% of the slide.`,
132
185
  );
133
186
  if (parsed.jsxPath.startsWith("layouts/")) {
134
187
  out.push(
@@ -139,17 +192,13 @@ export function formatCommentsForHarness(deckDir, comments) {
139
192
  }
140
193
  } else {
141
194
  out.push(
142
- `${head}slide-level note at ${pct(c.point.x)}%/${pct(c.point.y)}% ` +
195
+ `${head}slide-level note at ${pct(x)}%/${pct(y)}% ` +
143
196
  `of the slide (no specific element).`,
144
197
  );
145
198
  }
146
- out.push(` Comment: ${c.comment}`);
199
+ out.push(` Comment: ${c.text}`);
147
200
  out.push("");
148
201
  });
149
- // Imperative, stateful tail (mirrors plan.mjs's formatPlanFeedbackForHarness):
150
- // after a comments round the user's tab waits for the NEXT review run — an
151
- // agent that ends its turn here deadlocks the session.
152
- //
153
202
  // The recovery pointer sits in the TAIL deliberately: when a harness capture
154
203
  // truncates long output it usually drops the TOP, so the pointer to the full
155
204
  // persisted round is what survives.
@@ -160,12 +209,19 @@ export function formatCommentsForHarness(deckDir, comments) {
160
209
  `instead of guessing or asking the user to re-type anything.`,
161
210
  );
162
211
  out.push("");
163
- out.push("The user's browser tab is still open, waiting for the next round. They are");
164
- out.push("waiting on YOU now — do both steps in this same turn:");
212
+ // Imperative, stateful tail (mirrors plan.mjs's formatPlanCommentsForHarness):
213
+ // the reviewer's tab is LIVE while you fix — they keep browsing the preview and
214
+ // keep dropping pins — and those pins are only delivered by the next
215
+ // `slidemind review` run. An agent that ends its turn here strands them.
216
+ out.push("Fix exactly what the comments ask for at the cited lines — a pin may");
217
+ out.push("reference the PREVIOUS render (see its round tag), so read the file before");
218
+ out.push("you edit. Then re-run the gate in the same turn:");
165
219
  out.push(" 1. Edit the cited slides/*.jsx file(s) at the given lines.");
166
220
  out.push(` 2. Re-run: slidemind review ${path.basename(deckDir)}`);
167
- out.push("The re-run rebuilds the preview and reopens their same tab (the prior round");
168
- out.push("shows as ghost pins). Do NOT end your turn, do NOT export anything, and do");
221
+ out.push("The re-run rebuilds the preview and reopens their same tab (addressed pins");
222
+ out.push("show as ghost pins). The user may queue further comments while you fix; they");
223
+ out.push("are delivered on that next run, which can return immediately with them — that");
224
+ out.push("is normal, not an error. Do NOT end your turn, do NOT export anything, and do");
169
225
  out.push("NOT send the user back to the browser before step 2.");
170
226
  return out.join("\n") + "\n";
171
227
  }
@@ -190,35 +246,47 @@ export function formatReviewPendingForHarness(deckDir, { timeoutMs, pageLoaded,
190
246
  ].join("\n") + "\n";
191
247
  }
192
248
 
193
- function injectReview(html, previousComments, overlayJs, logoMark = null, round = null) {
249
+ function injectReview(html, cfgExtra, overlayJs, logoMark = null, round = null) {
194
250
  const cfg = safeInlineJson({
195
251
  endpoint: "/review",
252
+ backlogEndpoint: "/review/backlog",
196
253
  heartbeat: "/__review/heartbeat",
197
254
  round,
198
255
  pollMs: POLL_MS,
199
256
  pollTtlMs: POLL_TTL_MS,
200
- previousComments,
201
257
  logoMark,
258
+ ...cfgExtra,
202
259
  });
203
260
  const overlay = overlayJs.replace(/<\/script>/g, "<\\/script>");
261
+ // The shared gate session goes in FIRST — the overlay reads it at boot.
262
+ const gateJs = bundleGateSession().replace(/<\/script>/g, "<\\/script>");
204
263
  const block =
205
264
  `<script>window.__SLIDEMIND_REVIEW__ = ${cfg};</script>\n` +
265
+ `<script>\n${gateJs}\n</script>\n` +
206
266
  `<script>\n${overlay}\n</script>\n</body>`;
207
267
  return html.includes("</body>") ? html.replace("</body>", block) : html + block;
208
268
  }
209
269
 
270
+ const EMPTY_STATE = { version: 2, decision: null, comments: [] };
271
+
272
+ // v2 only — the file is ephemeral per-deck round state, so older shapes are not
273
+ // read (clean break, no compat shim). `decision` must always be explicit: it
274
+ // drives the tab-reuse grace window in runReview (a waiting tab can only exist
275
+ // after a prior comments round).
210
276
  export function loadReviewState(deckDir) {
211
277
  const f = path.join(deckDir, REVIEW_STATE_FILE);
212
- if (!fs.existsSync(f)) return { version: 1, decision: null, comments: [] };
278
+ if (!fs.existsSync(f)) return { ...EMPTY_STATE };
213
279
  try {
214
280
  const data = JSON.parse(fs.readFileSync(f, "utf8"));
281
+ if (!data || data.version !== 2) return { ...EMPTY_STATE };
215
282
  return {
216
- version: 1,
283
+ version: 2,
217
284
  decision: data.decision ?? null,
218
- comments: Array.isArray(data.comments) ? data.comments : [],
285
+ comments: normalizeReviewComments(data.comments),
286
+ at: data.at ?? null,
219
287
  };
220
288
  } catch {
221
- return { version: 1, decision: null, comments: [] };
289
+ return { ...EMPTY_STATE };
222
290
  }
223
291
  }
224
292
 
@@ -241,7 +309,7 @@ export function preferredPortForDeck(deckDir) {
241
309
  // presentation.html must already exist in deckDir (build it first).
242
310
  export async function createReviewServer({
243
311
  deckDir,
244
- previousComments = [],
312
+ ledgerSeed = [],
245
313
  overlayJs,
246
314
  logoMark = null,
247
315
  port = 0,
@@ -255,7 +323,17 @@ export async function createReviewServer({
255
323
  overlayJs ?? fs.readFileSync(OVERLAY_PATH, "utf8");
256
324
  const indexHtml = injectReview(
257
325
  fs.readFileSync(path.join(deckDir, "presentation.html"), "utf8"),
258
- previousComments,
326
+ {
327
+ // deck: the sessionStorage namespace for this deck's pin ledger.
328
+ // ledgerSeed: the previous round's pins — a FRESH tab (no session) renders
329
+ // them as delivered-but-unanswered rows; a reused tab merges them by id
330
+ // under its own. The ROUND identity is the heartbeat nonce (hb.round), not
331
+ // a content hash: a fix round that edited nothing hashes identically, and
332
+ // an unreadable slides/ dir hashed to "" — both made the transition
333
+ // undetectable, which stranded pins in "sent" forever.
334
+ deck: path.basename(path.resolve(deckDir)),
335
+ ledgerSeed: normalizeReviewComments(ledgerSeed),
336
+ },
259
337
  overlay,
260
338
  logoMark,
261
339
  hb.round,
@@ -266,25 +344,30 @@ export async function createReviewServer({
266
344
  const u = new URL(req.url, "http://127.0.0.1");
267
345
  const pathname = decodeURIComponent(u.pathname);
268
346
 
269
- if (req.method === "POST" && pathname === "/review") {
270
- let body = "";
271
- req.on("data", (chunk) => {
272
- body += chunk;
273
- if (body.length > 5e6) req.destroy();
347
+ // Both POST routes are gate-server's shared handlers: the backlog drain and
348
+ // the decision + 409 guard behave identically in both gates. `gate.settled()`
349
+ // guards BOTH: once one tab's drain or decision resolved this run, a late
350
+ // POST from another waiting tab is refused instead of being acked and
351
+ // silently dropped (every waiter reloads into a new round, so two tabs
352
+ // posting into one run is the normal multi-window case, not an anomaly).
353
+ if (req.method === "POST" && pathname === "/review/backlog") {
354
+ serveBacklogPost(req, res, {
355
+ normalize: normalizeReviewComments,
356
+ isSuperseded: () => gate.settled(),
357
+ onDrained: (comments) => {
358
+ gate.resolve({ decision: "comments", comments, drained: true });
359
+ },
274
360
  });
275
- req.on("end", () => {
276
- let payload;
277
- try {
278
- payload = JSON.parse(body || "{}");
279
- } catch {
280
- payload = {};
281
- }
282
- res.writeHead(200, { "Content-Type": MIME[".json"] });
283
- res.end(JSON.stringify({ ok: true }));
284
- gate.resolve({
285
- decision: payload.decision === "comments" ? "comments" : "approve",
286
- comments: Array.isArray(payload.comments) ? payload.comments : [],
287
- });
361
+ return;
362
+ }
363
+
364
+ if (req.method === "POST" && pathname === "/review") {
365
+ serveDecisionPost(req, res, {
366
+ normalize: normalizeReviewComments,
367
+ isSuperseded: () => gate.settled(),
368
+ onDecision: (decision, _payload, comments) => {
369
+ gate.resolve({ decision, comments, drained: false });
370
+ },
288
371
  });
289
372
  return;
290
373
  }
@@ -401,7 +484,7 @@ export async function runReview({
401
484
  const wantPort = port == null ? preferredPortForDeck(deckDir) : port;
402
485
  const server = await createReviewServer({
403
486
  deckDir,
404
- previousComments: prev.comments || [],
487
+ ledgerSeed: prev.comments || [],
405
488
  logoMark,
406
489
  port: wantPort,
407
490
  });
@@ -451,10 +534,11 @@ export async function runReview({
451
534
  server.close();
452
535
  }
453
536
 
537
+ const comments = normalizeReviewComments(decision.comments);
454
538
  saveReviewState(deckDir, {
455
- version: 1,
539
+ version: 2,
456
540
  decision: decision.decision,
457
- comments: decision.comments,
541
+ comments,
458
542
  at: new Date().toISOString(),
459
543
  });
460
544
 
@@ -465,7 +549,9 @@ export async function runReview({
465
549
  "for (slidemind push / slidemind build-pptx). One approval covers the task.\n",
466
550
  );
467
551
  } else {
468
- process.stdout.write(formatCommentsForHarness(deckDir, decision.comments));
552
+ process.stdout.write(
553
+ formatCommentsForHarness(deckDir, comments, { drained: !!decision.drained }),
554
+ );
469
555
  }
470
556
  return decision;
471
557
  }