visualproof 0.0.0-stage → 0.1.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.
Files changed (128) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -2
  3. package/dist/assets/fonts/JuliaMono-Regular.ttf +0 -0
  4. package/dist/assets/fonts/JuliaMono-RegularItalic.ttf +0 -0
  5. package/dist/assets/fonts/JuliaMono-SemiBold.ttf +0 -0
  6. package/dist/assets/fonts/LICENSE-JuliaMono.txt +93 -0
  7. package/dist/assets/latex/visualproof.sty +308 -0
  8. package/dist/extractor/Extract.lean +1180 -0
  9. package/dist/extractor/Usage.lean +900 -0
  10. package/dist/mcp/visualproof-mcp.mjs +476 -0
  11. package/dist/server/access.js +95 -0
  12. package/dist/server/agent.js +3235 -0
  13. package/dist/server/agentdocs.js +35 -0
  14. package/dist/server/agentlibrary.js +202 -0
  15. package/dist/server/agentnotes.js +701 -0
  16. package/dist/server/agentviews.js +703 -0
  17. package/dist/server/askfollow.js +286 -0
  18. package/dist/server/boardspots.js +159 -0
  19. package/dist/server/dirs.js +175 -0
  20. package/dist/server/doctor.js +186 -0
  21. package/dist/server/events.js +198 -0
  22. package/dist/server/export.js +420 -0
  23. package/dist/server/extract.js +204 -0
  24. package/dist/server/fetchurl.js +321 -0
  25. package/dist/server/figures.js +158 -0
  26. package/dist/server/history.js +741 -0
  27. package/dist/server/historyapi.js +304 -0
  28. package/dist/server/hover.js +595 -0
  29. package/dist/server/identity.js +114 -0
  30. package/dist/server/invites.js +241 -0
  31. package/dist/server/layoutthread.js +51 -0
  32. package/dist/server/library.js +600 -0
  33. package/dist/server/libraryroute.js +211 -0
  34. package/dist/server/live.js +1101 -0
  35. package/dist/server/lostfound.js +199 -0
  36. package/dist/server/main.js +1394 -0
  37. package/dist/server/mcptools.js +497 -0
  38. package/dist/server/pastdoc.js +73 -0
  39. package/dist/server/pipeline.js +156 -0
  40. package/dist/server/poppler.js +251 -0
  41. package/dist/server/presence.js +252 -0
  42. package/dist/server/project.js +222 -0
  43. package/dist/server/prompt.js +53 -0
  44. package/dist/server/readonly.js +152 -0
  45. package/dist/server/readonlygate.js +123 -0
  46. package/dist/server/renames.js +268 -0
  47. package/dist/server/review.js +334 -0
  48. package/dist/server/reviewapi.js +188 -0
  49. package/dist/server/snapshot.js +49 -0
  50. package/dist/server/sourcestore.js +473 -0
  51. package/dist/server/status.js +360 -0
  52. package/dist/server/talk.js +476 -0
  53. package/dist/server/tokens.js +221 -0
  54. package/dist/server/usage.js +501 -0
  55. package/dist/server/versions.js +82 -0
  56. package/dist/server/view.js +157 -0
  57. package/dist/server/views.js +1167 -0
  58. package/dist/shared/askfollow.js +46 -0
  59. package/dist/shared/board.js +332 -0
  60. package/dist/shared/build.js +2 -0
  61. package/dist/shared/collab.js +148 -0
  62. package/dist/shared/comment.js +285 -0
  63. package/dist/shared/context.js +157 -0
  64. package/dist/shared/doc/authors.js +98 -0
  65. package/dist/shared/doc/build.js +358 -0
  66. package/dist/shared/doc/latex.js +455 -0
  67. package/dist/shared/doc/markdown.js +159 -0
  68. package/dist/shared/doc/model.js +37 -0
  69. package/dist/shared/doc/order.js +107 -0
  70. package/dist/shared/doc/proof.js +272 -0
  71. package/dist/shared/doc/sections.js +93 -0
  72. package/dist/shared/doc/settings.js +75 -0
  73. package/dist/shared/edgecodec.js +102 -0
  74. package/dist/shared/editor.js +64 -0
  75. package/dist/shared/excerpt.js +87 -0
  76. package/dist/shared/graph.js +16 -0
  77. package/dist/shared/items.js +92 -0
  78. package/dist/shared/keys.js +203 -0
  79. package/dist/shared/layout.js +926 -0
  80. package/dist/shared/lexer.js +130 -0
  81. package/dist/shared/library.js +711 -0
  82. package/dist/shared/lostfound.js +150 -0
  83. package/dist/shared/moves.js +243 -0
  84. package/dist/shared/notecard.js +295 -0
  85. package/dist/shared/noteplace.js +505 -0
  86. package/dist/shared/palette.js +46 -0
  87. package/dist/shared/pastgraph.js +95 -0
  88. package/dist/shared/places.js +80 -0
  89. package/dist/shared/projectnotes.js +244 -0
  90. package/dist/shared/query.js +810 -0
  91. package/dist/shared/review.js +642 -0
  92. package/dist/shared/reviewqueue.js +333 -0
  93. package/dist/shared/ring.js +366 -0
  94. package/dist/shared/scope.js +255 -0
  95. package/dist/shared/scopeedit.js +81 -0
  96. package/dist/shared/search.js +191 -0
  97. package/dist/shared/separate.js +178 -0
  98. package/dist/shared/sharesnap.js +280 -0
  99. package/dist/shared/sticky.js +501 -0
  100. package/dist/shared/timeline.js +73 -0
  101. package/dist/shared/titles.js +298 -0
  102. package/dist/shared/viewgraph.js +139 -0
  103. package/dist/shared/views.js +859 -0
  104. package/dist/web/bundle/app.js +48733 -0
  105. package/dist/web/bundle/edges.js +409 -0
  106. package/dist/web/bundle/layout.js +1010 -0
  107. package/dist/web/bundle/pdfworker.js +48322 -0
  108. package/dist/web/index.html +239 -0
  109. package/dist/web/src/motion.js +430 -0
  110. package/dist/web/src/past.js +224 -0
  111. package/dist/web/src/scene.js +787 -0
  112. package/dist/web/style.css +2312 -0
  113. package/docs/agent-api/README.md +91 -0
  114. package/docs/agent-api/advice.md +108 -0
  115. package/docs/agent-api/export.md +120 -0
  116. package/docs/agent-api/following.md +140 -0
  117. package/docs/agent-api/history.md +142 -0
  118. package/docs/agent-api/library.md +65 -0
  119. package/docs/agent-api/mcp.md +104 -0
  120. package/docs/agent-api/notes.md +93 -0
  121. package/docs/agent-api/reading.md +166 -0
  122. package/docs/agent-api/requests.md +94 -0
  123. package/docs/agent-api/talking.md +100 -0
  124. package/docs/agent-api/tcb.md +135 -0
  125. package/docs/agent-api/views.md +124 -0
  126. package/docs/agent-api/words.md +56 -0
  127. package/package.json +39 -4
  128. package/test/toolchains.json +5 -0
@@ -0,0 +1,156 @@
1
+ // From a project to what the page loads: extraction (or a saved one), then the view, both cached
2
+ // in the project's cache directory by the extraction's fingerprint (server/dirs.ts); the memory of
3
+ // the layout last served is the site's.
4
+ import { createHash } from "node:crypto";
5
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { gzipSync } from "node:zlib";
9
+ import { LAYOUT_VERSION } from "../shared/graph.js";
10
+ import { cachedExtraction, extract, loadSaved, prune } from "./extract.js";
11
+ import { memoryOf } from "../shared/layout.js";
12
+ import { buildViewOffThread } from "./view.js";
13
+ import { cacheHome } from "./dirs.js";
14
+ /** the tool's own folder, `src/`; the repository is above it */
15
+ export const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "..");
16
+ export const REPO = resolve(SRC, "..");
17
+ export const WEB = join(SRC, "web");
18
+ /** The page's bundle made when the package was built (scripts/build.mjs), beside the page: an
19
+ * installed package serves it as it is, and has no esbuild to make another. A checkout has none. */
20
+ export const PREBUILT = join(WEB, "bundle");
21
+ const prebuilt = existsSync(join(PREBUILT, "app.js"));
22
+ /** The page's bundle under a cache root: one folder per checkout of the tool, so that two never
23
+ * write over each other's. */
24
+ export function bundleDirIn(cacheRoot) {
25
+ return join(cacheRoot, "web", createHash("sha1").update(SRC).digest("hex").slice(0, 8));
26
+ }
27
+ let bundle = null;
28
+ /** Where the page's bundle is made and served from: set once at start, from `--cache`. */
29
+ export function useBundleDir(dir) {
30
+ bundle = dir;
31
+ }
32
+ export function bundleDir() {
33
+ if (prebuilt)
34
+ return PREBUILT;
35
+ return (bundle ??= bundleDirIn(cacheHome()));
36
+ }
37
+ /** Bumped when the page data derived from an extraction changes shape or layout, so cached
38
+ * views from an older server are rebuilt rather than served. 7: a notation unit's statement is
39
+ * its command's text. 8: notation units carry their Lean constant, display form, what they
40
+ * interpret and their rules. 9: a node's hash is its version, beside the kernel's
41
+ * (server/versions.ts). */
42
+ const VIEW_VERSION = 9;
43
+ function loadMemory(siteDir) {
44
+ try {
45
+ const mem = JSON.parse(readFileSync(join(siteDir, "layout-memory.json"), "utf8"));
46
+ return mem.version === LAYOUT_VERSION ? mem : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ function saveMemory(siteDir, fingerprint, meta, before) {
53
+ const mem = { version: LAYOUT_VERSION, fingerprint, layouts: {}, ...(before !== undefined ? { before } : {}) };
54
+ for (const mode of ["namespace", "module"])
55
+ mem.layouts[mode] = memoryOf(meta.layouts[mode], meta.nodes);
56
+ mkdirSync(siteDir, { recursive: true });
57
+ writeFileSync(join(siteDir, "layout-memory.json"), JSON.stringify(mem));
58
+ }
59
+ export async function prepare(project, dirs, saved, log, opts = {}) {
60
+ // `reuse`: lay out an extraction already held, without reading the build (which may be
61
+ // half-written right now); `extraction`: one in hand (the usage pass's, merged); `provisional`:
62
+ // the graph waits for its usage pass; `completes`: the fingerprint of the build's extraction that
63
+ // this graph, the usage pass's merged, completes
64
+ const held = opts.extraction ?? (opts.reuse ? cachedExtraction(dirs.cache, opts.reuse) : null);
65
+ if (opts.reuse && !held)
66
+ throw new Error("the graph on screen is no longer cached; it is laid out afresh at the next build");
67
+ const ex = held ?? (saved ? loadSaved(saved) : await extract(project, dirs, log));
68
+ if (!opts.extraction)
69
+ log(ex.cached ? `extraction ${ex.fingerprint} reused` : `extraction ${ex.fingerprint} done`);
70
+ const usage = ex.graph.usage ?? null;
71
+ const viewDir = join(dirs.cache, "view");
72
+ mkdirSync(viewDir, { recursive: true });
73
+ const metaFile = join(viewDir, `${ex.fingerprint}-v${VIEW_VERSION}.meta.json.gz`);
74
+ const edgeFile = join(viewDir, `${ex.fingerprint}-v${VIEW_VERSION}.edges.bin.gz`);
75
+ const memory = opts.freshLayout ? null : loadMemory(dirs.site);
76
+ // a cached view is only right if it is the one this site last served (a cache may outlive its
77
+ // site, or serve another): otherwise lay out again, from the memory of what was last served
78
+ const current = memory !== null && memory.fingerprint === ex.fingerprint;
79
+ if (!opts.freshLayout && current && existsSync(metaFile) && existsSync(edgeFile)) {
80
+ return { fingerprint: ex.fingerprint, cached: ex.cached, metaGz: readFileSync(metaFile), edgesGz: readFileSync(edgeFile), usage };
81
+ }
82
+ // the usage pass completing the build last laid out: against what that provisional layout was
83
+ // laid out against, so that the build's graph is laid out as if the notation had come with it
84
+ const completing = memory !== null && opts.completes !== undefined && memory.fingerprint === opts.completes && memory.before !== undefined;
85
+ const against = completing ? (memory.before ?? null) : (memory?.layouts ?? null);
86
+ const view = await buildViewOffThread(project, ex, log, against);
87
+ saveMemory(dirs.site, ex.fingerprint, view.meta, opts.provisional ? against : undefined);
88
+ const metaGz = gzipSync(JSON.stringify(view.meta));
89
+ const edgesGz = gzipSync(Buffer.from(view.edges.buffer, view.edges.byteOffset, view.edges.byteLength));
90
+ writeFileSync(metaFile, metaGz);
91
+ writeFileSync(edgeFile, edgesGz);
92
+ prune(viewDir, /\.meta\.json\.gz$/, 3);
93
+ prune(viewDir, /\.edges\.bin\.gz$/, 3);
94
+ return { fingerprint: ex.fingerprint, cached: ex.cached, metaGz, edgesGz, usage };
95
+ }
96
+ function readdirRec(dir) {
97
+ const out = [];
98
+ for (const ent of readdirSync(dir, { withFileTypes: true })) {
99
+ const p = join(dir, ent.name);
100
+ if (ent.isDirectory())
101
+ out.push(...readdirRec(p));
102
+ else
103
+ out.push(p);
104
+ }
105
+ return out;
106
+ }
107
+ /** The page is bundled when its sources are newer than the bundle, so a checkout runs without a
108
+ * separate build step; an installed package serves the bundle it was built with (`PREBUILT`).
109
+ * What the page shares with the server (shared/) is its source too. Bundled into a folder of its
110
+ * own beside the bundle's, then moved into it a file at a time: a server reading the bundle
111
+ * meanwhile never reads a file half written. */
112
+ export async function ensureBundle() {
113
+ if (prebuilt)
114
+ return;
115
+ const dist = bundleDir();
116
+ const out = join(dist, "app.js");
117
+ let newest = 0;
118
+ for (const f of [...readdirRec(join(WEB, "src")), ...readdirRec(join(SRC, "shared"))])
119
+ newest = Math.max(newest, statSync(f).mtimeMs);
120
+ const beside = ["layout.js", "edges.js", "pdfworker.js"].every((f) => existsSync(join(dist, f)));
121
+ if (existsSync(out) && beside && statSync(out).mtimeMs >= newest)
122
+ return;
123
+ mkdirSync(dirname(dist), { recursive: true });
124
+ const tmp = mkdtempSync(`${dist}.tmp-`);
125
+ try {
126
+ await bundlePage(tmp);
127
+ mkdirSync(dist, { recursive: true });
128
+ for (const f of readdirSync(tmp))
129
+ renameSync(join(tmp, f), join(dist, f));
130
+ }
131
+ finally {
132
+ rmSync(tmp, { recursive: true, force: true });
133
+ }
134
+ }
135
+ /** The page's bundle, from `web/src/` of a checkout, into `outdir`: by `ensureBundle` at start, and
136
+ * by scripts/build.mjs into the package. Beside the page, the workers that lay out the Time
137
+ * Machine's past graphs (web/src/layoutworker.ts) and choose the overview's edges
138
+ * (web/src/edgeworker.ts) off the page's thread. */
139
+ export async function bundlePage(outdir, sourcemap = true) {
140
+ const esbuild = await import("esbuild");
141
+ const srcDir = join(WEB, "src");
142
+ await esbuild.build({
143
+ // pdf.js's worker, which reads the library's PDFs for the reference window (AttachmentSpec
144
+ // decision 11), is bundled beside the page's own workers and served as they are: no outside
145
+ // service. Its legacy build, as the page's, for browsers that lack what the modern one uses
146
+ entryPoints: { app: join(srcDir, "main.ts"), layout: join(srcDir, "layoutworker.ts"), edges: join(srcDir, "edgeworker.ts"), pdfworker: fileURLToPath(import.meta.resolve("pdfjs-dist/legacy/build/pdf.worker.min.mjs")) },
147
+ bundle: true,
148
+ format: "esm",
149
+ // the owner reads in Safari: what Safari 16 lacks of the syntax (pdf.js's class static blocks; a
150
+ // regular expression's lookbehind, read at run time instead) is lowered
151
+ target: ["es2022", "safari16"],
152
+ sourcemap,
153
+ outdir,
154
+ logLevel: "warning",
155
+ });
156
+ }
@@ -0,0 +1,251 @@
1
+ // What the server reads of a PDF (AttachmentSpec, "Implementation notes"): poppler's tools, run on
2
+ // the file in the library's store — `pdfinfo` for its title, its pages and their sizes,
3
+ // `pdftotext -bbox-layout` for its words and where each is on its page, `pdftoppm` for a page as an
4
+ // image and for a clip, a region of a page (what a note will carry, decision 12). The words are read
5
+ // once per file, every page at a time, and kept; images are kept by file, page, region and width,
6
+ // so that the same request is answered from the disk.
7
+ //
8
+ // Every place is in the page's own fractions (0–1, from its top left), as a link's region is
9
+ // (shared/library.ts), so that nothing depends on how large the page is drawn. A PDF is untrusted
10
+ // input: the tools are run without a shell, on a path of the server's own, with a time limit, and at
11
+ // most two at once.
12
+ import { execFile } from "node:child_process";
13
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { gunzipSync, gzipSync } from "node:zlib";
16
+ import { wordsText } from "../shared/library.js";
17
+ /** the longest each tool may run, in ms */
18
+ const INFO_MS = 20_000;
19
+ const TEXT_MS = 120_000;
20
+ const IMAGE_MS = 60_000;
21
+ /** the most the tools' output is read, in bytes: the words of a long paper, a large page */
22
+ const MAX_OUT = 256 * 1024 * 1024;
23
+ /** how many of the tools run at once */
24
+ const AT_ONCE = 2;
25
+ let running = 0;
26
+ const waiting = [];
27
+ /** Run a tool, at most AT_ONCE at a time; its standard output, or why it failed. */
28
+ async function run(cmd, args, ms) {
29
+ if (running >= AT_ONCE)
30
+ await new Promise((ok) => waiting.push(ok));
31
+ running++;
32
+ try {
33
+ return await new Promise((ok, no) => {
34
+ execFile(cmd, args, { timeout: ms, maxBuffer: MAX_OUT, encoding: "buffer", windowsHide: true }, (err, out, errOut) => {
35
+ if (err)
36
+ no(new Error(`${cmd} failed: ${(errOut?.toString("utf8").trim().split("\n").pop() ?? "") || err.message}`));
37
+ else
38
+ ok(out);
39
+ });
40
+ });
41
+ }
42
+ finally {
43
+ running--;
44
+ waiting.shift()?.();
45
+ }
46
+ }
47
+ /** A PDF's own title (where it has one that says something), its pages, and each page's size. */
48
+ export async function pdfInfo(file) {
49
+ const first = (await run("pdfinfo", ["-enc", "UTF-8", file], INFO_MS)).toString("utf8");
50
+ const pages = Number(/^Pages:[ \t]+(\d+)/m.exec(first)?.[1] ?? "0");
51
+ const title = /^Title:[ \t]*(.*)$/m.exec(first)?.[1]?.trim() || null;
52
+ const sizes = [];
53
+ if (pages > 0) {
54
+ // every page's size and rotation, so that a region in a page's fractions is read on that page
55
+ const all = (await run("pdfinfo", ["-f", "1", "-l", String(pages), file], INFO_MS)).toString("utf8");
56
+ const size = new Map();
57
+ const rot = new Map();
58
+ for (const m of all.matchAll(/^Page\s+(\d+) size:\s+([\d.]+) x ([\d.]+) pts/gm))
59
+ size.set(Number(m[1]), { w: Number(m[2]), h: Number(m[3]) });
60
+ for (const m of all.matchAll(/^Page\s+(\d+) rot:\s+(\d+)/gm))
61
+ rot.set(Number(m[1]), Number(m[2]));
62
+ for (let p = 1; p <= pages; p++) {
63
+ const s = size.get(p) ?? size.get(1) ?? { w: 612, h: 792 };
64
+ sizes.push((rot.get(p) ?? 0) % 180 ? { w: s.h, h: s.w } : s);
65
+ }
66
+ }
67
+ return { title: usefulTitle(title), pages, sizes };
68
+ }
69
+ /** A title a PDF gives itself that says something: not empty, not a file name or a word-processor's
70
+ * placeholder, nor only an identifier (an arXiv paper's, a DOI), which names a paper without saying
71
+ * what it is. */
72
+ export function usefulTitle(t) {
73
+ if (!t)
74
+ return null;
75
+ const s = t.replace(/\s+/g, " ").trim();
76
+ if (!s || /^(untitled|microsoft word - .*|.*\.(docx?|tex|dvi|pdf))$/i.test(s) || IDENTIFIER.test(s))
77
+ return null;
78
+ return s.length > 300 ? s.slice(0, 299) + "…" : s;
79
+ }
80
+ /** A paper's identifier alone: arXiv's, new style (`1910.09336`, `1910.09336v1`) or old
81
+ * (`math/0211159v1`, `math.GT/0309136`), maybe as `arXiv:…` and with its category after it in
82
+ * brackets, as arXiv stamps it; or a DOI, bare, as `doi:…` or as its address. */
83
+ const IDENTIFIER = /^(?:(?:arxiv:\s*)?(?:\d{4}\.\d{4,5}|[a-z-]+(?:\.[a-z-]+)?\/\d{7})(?:v\d+)?(?:\s*\[[^\]]+\])?|(?:doi:\s*|https?:\/\/(?:dx\.)?doi\.org\/)?10\.\d{4,9}\/\S+)$/i;
84
+ const ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" };
85
+ const unescapeXml = (s) => s.replace(/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (_, e) => e[0] === "#" ? String.fromCodePoint(e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : Number(e.slice(1))) : ENTITIES[e.toLowerCase()] ?? "");
86
+ const frac = (v) => Math.round(Math.min(1, Math.max(0, v)) * 1e4) / 1e4;
87
+ /** `pdftotext -bbox-layout`'s XHTML, read: every page's words with their boxes. */
88
+ export function parseBboxLayout(xhtml) {
89
+ const out = [];
90
+ const pageRe = /<page width="([\d.]+)" height="([\d.]+)">([\s\S]*?)<\/page>/g;
91
+ for (let m = pageRe.exec(xhtml), p = 1; m; m = pageRe.exec(xhtml), p++) {
92
+ const width = Number(m[1]), height = Number(m[2]);
93
+ const words = [];
94
+ const lines = [];
95
+ for (const line of m[3].matchAll(/<line[^>]*>([\s\S]*?)<\/line>/g)) {
96
+ lines.push(words.length);
97
+ for (const w of line[1].matchAll(/<word xMin="([\d.-]+)" yMin="([\d.-]+)" xMax="([\d.-]+)" yMax="([\d.-]+)">([\s\S]*?)<\/word>/g)) {
98
+ // a glyph its font maps to no character comes as a control character: no text
99
+ const text = unescapeXml(w[5]).replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
100
+ if (text)
101
+ words.push([text, frac(Number(w[1]) / width), frac(Number(w[2]) / height), frac(Number(w[3]) / width), frac(Number(w[4]) / height)]);
102
+ }
103
+ }
104
+ out.push({ page: p, width, height, words, lines });
105
+ }
106
+ return out;
107
+ }
108
+ /** A page's text as its words make it: a line's words joined by spaces, lines by line breaks; and
109
+ * where each word starts in it. */
110
+ export function pageText(p) {
111
+ return wordsText(p.words, p.lines);
112
+ }
113
+ /** Find `q` in every page's text: case and runs of white space read loosely; at most `most`
114
+ * matches, in page order. */
115
+ export function searchWords(pages, q, most = 100) {
116
+ const want = q.replace(/\s+/g, " ").trim().toLowerCase();
117
+ const matches = [];
118
+ if (!want)
119
+ return { matches, more: false };
120
+ for (const p of pages) {
121
+ const { text, starts } = pageText(p);
122
+ const flat = text.replace(/\n/g, " ").toLowerCase();
123
+ for (let at = flat.indexOf(want); at >= 0; at = flat.indexOf(want, at + 1)) {
124
+ if (matches.length >= most)
125
+ return { matches, more: true };
126
+ const end = at + want.length;
127
+ const first = Math.max(0, upper(starts, at));
128
+ const last = Math.max(first, upper(starts, end - 1));
129
+ const boxes = p.words.slice(first, last + 1).map(([, x0, y0, x1, y1]) => [x0, y0, x1, y1]);
130
+ matches.push({ page: p.page, boxes, text: text.slice(at, end), before: text.slice(Math.max(0, at - 60), at).replace(/\s+/g, " "), after: text.slice(end, end + 60).replace(/\s+/g, " ") });
131
+ }
132
+ }
133
+ return { matches, more: false };
134
+ }
135
+ /** The index of the last start at or before `at`. */
136
+ function upper(starts, at) {
137
+ let lo = 0, hi = starts.length - 1, best = -1;
138
+ while (lo <= hi) {
139
+ const mid = (lo + hi) >> 1;
140
+ if (starts[mid] <= at) {
141
+ best = mid;
142
+ lo = mid + 1;
143
+ }
144
+ else
145
+ hi = mid - 1;
146
+ }
147
+ return best;
148
+ }
149
+ /** The widths a page or a clip is drawn at: a few, so that what is kept stays small. */
150
+ const WIDTHS = [400, 800, 1200, 1600, 2000];
151
+ export const clampWidth = (w) => WIDTHS.find((x) => x >= w) ?? WIDTHS[WIDTHS.length - 1];
152
+ /** A PDF's words, pages and images, kept in `cacheDir/<sha>/`. */
153
+ export class PdfReader {
154
+ cacheDir;
155
+ words = new Map();
156
+ infos = new Map();
157
+ constructor(cacheDir) {
158
+ this.cacheDir = cacheDir;
159
+ }
160
+ dir(sha) {
161
+ const d = join(this.cacheDir, sha);
162
+ mkdirSync(d, { recursive: true });
163
+ return d;
164
+ }
165
+ /** What `pdfinfo` says of it, once per run. */
166
+ info(sha, file) {
167
+ let p = this.infos.get(sha);
168
+ if (!p) {
169
+ p = pdfInfo(file);
170
+ p.catch(() => this.infos.delete(sha));
171
+ this.infos.set(sha, p);
172
+ }
173
+ return p;
174
+ }
175
+ /** Every page's words: read once, kept on disk (gzipped JSON) and in memory for the last few. */
176
+ allWords(sha, file) {
177
+ let p = this.words.get(sha);
178
+ if (!p) {
179
+ p = (async () => {
180
+ const kept = join(this.dir(sha), "words.json.gz");
181
+ if (existsSync(kept)) {
182
+ try {
183
+ return JSON.parse(gunzipSync(readFileSync(kept)).toString("utf8"));
184
+ }
185
+ catch {
186
+ // a file cut short: read again
187
+ }
188
+ }
189
+ const xhtml = (await run("pdftotext", ["-bbox-layout", "-enc", "UTF-8", file, "-"], TEXT_MS)).toString("utf8");
190
+ const pages = parseBboxLayout(xhtml);
191
+ writeAtomic(kept, gzipSync(JSON.stringify(pages)));
192
+ return pages;
193
+ })();
194
+ p.catch(() => this.words.delete(sha));
195
+ if (this.words.size >= 8)
196
+ this.words.delete(this.words.keys().next().value);
197
+ this.words.set(sha, p);
198
+ }
199
+ return p;
200
+ }
201
+ /** A page drawn as a PNG `width` pixels wide (one of a few widths). */
202
+ async page(sha, file, page, width) {
203
+ const w = clampWidth(width);
204
+ const kept = join(this.dir(sha), `page-${page}-w${w}.png`);
205
+ if (existsSync(kept))
206
+ return readFileSync(kept);
207
+ const tmp = join(this.dir(sha), `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
208
+ try {
209
+ await run("pdftoppm", ["-png", "-f", String(page), "-l", String(page), "-singlefile", "-scale-to-x", String(w), "-scale-to-y", "-1", file, tmp], IMAGE_MS);
210
+ renameSync(`${tmp}.png`, kept);
211
+ }
212
+ finally {
213
+ rmSync(`${tmp}.png`, { force: true });
214
+ }
215
+ return readFileSync(kept);
216
+ }
217
+ /** A region of a page drawn as a PNG about `width` pixels wide: what a clip shows. */
218
+ async clip(sha, file, page, size, region, width) {
219
+ const w = clampWidth(width);
220
+ const r = region.map((x) => Math.round(x * 1e4) / 1e4);
221
+ const kept = join(this.dir(sha), `clip-${page}-${r.join("_")}-w${w}.png`);
222
+ if (existsSync(kept))
223
+ return readFileSync(kept);
224
+ // the resolution at which the region is `w` pixels wide, within reason
225
+ const dpi = Math.min(600, Math.max(36, (w / ((r[2] - r[0]) * size.w)) * 72));
226
+ const px = (v, of) => Math.round((v * of * dpi) / 72);
227
+ const x = px(r[0], size.w), y = px(r[1], size.h);
228
+ const W = Math.max(1, px(r[2], size.w) - x), H = Math.max(1, px(r[3], size.h) - y);
229
+ const tmp = join(this.dir(sha), `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`);
230
+ try {
231
+ await run("pdftoppm", ["-png", "-f", String(page), "-l", String(page), "-singlefile", "-r", dpi.toFixed(2), "-x", String(x), "-y", String(y), "-W", String(W), "-H", String(H), file, tmp], IMAGE_MS);
232
+ renameSync(`${tmp}.png`, kept);
233
+ }
234
+ finally {
235
+ rmSync(`${tmp}.png`, { force: true });
236
+ }
237
+ return readFileSync(kept);
238
+ }
239
+ /** What is kept of a file goes (its bytes purged). */
240
+ forget(sha) {
241
+ this.words.delete(sha);
242
+ this.infos.delete(sha);
243
+ rmSync(join(this.cacheDir, sha), { recursive: true, force: true });
244
+ }
245
+ }
246
+ /** Write a file whole or not at all: to a name of its own first, then renamed into place. */
247
+ export function writeAtomic(path, data) {
248
+ const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(16).slice(2)}`;
249
+ writeFileSync(tmp, data);
250
+ renameSync(tmp, path);
251
+ }
@@ -0,0 +1,252 @@
1
+ // Who is here (CollabSpec decision 4): one entry per open page, kept by a heartbeat every ten
2
+ // seconds and dropped when the page's event stream closes or its heartbeats stop. Every change goes
3
+ // out to every page as the whole list — a few readers, so a few hundred bytes.
4
+ //
5
+ // A page's entry is its event stream's (server/live.ts): the server names each stream with a public
6
+ // id, the one every page is told, and hands the page alone a secret its heartbeats carry. So no page
7
+ // can speak for another, rename it or make it leave, and there are no more entries than streams.
8
+ //
9
+ // An agent (AgentSpec decision 1) has no page and no event stream: one entry per agent name, kept
10
+ // by its requests. An agent thinks between them for longer than a page waits between heartbeats, so
11
+ // it is shown away after a minute without one and gone after three, or when it says it leaves. At
12
+ // most MAX_AGENTS are listed: a name beyond them is served, and listed once one goes.
13
+ //
14
+ // An agent with a token of its own (server/tokens.ts) is one entry by its token, not by the name it
15
+ // acts under, so that nobody else can speak for it by giving that name; and it is listed as long as
16
+ // its token is in force (AgentSpec decision 8), with its state as server/talk.ts infers it from what
17
+ // it does — listening, thinking, working or offline — and when it was last seen. It is shown away
18
+ // only while offline.
19
+ //
20
+ // A private page (CollabSpec decision 8) is an entry with nothing in it — no name, no place — and is
21
+ // never listed: every page is told how many there are, and nothing else of them. Its stream is named
22
+ // afresh when it goes private and when it shows itself again (server/live.ts), so its entries under
23
+ // either id say nothing of the other.
24
+ //
25
+ // What an agent asks to follow the people it helps (GET /api/agent/companion) waits for the list to
26
+ // change: `rev` counts the changes that matter to it — someone came, went, moved, went idle, an
27
+ // agent went offline or came back — and leaves out a camera that moved, which a page reports
28
+ // every second or two while one pans, and what an agent that is not offline does from one request
29
+ // to the next.
30
+ import { createHash } from "node:crypto";
31
+ /** a page not heard from for this long has gone */
32
+ const GONE_MS = 35_000;
33
+ /** an agent without a token not heard from for this long is away, and then gone */
34
+ const AGENT_IDLE_MS = 60_000;
35
+ const AGENT_GONE_MS = 180_000;
36
+ /** the most agents listed at once: every name an agent gives is an entry everyone is sent */
37
+ export const MAX_AGENTS = 32;
38
+ /** The entry an agent's requests stand for: one per name; its id, as pages are told it. */
39
+ export function agentSession(name) {
40
+ return createHash("sha1").update(`agent\u0000${name}`).digest("hex");
41
+ }
42
+ /** The entry an agent stands for: its token's, or its name's. */
43
+ export function agentKey(who) {
44
+ const w = typeof who === "string" ? { name: who } : who;
45
+ return w.token ? createHash("sha1").update(`agent token\u0000${w.token}`).digest("hex") : agentSession(w.name);
46
+ }
47
+ export class Presence {
48
+ sessions = new Map();
49
+ /** agents with a token in force, by the token's id */
50
+ tokens = new Map();
51
+ /** pages whose event stream closed, and when: a heartbeat sent as one closes (a reload) comes
52
+ * after its stream ended, and would keep it on the list — and the views it had open alive
53
+ * (server/views.ts) — until it times out */
54
+ closed = new Map();
55
+ send;
56
+ /** the changes an agent following people waits for (GET /api/agent/companion), counted */
57
+ rev = 0;
58
+ lastSig = "";
59
+ waiters = new Set();
60
+ constructor(send) {
61
+ this.send = send;
62
+ setInterval(() => this.expire(), 5000).unref();
63
+ }
64
+ /** The list changed: every page is sent it, and an agent waiting for a change that matters to
65
+ * it is told. */
66
+ notify() {
67
+ const list = this.list();
68
+ this.send(list);
69
+ // neither a camera that pans nor what an agent does while online is a move; an agent going
70
+ // offline or coming back is, and so is a private reader coming or going
71
+ const sig = JSON.stringify([this.privateCount(), list.map((p) => ({ ...p, view: p.view ? { ...p.view, cam: null } : null, talk: p.talk ? p.talk.state === "offline" : undefined }))]);
72
+ if (sig === this.lastSig)
73
+ return;
74
+ this.lastSig = sig;
75
+ this.rev++;
76
+ for (const w of [...this.waiters])
77
+ w();
78
+ }
79
+ /** Resolves once `rev` is not `since` any more, or after `ms`. */
80
+ waitChange(since, ms) {
81
+ if (since !== this.rev || ms <= 0)
82
+ return Promise.resolve();
83
+ return new Promise((done) => {
84
+ const end = () => {
85
+ clearTimeout(timer);
86
+ this.waiters.delete(end);
87
+ done();
88
+ };
89
+ const timer = setTimeout(end, ms);
90
+ this.waiters.add(end);
91
+ });
92
+ }
93
+ /** A page says where it is (by its stream's id); or an agent, with each request (`agent`), where
94
+ * `view` null keeps the view it gave last. `verified`: the page of someone invited by a personal
95
+ * link, whose name is theirs. */
96
+ beat(id, name, view, idle, agent = false, verified = false) {
97
+ if (this.closed.has(id))
98
+ return;
99
+ const was = this.sessions.get(id);
100
+ if (!was && agent && [...this.sessions.values()].filter((e) => e.agent).length >= MAX_AGENTS)
101
+ return;
102
+ const next = { name, view: view ?? was?.view ?? null, idle, seen: Date.now(), agent, ...(verified && name ? { verified: true } : {}) };
103
+ this.sessions.set(id, next);
104
+ const same = was && !was.private && was.name === next.name && was.idle === next.idle && was.agent === next.agent && was.verified === next.verified && JSON.stringify(was.view) === JSON.stringify(next.view);
105
+ if (!same)
106
+ this.notify();
107
+ }
108
+ /** A private page's heartbeat (CollabSpec decision 8): it is here, and that is all that is kept
109
+ * of it — no name, no place, never idle. */
110
+ beatPrivate(id) {
111
+ if (this.closed.has(id))
112
+ return;
113
+ const was = this.sessions.get(id);
114
+ this.sessions.set(id, { name: null, view: null, idle: false, seen: Date.now(), agent: false, private: true });
115
+ if (!was?.private)
116
+ this.notify();
117
+ }
118
+ /** A page named afresh as it went private or showed itself again (server/live.ts): its entry under
119
+ * `old` goes, and comes back under `id` — counted, or listed with its name and place — as one
120
+ * change, so that no list goes out with the page under both ids, or under neither. */
121
+ renamed(old, id, priv, name, view, idle, verified = false) {
122
+ this.sessions.delete(old);
123
+ this.closed.delete(id);
124
+ this.sessions.set(id, priv ? { name: null, view: null, idle: false, seen: Date.now(), agent: false, private: true } : { name, view, idle, seen: Date.now(), agent: false, ...(verified && name ? { verified: true } : {}) });
125
+ this.notify();
126
+ }
127
+ /** How many private pages are here: what every page and the agents' companion are told of them. */
128
+ privateCount() {
129
+ let n = 0;
130
+ for (const e of this.sessions.values())
131
+ if (e.private)
132
+ n++;
133
+ return n;
134
+ }
135
+ /** Where a page that is listed last said it was; null for any other. */
136
+ pageView(id) {
137
+ const e = this.sessions.get(id);
138
+ return e && !e.agent && !e.private ? e.view : null;
139
+ }
140
+ /** An agent's request: it is here, with the view it gave (or the one it gave last). */
141
+ agentBeat(who, view = null) {
142
+ if (typeof who !== "string" && who.token) {
143
+ // its state is server/talk.ts's to say (setTalk); a token that is not listed (ended) is not
144
+ // listed again
145
+ const t = this.tokens.get(who.token);
146
+ if (!t)
147
+ return;
148
+ const before = JSON.stringify(t.view);
149
+ t.view = view ?? t.view;
150
+ if (JSON.stringify(t.view) !== before)
151
+ this.notify();
152
+ return;
153
+ }
154
+ const key = agentKey(who);
155
+ // an agent that said it leaves is back with its next request: it has no stream to reopen
156
+ this.closed.delete(key);
157
+ this.beat(key, typeof who === "string" ? who : who.name, view, false, true);
158
+ }
159
+ /** What an agent last said it looks at; null when it is not here. */
160
+ agentView(who) {
161
+ if (typeof who !== "string" && who.token)
162
+ return this.tokens.get(who.token)?.view ?? null;
163
+ return this.sessions.get(agentKey(who))?.view ?? null;
164
+ }
165
+ /** A page closed (its event stream ended), or an agent left (its key, agentKey). An agent with a
166
+ * token stays listed: it is no longer anywhere. */
167
+ drop(session) {
168
+ for (const [id, t] of this.tokens)
169
+ if (agentKey({ name: t.name, token: id }) === session) {
170
+ if (t.view) {
171
+ t.view = null;
172
+ this.notify();
173
+ }
174
+ return;
175
+ }
176
+ this.closed.set(session, Date.now());
177
+ if (this.sessions.delete(session))
178
+ this.notify();
179
+ }
180
+ /** The tokens in force (server/tokens.ts): each one's agent is listed, in the state `talkOf`
181
+ * says (offline until it is seen), with when its token was last used until it is; an agent whose
182
+ * token ended goes. */
183
+ setTokens(list, talkOf) {
184
+ let changed = false;
185
+ const ids = new Set(list.map((r) => r.id));
186
+ for (const id of [...this.tokens.keys()])
187
+ if (!ids.has(id)) {
188
+ this.tokens.delete(id);
189
+ changed = true;
190
+ }
191
+ for (const r of list) {
192
+ const t = this.tokens.get(r.id);
193
+ if (t) {
194
+ t.by = r.by;
195
+ continue;
196
+ }
197
+ const talk = talkOf?.(r.id) ?? { state: "offline", since: new Date().toISOString(), doing: null, status: null, read: 0, lastSeen: null };
198
+ this.tokens.set(r.id, { name: r.name, by: r.by, view: null, talk: { ...talk, lastSeen: talk.lastSeen ?? r.lastUsed ?? null } });
199
+ changed = true;
200
+ }
201
+ if (changed)
202
+ this.notify();
203
+ }
204
+ /** What an agent with a token does now changed (server/talk.ts). */
205
+ setTalk(id, talk) {
206
+ const t = this.tokens.get(id);
207
+ if (!t)
208
+ return;
209
+ t.talk = { ...talk, lastSeen: talk.lastSeen ?? t.talk.lastSeen };
210
+ this.notify();
211
+ }
212
+ /** The token of the agent pages know by `id` (a token agent's entry), or null. */
213
+ tokenOf(id) {
214
+ for (const [tok, t] of this.tokens)
215
+ if (agentKey({ name: t.name, token: tok }) === id)
216
+ return tok;
217
+ return null;
218
+ }
219
+ /** A page's event stream opened (again, after a lost connection): its heartbeats count. */
220
+ open(session) {
221
+ this.closed.delete(session);
222
+ }
223
+ /** Who is here, as every page is told: a private page is not (privateCount). */
224
+ list() {
225
+ const out = [...this.sessions]
226
+ .filter(([, e]) => !e.private)
227
+ .map(([id, e]) => ({ id, name: e.name, view: e.view, idle: e.idle, ...(e.agent ? { agent: true } : {}), ...(e.verified ? { verified: true } : {}) }));
228
+ for (const [tok, t] of this.tokens)
229
+ out.push({ id: agentKey({ name: t.name, token: tok }), name: t.name, view: t.view, idle: t.talk.state === "offline", agent: true, talk: t.talk, by: t.by });
230
+ return out;
231
+ }
232
+ /** Pages whose heartbeats stopped go; agents silent for a while are away, then go. */
233
+ expire(now = Date.now()) {
234
+ let changed = false;
235
+ for (const [s, at] of this.closed)
236
+ if (now - at > GONE_MS)
237
+ this.closed.delete(s);
238
+ for (const [s, e] of this.sessions) {
239
+ if (now - e.seen > (e.agent ? AGENT_GONE_MS : GONE_MS)) {
240
+ this.sessions.delete(s);
241
+ changed = true;
242
+ }
243
+ else if (e.agent && !e.idle && now - e.seen > AGENT_IDLE_MS) {
244
+ e.idle = true;
245
+ changed = true;
246
+ }
247
+ }
248
+ // an agent with a token is away while offline, which server/talk.ts says
249
+ if (changed)
250
+ this.notify();
251
+ }
252
+ }