baesian 0.0.0-stage → 1.0.1

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.
Files changed (69) hide show
  1. package/LICENSE.md +105 -0
  2. package/README.md +217 -2
  3. package/dist/agents.js +249 -0
  4. package/dist/api.js +13 -0
  5. package/dist/bin.js +14 -0
  6. package/dist/ci.js +82 -0
  7. package/dist/cite.js +66 -0
  8. package/dist/cli.js +831 -0
  9. package/dist/client/assets/alegreya-cyrillic-ext-wght-italic-DzWRXZb0.woff2 +0 -0
  10. package/dist/client/assets/alegreya-cyrillic-ext-wght-normal-CN0-BfWD.woff2 +0 -0
  11. package/dist/client/assets/alegreya-cyrillic-wght-italic-Dhrv2xfk.woff2 +0 -0
  12. package/dist/client/assets/alegreya-cyrillic-wght-normal-CU5lb38f.woff2 +0 -0
  13. package/dist/client/assets/alegreya-greek-ext-wght-italic-CoUI9EEu.woff2 +0 -0
  14. package/dist/client/assets/alegreya-greek-ext-wght-normal-C8PPRmwJ.woff2 +0 -0
  15. package/dist/client/assets/alegreya-greek-wght-italic-CVt9-Jr4.woff2 +0 -0
  16. package/dist/client/assets/alegreya-greek-wght-normal-BPx8yWcZ.woff2 +0 -0
  17. package/dist/client/assets/alegreya-latin-ext-wght-italic-BIuo_2sR.woff2 +0 -0
  18. package/dist/client/assets/alegreya-latin-ext-wght-normal-o8axeAGu.woff2 +0 -0
  19. package/dist/client/assets/alegreya-latin-wght-italic-Bxkcgmge.woff2 +0 -0
  20. package/dist/client/assets/alegreya-latin-wght-normal-BG9rTJo4.woff2 +0 -0
  21. package/dist/client/assets/alegreya-vietnamese-wght-italic-BAPuWgnQ.woff2 +0 -0
  22. package/dist/client/assets/alegreya-vietnamese-wght-normal-DOV9JYup.woff2 +0 -0
  23. package/dist/client/assets/archivo-latin-ext-wdth-normal-7khWdh9v.woff2 +0 -0
  24. package/dist/client/assets/archivo-latin-wdth-normal-DY7AcnAa.woff2 +0 -0
  25. package/dist/client/assets/archivo-vietnamese-wdth-normal-rJmnGBSt.woff2 +0 -0
  26. package/dist/client/assets/index-9yf70Vqv.css +1 -0
  27. package/dist/client/assets/index-Dwg9e0YR.js +15 -0
  28. package/dist/client/index.html +15 -0
  29. package/dist/facts.js +278 -0
  30. package/dist/folder-storage.js +71 -0
  31. package/dist/frontmatter.js +38 -0
  32. package/dist/gate-paths.js +77 -0
  33. package/dist/gate.js +237 -0
  34. package/dist/git.js +100 -0
  35. package/dist/globs.js +120 -0
  36. package/dist/hookblock.js +146 -0
  37. package/dist/hooks.js +911 -0
  38. package/dist/ids.js +23 -0
  39. package/dist/login.js +301 -0
  40. package/dist/mcp.js +303 -0
  41. package/dist/merges.js +849 -0
  42. package/dist/modes.js +245 -0
  43. package/dist/notify.js +91 -0
  44. package/dist/ops.js +381 -0
  45. package/dist/passkey.js +86 -0
  46. package/dist/personal.js +163 -0
  47. package/dist/practice-answers.js +42 -0
  48. package/dist/proposals.js +197 -0
  49. package/dist/quote.js +28 -0
  50. package/dist/report.js +261 -0
  51. package/dist/routing.js +411 -0
  52. package/dist/schema.js +1178 -0
  53. package/dist/server.js +514 -0
  54. package/dist/similarity.js +45 -0
  55. package/dist/sshsig.js +159 -0
  56. package/dist/state.js +135 -0
  57. package/dist/storage.js +61 -0
  58. package/dist/store.js +2259 -0
  59. package/dist/style.js +408 -0
  60. package/dist/sync.js +797 -0
  61. package/dist/trailers.js +121 -0
  62. package/dist/trust.js +222 -0
  63. package/dist/usage-fields.js +161 -0
  64. package/dist/vault.js +849 -0
  65. package/dist/verify.js +443 -0
  66. package/dist/version.js +70 -0
  67. package/dist/wait.js +449 -0
  68. package/dist/wikilinks.js +25 -0
  69. package/package.json +71 -4
package/dist/server.js ADDED
@@ -0,0 +1,514 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
+ import { createServer } from "node:http";
5
+ import { userInfo } from "node:os";
6
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { PREFERENCE_FIELDS, VERDICT_FIELDS } from "./api.js";
9
+ import { unmarkJson } from "./cite.js";
10
+ import { PRACTICE_DIR, PracticeRefused, inOrder, practiceEntry } from "./practice-answers.js";
11
+ import { afterVerdict } from "./proposals.js";
12
+ import { LastPort, PracticeEntry, ServerInfo, asWritten, newestFormat, upgradeFile } from "./schema.js";
13
+ import { presets, snapshot } from "./state.js";
14
+ import { StalePreferences, ValidationError } from "./store.js";
15
+ import { contextNotes } from "./vault.js";
16
+ /**
17
+ * The sign-in cookie, named for the server's port. Browsers keep cookies apart by host but not by port, so two
18
+ * projects served at once would otherwise overwrite each other's sign-in, and each would send its token to the other.
19
+ */
20
+ const cookieName = (port) => `baesian-${port}`;
21
+ const MAX_BODY = 64 * 1024;
22
+ /**
23
+ * The live layer's own bookkeeping, which never changes what the client shows: sessions' models among it (B-k0w7d), and
24
+ * practice answers (B-rehdw), which aren't in the state: the client reads them at their own address.
25
+ */
26
+ const IGNORED = (rel) => rel.endsWith(".tmp") || rel === "presence.json" || rel === "server.json" || rel === "port.json" || ["cursors", "models", PRACTICE_DIR].some((d) => rel === d || rel.startsWith(`${d}${sep}`) || rel.startsWith(`${d}/`));
27
+ /**
28
+ * The practice entries kept in a clone's live layer (B-rehdw), oldest first: one file each in `practice/`. A file that
29
+ * can't be read, half-written or in a newer format (D157), is skipped.
30
+ */
31
+ export function readPractice(live) {
32
+ const dir = join(live, PRACTICE_DIR);
33
+ if (!existsSync(dir))
34
+ return [];
35
+ const out = [];
36
+ for (const name of readdirSync(dir).filter((n) => n.endsWith(".json")).sort()) {
37
+ try {
38
+ out.push(PracticeEntry.parse(upgradeFile("practice", JSON.parse(readFileSync(join(dir, name), "utf8")), `${PRACTICE_DIR}/${name}`)));
39
+ }
40
+ catch {
41
+ // half-written, or not one this Baesian reads: skipped
42
+ }
43
+ }
44
+ return inOrder(out);
45
+ }
46
+ /** Keep one practice entry in a clone's live layer (B-rehdw): `practice/<at>-<suffix>.json`, written once. */
47
+ export function writePractice(live, e) {
48
+ const dir = join(live, PRACTICE_DIR);
49
+ mkdirSync(dir, { recursive: true });
50
+ const name = `${e.at.replace(/[-:]/g, "")}-${randomBytes(4).toString("hex")}.json`;
51
+ const tmp = join(dir, `${name}.tmp`);
52
+ writeFileSync(tmp, `${JSON.stringify(asWritten("practice", e), null, 2)}\n`, { flag: "wx" });
53
+ renameSync(tmp, join(dir, name));
54
+ return `${PRACTICE_DIR}/${name}`;
55
+ }
56
+ /** Who answers in the client: git's `user.name`, or the account name. */
57
+ export function reviewerName(toplevel) {
58
+ try {
59
+ const name = execFileSync("git", ["config", "user.name"], { cwd: toplevel, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
60
+ if (name)
61
+ return name;
62
+ }
63
+ catch {
64
+ // no git identity: fall through
65
+ }
66
+ return userInfo().username;
67
+ }
68
+ /** The server another `baesian serve` started, if it's still answering (D97). A stale file is ignored, as is one this Baesian can't read, a newer format's included (D157). */
69
+ export async function findRunningServer(store) {
70
+ const path = join(store.live, "server.json");
71
+ if (!existsSync(path))
72
+ return undefined;
73
+ let info;
74
+ try {
75
+ info = ServerInfo.parse(upgradeFile("server", JSON.parse(readFileSync(path, "utf8")), "server.json"));
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ try {
81
+ process.kill(info.pid, 0);
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ try {
87
+ const r = await fetch(`http://127.0.0.1:${info.port}/api/health`, { headers: { authorization: `Bearer ${info.token}` }, signal: AbortSignal.timeout(1000) });
88
+ return r.ok ? info : undefined;
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ }
94
+ /** The port the last server used, if it's on record and readable (D103): a file in a newer format isn't (D157). */
95
+ function lastPort(path) {
96
+ try {
97
+ return LastPort.parse(upgradeFile("port", JSON.parse(readFileSync(path, "utf8")), "port.json")).port;
98
+ }
99
+ catch {
100
+ return undefined;
101
+ }
102
+ }
103
+ export const serverUrl = (info) => `http://127.0.0.1:${info.port}/?token=${info.token}`;
104
+ /**
105
+ * The state `GET /api/state` returns (D96), as `baesian serve` gives it: built by `snapshot` (src/state.ts, which the
106
+ * relay serves too), with this repo's name and branch, this process, and a deck's context notes resolved in the vault (M6),
107
+ * which is listed once per snapshot, and only when a deck has context notes.
108
+ */
109
+ export function serverState(store, extra) {
110
+ const vaultNotes = {};
111
+ return snapshot(store, {
112
+ version: extra.version,
113
+ server: { pid: process.pid, started: extra.started, watch: extra.watch },
114
+ project: { name: basename(store.repo.toplevel), branch: store.repo.branch },
115
+ notes: (links) => contextNotes(store, links, vaultNotes),
116
+ });
117
+ }
118
+ /** A cheap summary of the live layer's files, to tell whether anything changed between two looks. */
119
+ function fingerprint(dir) {
120
+ const hash = createHash("sha1");
121
+ const walk = (d) => {
122
+ let names;
123
+ try {
124
+ names = readdirSync(d).sort();
125
+ }
126
+ catch {
127
+ return;
128
+ }
129
+ for (const name of names) {
130
+ const path = join(d, name);
131
+ const rel = relative(dir, path);
132
+ if (IGNORED(rel))
133
+ continue;
134
+ try {
135
+ const st = statSync(path);
136
+ if (st.isDirectory())
137
+ walk(path);
138
+ else
139
+ hash.update(`${rel}\0${st.size}\0${st.mtimeMs}\n`);
140
+ }
141
+ catch {
142
+ // gone between listing and stat: the next look sees it
143
+ }
144
+ }
145
+ };
146
+ walk(dir);
147
+ return hash.digest("hex");
148
+ }
149
+ /**
150
+ * Watch the live layer (D84): recursive `fs.watch`, plus a rescan on a timer for anything it misses. If the
151
+ * watcher fails, or `poll` is set, only the timer runs, at the polling interval.
152
+ */
153
+ function watchLive(dir, onChange, opts) {
154
+ let mode = opts.poll ? "poll" : "events";
155
+ let last = fingerprint(dir);
156
+ const look = () => {
157
+ const now = fingerprint(dir);
158
+ if (now !== last) {
159
+ last = now;
160
+ onChange();
161
+ }
162
+ };
163
+ let timer = setInterval(look, mode === "poll" ? opts.pollMs : opts.rescanMs);
164
+ let watcher;
165
+ const fallBack = () => {
166
+ watcher?.close();
167
+ watcher = undefined;
168
+ mode = "poll";
169
+ clearInterval(timer);
170
+ timer = setInterval(look, opts.pollMs);
171
+ };
172
+ if (mode === "events") {
173
+ try {
174
+ watcher = watch(dir, { recursive: true }, (_type, name) => {
175
+ if (name && IGNORED(String(name)))
176
+ return;
177
+ onChange();
178
+ });
179
+ watcher.on("error", fallBack);
180
+ }
181
+ catch {
182
+ fallBack();
183
+ }
184
+ }
185
+ return {
186
+ mode: () => mode,
187
+ close: () => {
188
+ watcher?.close();
189
+ clearInterval(timer);
190
+ },
191
+ };
192
+ }
193
+ function sameSecret(a, b) {
194
+ if (!a)
195
+ return false;
196
+ const x = Buffer.from(a), y = Buffer.from(b);
197
+ return x.length === y.length && timingSafeEqual(x, y);
198
+ }
199
+ function cookieValue(req, name) {
200
+ for (const part of (req.headers.cookie ?? "").split(";")) {
201
+ const [k, ...v] = part.trim().split("=");
202
+ if (k === name)
203
+ return v.join("=");
204
+ }
205
+ return undefined;
206
+ }
207
+ const HEADERS = { "cache-control": "no-store", "x-content-type-options": "nosniff", "referrer-policy": "no-referrer" };
208
+ function send(res, status, body, type = "application/json") {
209
+ // Messages the store wrote may mark citations of Baesian's decisions (src/cite.ts): the client never shows them.
210
+ const text = type === "application/json" ? `${JSON.stringify(body, unmarkJson)}\n` : String(body);
211
+ res.writeHead(status, { ...HEADERS, "content-type": `${type}; charset=utf-8` });
212
+ res.end(text);
213
+ }
214
+ function page(title, text) {
215
+ return `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font: 16px/1.5 system-ui; margin: 3rem auto; max-width: 36rem"><h1 style="font-size: 1.3rem">${title}</h1><p>${text}</p>`;
216
+ }
217
+ async function readJson(req) {
218
+ const chunks = [];
219
+ let size = 0;
220
+ for await (const chunk of req) {
221
+ size += chunk.length;
222
+ if (size > MAX_BODY)
223
+ throw Object.assign(new Error("the request is too large"), { status: 413 });
224
+ chunks.push(chunk);
225
+ }
226
+ try {
227
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
228
+ }
229
+ catch {
230
+ throw Object.assign(new Error("the request isn't valid JSON"), { status: 400 });
231
+ }
232
+ }
233
+ /** The client's build (D98): beside this module once built, or in `dist/` when the server runs from source. */
234
+ export function findClient() {
235
+ const here = dirname(fileURLToPath(import.meta.url));
236
+ return [join(here, "client"), join(here, "..", "dist", "client")].find((d) => existsSync(join(d, "index.html")));
237
+ }
238
+ const TYPES = {
239
+ ".html": "text/html; charset=utf-8",
240
+ ".js": "text/javascript; charset=utf-8",
241
+ ".css": "text/css; charset=utf-8",
242
+ ".json": "application/json; charset=utf-8",
243
+ ".map": "application/json; charset=utf-8",
244
+ ".svg": "image/svg+xml",
245
+ ".png": "image/png",
246
+ ".ico": "image/x-icon",
247
+ ".woff2": "font/woff2",
248
+ ".woff": "font/woff",
249
+ };
250
+ /** Only the server's own origin, for everything: the client needs nothing else, fonts included (D99). */
251
+ const CSP = "default-src 'self'; img-src 'self' data:; font-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
252
+ /** Serve a file of the client's build. A path with no extension is one of the client's own routes. */
253
+ function serveClient(res, dir, pathname) {
254
+ if (!dir || !existsSync(join(dir, "index.html"))) {
255
+ return send(res, 200, page("Baesian is running", "The client hasn't been built: run <code>npm run build</code> in Baesian's repo. Until then, <a href=\"/api/state\">/api/state</a> shows the live layer."), "text/html");
256
+ }
257
+ let rel;
258
+ try {
259
+ rel = decodeURIComponent(pathname).replace(/^\/+/, "");
260
+ }
261
+ catch {
262
+ return send(res, 400, { error: "a malformed path" });
263
+ }
264
+ const file = resolve(dir, rel || "index.html");
265
+ const inside = file.startsWith(dir + sep);
266
+ let path = inside && existsSync(file) && statSync(file).isFile() ? file : undefined;
267
+ if (!path && inside && !extname(rel))
268
+ path = join(dir, "index.html");
269
+ if (!path)
270
+ return send(res, 404, { error: "not found" });
271
+ const hashed = path.startsWith(join(dir, "assets") + sep);
272
+ const body = readFileSync(path); // before any header goes out, so a failed read can still become an error
273
+ res.writeHead(200, {
274
+ ...HEADERS,
275
+ "content-type": TYPES[extname(path)] ?? "application/octet-stream",
276
+ "cache-control": hashed ? "public, max-age=31536000, immutable" : "no-store",
277
+ ...(extname(path) === ".html" ? { "content-security-policy": CSP } : {}),
278
+ });
279
+ res.end(body);
280
+ }
281
+ /** Start the server. It runs until `close`. */
282
+ export async function startServer(store, opts = {}) {
283
+ const token = randomBytes(32).toString("base64url");
284
+ const reviewer = opts.reviewer ?? reviewerName(store.repo.toplevel);
285
+ const started = new Date().toISOString();
286
+ const debounceMs = opts.debounceMs ?? 100;
287
+ const streams = new Set();
288
+ let version = 1;
289
+ let pending;
290
+ const changed = () => {
291
+ if (pending)
292
+ return;
293
+ pending = setTimeout(() => {
294
+ pending = undefined;
295
+ version += 1;
296
+ for (const s of streams)
297
+ s.write(`event: changed\ndata: ${JSON.stringify({ version })}\n\n`);
298
+ }, debounceMs);
299
+ };
300
+ const watcher = watchLive(store.live, changed, { poll: opts.poll ?? false, rescanMs: opts.rescanMs ?? 30_000, pollMs: opts.pollMs ?? 1_000 });
301
+ let port = 0;
302
+ const server = createServer(async (req, res) => {
303
+ try {
304
+ // Only this machine's own addresses: a page on another site that rebinds a name to 127.0.0.1 is refused.
305
+ const host = req.headers.host ?? "";
306
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`)
307
+ return send(res, 403, { error: "wrong host" });
308
+ const url = new URL(req.url ?? "/", `http://${host}`);
309
+ // The address `baesian serve` opens: trade the token for a cookie, and drop it from the address bar.
310
+ const offered = url.searchParams.get("token");
311
+ if (url.pathname === "/" && offered !== null) {
312
+ if (!sameSecret(offered, token))
313
+ return send(res, 403, page("Baesian", "That link has expired. Run <code>baesian serve</code> for a new one."), "text/html");
314
+ res.writeHead(302, { ...HEADERS, location: "/", "set-cookie": `${cookieName(port)}=${token}; HttpOnly; SameSite=Strict; Path=/` });
315
+ return res.end();
316
+ }
317
+ const bearer = req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : undefined;
318
+ if (!sameSecret(cookieValue(req, cookieName(port)), token) && !sameSecret(bearer, token)) {
319
+ if (url.pathname.startsWith("/api/"))
320
+ return send(res, 401, { error: "not signed in: open the address `baesian serve` printed" });
321
+ return send(res, 401, page("Baesian", "Open the address <code>baesian serve</code> printed. It signs this browser in."), "text/html");
322
+ }
323
+ if (req.method === "POST") {
324
+ // A form on another site can't send JSON without a preflight, and there's no CORS to pass one.
325
+ if (!(req.headers["content-type"] ?? "").startsWith("application/json"))
326
+ return send(res, 415, { error: "send application/json" });
327
+ if (url.pathname === "/api/verdicts") {
328
+ const input = (await readJson(req));
329
+ const unknown = Object.keys(input ?? {}).filter((k) => !VERDICT_FIELDS.has(k));
330
+ if (!input || typeof input !== "object" || unknown.length)
331
+ return send(res, 400, { error: `unexpected fields: ${unknown.join(", ")}` });
332
+ try {
333
+ const verdict = store.addVerdict({ ...input, reviewer, client: "web" });
334
+ // Baesian's own proposals from the record (D161, B-y6jn6). A failure there never fails the answer.
335
+ let filed = [];
336
+ if (opts.proposals !== false) {
337
+ try {
338
+ filed = afterVerdict(store, verdict).map((c) => c.meta.id);
339
+ }
340
+ catch (e) {
341
+ process.stderr.write(`baesian serve: proposals failed: ${e instanceof Error ? e.message : String(e)}
342
+ `);
343
+ }
344
+ }
345
+ return send(res, 201, { verdict, status: store.status(verdict.card), ...(filed.length ? { filed } : {}) });
346
+ }
347
+ catch (e) {
348
+ const message = e instanceof Error ? e.message : String(e);
349
+ if (e instanceof ValidationError)
350
+ return send(res, 400, { error: message });
351
+ if (/stale/.test(message))
352
+ return send(res, 409, { error: message });
353
+ if (/^no card/.test(message))
354
+ return send(res, 404, { error: message });
355
+ throw e;
356
+ }
357
+ }
358
+ // Merging two topics (D44, D111): `{ from, to }`, recorded as an alias by the reviewer.
359
+ if (url.pathname === "/api/aliases") {
360
+ const input = (await readJson(req));
361
+ const unknown = Object.keys(input ?? {}).filter((k) => k !== "from" && k !== "to");
362
+ if (!input || typeof input.from !== "string" || typeof input.to !== "string" || unknown.length) {
363
+ return send(res, 400, { error: unknown.length ? `unexpected fields: ${unknown.join(", ")}` : "send { from, to }: the two topics" });
364
+ }
365
+ try {
366
+ return send(res, 201, { alias: store.mergeTopics(input.from, input.to, reviewer) });
367
+ }
368
+ catch (e) {
369
+ if (e instanceof ValidationError)
370
+ return send(res, 400, { error: e.message });
371
+ throw e;
372
+ }
373
+ }
374
+ // A change to the preferences (D147): only the client writes them, and only through here.
375
+ if (url.pathname === "/api/preferences") {
376
+ const input = (await readJson(req));
377
+ const unknown = Object.keys(input ?? {}).filter((k) => !PREFERENCE_FIELDS.has(k));
378
+ if (!input || typeof input !== "object" || Array.isArray(input) || unknown.length)
379
+ return send(res, 400, { error: `unexpected fields: ${unknown.join(", ")}` });
380
+ try {
381
+ const written = store.writePreferences(input, { by: reviewer, client: "web" });
382
+ return send(res, 201, written);
383
+ }
384
+ catch (e) {
385
+ if (e instanceof StalePreferences)
386
+ return send(res, 409, { error: e.message });
387
+ if (e instanceof ValidationError)
388
+ return send(res, 400, { error: e.message });
389
+ throw e;
390
+ }
391
+ }
392
+ if (url.pathname === "/api/presence") {
393
+ store.touchPresence("web");
394
+ res.writeHead(204, HEADERS);
395
+ return res.end();
396
+ }
397
+ // A practice answer, a mark in the blind test, or its start (B-rehdw): kept in the live layer, apart from the state.
398
+ if (url.pathname === "/api/practice") {
399
+ const input = await readJson(req);
400
+ try {
401
+ const entry = practiceEntry(input, { reviewer, client: "web" }, readPractice(store.live));
402
+ writePractice(store.live, entry);
403
+ return send(res, 201, { entry });
404
+ }
405
+ catch (e) {
406
+ if (e instanceof PracticeRefused)
407
+ return send(res, e.status, { error: e.message });
408
+ throw e;
409
+ }
410
+ }
411
+ return send(res, 404, { error: "not found" });
412
+ }
413
+ if (req.method !== "GET")
414
+ return send(res, 405, { error: "method not allowed" });
415
+ if (url.pathname === "/api/health")
416
+ return send(res, 200, { ok: true, pid: process.pid });
417
+ if (url.pathname === "/api/state")
418
+ return send(res, 200, serverState(store, { version, started, watch: watcher.mode() }));
419
+ if (url.pathname === "/api/presets")
420
+ return send(res, 200, presets(store));
421
+ if (url.pathname === "/api/practice")
422
+ return send(res, 200, { entries: readPractice(store.live) });
423
+ if (url.pathname === "/api/events") {
424
+ res.writeHead(200, { ...HEADERS, "content-type": "text/event-stream; charset=utf-8", connection: "keep-alive" });
425
+ res.write(`retry: 2000\nevent: changed\ndata: ${JSON.stringify({ version })}\n\n`);
426
+ streams.add(res);
427
+ req.on("close", () => streams.delete(res));
428
+ return;
429
+ }
430
+ if (!url.pathname.startsWith("/api/"))
431
+ return serveClient(res, opts.clientDir ?? findClient(), url.pathname);
432
+ return send(res, 404, { error: "not found" });
433
+ }
434
+ catch (e) {
435
+ // Headers already sent: the response can't become an error any more, so end it rather than leave it hanging.
436
+ if (res.headersSent)
437
+ return res.destroy();
438
+ const status = e.status ?? 500;
439
+ return send(res, status, { error: e instanceof Error ? e.message : String(e) });
440
+ }
441
+ });
442
+ const listen = (p) => new Promise((resolve, reject) => {
443
+ const failed = (e) => {
444
+ server.off("listening", listening);
445
+ reject(e);
446
+ };
447
+ const listening = () => {
448
+ server.off("error", failed);
449
+ resolve();
450
+ };
451
+ server.once("error", failed);
452
+ server.once("listening", listening);
453
+ server.listen(p, "127.0.0.1");
454
+ });
455
+ // The same address as last time keeps what the browser stored for it (D103). If that port is taken, any will do.
456
+ const reuse = opts.port === undefined && opts.reusePort !== false;
457
+ const portPath = join(store.live, "port.json");
458
+ const last = reuse ? lastPort(portPath) : undefined;
459
+ try {
460
+ await listen(opts.port ?? last ?? 0);
461
+ }
462
+ catch (e) {
463
+ if (last === undefined || e.code !== "EADDRINUSE")
464
+ throw e;
465
+ await listen(0);
466
+ }
467
+ const address = server.address();
468
+ port = typeof address === "object" && address ? address.port : 0;
469
+ if (reuse && port !== last) {
470
+ const tmp = `${portPath}.${randomBytes(4).toString("hex")}.tmp`;
471
+ writeFileSync(tmp, `${JSON.stringify(asWritten("port", LastPort.parse({ type: "port", format: newestFormat("port"), port })))}\n`);
472
+ renameSync(tmp, portPath);
473
+ }
474
+ const infoPath = join(store.live, "server.json");
475
+ const keepAlive = setInterval(() => {
476
+ for (const s of streams)
477
+ s.write(": keep-alive\n\n");
478
+ }, opts.keepAliveMs ?? 15_000);
479
+ if (opts.writeServerFile !== false) {
480
+ const info = ServerInfo.parse({ type: "server", format: newestFormat("server"), pid: process.pid, port, token, started });
481
+ const tmp = `${infoPath}.${randomBytes(4).toString("hex")}.tmp`;
482
+ writeFileSync(tmp, `${JSON.stringify(asWritten("server", info))}\n`, { mode: 0o600 });
483
+ renameSync(tmp, infoPath);
484
+ }
485
+ return {
486
+ port,
487
+ token,
488
+ url: serverUrl({ port, token }),
489
+ version: () => version,
490
+ watchMode: () => watcher.mode(),
491
+ close: async () => {
492
+ watcher.close();
493
+ clearInterval(keepAlive);
494
+ if (pending)
495
+ clearTimeout(pending);
496
+ for (const s of streams)
497
+ s.end();
498
+ streams.clear();
499
+ server.closeAllConnections();
500
+ await new Promise((resolve) => server.close(() => resolve()));
501
+ // Only remove the file if it's still this server's.
502
+ if (opts.writeServerFile !== false && existsSync(infoPath)) {
503
+ try {
504
+ if (JSON.parse(readFileSync(infoPath, "utf8")).token === token)
505
+ rmSync(infoPath, { force: true });
506
+ }
507
+ catch {
508
+ // unreadable: leave it; the next serve replaces a stale file
509
+ }
510
+ }
511
+ },
512
+ };
513
+ }
514
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,45 @@
1
+ // Cheap, deterministic matching for the filing check (D36, D38, D44). No model in the loop.
2
+ const STOP = new Set("a an and are as at be by do for from how in is it of on or should the this to use we what which with".split(" "));
3
+ export function tokens(text) {
4
+ return new Set(text
5
+ .toLowerCase()
6
+ .split(/[^a-z0-9]+/)
7
+ .filter((t) => t.length > 1 && !STOP.has(t)));
8
+ }
9
+ export function jaccard(a, b) {
10
+ if (a.size === 0 && b.size === 0)
11
+ return 0;
12
+ let shared = 0;
13
+ for (const t of a)
14
+ if (b.has(t))
15
+ shared++;
16
+ return shared / (a.size + b.size - shared);
17
+ }
18
+ /** Two card titles that probably ask the same question. */
19
+ export const SIMILAR_TITLE = 0.5;
20
+ export function levenshtein(a, b) {
21
+ const row = Array.from({ length: b.length + 1 }, (_, j) => j);
22
+ for (let i = 1; i <= a.length; i++) {
23
+ let diagonal = row[0];
24
+ row[0] = i;
25
+ for (let j = 1; j <= b.length; j++) {
26
+ const above = row[j];
27
+ row[j] = Math.min(above + 1, row[j - 1] + 1, diagonal + (a[i - 1] === b[j - 1] ? 0 : 1));
28
+ diagonal = above;
29
+ }
30
+ }
31
+ return row[b.length];
32
+ }
33
+ /** Existing topics close enough to `topic` to be worth a "did you mean" (D44). */
34
+ export function nearTopics(topic, existing) {
35
+ const last = (t) => t.split("/").at(-1) ?? t;
36
+ const out = [];
37
+ for (const other of existing) {
38
+ if (other === topic)
39
+ continue;
40
+ if (levenshtein(topic, other) <= 2 || last(topic) === last(other))
41
+ out.push(other);
42
+ }
43
+ return out.sort();
44
+ }
45
+ //# sourceMappingURL=similarity.js.map