slidemind 0.1.6 → 0.1.8

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,223 @@
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 and reloads only when BOTH the
168
+ // service marker matches its own gate (a foreign app or the other gate on this
169
+ // port must never trigger a reload) AND the round nonce differs from the one it
170
+ // was served with (same nonce = this run's own orphan — never self-reload).
171
+ // `waitForHeartbeat(ms)` is the server side of the open-browser grace window.
172
+ export function createHeartbeat({ service }) {
173
+ const round = crypto.randomUUID();
174
+ let seen = false;
175
+ const waiters = [];
176
+ return {
177
+ round,
178
+ respond(res) {
179
+ seen = true;
180
+ while (waiters.length) waiters.shift()();
181
+ res.writeHead(200, { "Content-Type": MIME[".json"], "Cache-Control": "no-store" });
182
+ res.end(JSON.stringify({ ok: true, service, round }));
183
+ },
184
+ waitForHeartbeat(ms) {
185
+ if (seen) return Promise.resolve(true);
186
+ return new Promise((resolve) => {
187
+ const t = setTimeout(() => resolve(false), ms);
188
+ waiters.push(() => {
189
+ clearTimeout(t);
190
+ resolve(true);
191
+ });
192
+ });
193
+ },
194
+ };
195
+ }
196
+
197
+ // Print the gate URL, then decide whether to open a browser. If a tab left
198
+ // waiting from a previous comments round pings the gate's heartbeat within the
199
+ // grace window, reuse it (it reloads itself) instead of opening a duplicate.
200
+ // Returns true if an existing tab was reused. `open` is required — gates pass
201
+ // their browser opener so this module stays dependency-light and tests inject
202
+ // a fake. The message strings are per-gate; defaults speak the preview gate's.
203
+ export async function serveAndOpen(server, {
204
+ open,
205
+ graceMs = GRACE_MS,
206
+ readyLabel = "Preview review",
207
+ reuseNoun = "review tab",
208
+ openedLine = "Opened your browser — leave comments or approve, then return here.",
209
+ waitLine = null,
210
+ } = {}) {
211
+ process.stderr.write(`${readyLabel} ready at ${server.url}\n`); // URL always printed first
212
+ const reused = await server.waitForHeartbeat(graceMs);
213
+ if (reused) {
214
+ process.stderr.write(`Reusing your open ${reuseNoun} — no new browser window.\n`);
215
+ } else {
216
+ open(server.url);
217
+ process.stderr.write(`${openedLine}\n`);
218
+ }
219
+ // Spoken to the AGENT running this command: blocking here is the designed
220
+ // behavior, not a hang — don't kill the call because it sits quiet.
221
+ if (waitLine) process.stderr.write(`${waitLine}\n`);
222
+ return reused;
223
+ }
@@ -76,8 +76,8 @@ function resolveEngine(deckDir) {
76
76
  export async function lintSlidesOrThrow({ deckDir, slideFiles, layoutSources }) {
77
77
  const { enginePath, version, primitives } = resolveEngine(deckDir);
78
78
 
79
- // `?v=` busts Node's per-process module cache so a long-lived process (the
80
- // MCP server) picks up a refreshed engine at the same path.
79
+ // `?v=` busts Node's per-process module cache so a re-import picks up a
80
+ // refreshed engine at the same path after a bundle sync.
81
81
  const engine = await import(
82
82
  `${pathToFileURL(enginePath).href}?v=${encodeURIComponent(version)}`
83
83
  );
@@ -0,0 +1,216 @@
1
+ // A tiny, self-contained markdown → HTML renderer for the constrained subset an
2
+ // agent-authored plan.md uses. Deliberately NO CDN and no dependencies: the plan
3
+ // gate ships this exact source into the browser (exports stripped, exposed as
4
+ // window.__SLIDEMIND_RENDER_MD__) so the editor's live preview renders with the
5
+ // SAME renderer as the served page. Every block still carries its on-disk source
6
+ // line/kind/section stamps — cheap, tested metadata for tooling.
7
+ //
8
+ // Supported blocks: ATX headings (#..######), blank-line-separated paragraphs,
9
+ // unordered (-/*) and ordered (1.) lists with each <li> stamped individually,
10
+ // > blockquotes, ``` fenced code, and simple pipe tables. Inline: **bold**,
11
+ // *italic*, `code`. All text is HTML-escaped BEFORE inline formatting is applied
12
+ // (defense in depth — the content is model/user-authored). Anything unrecognized
13
+ // degrades to a paragraph. One level of nesting only (nested lists may flatten).
14
+
15
+ function escapeHtml(s) {
16
+ return String(s)
17
+ .replace(/&/g, "&amp;")
18
+ .replace(/</g, "&lt;")
19
+ .replace(/>/g, "&gt;");
20
+ }
21
+
22
+ // For an HTML attribute value: escape as text, then also quotes.
23
+ function escapeAttr(s) {
24
+ return escapeHtml(s).replace(/"/g, "&quot;");
25
+ }
26
+
27
+ // Escape first, THEN apply inline formatting. Code spans are handled before
28
+ // bold/italic so their contents aren't re-interpreted; bold before italic so a
29
+ // **run** isn't half-consumed by the single-* rule.
30
+ function renderInline(text) {
31
+ let s = escapeHtml(text);
32
+ s = s.replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`);
33
+ s = s.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
34
+ s = s.replace(/\*([^*]+)\*/g, "<em>$1</em>");
35
+ return s;
36
+ }
37
+
38
+ // Plain-text form of a heading (markers stripped) for the breadcrumb attribute.
39
+ function stripInlineMarkers(text) {
40
+ return String(text)
41
+ .replace(/`([^`]+)`/g, "$1")
42
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
43
+ .replace(/\*([^*]+)\*/g, "$1")
44
+ .trim();
45
+ }
46
+
47
+ function isListItem(line) {
48
+ return /^\s*(?:[-*]\s+|\d+[.)]\s+)/.test(line);
49
+ }
50
+
51
+ // A table separator row: pipes and dashes only, optional alignment colons.
52
+ function isTableSeparator(line) {
53
+ const s = line.trim();
54
+ if (!s.includes("-")) return false;
55
+ return /^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?$/.test(s);
56
+ }
57
+
58
+ function splitTableRow(line) {
59
+ let s = line.trim();
60
+ if (s.startsWith("|")) s = s.slice(1);
61
+ if (s.endsWith("|")) s = s.slice(0, -1);
62
+ return s.split("|").map((c) => c.trim());
63
+ }
64
+
65
+ // The three stamp attributes carried by every block.
66
+ function stamp(absLine, kind, section) {
67
+ return (
68
+ ` data-md-line="${absLine}" data-md-kind="${kind}"` +
69
+ ` data-md-section="${escapeAttr(section)}"`
70
+ );
71
+ }
72
+
73
+ export function renderPlanMarkdown(body, { lineOffset = 0 } = {}) {
74
+ const lines = String(body).split("\n");
75
+ const out = [];
76
+ let section = ""; // nearest preceding heading text (breadcrumb)
77
+ let i = 0;
78
+ // idx is 0-based into `lines`; local line number is idx+1, absolute adds the
79
+ // frontmatter offset.
80
+ const abs = (idx) => lineOffset + idx + 1;
81
+
82
+ while (i < lines.length) {
83
+ const line = lines[i];
84
+
85
+ if (/^\s*$/.test(line)) {
86
+ i++;
87
+ continue;
88
+ }
89
+
90
+ // Fenced code — stamped on the opening fence, contents escaped verbatim.
91
+ if (/^\s*```/.test(line)) {
92
+ const startIdx = i;
93
+ i++;
94
+ const code = [];
95
+ while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) {
96
+ code.push(lines[i]);
97
+ i++;
98
+ }
99
+ if (i < lines.length) i++; // consume the closing fence
100
+ out.push(
101
+ `<pre${stamp(abs(startIdx), "code", section)}><code>` +
102
+ escapeHtml(code.join("\n")) +
103
+ `</code></pre>`,
104
+ );
105
+ continue;
106
+ }
107
+
108
+ // ATX heading.
109
+ const heading = line.match(/^(#{1,6})\s+(.*)$/);
110
+ if (heading) {
111
+ const level = heading[1].length;
112
+ const raw = heading[2].replace(/\s+#+\s*$/, "").trim(); // drop optional closing #
113
+ section = stripInlineMarkers(raw);
114
+ const kind = "h" + level;
115
+ out.push(
116
+ `<${kind}${stamp(abs(i), kind, section)}>` +
117
+ renderInline(raw) +
118
+ `</${kind}>`,
119
+ );
120
+ i++;
121
+ continue;
122
+ }
123
+
124
+ // Pipe table — header row followed by a separator row.
125
+ if (
126
+ line.includes("|") &&
127
+ i + 1 < lines.length &&
128
+ isTableSeparator(lines[i + 1])
129
+ ) {
130
+ const headerIdx = i;
131
+ const header = splitTableRow(line);
132
+ i += 2; // skip header + separator
133
+ const rows = [];
134
+ while (i < lines.length && !/^\s*$/.test(lines[i]) && lines[i].includes("|")) {
135
+ rows.push(splitTableRow(lines[i]));
136
+ i++;
137
+ }
138
+ let html = `<table${stamp(abs(headerIdx), "table", section)}>`;
139
+ html +=
140
+ "<thead><tr>" +
141
+ header.map((c) => `<th>${renderInline(c)}</th>`).join("") +
142
+ "</tr></thead>";
143
+ html +=
144
+ "<tbody>" +
145
+ rows
146
+ .map(
147
+ (r) => "<tr>" + r.map((c) => `<td>${renderInline(c)}</td>`).join("") + "</tr>",
148
+ )
149
+ .join("") +
150
+ "</tbody>";
151
+ html += "</table>";
152
+ out.push(html);
153
+ continue;
154
+ }
155
+
156
+ // Blockquote — consecutive > lines, joined.
157
+ if (/^\s*>/.test(line)) {
158
+ const startIdx = i;
159
+ const quote = [];
160
+ while (i < lines.length && /^\s*>/.test(lines[i])) {
161
+ quote.push(lines[i].replace(/^\s*>\s?/, ""));
162
+ i++;
163
+ }
164
+ out.push(
165
+ `<blockquote${stamp(abs(startIdx), "blockquote", section)}>` +
166
+ renderInline(quote.join(" ")) +
167
+ `</blockquote>`,
168
+ );
169
+ continue;
170
+ }
171
+
172
+ // List — each <li> stamped with its own line; the wrapper is unstamped.
173
+ if (isListItem(line)) {
174
+ const ordered = /^\s*\d+[.)]\s+/.test(line);
175
+ const tag = ordered ? "ol" : "ul";
176
+ const items = [];
177
+ while (i < lines.length && isListItem(lines[i])) {
178
+ const content = lines[i].replace(/^\s*(?:[-*]|\d+[.)])\s+/, "");
179
+ items.push(
180
+ `<li${stamp(abs(i), "li", section)}>` + renderInline(content) + `</li>`,
181
+ );
182
+ i++;
183
+ }
184
+ out.push(`<${tag}>` + items.join("") + `</${tag}>`);
185
+ continue;
186
+ }
187
+
188
+ // Paragraph (and the degrade-to-<p> fallback for anything unrecognized):
189
+ // gather until a blank line or the start of another block.
190
+ const startIdx = i;
191
+ const para = [];
192
+ while (
193
+ i < lines.length &&
194
+ !/^\s*$/.test(lines[i]) &&
195
+ !/^(#{1,6})\s+/.test(lines[i]) &&
196
+ !/^\s*```/.test(lines[i]) &&
197
+ !/^\s*>/.test(lines[i]) &&
198
+ !isListItem(lines[i]) &&
199
+ !(
200
+ lines[i].includes("|") &&
201
+ i + 1 < lines.length &&
202
+ isTableSeparator(lines[i + 1])
203
+ )
204
+ ) {
205
+ para.push(lines[i]);
206
+ i++;
207
+ }
208
+ out.push(
209
+ `<p${stamp(abs(startIdx), "p", section)}>` +
210
+ renderInline(para.join(" ")) +
211
+ `</p>`,
212
+ );
213
+ }
214
+
215
+ return out.join("\n");
216
+ }