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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/AGENTS.md +65 -15
- package/README.md +36 -2
- package/bin/slidemind.mjs +1609 -225
- package/install.ps1 +70 -0
- package/install.sh +65 -0
- package/package.json +3 -1
- package/skills/create-slides/SKILL.md +47 -19
- package/skills/search/SKILL.md +26 -18
- package/src/bridge/build-and-push.mjs +431 -38
- package/src/bridge/capture.mjs +12 -1
- package/src/bridge/client.mjs +1214 -107
- package/src/bridge/config.mjs +193 -1
- package/src/bridge/engine-download.mjs +61 -8
- package/src/bridge/graph-auth.mjs +859 -0
- package/src/bridge/http-json.mjs +15 -6
- package/src/bridge/secrets.mjs +75 -0
- package/src/bridge/setup-claim.mjs +6 -0
- package/src/builders/build.mjs +3 -1
- package/src/builders/gate-server.mjs +156 -0
- package/src/builders/gate-session.js +355 -0
- package/src/builders/plan-overlay.js +507 -225
- package/src/builders/plan.mjs +169 -98
- package/src/builders/resolve-skill.mjs +64 -2
- package/src/builders/review-overlay.js +390 -302
- package/src/builders/review.mjs +136 -50
- package/src/lib/adopt.mjs +407 -0
- package/src/lib/conversions.mjs +263 -0
- package/src/lib/overwrite-guard.mjs +91 -0
- package/src/lib/reconcile-ack.mjs +46 -0
- package/src/lib/reconcile.mjs +306 -0
- package/src/lib/slide-guids.mjs +143 -0
- package/src/pptx/fill/engine.mjs +181 -135
- package/src/pptx/hidden-ole.mjs +22 -1
- package/src/pptx/slide-swap.mjs +23 -1
- package/src/pptx/tags.mjs +676 -0
package/src/builders/review.mjs
CHANGED
|
@@ -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
|
|
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
|
|
18
|
-
// as
|
|
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
|
|
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
|
-
|
|
165
|
+
rows.forEach((c, i) => {
|
|
118
166
|
const region = c.resolvedTarget || null;
|
|
119
167
|
const parsed = region ? parseLoc(region.loc) : null;
|
|
120
|
-
|
|
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((
|
|
128
|
-
const exY = b.h > 0 ? pct((
|
|
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(
|
|
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(
|
|
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.
|
|
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
|
-
|
|
164
|
-
|
|
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 (
|
|
168
|
-
out.push("
|
|
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,
|
|
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 {
|
|
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:
|
|
283
|
+
version: 2,
|
|
217
284
|
decision: data.decision ?? null,
|
|
218
|
-
comments:
|
|
285
|
+
comments: normalizeReviewComments(data.comments),
|
|
286
|
+
at: data.at ?? null,
|
|
219
287
|
};
|
|
220
288
|
} catch {
|
|
221
|
-
return {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
-
|
|
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:
|
|
539
|
+
version: 2,
|
|
456
540
|
decision: decision.decision,
|
|
457
|
-
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(
|
|
552
|
+
process.stdout.write(
|
|
553
|
+
formatCommentsForHarness(deckDir, comments, { drained: !!decision.drained }),
|
|
554
|
+
);
|
|
469
555
|
}
|
|
470
556
|
return decision;
|
|
471
557
|
}
|