slidemind 0.1.7 → 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.
@@ -0,0 +1,364 @@
1
+ // Shared low-level primitives for the CLI's blocking review gates (the spatial
2
+ // preview gate in review.mjs and the plan sign-off gate in plan.mjs).
3
+ //
4
+ // These were lifted out of review.mjs so both gates share one implementation of
5
+ // the MIME table, the traversal-guarded static responder, the decision promise +
6
+ // timeout wrapper, and the port + tab-reuse machinery: a deterministic per-deck
7
+ // per-gate port (so a tab left waiting from the previous round reconnects to the
8
+ // same URL), a bind that never fails on a taken port (falls back to an
9
+ // OS-assigned one with a stderr notice), and a heartbeat endpoint each gate
10
+ // serves under its own service marker so a waiting tab only ever reloads into
11
+ // its OWN gate's next round — never into the other gate or a foreign app.
12
+
13
+ import crypto from "node:crypto";
14
+ import fs from "node:fs";
15
+ import path from "node:path";
16
+
17
+ // Tab-reuse timing contract, shared by both gates and injected into their
18
+ // overlays as CFG.pollMs / CFG.pollTtlMs:
19
+ // GRACE_MS > POLL_MS so a waiting tab always lands one poll inside the
20
+ // open-browser grace window;
21
+ // POLL_TTL_MS > the gates' 30-min decision timeout so an abandoned tab stops
22
+ // polling only after the server side has certainly given up.
23
+ export const GRACE_MS = 1500;
24
+ export const POLL_MS = 1000;
25
+ export const POLL_TTL_MS = 35 * 60 * 1000;
26
+
27
+ const PORT_BASE = 40000;
28
+ const PORT_SPAN = 1000; // preferred ports live in 40000..40999
29
+
30
+ // Extension → content-type table (lifted verbatim from review.mjs).
31
+ export const MIME = {
32
+ ".html": "text/html; charset=utf-8",
33
+ ".css": "text/css; charset=utf-8",
34
+ ".js": "text/javascript; charset=utf-8",
35
+ ".json": "application/json; charset=utf-8",
36
+ ".svg": "image/svg+xml",
37
+ ".png": "image/png",
38
+ ".jpg": "image/jpeg",
39
+ ".jpeg": "image/jpeg",
40
+ ".gif": "image/gif",
41
+ ".webp": "image/webp",
42
+ ".woff": "font/woff",
43
+ ".woff2": "font/woff2",
44
+ };
45
+
46
+ // Traversal-guarded static responder, generalized to MULTIPLE roots. `pathname`
47
+ // is expected already URL-decoded (review.mjs decodes once at the top of its
48
+ // request handler and passes the decoded value in). Each root is tried in order:
49
+ // the pathname is resolved within the root and required to stay inside it (via
50
+ // path.sep so the boundary check is correct on Windows). The first root that
51
+ // holds a matching file serves it (200 + MIME, default application/octet-stream)
52
+ // and returns true. If no root matches — file missing everywhere, or a traversal
53
+ // attempt escaped every root — returns false WITHOUT writing a response, leaving
54
+ // the caller to send its own 404.
55
+ export function serveStaticFile({ roots, pathname, res }) {
56
+ const rel = String(pathname).replace(/^\/+/, "");
57
+ for (const root of roots || []) {
58
+ if (!root) continue;
59
+ const base = path.resolve(root);
60
+ const resolved = path.resolve(base, rel);
61
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) {
62
+ continue; // escaped this root — a later root might still legitimately match
63
+ }
64
+ let data;
65
+ try {
66
+ const st = fs.statSync(resolved);
67
+ if (!st.isFile()) continue;
68
+ data = fs.readFileSync(resolved);
69
+ } catch {
70
+ continue; // missing here — try the next root
71
+ }
72
+ const ext = path.extname(resolved).toLowerCase();
73
+ res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
74
+ res.end(data);
75
+ return true;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ // The decision promise + timeout wrapper both gates block on. `resolve(value)`
81
+ // settles the internal promise once (a Promise never re-settles, so repeat calls
82
+ // are no-ops). `waitForDecision({ timeoutMs })` returns the promise directly when
83
+ // no/falsy timeout is given, otherwise races it against a setTimeout that rejects
84
+ // with `timeoutMessage`. The timer is cleared the moment the decision wins so the
85
+ // process can exit instead of being held open by a pending timeout.
86
+ //
87
+ // A timeout rejection carries `code: "GATE_TIMEOUT"` so gates can tell "the user
88
+ // never answered" (an expected outcome with its own DECISION: pending contract)
89
+ // apart from a real failure.
90
+ export function createDecisionGate({
91
+ timeoutMessage = "review timed out — no decision received",
92
+ } = {}) {
93
+ let resolveDecision;
94
+ const decisionPromise = new Promise((res) => {
95
+ resolveDecision = res;
96
+ });
97
+ return {
98
+ resolve(value) {
99
+ resolveDecision(value);
100
+ },
101
+ waitForDecision({ timeoutMs } = {}) {
102
+ if (!timeoutMs) return decisionPromise;
103
+ return new Promise((resolve, reject) => {
104
+ const t = setTimeout(() => {
105
+ const err = new Error(timeoutMessage);
106
+ err.code = "GATE_TIMEOUT";
107
+ reject(err);
108
+ }, timeoutMs);
109
+ decisionPromise.then((d) => {
110
+ clearTimeout(t);
111
+ resolve(d);
112
+ });
113
+ });
114
+ },
115
+ };
116
+ }
117
+
118
+ // Deterministic per-deck, per-gate preferred port so a tab left waiting from
119
+ // the previous round can reconnect to the same URL. Stateless — derived purely
120
+ // from the resolved deck path plus the gate name. The gate name is the salt:
121
+ // the preview and plan gates of the SAME deck get independent ports, so their
122
+ // waiting tabs can never sit on each other's URL.
123
+ export function preferredGatePort(deckDir, gateName) {
124
+ const key = `${path.resolve(deckDir)}\u0000${gateName}`;
125
+ const n = crypto.createHash("sha1").update(key).digest().readUInt32BE(0);
126
+ return PORT_BASE + (n % PORT_SPAN);
127
+ }
128
+
129
+ // Bind `server` to `port`, falling back to an OS-assigned port when the
130
+ // preferred one is taken or blocked. An http.Server is reusable after a failed
131
+ // listen, so we retry listen(0) on the SAME instance. onFallback runs once,
132
+ // before the retry, so the caller can warn on stderr. EADDRINUSE: port already
133
+ // bound. EACCES: Windows excluded/reserved port range. Anything else (or a
134
+ // fallback-attempt failure) is a real error and rejects.
135
+ export function listenWithFallback(server, port, host, onFallback) {
136
+ return new Promise((resolve, reject) => {
137
+ const onFirstError = (err) => {
138
+ server.removeListener("listening", onFirstListening);
139
+ if (err && (err.code === "EADDRINUSE" || err.code === "EACCES")) {
140
+ onFallback(port, err);
141
+ const onFallbackError = (err2) => {
142
+ server.removeListener("listening", onFallbackListening);
143
+ reject(err2);
144
+ };
145
+ const onFallbackListening = () => {
146
+ server.removeListener("error", onFallbackError);
147
+ resolve();
148
+ };
149
+ server.once("error", onFallbackError);
150
+ server.once("listening", onFallbackListening);
151
+ server.listen(0, host);
152
+ return;
153
+ }
154
+ reject(err);
155
+ };
156
+ const onFirstListening = () => {
157
+ server.removeListener("error", onFirstError);
158
+ resolve();
159
+ };
160
+ server.once("error", onFirstError);
161
+ server.once("listening", onFirstListening);
162
+ server.listen(port, host);
163
+ });
164
+ }
165
+
166
+ // Per-run heartbeat state for one gate server. The gate mounts `respond` under
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.
195
+ export function createHeartbeat({ service }) {
196
+ const round = crypto.randomUUID();
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;
200
+ const waiters = [];
201
+ let pageSeen = false;
202
+ const pageWaiters = [];
203
+ return {
204
+ round,
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";
238
+ res.writeHead(200, { "Content-Type": MIME[".json"], "Cache-Control": "no-store" });
239
+ res.end(JSON.stringify({ ok: true, service, round, action }));
240
+ },
241
+ markFresh() {
242
+ if (mode === null) mode = "fresh";
243
+ },
244
+ waitForHeartbeat(ms) {
245
+ if (mode === "reuse") return Promise.resolve(true);
246
+ if (mode === "fresh" || !(ms > 0)) return Promise.resolve(false);
247
+ return new Promise((resolve) => {
248
+ const t = setTimeout(() => resolve(false), ms);
249
+ waiters.push(() => {
250
+ clearTimeout(t);
251
+ resolve(true);
252
+ });
253
+ });
254
+ },
255
+ };
256
+ }
257
+
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.
318
+ export async function serveAndOpen(server, {
319
+ open,
320
+ graceMs = GRACE_MS,
321
+ openConfirmMs = OPEN_CONFIRM_MS,
322
+ readyLabel = "Preview review",
323
+ reuseNoun = "review tab",
324
+ gateNoun = "review",
325
+ waitLine = null,
326
+ } = {}) {
327
+ process.stderr.write(`${readyLabel} ready at ${server.url}\n`); // URL always printed first
328
+ const reused = await server.waitForHeartbeat(graceMs);
329
+ let launched = null;
330
+ let confirmed = null;
331
+ if (reused) {
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;
336
+ } else {
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
+ }
359
+ }
360
+ // Spoken to the AGENT running this command: blocking here is the designed
361
+ // behavior, not a hang — don't kill the call because it sits quiet.
362
+ if (waitLine) process.stderr.write(`${waitLine}\n`);
363
+ return { reused, launched, confirmed, url: server.url };
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
+ }