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.
- package/CLAUDE.md +7 -6
- package/README.md +19 -7
- package/bin/slidemind.mjs +377 -39
- package/package.json +2 -1
- package/skills/create-slides/SKILL.md +66 -15
- package/skills/search/SKILL.md +15 -2
- package/src/bridge/build-and-push.mjs +11 -0
- package/src/bridge/bundle.mjs +15 -4
- package/src/bridge/capture.mjs +20 -2
- package/src/bridge/client.mjs +99 -0
- package/src/builders/build.mjs +53 -23
- package/src/builders/gate-server.mjs +163 -22
- package/src/builders/plan-markdown-edit.mjs +120 -0
- package/src/builders/plan-overlay.js +1261 -230
- package/src/builders/plan.mjs +146 -25
- package/src/builders/red-thread-backbone.mjs +194 -0
- package/src/builders/review-overlay.js +44 -7
- package/src/builders/review.mjs +72 -10
- package/src/builders/storyboard-markdown.mjs +118 -0
- package/src/builders/storyboard-schema.mjs +180 -0
- package/src/builders/visual-families.mjs +32 -0
- package/src/pptx/app-props.mjs +222 -0
- package/src/pptx/hidden-ole.mjs +125 -0
- package/src/pptx/image-extensions.mjs +190 -0
- package/src/pptx/ooxml.mjs +169 -0
- package/src/pptx/read-local.mjs +206 -0
- package/src/pptx/slide-swap.mjs +429 -0
- package/src/pptx/zip.mjs +30 -0
|
@@ -164,25 +164,86 @@ export function listenWithFallback(server, port, host, onFallback) {
|
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
// Per-run heartbeat state for one gate server. The gate mounts `respond` under
|
|
167
|
-
// its own heartbeat path; a waiting tab polls it
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
167
|
+
// its own heartbeat path; a waiting tab polls it with its own round nonce and
|
|
168
|
+
// visibility state, and the response's service marker + round nonce + `action`
|
|
169
|
+
// tell it what to do (a foreign app or the other gate on this port must never
|
|
170
|
+
// trigger a reload; same nonce = this run's own orphan — never a waiter).
|
|
171
|
+
//
|
|
172
|
+
// The gate must ALWAYS end up visibly open in front of the user, so tab reuse
|
|
173
|
+
// is granted only to a waiting tab that reports itself VISIBLE (`visible: true`,
|
|
174
|
+
// from the overlay's document.visibilityState) inside the grace window. A tab
|
|
175
|
+
// buried in a background window must not silently swallow the round — it keeps
|
|
176
|
+
// polling (`action: "wait"`) until the grace window decides, then either
|
|
177
|
+
// reloads (`"reload"`, a visible tab won reuse — every waiter follows it into
|
|
178
|
+
// the new round) or is told the round opened in a fresh browser tab and it
|
|
179
|
+
// should retire (`"close"`).
|
|
180
|
+
//
|
|
181
|
+
// `waitForHeartbeat(ms)` is the server side of the open-browser grace window:
|
|
182
|
+
// it resolves true the moment a visible waiter claims reuse, false when the
|
|
183
|
+
// grace expires with none. The gate then calls `markFresh()` before opening a
|
|
184
|
+
// new browser tab so late pollers get `action: "close"` instead of reloading
|
|
185
|
+
// into a duplicate.
|
|
186
|
+
//
|
|
187
|
+
// `notePageLoad()` / `waitForPageLoad(ms)` / `pageLoaded()` track whether the
|
|
188
|
+
// gate PAGE was ever actually fetched: the gate's request handler calls
|
|
189
|
+
// `notePageLoad()` whenever it serves the root page, and serveAndOpen waits on
|
|
190
|
+
// it after launching the opener — the only end-to-end proof that a browser
|
|
191
|
+
// really opened the gate (a spawned opener exiting 0 proves nothing on a box
|
|
192
|
+
// with a broken default-browser association). `pageLoaded()` is re-checked at
|
|
193
|
+
// decision-timeout time so the DECISION: pending contract can tell the agent
|
|
194
|
+
// whether the user ever saw the gate at all.
|
|
172
195
|
export function createHeartbeat({ service }) {
|
|
173
196
|
const round = crypto.randomUUID();
|
|
174
|
-
|
|
197
|
+
// null = undecided (grace window running), "reuse" = a visible waiting tab
|
|
198
|
+
// reloads into this round, "fresh" = a new browser tab was opened for it.
|
|
199
|
+
let mode = null;
|
|
175
200
|
const waiters = [];
|
|
201
|
+
let pageSeen = false;
|
|
202
|
+
const pageWaiters = [];
|
|
176
203
|
return {
|
|
177
204
|
round,
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
while (
|
|
205
|
+
notePageLoad() {
|
|
206
|
+
pageSeen = true;
|
|
207
|
+
while (pageWaiters.length) pageWaiters.shift()();
|
|
208
|
+
},
|
|
209
|
+
pageLoaded() {
|
|
210
|
+
return pageSeen;
|
|
211
|
+
},
|
|
212
|
+
waitForPageLoad(ms) {
|
|
213
|
+
if (pageSeen) return Promise.resolve(true);
|
|
214
|
+
if (!(ms > 0)) return Promise.resolve(false);
|
|
215
|
+
return new Promise((resolve) => {
|
|
216
|
+
const t = setTimeout(() => resolve(false), ms);
|
|
217
|
+
pageWaiters.push(() => {
|
|
218
|
+
clearTimeout(t);
|
|
219
|
+
resolve(true);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
},
|
|
223
|
+
respond(res, { visible = false, tabRound = null } = {}) {
|
|
224
|
+
// A poll carrying THIS run's nonce is our own page (or an orphan of this
|
|
225
|
+
// run) — never a previous-round waiter, never a reuse claim.
|
|
226
|
+
const isWaiter = tabRound !== round;
|
|
227
|
+
if (isWaiter && mode === null && visible) {
|
|
228
|
+
mode = "reuse";
|
|
229
|
+
while (waiters.length) waiters.shift()();
|
|
230
|
+
}
|
|
231
|
+
const action = !isWaiter
|
|
232
|
+
? "none"
|
|
233
|
+
: mode === "reuse"
|
|
234
|
+
? "reload"
|
|
235
|
+
: mode === "fresh"
|
|
236
|
+
? "close"
|
|
237
|
+
: "wait";
|
|
181
238
|
res.writeHead(200, { "Content-Type": MIME[".json"], "Cache-Control": "no-store" });
|
|
182
|
-
res.end(JSON.stringify({ ok: true, service, round }));
|
|
239
|
+
res.end(JSON.stringify({ ok: true, service, round, action }));
|
|
240
|
+
},
|
|
241
|
+
markFresh() {
|
|
242
|
+
if (mode === null) mode = "fresh";
|
|
183
243
|
},
|
|
184
244
|
waitForHeartbeat(ms) {
|
|
185
|
-
if (
|
|
245
|
+
if (mode === "reuse") return Promise.resolve(true);
|
|
246
|
+
if (mode === "fresh" || !(ms > 0)) return Promise.resolve(false);
|
|
186
247
|
return new Promise((resolve) => {
|
|
187
248
|
const t = setTimeout(() => resolve(false), ms);
|
|
188
249
|
waiters.push(() => {
|
|
@@ -194,30 +255,110 @@ export function createHeartbeat({ service }) {
|
|
|
194
255
|
};
|
|
195
256
|
}
|
|
196
257
|
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
258
|
+
// Browser-status lines for a gate's DECISION: pending output, shared by both
|
|
259
|
+
// gates so the wording is identical. A pending decision has two very different
|
|
260
|
+
// stories: the user saw the gate and hasn't answered (benign — just wait), or
|
|
261
|
+
// no tab ever loaded it (the browser open failed and they never saw anything —
|
|
262
|
+
// the agent must hand them the link). `pageLoaded` comes from the server's
|
|
263
|
+
// page-load tracker re-checked at timeout time, so a tab that opened late still
|
|
264
|
+
// counts. Returns [] when the caller has no status to report (url unknown),
|
|
265
|
+
// keeping older call sites and tests valid.
|
|
266
|
+
export function pendingBrowserLines({ pageLoaded = null, url = null } = {}) {
|
|
267
|
+
if (!url) return [];
|
|
268
|
+
if (pageLoaded) {
|
|
269
|
+
return [
|
|
270
|
+
`Browser status: the gate DID open in the user's browser (a tab loaded ${url})`,
|
|
271
|
+
"— they just haven't decided yet.",
|
|
272
|
+
];
|
|
273
|
+
}
|
|
274
|
+
return [
|
|
275
|
+
"Browser status: NO tab ever loaded the gate — the browser most likely FAILED",
|
|
276
|
+
"to open on the user's machine, so they never saw it. When you tell the user,",
|
|
277
|
+
`give them the gate link to open manually once you re-run: ${url}`,
|
|
278
|
+
"(the re-run binds the same address when its port is free, and prints it again).",
|
|
279
|
+
];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// How long serveAndOpen waits, after launching the opener, for the fresh tab
|
|
283
|
+
// to actually fetch the gate page before reporting the open as unconfirmed.
|
|
284
|
+
// Generous enough for a cold browser start; the gate blocks on the decision
|
|
285
|
+
// anyway, so this delay only defers one status line.
|
|
286
|
+
export const OPEN_CONFIRM_MS = 8000;
|
|
287
|
+
|
|
288
|
+
// When the opener chain FAILED outright, the user will never see the gate on
|
|
289
|
+
// their own — blocking the agent for the full decision timeout helps nobody.
|
|
290
|
+
// The gates instead give a manually-opened tab this long to appear (the URL was
|
|
291
|
+
// printed, the user may click it from the terminal), then return the pending
|
|
292
|
+
// contract early so the agent can hand the user the link.
|
|
293
|
+
export const OPEN_FAILED_GRACE_MS = 90 * 1000;
|
|
294
|
+
|
|
295
|
+
// Print the gate URL, then decide whether to open a browser. If a VISIBLE tab
|
|
296
|
+
// left waiting from a previous comments round pings the gate's heartbeat within
|
|
297
|
+
// the grace window, reuse it (it reloads itself) instead of opening a duplicate.
|
|
298
|
+
// In every other case — first run, no waiting tab, or only hidden/background
|
|
299
|
+
// tabs polling — the browser is ALWAYS opened, and `markOpenedFresh` retires
|
|
300
|
+
// the hidden waiters so they don't reload into a duplicate.
|
|
301
|
+
//
|
|
302
|
+
// Opening a browser from a CLI is error-prone, so the outcome is REPORTED, not
|
|
303
|
+
// assumed: the opener's own result says whether anything launched, and the
|
|
304
|
+
// server's page-load tracker (`waitForPageLoad`, fed by the gate serving its
|
|
305
|
+
// root page) is the end-to-end proof a tab really opened. Every branch prints
|
|
306
|
+
// one agent-facing `BROWSER:` line that (a) says explicitly whether the gate
|
|
307
|
+
// auto-opened and (b) always carries the URL, so the agent can either tell the
|
|
308
|
+
// user "it's on your screen" or hand them the link to open manually.
|
|
309
|
+
//
|
|
310
|
+
// Returns { reused, launched, confirmed, url }: `launched` = an opener
|
|
311
|
+
// plausibly ran (null when reused — nothing was launched); `confirmed` = a tab
|
|
312
|
+
// provably fetched the gate page (null when the server exposes no
|
|
313
|
+
// waitForPageLoad, i.e. unknown). `open` is required — gates pass their browser
|
|
314
|
+
// opener so this module stays dependency-light and tests inject a fake; a
|
|
315
|
+
// legacy/fake opener returning undefined counts as launched (only an explicit
|
|
316
|
+
// `false` means the chain failed). The message nouns are per-gate; defaults
|
|
317
|
+
// speak the preview gate's.
|
|
203
318
|
export async function serveAndOpen(server, {
|
|
204
319
|
open,
|
|
205
320
|
graceMs = GRACE_MS,
|
|
321
|
+
openConfirmMs = OPEN_CONFIRM_MS,
|
|
206
322
|
readyLabel = "Preview review",
|
|
207
323
|
reuseNoun = "review tab",
|
|
208
|
-
|
|
324
|
+
gateNoun = "review",
|
|
209
325
|
waitLine = null,
|
|
210
326
|
} = {}) {
|
|
211
327
|
process.stderr.write(`${readyLabel} ready at ${server.url}\n`); // URL always printed first
|
|
212
328
|
const reused = await server.waitForHeartbeat(graceMs);
|
|
329
|
+
let launched = null;
|
|
330
|
+
let confirmed = null;
|
|
213
331
|
if (reused) {
|
|
214
|
-
process.stderr.write(
|
|
332
|
+
process.stderr.write(
|
|
333
|
+
`BROWSER: reused the user's already-open ${reuseNoun} at ${server.url} — it reloaded into this round; no new window was opened.\n`,
|
|
334
|
+
);
|
|
335
|
+
confirmed = true;
|
|
215
336
|
} else {
|
|
216
|
-
|
|
217
|
-
|
|
337
|
+
if (server.markOpenedFresh) server.markOpenedFresh();
|
|
338
|
+
launched = (await open(server.url)) !== false;
|
|
339
|
+
if (launched && server.waitForPageLoad) {
|
|
340
|
+
confirmed = await server.waitForPageLoad(openConfirmMs);
|
|
341
|
+
}
|
|
342
|
+
if (!launched) {
|
|
343
|
+
process.stderr.write(
|
|
344
|
+
`BROWSER: could NOT be opened automatically (no opener worked on this machine). ` +
|
|
345
|
+
`The user must open the ${gateNoun} themselves — give them this link: ${server.url}\n`,
|
|
346
|
+
);
|
|
347
|
+
} else if (confirmed === false) {
|
|
348
|
+
process.stderr.write(
|
|
349
|
+
`BROWSER: open was attempted but NO tab has loaded the ${gateNoun} yet — it may not have opened. ` +
|
|
350
|
+
`If the user doesn't see it, give them this link to open manually: ${server.url}\n`,
|
|
351
|
+
);
|
|
352
|
+
} else {
|
|
353
|
+
// confirmed === true (a tab fetched the page) or null (server can't track
|
|
354
|
+
// page loads — trust the opener's success).
|
|
355
|
+
process.stderr.write(
|
|
356
|
+
`BROWSER: opened automatically — the ${gateNoun} is on the user's screen at ${server.url}.\n`,
|
|
357
|
+
);
|
|
358
|
+
}
|
|
218
359
|
}
|
|
219
360
|
// Spoken to the AGENT running this command: blocking here is the designed
|
|
220
361
|
// behavior, not a hang — don't kill the call because it sits quiet.
|
|
221
362
|
if (waitLine) process.stderr.write(`${waitLine}\n`);
|
|
222
|
-
return reused;
|
|
363
|
+
return { reused, launched, confirmed, url: server.url };
|
|
223
364
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline-edit round-trip for the plan gate's rendered markdown blocks.
|
|
3
|
+
*
|
|
4
|
+
* The plan preview renders each `contenteditable` block stamped with its
|
|
5
|
+
* on-disk source line (`data-md-line`, 1-based, from plan-md.mjs) and kind
|
|
6
|
+
* (`data-md-kind`). When the user commits an edit, `replaceRenderedMarkdownBlock`
|
|
7
|
+
* maps the block's edited innerText back into the markdown source at that
|
|
8
|
+
* line, preserving list markers, heading levels, and blockquote prefixes.
|
|
9
|
+
* `addEditableAttributes` injects the `contenteditable`/`spellcheck` attributes
|
|
10
|
+
* onto the editable block kinds only.
|
|
11
|
+
*
|
|
12
|
+
* Pure string logic — no DOM, no Office (unit-testable).
|
|
13
|
+
*
|
|
14
|
+
* 1:1 port of the add-in's plan-markdown-edit.ts. Top-level helpers are
|
|
15
|
+
* prefixed `pme…` to keep the single-IIFE browser bundle collision-free;
|
|
16
|
+
* exported API names match the add-in verbatim.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export function isEditableKind(kind) {
|
|
20
|
+
return (
|
|
21
|
+
kind === "p" ||
|
|
22
|
+
kind === "li" ||
|
|
23
|
+
kind === "blockquote" ||
|
|
24
|
+
/^h[1-6]$/.test(kind)
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pmeIsBlockStart(line) {
|
|
29
|
+
return (
|
|
30
|
+
/^\s*$/.test(line) ||
|
|
31
|
+
/^(#{1,6})\s+/.test(line) ||
|
|
32
|
+
/^\s*```/.test(line) ||
|
|
33
|
+
/^\s*>/.test(line) ||
|
|
34
|
+
/^\s*(?:[-*]\s+|\d+[.)]\s+)/.test(line) ||
|
|
35
|
+
(line.includes("|") && /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?$/.test(line))
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function pmeGetParagraphRange(lines, index) {
|
|
40
|
+
let end = index + 1;
|
|
41
|
+
while (end < lines.length && !pmeIsBlockStart(lines[end] ?? "")) {
|
|
42
|
+
end++;
|
|
43
|
+
}
|
|
44
|
+
return { start: index, end };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function pmeNormalizeEditableText(text) {
|
|
48
|
+
return text
|
|
49
|
+
.replace(/\u00a0/g, " ")
|
|
50
|
+
.replace(/[ \t]+\n/g, "\n")
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function pmeMarkdownForEdit(kind, existingLine, text) {
|
|
55
|
+
const normalized = pmeNormalizeEditableText(text);
|
|
56
|
+
if (!normalized) return null;
|
|
57
|
+
|
|
58
|
+
const heading = normalized.match(/^(#{1,6})\s+(.*)$/);
|
|
59
|
+
if (heading) {
|
|
60
|
+
return `${heading[1]} ${(heading[2] ?? "").trim()}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const list = normalized.match(/^((?:[-*])|\d+[.)])\s+(.*)$/);
|
|
64
|
+
if (list) {
|
|
65
|
+
return `${list[1]} ${(list[2] ?? "").trim()}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (/^h[1-6]$/.test(kind)) {
|
|
69
|
+
const current = existingLine.match(/^(#{1,6})\s+/)?.[1] ?? "##";
|
|
70
|
+
return `${current} ${normalized}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (kind === "li") {
|
|
74
|
+
const marker = existingLine.match(/^\s*((?:[-*])|\d+[.)])\s+/)?.[1] ?? "-";
|
|
75
|
+
return `${marker} ${normalized}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (kind === "blockquote") {
|
|
79
|
+
return normalized
|
|
80
|
+
.split("\n")
|
|
81
|
+
.map((line) => `> ${line.trim()}`)
|
|
82
|
+
.join("\n");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return normalized;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Maps an edited rendered block's innerText back into the markdown source at
|
|
90
|
+
* the stamped 1-based line. Options: { markdown, line, kind, text }.
|
|
91
|
+
* No-op for non-editable kinds or out-of-range lines.
|
|
92
|
+
*/
|
|
93
|
+
export function replaceRenderedMarkdownBlock(options) {
|
|
94
|
+
const { markdown, line, kind, text } = options;
|
|
95
|
+
if (!isEditableKind(kind)) return markdown;
|
|
96
|
+
|
|
97
|
+
const lines = markdown.split("\n");
|
|
98
|
+
const index = line - 1;
|
|
99
|
+
if (index < 0 || index >= lines.length) return markdown;
|
|
100
|
+
|
|
101
|
+
const existingLine = lines[index] ?? "";
|
|
102
|
+
const range =
|
|
103
|
+
kind === "p"
|
|
104
|
+
? pmeGetParagraphRange(lines, index)
|
|
105
|
+
: { start: index, end: index + 1 };
|
|
106
|
+
const replacement = pmeMarkdownForEdit(kind, existingLine, text);
|
|
107
|
+
const nextLines = [
|
|
108
|
+
...lines.slice(0, range.start),
|
|
109
|
+
...(replacement === null ? [] : replacement.split("\n")),
|
|
110
|
+
...lines.slice(range.end),
|
|
111
|
+
];
|
|
112
|
+
return nextLines.join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function addEditableAttributes(html) {
|
|
116
|
+
return html.replace(
|
|
117
|
+
/data-md-kind="(h[1-6]|p|li|blockquote)"/g,
|
|
118
|
+
'data-md-kind="$1" contenteditable="true" spellcheck="true"',
|
|
119
|
+
);
|
|
120
|
+
}
|