canon-atlas 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shane Conner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # canon-atlas
2
+
3
+ An atlas over a catalog of markdown documents that reference each other. Point it at a directory and it draws the whole corpus as a constellation: documents as lit spheres, references as gossamer links, semantic clusters as colored regions of the map. There is no chrome beyond what the map needs: an icon rail on the left owns the panels (documents with search and ordering, the map's clusters and key, and in the app your folders), and a reader on the right opens a tab per document, with editing once a folder or a server backs the page. Every document you open is a browser history entry, so back and forward walk the trail of articles you read, and a hierarchical address renders as breadcrumbs above the document.
4
+
5
+ It is agnostic to what wrote the catalog. It was built as the human viewport onto [pi-canon](https://github.com/shaneconner/pi-canon) project memory, where mutable articles carry current ground truth and an immutable journal carries the journey to it, but any folder of markdown with `[[wikilinks]]` or relative links works: an Obsidian vault, a wiki, a notes directory, an agent memory store.
6
+
7
+ ![The constellation over a pi-canon memory store](docs/constellation.png)
8
+
9
+ ## Quick start
10
+
11
+ ```sh
12
+ npx canon-atlas open # open the atlas in your browser; pick folders in the page
13
+ npx canon-atlas embed . # write embeddings.json, so color means meaning
14
+ npx canon-atlas build . # write atlas.html, one self-contained chart of this folder
15
+ npx canon-atlas serve . # serve the live atlas at http://127.0.0.1:4747/
16
+ npx canon-atlas app # write atlas-app.html, the same app as one file
17
+ ```
18
+
19
+ Four ways into the same page.
20
+
21
+ - **open** is the everyday door, made for a launcher entry: it starts the little localhost host if one is not already running, opens your browser at it, and gets out of the way. Type a project's absolute path in the page and the host reads the corpus itself, remembers the path, and lists it in the folders panel as exactly that path, so ten projects whose stores are all named `.canon` still tell apart at a glance. Picking or dropping a folder works too; every project lives in its own browser tab off the one host. Atlas tabs quietly ping the host, and after a full day with no atlas tab open it exits itself, so nothing lingers. A desktop entry makes it a one-keystroke launch:
22
+
23
+ ```ini
24
+ [Desktop Entry]
25
+ Type=Application
26
+ Name=canon-atlas
27
+ Exec=canon-atlas open
28
+ Terminal=false
29
+ ```
30
+
31
+ - **app** emits the pure-client app itself: one HTML file with the renderer and the whole build pipeline inlined, and no data of its own. Host it anywhere static, or open it straight from disk. Editing uses the File System Access API, which needs Chrome or Edge and an http or https page: browsers block the writable picker on `file://`, and some browsers (Brave, Firefox) ship without the API (Brave re-enables it at `brave://flags/#file-system-access-api`). Everywhere else the page falls back to a read-only folder picker, and a folder dropped anywhere on the page opens too, so the constellation still works from a double-clicked file. Remembered folders label themselves from the corpus (the picker API never reveals paths), and any label can be renamed in the folders panel; a name you type, like the full path, is kept as written. When the page comes from the local `open` host, the path door above does better: the host knows real paths, and its workspaces need no picker at all.
32
+ - **build** emits a chart: a single HTML file with the renderer, the graph, and every document inlined. It opens from `file://`, ships as an email attachment, and publishes as a static page. Read only by nature.
33
+ - **serve** binds to localhost and puts the corpus behind the same page: create, edit, and delete from the reader panel, with changes on disk immediately. Immutable collections are append-only, so the tool will create a journal entry but never rewrite or delete one.
34
+
35
+ The **open**, **app**, and **serve** paths build the wire data from the exact same pipeline, so the constellation is identical whether the markdown is read in the browser or by the server.
36
+
37
+ ## Collections
38
+
39
+ A corpus is described by collections: named groups of documents with their own frontmatter mapping and their own mutability. Without configuration:
40
+
41
+ - A root holding `articles/` and `journal/` gets the **pi-canon preset**: articles are mutable, the journal is immutable and append-only, `capsule` becomes the summary, a journal entry's `subject` list becomes edges to the articles it concerns, and tags of the form `path:VALUE`, which a migrated store carries as provenance, become source metadata instead of tags.
42
+ - Anything else is read as a single mutable collection of notes.
43
+
44
+ To define your own, put a `canon-atlas.json` at the root:
45
+
46
+ ```json
47
+ {
48
+ "title": "team wiki",
49
+ "collections": [
50
+ { "name": "pages", "match": "wiki/" },
51
+ { "name": "decisions", "match": "wiki/adr/", "immutable": true,
52
+ "fields": { "summary": "status", "date": "decided" } }
53
+ ]
54
+ }
55
+ ```
56
+
57
+ `match` is a directory prefix; the longest prefix claims a file. `fields` maps your frontmatter keys onto the ones the atlas reads: `title`, `tags`, `date`, `summary`, and `refs` (a frontmatter list of documents this one concerns, rendered as edges). Titles fall back to the first heading, then to the path. `immutable` marks a collection append-only and draws its documents as rings instead of spheres.
58
+
59
+ `reveal` controls whether a collection starts on the map: `"always"` (the default) or `"off"` (`"focus"` is accepted and means the same). A collection is on the map exactly when its row in the map panel's key is on: off means gone, layout included, so a hidden tier claims no empty space and nothing lingers. The pi-canon preset starts the journal off: a hundred immutable event entries are detail on demand, not weather. This is why a large corpus reads as a calm sky.
60
+
61
+ References that resolve nowhere are not errors: they render as dashed, unwritten nodes, because a name the corpus reaches for but has not written yet is a fact worth seeing. In live mode an unwritten node offers to be written.
62
+
63
+ ## Color
64
+
65
+ Color is the cluster a document belongs to, sampled from one perceptual ramp (Batlow), and the force layout pulls each cluster into its own region, so a band of the palette and a region of the map mean the same thing.
66
+
67
+ Clusters come from the best signal available:
68
+
69
+ - **Embeddings**, when the root holds an `embeddings.json` mapping each document path to a vector. Spherical k-means picks the cluster count by silhouette. Bring vectors from any model; the file is the interface.
70
+
71
+ ```json
72
+ { "articles/core.md": [0.021, -0.113, ...], "articles/helper.md": [...] }
73
+ ```
74
+
75
+ `canon-atlas embed [root]` writes that file for you. It is the one command in the atlas that touches a network, and only the provider named: the default is a local [Ollama](https://ollama.com) (`nomic-embed-text`; `OLLAMA_HOST` overrides the address), so by default nothing leaves the machine. `-m openai` or `-m openai:MODEL` uses the OpenAI API instead, reading `OPENAI_API_KEY` from the environment. Reruns embed only documents whose content changed, drop vectors for deleted documents, and write atomically after every batch, so an interrupted run keeps its progress. A corpus embedded once keeps its recorded model on bare reruns; `-m` switches models and re-embeds everything. A small `__meta__` record inside the file carries the model name and content hashes; readers ignore it.
76
+
77
+ - **Paths**, when addresses form a real hierarchy (`src/core/config`, `meta/memory/folds/x`): documents cluster by the first path segment, labeled by the segment itself. A hierarchical store names its own neighborhoods.
78
+
79
+ - **Link structure** otherwise: deterministic greedy modularity over the reference graph, at zero cost, fully offline.
80
+
81
+ The map panel can also color by collection or by tag.
82
+
83
+ Labels reveal progressively with zoom: the most central documents keep their names at any distance, and the rest earn them as you approach.
84
+
85
+ ## Vocabulary
86
+
87
+ The tool is the **atlas**. The graph view is the **constellation**. The self-contained file the build emits is a **chart**. The serverless page that opens folders in the browser is the **app**. A star atlas is a book of constellation maps, and in mathematics an atlas is a collection of charts that together cover a space; both senses are meant.
88
+
89
+ ## Design constraints
90
+
91
+ - No runtime dependencies. d3 is vendored (ISC, see `vendor/LICENSE.d3`), everything else is the platform.
92
+ - The chart is one file. The only external reference is the Google Fonts stylesheet, and the page falls back to system fonts without it.
93
+ - The server binds to 127.0.0.1 only, refuses any path outside the corpus, refuses any path that crosses a symbolic link, and enforces collection mutability at the API, not just in the UI. Because the page inlines the whole corpus, the editing API also requires a loopback Host and a same-origin, JSON request, so a web page on another origin cannot drive it by DNS rebinding or a cross-site POST. The launcher host's workspace registry sits behind the same guards, and every registered workspace is served by the same handlers.
94
+ - Rendered markdown is escaped before anything else touches it; a document cannot inject markup into the page.
95
+ - The gate suite is the contract: `node tests/verify.mjs`, every gate green before anything lands.
96
+
97
+ ## Provenance
98
+
99
+ The constellation rendering is a port of the graph view the author built for the pi-canon project pages, itself descended from a knowledge-graph viewer built for an internal agent harness. The articles and journal framing, one mutable tier of ground truth over one immutable tier of events, comes from pi-canon; the atlas only asks that your corpus be markdown files that name each other.
100
+
101
+ ## License
102
+
103
+ MIT
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "canon-atlas",
3
+ "version": "0.1.0",
4
+ "description": "An atlas over a catalog of markdown documents that reference each other: a constellation graph, search, a reader, and local editing. Agnostic to what wrote the catalog.",
5
+ "type": "module",
6
+ "bin": {
7
+ "canon-atlas": "src/cli.mjs"
8
+ },
9
+ "exports": {
10
+ ".": "./src/build.mjs",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "vendor",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "engines": {
20
+ "node": ">=20"
21
+ },
22
+ "scripts": {
23
+ "test": "node tests/verify.mjs"
24
+ },
25
+ "keywords": [
26
+ "knowledge-graph",
27
+ "constellation",
28
+ "markdown",
29
+ "wiki",
30
+ "memory",
31
+ "agent-memory",
32
+ "llm-wiki",
33
+ "visualization",
34
+ "d3",
35
+ "pi-canon",
36
+ "obsidian"
37
+ ],
38
+ "author": "Shane Conner",
39
+ "license": "MIT",
40
+ "repository": {
41
+ "type": "git",
42
+ "url": "git+https://github.com/shaneconner/canon-atlas.git"
43
+ },
44
+ "bugs": "https://github.com/shaneconner/canon-atlas/issues",
45
+ "homepage": "https://github.com/shaneconner/canon-atlas#readme"
46
+ }
package/src/build.mjs ADDED
@@ -0,0 +1,37 @@
1
+ /* Build: corpus in, one self-contained chart out. Or, with no corpus, the
2
+ pure-client app: one self-contained page that opens folders in the browser. */
3
+
4
+ import { writeFileSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import { loadConfig, loadVectors } from "./config.mjs";
7
+ import { scanCorpus } from "./scan.mjs";
8
+ import { buildGraph } from "./graph.mjs";
9
+ import { buildData, renderPage, renderAppPage } from "./page.mjs";
10
+
11
+ export function build(rootArg, outArg) {
12
+ const root = resolve(rootArg || ".");
13
+ const out = resolve(outArg || "atlas.html");
14
+ const config = loadConfig(root);
15
+ config.vectors = loadVectors(root, config);
16
+ const docs = scanCorpus(root, config);
17
+ if (!docs.length) throw new Error(`no markdown documents under ${root}`);
18
+ const graph = buildGraph(docs);
19
+ const data = buildData(config, graph, "chart");
20
+ writeFileSync(out, renderPage(data));
21
+ return {
22
+ out,
23
+ documents: docs.length,
24
+ references: graph.edges.length,
25
+ clusters: data.clusters.length,
26
+ basis: data.basis,
27
+ };
28
+ }
29
+
30
+ /* The pure-client app carries no corpus: it reads folders in the browser. One
31
+ self-contained file, opened once, serves every project. */
32
+ export function buildApp(outArg) {
33
+ const out = resolve(outArg || "atlas-app.html");
34
+ writeFileSync(out, renderAppPage());
35
+ return { out };
36
+ }
37
+
package/src/cli.mjs ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ /* canon-atlas: an atlas over a catalog of markdown that references itself.
3
+
4
+ canon-atlas build [root] [-o atlas.html] one self-contained chart
5
+ canon-atlas serve [root] [-p 4747] the live atlas, with editing */
6
+
7
+ import { spawn } from "node:child_process";
8
+ import { build, buildApp } from "./build.mjs";
9
+ import { embed } from "./embed.mjs";
10
+ import { serve, serveApp } from "./serve.mjs";
11
+
12
+ const USAGE = `canon-atlas: view a catalog of markdown documents as a constellation
13
+
14
+ usage:
15
+ canon-atlas open [-p 4700] open the atlas in your browser,
16
+ starting the local host if needed;
17
+ made for a launcher entry, and it
18
+ exits after a day with no atlas tab
19
+ canon-atlas app [-o atlas-app.html] write the pure-client app as one
20
+ file, to host anywhere static
21
+ canon-atlas embed [root] [-m PROVIDER] write embeddings.json at the corpus
22
+ root, upgrading cluster color from
23
+ link structure to meaning; reruns
24
+ embed only what changed
25
+ canon-atlas build [root] [-o atlas.html] write one self-contained chart
26
+ canon-atlas serve [root] [-p 4747] serve one corpus live, with editing
27
+
28
+ The root is a directory of markdown documents, current directory by default.
29
+ A canon-atlas.json at the root defines collections; without one, a root that
30
+ holds articles/ and journal/ gets the pi-canon preset, and anything else is
31
+ read as a single mutable collection. An embeddings.json of path to vector
32
+ upgrades cluster color from link structure to meaning.
33
+
34
+ embed is the one command that touches a network, and only the provider named:
35
+ the default is ollama (local, so nothing leaves the machine; OLLAMA_HOST
36
+ overrides the address, model nomic-embed-text unless -m ollama:MODEL names
37
+ another). -m openai[:MODEL] uses the OpenAI API instead, with OPENAI_API_KEY
38
+ and optionally OPENAI_BASE_URL from the environment. A corpus embedded before
39
+ keeps its recorded model on rerun; -m switches it, re-embedding everything.
40
+
41
+ The app needs no root: it reads folders in the browser, so one page serves
42
+ every project and each browser tab holds one. Anywhere the File System Access
43
+ API runs (Chrome or Edge over http or https) it edits in place; elsewhere,
44
+ and on file:// pages, it browses read-only, which is why hosting it (-p, or
45
+ any static host) is the full experience.`;
46
+
47
+ function parse(argv) {
48
+ const args = { _: [] };
49
+ for (let i = 0; i < argv.length; i++) {
50
+ const a = argv[i];
51
+ if (a === "-o" || a === "--out") args.out = argv[++i];
52
+ else if (a === "-p" || a === "--port") args.port = Number(argv[++i]);
53
+ else if (a === "-m" || a === "--model") args.model = argv[++i];
54
+ else if (a === "-h" || a === "--help") args.help = true;
55
+ else args._.push(a);
56
+ }
57
+ return args;
58
+ }
59
+
60
+ const args = parse(process.argv.slice(2));
61
+ const cmd = args._[0];
62
+
63
+ try {
64
+ if (args.help || !cmd) {
65
+ console.log(USAGE);
66
+ } else if (cmd === "open") {
67
+ const port = args.port ?? 4700;
68
+ const url = `http://127.0.0.1:${port}/`;
69
+ // Constants over knobs: a full day of no atlas tab, then the host exits.
70
+ const IDLE_MS = 24 * 60 * 60 * 1000;
71
+ const browse = () => {
72
+ const child = spawn("xdg-open", [url], { detached: true, stdio: "ignore" });
73
+ child.on("error", () => console.error(`xdg-open is missing; open ${url} yourself`));
74
+ child.unref();
75
+ };
76
+ try {
77
+ await serveApp(port, { idleMs: IDLE_MS, onIdle: () => process.exit(0) });
78
+ browse();
79
+ console.log(`${url}\nexits after a day with no atlas tab open`);
80
+ } catch (e) {
81
+ if (e && e.code === "EADDRINUSE") {
82
+ // A host is already on the port: reuse it if it is ours.
83
+ const ours = await fetch(url + "ping")
84
+ .then((r) => r.headers.get("x-canon-atlas") === "app")
85
+ .catch(() => false);
86
+ if (!ours) throw new Error(`port ${port} is taken by something else`);
87
+ browse();
88
+ // The running host serves the tab; this process is done. An explicit
89
+ // exit, because the probe fetch can hold the loop open on keep-alive.
90
+ setTimeout(() => process.exit(0), 300);
91
+ } else throw e;
92
+ }
93
+ } else if (cmd === "app") {
94
+ const r = buildApp(args.out);
95
+ console.log(`${r.out}\nopen it in a Chromium browser, then pick a folder`);
96
+ } else if (cmd === "embed") {
97
+ const r = await embed(args._[1], args.model, (p) => process.stderr.write(`\rembedding ${p} `));
98
+ if (r.embedded) process.stderr.write("\n");
99
+ console.log(
100
+ `${r.file}\n${r.embedded} embedded, ${r.reused} unchanged` +
101
+ (r.removed ? `, ${r.removed} removed` : "") +
102
+ `, model ${r.model}\nbuild, serve, and the app pick it up on the next load`
103
+ );
104
+ } else if (cmd === "build") {
105
+ const r = build(args._[1], args.out);
106
+ console.log(
107
+ `${r.out}\n${r.documents} documents, ${r.references} references, ` +
108
+ `${r.clusters} clusters from ${r.basis === "embeddings" ? "embeddings" : "link structure"}`
109
+ );
110
+ } else if (cmd === "serve") {
111
+ const r = await serve(args._[1], args.port);
112
+ console.log(`canon-atlas over ${r.root}\nhttp://127.0.0.1:${r.port}/`);
113
+ } else {
114
+ console.error(`unknown command: ${cmd}\n\n${USAGE}`);
115
+ process.exit(2);
116
+ }
117
+ } catch (e) {
118
+ console.error(String(e.message || e));
119
+ process.exit(1);
120
+ }
@@ -0,0 +1,11 @@
1
+ /* Semantic clusters: the color and region signal for the constellation.
2
+ Spherical k-means over embeddings when the corpus ships them, deterministic
3
+ greedy modularity over the link graph otherwise. The logic lives in
4
+ ui/pipeline.cjs so the browser clusters identically; this re-exports it. */
5
+
6
+ import { createRequire } from "node:module";
7
+
8
+ const P = createRequire(import.meta.url)("./ui/pipeline.cjs");
9
+
10
+ export const communities = P.communities;
11
+ export const assignClusters = P.assignClusters;
package/src/config.mjs ADDED
@@ -0,0 +1,42 @@
1
+ /* Corpus configuration in Node: pick the raw config from a canon-atlas.json at
2
+ the root or from the directory shape, then hand off to the shared pipeline
3
+ for normalization. The pure parts (buildConfig, the presets, parseVectors,
4
+ collectionFor) live in ui/pipeline.cjs, so the browser builds the same config
5
+ from the same rules. */
6
+
7
+ import { existsSync, readFileSync, statSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { createRequire } from "node:module";
10
+
11
+ const P = createRequire(import.meta.url)("./ui/pipeline.cjs");
12
+
13
+ export const CONFIG_NAME = P.CONFIG_NAME;
14
+ export const collectionFor = P.collectionFor;
15
+
16
+ function isDir(p) {
17
+ try {
18
+ return statSync(p).isDirectory();
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ /* Resolution order: an explicit canon-atlas.json at the root wins, then preset
25
+ detection (a store with both articles/ and journal/ is pi-canon), then the
26
+ bare default of one mutable collection over every markdown file. A config
27
+ that names a title but no collections keeps the detected preset. */
28
+ export function loadConfig(root) {
29
+ const file = join(root, CONFIG_NAME);
30
+ const raw = existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : null;
31
+ const detect = () =>
32
+ isDir(join(root, "articles")) && isDir(join(root, "journal")) ? P.PI_CANON_PRESET : P.DEFAULT_CONFIG;
33
+ return P.buildConfig(P.composeConfig(raw, detect));
34
+ }
35
+
36
+ /* Optional embedding vectors at the path config.embeddings names. Absent is
37
+ normal; clustering falls back to link structure. */
38
+ export function loadVectors(root, config) {
39
+ const file = join(root, config.embeddings);
40
+ if (!existsSync(file)) return null;
41
+ return P.parseVectors(JSON.parse(readFileSync(file, "utf8")));
42
+ }
package/src/embed.mjs ADDED
@@ -0,0 +1,179 @@
1
+ /* Embedding generation: the one network-touching command in the atlas.
2
+ Everything else is deterministic and offline; this writes the embeddings.json
3
+ that build, serve, and the app consume, and it only runs when asked.
4
+
5
+ The default provider is a local Ollama, so by default nothing leaves the
6
+ machine; OpenAI is the hosted alternative. Vectors are keyed by root-relative
7
+ path. A __meta__ entry records the model and a content hash per document
8
+ (the reader's parseVectors skips it, since its value is not a numeric array),
9
+ so a rerun embeds only what changed and a model switch re-embeds everything.
10
+ Writes are atomic and land after every batch, so an interrupted run keeps
11
+ its progress and resumes where it stopped. */
12
+
13
+ import { createHash } from "node:crypto";
14
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
15
+ import { request as httpRequest } from "node:http";
16
+ import { request as httpsRequest } from "node:https";
17
+ import { join, resolve } from "node:path";
18
+ import { loadConfig } from "./config.mjs";
19
+ import { scanCorpus } from "./scan.mjs";
20
+
21
+ const BATCH = 16;
22
+ const MAX_CHARS = 8000;
23
+
24
+ const PROVIDERS = {
25
+ ollama: {
26
+ model: "nomic-embed-text",
27
+ url: () => (process.env.OLLAMA_HOST || "http://127.0.0.1:11434") + "/api/embed",
28
+ headers: () => ({ "Content-Type": "application/json" }),
29
+ body: (model, texts) => ({ model, input: texts }),
30
+ vectors: (json) => json.embeddings,
31
+ hint: "is Ollama running? set OLLAMA_HOST, or use -m openai:text-embedding-3-small",
32
+ },
33
+ openai: {
34
+ model: "text-embedding-3-small",
35
+ url: () => (process.env.OPENAI_BASE_URL || "https://api.openai.com") + "/v1/embeddings",
36
+ headers: () => {
37
+ if (!process.env.OPENAI_API_KEY) throw new Error("OPENAI_API_KEY is not set");
38
+ return { "Content-Type": "application/json", Authorization: `Bearer ${process.env.OPENAI_API_KEY}` };
39
+ },
40
+ body: (model, texts) => ({ model, input: texts }),
41
+ vectors: (json) => json.data.map((d) => d.embedding),
42
+ hint: "check OPENAI_API_KEY and OPENAI_BASE_URL",
43
+ },
44
+ };
45
+
46
+ function sha(text) {
47
+ return createHash("sha256").update(text).digest("hex");
48
+ }
49
+
50
+ /* Six decimals is well past what cosine distance can feel, and it keeps the
51
+ JSON a third the size a full double would write at high dimensions. */
52
+ function round6(v) {
53
+ return Math.round(v * 1e6) / 1e6;
54
+ }
55
+
56
+ function atomicWrite(file, content) {
57
+ const tmp = file + ".atlas-tmp-" + process.pid;
58
+ writeFileSync(tmp, content);
59
+ renameSync(tmp, file);
60
+ }
61
+
62
+ /* Node's fetch gives up on any response whose headers take five minutes, and
63
+ a batch on a big local model legitimately thinks longer than that. Plain
64
+ node:http imposes no deadline: the model answers when it answers. */
65
+ function post(url, headers, body) {
66
+ return new Promise((fulfil, reject) => {
67
+ const u = new URL(url);
68
+ const req = (u.protocol === "https:" ? httpsRequest : httpRequest)(
69
+ u,
70
+ { method: "POST", headers },
71
+ (res) => {
72
+ let text = "";
73
+ res.setEncoding("utf8");
74
+ res.on("data", (c) => (text += c));
75
+ res.on("end", () => fulfil({ status: res.statusCode, text }));
76
+ }
77
+ );
78
+ req.on("error", reject);
79
+ req.end(body);
80
+ });
81
+ }
82
+
83
+ /* modelArg is provider or provider:model, e.g. "ollama", "openai:text-embedding-3-large".
84
+ Without one, a corpus that was embedded before keeps its recorded model, so a
85
+ bare rerun never silently switches models and re-embeds the world. */
86
+ export async function embed(rootArg, modelArg, log = () => {}) {
87
+ const root = resolve(rootArg || ".");
88
+ const config = loadConfig(root);
89
+
90
+ const file = join(root, config.embeddings);
91
+ let store = {};
92
+ if (existsSync(file)) {
93
+ try {
94
+ store = JSON.parse(readFileSync(file, "utf8"));
95
+ } catch {
96
+ store = {};
97
+ }
98
+ }
99
+
100
+ const spec = modelArg || (store.__meta__ && store.__meta__.model) || "ollama";
101
+ const sep = spec.indexOf(":");
102
+ const providerName = sep < 0 ? spec : spec.slice(0, sep);
103
+ const provider = PROVIDERS[providerName];
104
+ if (!provider) {
105
+ throw new Error(`unknown provider: ${providerName}; use ollama[:model] or openai[:model]`);
106
+ }
107
+ const model = sep < 0 ? provider.model : spec.slice(sep + 1);
108
+ const modelId = providerName + ":" + model;
109
+
110
+ const docs = scanCorpus(root, config);
111
+ if (!docs.length) throw new Error(`no markdown documents under ${root}`);
112
+
113
+ const meta =
114
+ store.__meta__ && store.__meta__.model === modelId
115
+ ? store.__meta__
116
+ : { model: modelId, hashes: {} };
117
+ store.__meta__ = meta;
118
+
119
+ // What needs embedding: new documents, changed documents, or everything
120
+ // after a model switch. Unchanged documents keep their stored vectors.
121
+ const current = Object.create(null);
122
+ const pending = [];
123
+ for (const d of docs) {
124
+ const text = (d.title + "\n\n" + d.body).slice(0, MAX_CHARS);
125
+ const hash = sha(text);
126
+ current[d.path] = true;
127
+ if (meta.hashes[d.path] === hash && Array.isArray(store[d.path])) continue;
128
+ pending.push({ path: d.path, text, hash });
129
+ }
130
+
131
+ // Vectors for documents that no longer exist come out.
132
+ let removed = 0;
133
+ for (const key of Object.keys(store)) {
134
+ if (key !== "__meta__" && !current[key]) {
135
+ delete store[key];
136
+ delete meta.hashes[key];
137
+ removed++;
138
+ }
139
+ }
140
+
141
+ let done = 0;
142
+ for (let i = 0; i < pending.length; i += BATCH) {
143
+ const batch = pending.slice(i, i + BATCH);
144
+ let res;
145
+ try {
146
+ res = await post(
147
+ provider.url(),
148
+ provider.headers(),
149
+ JSON.stringify(provider.body(model, batch.map((b) => b.text)))
150
+ );
151
+ } catch (e) {
152
+ throw new Error(`${providerName} unreachable at ${provider.url()} (${e.message}); ${provider.hint}`);
153
+ }
154
+ if (res.status < 200 || res.status >= 300) {
155
+ throw new Error(`${providerName} answered ${res.status}: ${res.text.slice(0, 300)}`);
156
+ }
157
+ const vecs = provider.vectors(JSON.parse(res.text));
158
+ if (!Array.isArray(vecs) || vecs.length !== batch.length) {
159
+ throw new Error(`${providerName} returned ${vecs ? vecs.length : "no"} vectors for ${batch.length} documents`);
160
+ }
161
+ batch.forEach((b, j) => {
162
+ store[b.path] = vecs[j].map(round6);
163
+ meta.hashes[b.path] = b.hash;
164
+ });
165
+ atomicWrite(file, JSON.stringify(store));
166
+ done += batch.length;
167
+ log(`${done}/${pending.length}`);
168
+ }
169
+ if (!pending.length && removed) atomicWrite(file, JSON.stringify(store));
170
+
171
+ return {
172
+ file,
173
+ model: modelId,
174
+ documents: docs.length,
175
+ embedded: pending.length,
176
+ reused: docs.length - pending.length,
177
+ removed,
178
+ };
179
+ }
package/src/graph.mjs ADDED
@@ -0,0 +1,11 @@
1
+ /* Link graph: the shared pipeline resolves every authored link through the same
2
+ resolver the reader uses, keeps the ones that land, and surfaces the ones
3
+ that do not as phantom nodes. This wrapper re-exports it for the Node build
4
+ path and the gate suite; the logic lives in ui/pipeline.cjs. */
5
+
6
+ import { createRequire } from "node:module";
7
+
8
+ const P = createRequire(import.meta.url)("./ui/pipeline.cjs");
9
+
10
+ export const buildGraph = P.buildGraph;
11
+ export const pagerank = P.pagerank;
@@ -0,0 +1,10 @@
1
+ /* The reader panel's small markdown renderer. Everything is escaped first, so a
2
+ document can never inject markup; wikilinks and relative markdown links become
3
+ internal anchors the app wires to the graph. The logic lives in
4
+ ui/pipeline.cjs so the browser renders identically; this re-exports it. */
5
+
6
+ import { createRequire } from "node:module";
7
+
8
+ const P = createRequire(import.meta.url)("./ui/pipeline.cjs");
9
+
10
+ export const renderMarkdown = P.renderMarkdown;