brane 0.0.2 → 0.3.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/AGENTS.md +362 -0
- package/CHANGELOG.md +33 -0
- package/CONTRIBUTING.md +91 -0
- package/DESIGN.md +402 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +205 -0
- package/dist/absorb/brane-id.js +11 -0
- package/dist/absorb/chatgpt.js +161 -0
- package/dist/absorb/claude-code.js +504 -0
- package/dist/absorb/index.js +543 -0
- package/dist/absorb/organelle.js +345 -0
- package/dist/absorb/run.js +520 -0
- package/dist/absorb/sources.js +168 -0
- package/dist/absorb/types.js +3 -0
- package/dist/agent-env.js +45 -0
- package/dist/agents.js +1168 -0
- package/dist/claude-live-session.js +124 -0
- package/dist/cli.js +50 -0
- package/dist/codex-tool-log.js +144 -0
- package/dist/commands/absorb.js +291 -0
- package/dist/commands/attach.js +31 -0
- package/dist/commands/certify.js +62 -0
- package/dist/commands/config.js +116 -0
- package/dist/commands/cost.js +126 -0
- package/dist/commands/daemon.js +350 -0
- package/dist/commands/doctor.js +262 -0
- package/dist/commands/fold.js +95 -0
- package/dist/commands/list.js +89 -0
- package/dist/commands/mcp.js +84 -0
- package/dist/commands/meld.js +332 -0
- package/dist/commands/memory.js +215 -0
- package/dist/commands/revive.js +74 -0
- package/dist/commands/run.js +85 -0
- package/dist/commands/spawn.js +1094 -0
- package/dist/commands/token.js +153 -0
- package/dist/copilot-tool-log.js +145 -0
- package/dist/cost/pricing.js +116 -0
- package/dist/cost/reprice.js +136 -0
- package/dist/crush-tool-log.js +293 -0
- package/dist/daemon/agent-scope.js +116 -0
- package/dist/daemon/awaiting.js +54 -0
- package/dist/daemon/cert-manager.js +349 -0
- package/dist/daemon/client.js +262 -0
- package/dist/daemon/gated-routes.js +112 -0
- package/dist/daemon/keep-awake.js +103 -0
- package/dist/daemon/local-socket.js +145 -0
- package/dist/daemon/presence-gate.js +206 -0
- package/dist/daemon/server.js +6349 -0
- package/dist/daemon/signal-source.js +35 -0
- package/dist/daemon/tailscale-rebind.js +58 -0
- package/dist/daemon/token-ring.js +263 -0
- package/dist/daemon/token-routes.js +190 -0
- package/dist/db/events.js +152 -0
- package/dist/db/mentions.js +179 -0
- package/dist/db/schema.js +633 -0
- package/dist/db/tokens.js +583 -0
- package/dist/db/tool-uses.js +647 -0
- package/dist/diagram/index.js +85 -0
- package/dist/diagram/label.js +455 -0
- package/dist/diagram/layout.js +476 -0
- package/dist/diagram/links.js +50 -0
- package/dist/diagram/metrics.js +152 -0
- package/dist/diagram/parse.js +487 -0
- package/dist/diagram/svg.js +389 -0
- package/dist/diagram/types.js +9 -0
- package/dist/export.js +629 -0
- package/dist/files/archive-types.js +2 -0
- package/dist/files/archive.js +182 -0
- package/dist/files/browse.js +327 -0
- package/dist/files/bytes.js +162 -0
- package/dist/files/codecs.js +33 -0
- package/dist/files/find.js +212 -0
- package/dist/files/tar.js +223 -0
- package/dist/files/zip.js +209 -0
- package/dist/folds.js +72 -0
- package/dist/grammars/ebnf.js +92 -0
- package/dist/highlight.js +250 -0
- package/dist/ids.js +24 -0
- package/dist/markdown-diagnostics.js +233 -0
- package/dist/math.js +168 -0
- package/dist/mcp/server.js +886 -0
- package/dist/memory/embedder.js +145 -0
- package/dist/memory/embedding-worker.js +71 -0
- package/dist/memory/embeddings.js +91 -0
- package/dist/memory/index.js +232 -0
- package/dist/memory/storage.js +587 -0
- package/dist/memory/wiki.js +688 -0
- package/dist/memory/wordpiece.js +247 -0
- package/dist/mentions-scan.js +40 -0
- package/dist/presence.js +259 -0
- package/dist/session-affinity.js +185 -0
- package/dist/shared/api-request.js +124 -0
- package/dist/shared/block-glyphs.js +114 -0
- package/dist/shared/diagram-css.js +17 -0
- package/dist/shared/export-selection.js +101 -0
- package/dist/shared/find-in-text.js +110 -0
- package/dist/shared/glow-hues.js +225 -0
- package/dist/shared/hex-dump.js +62 -0
- package/dist/shared/hex-pages.js +52 -0
- package/dist/shared/hit-drum.js +79 -0
- package/dist/shared/known-nids.js +58 -0
- package/dist/shared/list-scroll-keys.js +56 -0
- package/dist/shared/markdown-lists.js +72 -0
- package/dist/shared/markdown-math.js +485 -0
- package/dist/shared/markdown-mentions.js +79 -0
- package/dist/shared/markdown-nids.js +41 -0
- package/dist/shared/markdown-protocols.js +53 -0
- package/dist/shared/markdown-refs.js +123 -0
- package/dist/shared/membrane-invasion.js +342 -0
- package/dist/shared/membrane-motion.js +83 -0
- package/dist/shared/membrane-palette.js +253 -0
- package/dist/shared/membrane-terrain.js +212 -0
- package/dist/shared/message-selection.js +123 -0
- package/dist/shared/mo-glyph.js +57 -0
- package/dist/shared/narrative-format.js +237 -0
- package/dist/shared/narrative-menu.js +82 -0
- package/dist/shared/narrative-sort.js +47 -0
- package/dist/shared/narrative-status.js +52 -0
- package/dist/shared/no-pan.js +85 -0
- package/dist/shared/pane-layout.js +50 -0
- package/dist/shared/payload-inline.js +83 -0
- package/dist/shared/speaker-color.js +61 -0
- package/dist/shared/token-rotation.js +14 -0
- package/dist/shared/view-url.js +197 -0
- package/dist/shared/wiki-links.js +70 -0
- package/dist/shared/wiki-window.js +4 -0
- package/dist/tailscale.js +55 -0
- package/dist/test/absorb-claude.test.js +892 -0
- package/dist/test/agent-scope.test.js +193 -0
- package/dist/test/agents.test.js +55 -0
- package/dist/test/api-request.test.js +122 -0
- package/dist/test/awaiting-glow.test.js +54 -0
- package/dist/test/awaiting.test.js +122 -0
- package/dist/test/bind-token.test.js +88 -0
- package/dist/test/block-glyphs.test.js +68 -0
- package/dist/test/cert-manager.test.js +262 -0
- package/dist/test/claude-live-session.test.js +110 -0
- package/dist/test/codex-tool-log.test.js +568 -0
- package/dist/test/config.test.js +71 -0
- package/dist/test/copilot-tool-log.test.js +93 -0
- package/dist/test/cors-preflight.test.js +49 -0
- package/dist/test/cost-linger.test.js +31 -0
- package/dist/test/cost-pricing.test.js +72 -0
- package/dist/test/cost-reprice.test.js +139 -0
- package/dist/test/cost-sessions.test.js +301 -0
- package/dist/test/crush-tool-log.test.js +289 -0
- package/dist/test/daemon-client.test.js +109 -0
- package/dist/test/daemon-socket.test.js +293 -0
- package/dist/test/diagram-arrows.test.js +205 -0
- package/dist/test/diagram-export.test.js +210 -0
- package/dist/test/diagram-labels.test.js +313 -0
- package/dist/test/diagram-parse.test.js +191 -0
- package/dist/test/diagram-render.test.js +175 -0
- package/dist/test/diagram-review.test.js +288 -0
- package/dist/test/diagram-self-loop-labels.test.js +303 -0
- package/dist/test/embedder.test.js +84 -0
- package/dist/test/events.test.js +123 -0
- package/dist/test/export-selection.test.js +102 -0
- package/dist/test/export.test.js +505 -0
- package/dist/test/files-browse.test.js +393 -0
- package/dist/test/files-find.test.js +272 -0
- package/dist/test/find-in-text.test.js +86 -0
- package/dist/test/first-paint-viewport.test.js +59 -0
- package/dist/test/fixtures/embedding-golden.js +23 -0
- package/dist/test/fixtures/wordpiece-golden.js +167 -0
- package/dist/test/glow-hues.test.js +202 -0
- package/dist/test/harness-cost.test.js +423 -0
- package/dist/test/helpers.js +21 -0
- package/dist/test/hex-pages.test.js +68 -0
- package/dist/test/highlight.test.js +69 -0
- package/dist/test/hit-drum.test.js +71 -0
- package/dist/test/hook-config.test.js +262 -0
- package/dist/test/json-gzip.test.js +70 -0
- package/dist/test/key-routing.test.js +58 -0
- package/dist/test/known-nids.test.js +106 -0
- package/dist/test/link-preview.test.js +67 -0
- package/dist/test/list-scroll-keys.test.js +66 -0
- package/dist/test/listen-own-words.test.js +147 -0
- package/dist/test/listen-timeout.test.js +33 -0
- package/dist/test/markdown-diagnostics.test.js +121 -0
- package/dist/test/markdown-lists.test.js +182 -0
- package/dist/test/markdown-math.test.js +256 -0
- package/dist/test/markdown-nids.test.js +146 -0
- package/dist/test/markdown-protocols.test.js +139 -0
- package/dist/test/mcp-bridge-cancel.test.js +98 -0
- package/dist/test/mcp-bridge-reconnect.test.js +121 -0
- package/dist/test/mcp-narrative.test.js +193 -0
- package/dist/test/mcp.test.js +548 -0
- package/dist/test/meld.test.js +85 -0
- package/dist/test/membrane-invasion.test.js +482 -0
- package/dist/test/membrane-motion.test.js +101 -0
- package/dist/test/membrane-palette.test.js +166 -0
- package/dist/test/membrane-terrain.test.js +170 -0
- package/dist/test/memory-log.test.js +202 -0
- package/dist/test/mentions-e2e.test.js +261 -0
- package/dist/test/mentions-ledger.test.js +265 -0
- package/dist/test/mentions-listen.test.js +265 -0
- package/dist/test/mentions.test.js +238 -0
- package/dist/test/message-selection.test.js +114 -0
- package/dist/test/mo-glyph.test.js +22 -0
- package/dist/test/model-resolution.test.js +166 -0
- package/dist/test/narrate-validate.test.js +77 -0
- package/dist/test/narrate-wait.test.js +160 -0
- package/dist/test/narrative-format.test.js +219 -0
- package/dist/test/narrative-menu.test.js +137 -0
- package/dist/test/narrative-sort.test.js +75 -0
- package/dist/test/no-pan.test.js +60 -0
- package/dist/test/pane-layout.test.js +153 -0
- package/dist/test/paths.test.js +37 -0
- package/dist/test/payload-inline.test.js +91 -0
- package/dist/test/precache-fonts.test.js +72 -0
- package/dist/test/presence-gate.test.js +440 -0
- package/dist/test/presence.test.js +160 -0
- package/dist/test/read-events.test.js +220 -0
- package/dist/test/remote-meld.test.js +115 -0
- package/dist/test/revive-cli.test.js +93 -0
- package/dist/test/search-floor.test.js +206 -0
- package/dist/test/search-fusion.test.js +168 -0
- package/dist/test/search.test.js +205 -0
- package/dist/test/security.test.js +48 -0
- package/dist/test/selection-wiring.test.js +61 -0
- package/dist/test/session-affinity.test.js +295 -0
- package/dist/test/signal-source.test.js +32 -0
- package/dist/test/socket-account.test.js +268 -0
- package/dist/test/spawn-toml.test.js +72 -0
- package/dist/test/speaker-color.test.js +90 -0
- package/dist/test/tailscale-rebind.test.js +79 -0
- package/dist/test/terminal-cursor.test.js +61 -0
- package/dist/test/terminal-resync.test.js +23 -0
- package/dist/test/thin-cli.test.js +382 -0
- package/dist/test/token-bootstrap.test.js +272 -0
- package/dist/test/token-devices.test.js +587 -0
- package/dist/test/token-lapse.test.js +94 -0
- package/dist/test/token-ring.test.js +188 -0
- package/dist/test/token-rotate-cli.test.js +58 -0
- package/dist/test/token-routes.test.js +95 -0
- package/dist/test/tool-uses.test.js +1006 -0
- package/dist/test/usage-ledger.test.js +126 -0
- package/dist/test/view-url.test.js +581 -0
- package/dist/test/wiki-links.test.js +57 -0
- package/dist/test/wiki-sources.test.js +85 -0
- package/dist/test/wiki-window.test.js +31 -0
- package/dist/test/wiki.test.js +399 -0
- package/dist/test/wordpiece.test.js +68 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +202 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +141 -0
- package/docs/README.md +3 -0
- package/docs/absorb.md +231 -0
- package/docs/cost-ledger.md +125 -0
- package/docs/daemon-api.md +189 -0
- package/docs/diagrams.md +163 -0
- package/docs/doctor.md +36 -0
- package/docs/export-scope.md +69 -0
- package/docs/files-view.md +133 -0
- package/docs/link-previews.md +38 -0
- package/docs/math.md +32 -0
- package/docs/mcp.md +69 -0
- package/docs/memory-search.md +75 -0
- package/docs/mentions.md +101 -0
- package/docs/multi-brane.md +14 -0
- package/docs/passkeys.md +165 -0
- package/docs/presence.md +202 -0
- package/docs/releasing.md +53 -0
- package/docs/rendering-warnings.md +22 -0
- package/docs/request-layer.md +72 -0
- package/docs/selection-store.md +50 -0
- package/docs/session-affinity.md +61 -0
- package/docs/terminal-mirroring.md +211 -0
- package/docs/token-rotation.md +413 -0
- package/docs/view-urls.md +114 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e56efc1291dca7258ea00b8108b033a4.js +1097 -0
- package/expo/dist/apple-touch-icon.png +0 -0
- package/expo/dist/favicon.svg +3 -0
- package/expo/dist/index.html +177 -0
- package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/expo/dist/katex/katex.min.css +1 -0
- package/expo/dist/manifest.json +16 -0
- package/expo/dist/metadata.json +1 -0
- package/expo/dist/og/membrane.v1.png +0 -0
- package/expo/dist/pdf.min.mjs +28 -0
- package/expo/dist/pdf.worker.min.mjs +28 -0
- package/expo/dist/sw.js +154 -0
- package/package.json +81 -23
- package/protocols/LIVE_loop.md +407 -0
- package/protocols/bebrief.md +41 -0
- package/protocols/digest.md +231 -0
- package/protocols/fly.md +266 -0
- package/protocols/idle.md +81 -0
- package/protocols/keeper.md +829 -0
- package/protocols/markdown.md +80 -0
- package/protocols/memory.md +419 -0
- package/protocols/mentions.md +93 -0
- package/protocols/payloads.md +53 -0
- package/protocols/prelude.md +91 -0
- package/protocols/ramify.md +105 -0
- package/protocols/review.md +356 -0
- package/protocols/rrr.md +232 -0
- package/protocols/scribe.md +429 -0
- package/protocols/summarize.md +203 -0
- package/protocols/twin.md +174 -0
- package/protocols/wiki.md +438 -0
- package/protocols/worker.md +38 -0
- package/wiki/shared/append-only-event-log.md +148 -0
- package/wiki/shared/index.md +48 -0
- package/wiki/shared/owning-your-narratives.md +161 -0
- package/wiki/shared/premature-ontologies.md +435 -0
- package/wiki/shared/sedimentary-superintelligence.md +128 -0
- package/wiki/shared/sequestering-the-narrative.md +143 -0
- package/wiki/shared/why-wikis-work.md +216 -0
- package/.npmignore +0 -2
- package/.travis.yml +0 -5
- package/index.js +0 -8
- package/index.js~ +0 -12
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import Database from "better-sqlite3";
|
|
7
|
+
import { ensureInstallFold, wikiFoldLabels } from "../daemon/server.js";
|
|
8
|
+
import { toPortablePath } from "../db/schema.js";
|
|
9
|
+
function foldsDb() {
|
|
10
|
+
const db = new Database(":memory:");
|
|
11
|
+
db.exec(`CREATE TABLE folds (
|
|
12
|
+
path TEXT PRIMARY KEY,
|
|
13
|
+
created_at INTEGER NOT NULL,
|
|
14
|
+
last_spawned INTEGER,
|
|
15
|
+
archived INTEGER DEFAULT 0,
|
|
16
|
+
brane_id TEXT
|
|
17
|
+
)`);
|
|
18
|
+
return db;
|
|
19
|
+
}
|
|
20
|
+
describe("ensureInstallFold", () => {
|
|
21
|
+
it("registers the install directory once, in the given brane, and never rewrites it", () => {
|
|
22
|
+
const db = foldsDb();
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), "brane-install-"));
|
|
24
|
+
const first = ensureInstallFold(db, dir, () => "brane-home");
|
|
25
|
+
assert.equal(first, toPortablePath(dir));
|
|
26
|
+
const row = db.prepare("SELECT brane_id, archived FROM folds WHERE path = ?").get(first);
|
|
27
|
+
assert.equal(row.brane_id, "brane-home");
|
|
28
|
+
assert.equal(row.archived, 0);
|
|
29
|
+
// An operator's later changes to the row survive restarts.
|
|
30
|
+
db.prepare("UPDATE folds SET archived = 1, brane_id = 'brane-other' WHERE path = ?").run(first);
|
|
31
|
+
let called = 0;
|
|
32
|
+
const second = ensureInstallFold(db, dir, () => {
|
|
33
|
+
called++;
|
|
34
|
+
return "brane-home";
|
|
35
|
+
});
|
|
36
|
+
assert.equal(second, first);
|
|
37
|
+
assert.equal(called, 0, "brane resolution is not consulted for an existing row");
|
|
38
|
+
const after = db.prepare("SELECT brane_id, archived FROM folds WHERE path = ?").get(first);
|
|
39
|
+
assert.deepEqual(after, { brane_id: "brane-other", archived: 1 });
|
|
40
|
+
assert.equal(db.prepare("SELECT COUNT(*) AS n FROM folds").get().n, 1);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe("wikiFoldLabels", () => {
|
|
44
|
+
it("labels folds by basename and disambiguates collisions in registration order", () => {
|
|
45
|
+
const db = foldsDb();
|
|
46
|
+
const root = mkdtempSync(join(tmpdir(), "brane-labels-"));
|
|
47
|
+
const a = join(root, "one", "brane");
|
|
48
|
+
const b = join(root, "two", "brane");
|
|
49
|
+
const c = join(root, "personal");
|
|
50
|
+
const ins = db.prepare("INSERT INTO folds (path, created_at, brane_id) VALUES (?, ?, 'x')");
|
|
51
|
+
ins.run(toPortablePath(b), 20); // registered second, despite sorting first by path
|
|
52
|
+
ins.run(toPortablePath(a), 10);
|
|
53
|
+
ins.run(toPortablePath(c), 30);
|
|
54
|
+
const labels = wikiFoldLabels(db);
|
|
55
|
+
assert.equal(labels.get(a), "brane");
|
|
56
|
+
assert.equal(labels.get(b), "brane~2");
|
|
57
|
+
assert.equal(labels.get(c), "personal");
|
|
58
|
+
// Labels are unique across the map.
|
|
59
|
+
assert.equal(new Set(labels.values()).size, labels.size);
|
|
60
|
+
});
|
|
61
|
+
it("lets the install fold claim its basename ahead of an older fold with the same name", () => {
|
|
62
|
+
const db = foldsDb();
|
|
63
|
+
const root = mkdtempSync(join(tmpdir(), "brane-labels-"));
|
|
64
|
+
const older = join(root, "archive", "brane");
|
|
65
|
+
const install = join(root, "dev", "brane");
|
|
66
|
+
const ins = db.prepare("INSERT INTO folds (path, created_at, brane_id, archived) VALUES (?, ?, 'x', ?)");
|
|
67
|
+
ins.run(toPortablePath(older), 10, 1);
|
|
68
|
+
ins.run(toPortablePath(install), 20, 0);
|
|
69
|
+
// Registration order alone would hand the archived port the bare name.
|
|
70
|
+
assert.equal(wikiFoldLabels(db).get(install), "brane~2");
|
|
71
|
+
const labels = wikiFoldLabels(db, install);
|
|
72
|
+
assert.equal(labels.get(install), "brane");
|
|
73
|
+
assert.equal(labels.get(older), "brane~2");
|
|
74
|
+
// Naming a fold that isn't registered changes nothing.
|
|
75
|
+
assert.equal(wikiFoldLabels(db, join(root, "nowhere")).get(install), "brane~2");
|
|
76
|
+
});
|
|
77
|
+
it("never hands a fold the reserved 'protocols' label", () => {
|
|
78
|
+
const db = foldsDb();
|
|
79
|
+
const root = mkdtempSync(join(tmpdir(), "brane-labels-"));
|
|
80
|
+
const p = join(root, "protocols");
|
|
81
|
+
db.prepare("INSERT INTO folds (path, created_at, brane_id) VALUES (?, ?, 'x')").run(toPortablePath(p), 1);
|
|
82
|
+
assert.equal(wikiFoldLabels(db).get(p), "protocols~2");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
//# sourceMappingURL=wiki-sources.test.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { neighborColumns } from "../shared/wiki-window.js";
|
|
4
|
+
// The daemon's windowed read hands back neighbors nearest-first on both sides
|
|
5
|
+
// (pinned in wiki.test.ts). A column has to read top-to-bottom in document
|
|
6
|
+
// order, which for the side above the current section is the reverse.
|
|
7
|
+
describe("neighborColumns", () => {
|
|
8
|
+
it("lists the neighbors above in document order, furthest first", () => {
|
|
9
|
+
const { above } = neighborColumns(["step-3", "step-2", "step-1"], []);
|
|
10
|
+
assert.deepEqual(above, ["step-1", "step-2", "step-3"]);
|
|
11
|
+
});
|
|
12
|
+
it("keeps the neighbors below in document order, nearest first", () => {
|
|
13
|
+
const { below } = neighborColumns([], ["step-5", "step-6", "step-7"]);
|
|
14
|
+
assert.deepEqual(below, ["step-5", "step-6", "step-7"]);
|
|
15
|
+
});
|
|
16
|
+
it("reads 1 → 2 → 3 → [current] → 5 → 6 around a mid-page section", () => {
|
|
17
|
+
const { above, below } = neighborColumns(["3", "2", "1"], ["5", "6"]);
|
|
18
|
+
assert.deepEqual([...above, "[4]", ...below], ["1", "2", "3", "[4]", "5", "6"]);
|
|
19
|
+
});
|
|
20
|
+
it("does not mutate the arrays it is given", () => {
|
|
21
|
+
const prev = ["b", "a"];
|
|
22
|
+
const next = ["d"];
|
|
23
|
+
neighborColumns(prev, next);
|
|
24
|
+
assert.deepEqual(prev, ["b", "a"]);
|
|
25
|
+
assert.deepEqual(next, ["d"]);
|
|
26
|
+
});
|
|
27
|
+
it("is empty at a page edge", () => {
|
|
28
|
+
assert.deepEqual(neighborColumns([], []), { above: [], below: [] });
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
//# sourceMappingURL=wiki-window.test.js.map
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import Database from "better-sqlite3";
|
|
7
|
+
import { parseWikiPage, normalizeSlug, locatePage, listWiki, readWiki, readWikiSection, syncWikiIndex, searchWiki, } from "../memory/wiki.js";
|
|
8
|
+
import { VECTOR_DIM } from "../memory/embeddings.js";
|
|
9
|
+
describe("parseWikiPage", () => {
|
|
10
|
+
it("extracts frontmatter and strips it from the body", () => {
|
|
11
|
+
const page = parseWikiPage(["---", "description: A test page", 'title: "Quoted Title"', "---", "", "# Heading", "Body."].join("\n"));
|
|
12
|
+
assert.equal(page.frontmatter.description, "A test page");
|
|
13
|
+
assert.equal(page.frontmatter.title, "Quoted Title");
|
|
14
|
+
// Frontmatter must not leak into chunk content.
|
|
15
|
+
assert.ok(!page.chunks.some((c) => c.content.includes("description:")));
|
|
16
|
+
});
|
|
17
|
+
it("builds a TOC with GitHub-style anchors and disambiguates duplicates", () => {
|
|
18
|
+
const page = parseWikiPage(["# First Heading", "x", "## Sub Section!", "y", "## Sub Section!", "z"].join("\n"));
|
|
19
|
+
assert.deepEqual(page.toc.map((t) => t.anchor), ["first-heading", "sub-section", "sub-section-1"]);
|
|
20
|
+
assert.equal(page.toc[1].level, 2);
|
|
21
|
+
assert.equal(page.toc[0].heading, "First Heading");
|
|
22
|
+
});
|
|
23
|
+
it("roots each chunk at its enclosing heading anchor; preamble has empty anchor", () => {
|
|
24
|
+
const page = parseWikiPage(["intro text", "# Alpha", "a body", "# Beta", "b body"].join("\n"));
|
|
25
|
+
const anchors = page.chunks.map((c) => c.anchor);
|
|
26
|
+
assert.ok(anchors.includes(""), "preamble chunk should have empty anchor");
|
|
27
|
+
assert.ok(anchors.includes("alpha"));
|
|
28
|
+
assert.ok(anchors.includes("beta"));
|
|
29
|
+
});
|
|
30
|
+
it("does not split headings that live inside a code fence", () => {
|
|
31
|
+
const page = parseWikiPage(["# Real", "before", "```", "# Not A Heading", "```", "after"].join("\n"));
|
|
32
|
+
assert.deepEqual(page.toc.map((t) => t.anchor), ["real"]);
|
|
33
|
+
});
|
|
34
|
+
it("sub-splits an oversized section into parts carrying the same anchor", () => {
|
|
35
|
+
const para = "word ".repeat(120).trim(); // ~600 chars
|
|
36
|
+
const body = ["# Big", para, "", para, "", para, "", para].join("\n"); // ~2400 chars
|
|
37
|
+
const page = parseWikiPage(body);
|
|
38
|
+
const bigChunks = page.chunks.filter((c) => c.anchor === "big");
|
|
39
|
+
assert.ok(bigChunks.length >= 2, "oversized section should split into >=2 parts");
|
|
40
|
+
assert.deepEqual(bigChunks.map((c) => c.part), bigChunks.map((_, i) => i));
|
|
41
|
+
});
|
|
42
|
+
it("gives each chunk a content hash", () => {
|
|
43
|
+
const page = parseWikiPage(["# A", "alpha"].join("\n"));
|
|
44
|
+
assert.match(page.chunks[0].hash, /^[0-9a-f]{64}$/);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe("normalizeSlug", () => {
|
|
48
|
+
it("ensures a .md extension", () => {
|
|
49
|
+
assert.equal(normalizeSlug("live-loop"), "live-loop.md");
|
|
50
|
+
assert.equal(normalizeSlug("live-loop.md"), "live-loop.md");
|
|
51
|
+
assert.equal(normalizeSlug("/live-loop"), "live-loop.md");
|
|
52
|
+
assert.equal(normalizeSlug("shared/live-loop"), "shared/live-loop.md");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
// ── Local vs shared ─────────────────────────────────────────────────────────
|
|
56
|
+
//
|
|
57
|
+
// A fold's wiki is `wiki/` plus at most `wiki/shared/`. The caller's own fold
|
|
58
|
+
// (an owned source) sees both; any other fold (`shared_only`) sees only
|
|
59
|
+
// `shared/`. The slug is the path under `wiki/`, so a shared page is
|
|
60
|
+
// `shared/foo.md` for every reader.
|
|
61
|
+
function sharedFixture() {
|
|
62
|
+
const dir = join(mkdtempSync(join(tmpdir(), "wiki-shared-")), "wiki");
|
|
63
|
+
mkdirSync(join(dir, "shared"), { recursive: true });
|
|
64
|
+
writeFileSync(join(dir, "local.md"), ["# Local", "fold-private thinking about walruses", "## Sec", "more"].join("\n"));
|
|
65
|
+
writeFileSync(join(dir, "shared", "pub.md"), ["# Pub", "published claim about walruses", "## Sec", "more"].join("\n"));
|
|
66
|
+
// Same basename on both sides: the local one wins for the owner's bare slug.
|
|
67
|
+
writeFileSync(join(dir, "both.md"), "# Both (local)\nlocal body\n");
|
|
68
|
+
writeFileSync(join(dir, "shared", "both.md"), "# Both (shared)\nshared body\n");
|
|
69
|
+
return {
|
|
70
|
+
owned: { source: "fold", dir, fold_path: "~/fold" },
|
|
71
|
+
foreign: { source: "fold", dir, fold_path: "~/fold", shared_only: true },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
describe("locatePage", () => {
|
|
75
|
+
it("resolves a bare slug to the local page first, then falls through to shared/", () => {
|
|
76
|
+
const { owned } = sharedFixture();
|
|
77
|
+
assert.deepEqual(locatePage(owned, "local"), { status: "ok", filename: "local.md" });
|
|
78
|
+
assert.deepEqual(locatePage(owned, "pub"), { status: "ok", filename: "shared/pub.md" });
|
|
79
|
+
assert.deepEqual(locatePage(owned, "shared/pub.md"), { status: "ok", filename: "shared/pub.md" });
|
|
80
|
+
assert.deepEqual(locatePage(owned, "both"), { status: "ok", filename: "both.md" });
|
|
81
|
+
assert.equal(locatePage(owned, "ghost").status, "missing");
|
|
82
|
+
});
|
|
83
|
+
it("reports a foreign fold's local page as local, not missing, and refuses escapes", () => {
|
|
84
|
+
const { foreign } = sharedFixture();
|
|
85
|
+
assert.deepEqual(locatePage(foreign, "local"), { status: "local", filename: "local.md" });
|
|
86
|
+
assert.deepEqual(locatePage(foreign, "pub"), { status: "ok", filename: "shared/pub.md" });
|
|
87
|
+
// The bare slug still hits the local file first, and that is the honest
|
|
88
|
+
// answer: it exists, and it is not shared.
|
|
89
|
+
assert.deepEqual(locatePage(foreign, "both"), { status: "local", filename: "both.md" });
|
|
90
|
+
assert.deepEqual(locatePage(foreign, "shared/both"), { status: "ok", filename: "shared/both.md" });
|
|
91
|
+
assert.equal(locatePage(foreign, "../etc/passwd").status, "missing");
|
|
92
|
+
assert.equal(locatePage(foreign, "shared/../local").status, "missing");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe("shared/ visibility across list, read, search", () => {
|
|
96
|
+
it("the owner lists both; a foreign caller lists only shared/, with the shared flag set", () => {
|
|
97
|
+
const { owned, foreign } = sharedFixture();
|
|
98
|
+
const mine = listWiki([owned]).map((e) => [e.slug, e.shared]);
|
|
99
|
+
assert.deepEqual(mine, [
|
|
100
|
+
["both.md", false],
|
|
101
|
+
["local.md", false],
|
|
102
|
+
["shared/both.md", true],
|
|
103
|
+
["shared/pub.md", true],
|
|
104
|
+
]);
|
|
105
|
+
const theirs = listWiki([foreign]).map((e) => e.slug);
|
|
106
|
+
assert.deepEqual(theirs, ["shared/both.md", "shared/pub.md"]);
|
|
107
|
+
});
|
|
108
|
+
it("read: a foreign caller gets 'local, not shared' for a local page, and the page for a shared one", () => {
|
|
109
|
+
const { owned, foreign } = sharedFixture();
|
|
110
|
+
const ok = readWiki([owned], "fold", "local");
|
|
111
|
+
assert.ok(!("error" in ok));
|
|
112
|
+
const denied = readWiki([foreign], "fold", "local");
|
|
113
|
+
assert.ok("error" in denied);
|
|
114
|
+
if ("error" in denied)
|
|
115
|
+
assert.match(denied.error, /local to wiki source "fold", not shared/);
|
|
116
|
+
const pub = readWiki([foreign], "fold", "pub");
|
|
117
|
+
assert.ok(!("error" in pub));
|
|
118
|
+
if (!("error" in pub))
|
|
119
|
+
assert.equal(pub.citation, "shared/pub.md");
|
|
120
|
+
const section = readWikiSection([foreign], "fold", "shared/pub", { anchor: "sec" });
|
|
121
|
+
assert.ok(!("error" in section));
|
|
122
|
+
if (!("error" in section))
|
|
123
|
+
assert.equal(section.citation, "shared/pub.md#sec");
|
|
124
|
+
const deniedSection = readWikiSection([foreign], "fold", "local", { anchor: "sec" });
|
|
125
|
+
assert.ok("error" in deniedSection);
|
|
126
|
+
});
|
|
127
|
+
it("search: the same index answers the owner with both pages and a foreign caller with shared/ only", async () => {
|
|
128
|
+
const { owned, foreign } = sharedFixture();
|
|
129
|
+
const db = new Database(":memory:");
|
|
130
|
+
const embed = makeStubEmbedder();
|
|
131
|
+
// The stub embedder scores every chunk alike, so the interesting
|
|
132
|
+
// property is membership, not ranking: the owner's results reach the
|
|
133
|
+
// local page; a foreign caller's never do.
|
|
134
|
+
const mine = await searchWiki(db, [owned], "walruses", 10, embed);
|
|
135
|
+
const mineSlugs = mine.map((a) => a.slug);
|
|
136
|
+
assert.ok(mineSlugs.includes("local.md"), "owner search reaches the local page");
|
|
137
|
+
assert.ok(mineSlugs.includes("shared/pub.md"), "owner search reaches the shared page");
|
|
138
|
+
// Same DB, same rows — visibility is decided at query time, not index time.
|
|
139
|
+
const theirs = await searchWiki(db, [foreign], "walruses", 10, embed);
|
|
140
|
+
assert.ok(theirs.length > 0, "foreign search still answers");
|
|
141
|
+
assert.ok(theirs.every((a) => a.slug.startsWith("shared/")), "foreign search sees shared/ only");
|
|
142
|
+
assert.ok(theirs.every((a) => a.hits.every((h) => h.citation.startsWith("shared/"))), "every citation handed to a foreign caller is a shared one");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
function fixtureSources() {
|
|
146
|
+
const root = mkdtempSync(join(tmpdir(), "wiki-test-"));
|
|
147
|
+
const brane = join(root, "brane-wiki");
|
|
148
|
+
const fold = join(root, "fold-wiki");
|
|
149
|
+
mkdirSync(brane);
|
|
150
|
+
mkdirSync(fold);
|
|
151
|
+
writeFileSync(join(brane, "philosophy.md"), ["---", "description: brane's vantage point", "---", "# Philosophy", "Core idea.", "## Detail", "More."].join("\n"));
|
|
152
|
+
writeFileSync(join(fold, "notes.md"), ["# Notes", "Project notes here."].join("\n"));
|
|
153
|
+
writeFileSync(join(fold, "philosophy.md"), ["# Local Philosophy", "Fold-local."].join("\n"));
|
|
154
|
+
return [
|
|
155
|
+
{ source: "brane", dir: brane },
|
|
156
|
+
{ source: "fold", dir: fold, fold_path: "~/fold" },
|
|
157
|
+
];
|
|
158
|
+
}
|
|
159
|
+
describe("listWiki", () => {
|
|
160
|
+
it("lists pages from all sources with descriptions and source labels", () => {
|
|
161
|
+
const sources = fixtureSources();
|
|
162
|
+
const entries = listWiki(sources);
|
|
163
|
+
const phil = entries.find((e) => e.slug === "philosophy.md" && e.source === "brane");
|
|
164
|
+
assert.ok(phil);
|
|
165
|
+
assert.equal(phil.description, "brane's vantage point");
|
|
166
|
+
// Fold page without frontmatter falls back to first prose line.
|
|
167
|
+
const notes = entries.find((e) => e.slug === "notes.md");
|
|
168
|
+
assert.equal(notes?.description, "Project notes here.");
|
|
169
|
+
assert.equal(notes?.source, "fold");
|
|
170
|
+
});
|
|
171
|
+
it("filters by source and substring", () => {
|
|
172
|
+
const sources = fixtureSources();
|
|
173
|
+
assert.ok(listWiki(sources, { source: "fold" }).every((e) => e.source === "fold"));
|
|
174
|
+
assert.ok(listWiki(sources, { filter: "notes" }).every((e) => e.slug.includes("notes")));
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
describe("readWiki", () => {
|
|
178
|
+
it("returns the whole page plus a TOC and slug citation", () => {
|
|
179
|
+
const sources = fixtureSources();
|
|
180
|
+
const res = readWiki(sources, "brane", "philosophy");
|
|
181
|
+
assert.ok(!("error" in res));
|
|
182
|
+
if ("error" in res)
|
|
183
|
+
return;
|
|
184
|
+
assert.equal(res.source, "brane");
|
|
185
|
+
assert.equal(res.citation, "philosophy.md");
|
|
186
|
+
assert.ok(res.content.includes("Core idea."));
|
|
187
|
+
assert.deepEqual(res.toc.map((t) => t.anchor), ["philosophy", "detail"]);
|
|
188
|
+
});
|
|
189
|
+
it("returns a single anchored section with a slug#anchor citation", () => {
|
|
190
|
+
const sources = fixtureSources();
|
|
191
|
+
const res = readWiki(sources, "brane", "philosophy", { anchor: "detail" });
|
|
192
|
+
assert.ok(!("error" in res));
|
|
193
|
+
if ("error" in res)
|
|
194
|
+
return;
|
|
195
|
+
assert.equal(res.citation, "philosophy.md#detail");
|
|
196
|
+
assert.ok(res.content.includes("More."));
|
|
197
|
+
assert.ok(!res.content.includes("Core idea."));
|
|
198
|
+
});
|
|
199
|
+
it("errors on an unknown source or missing page or bad anchor", () => {
|
|
200
|
+
const sources = fixtureSources();
|
|
201
|
+
assert.ok("error" in readWiki(sources, "nope", "philosophy"));
|
|
202
|
+
assert.ok("error" in readWiki(sources, "brane", "missing"));
|
|
203
|
+
assert.ok("error" in readWiki(sources, "brane", "philosophy", { anchor: "ghost" }));
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
function navFixture() {
|
|
207
|
+
const dir = mkdtempSync(join(tmpdir(), "wiki-nav-"));
|
|
208
|
+
writeFileSync(join(dir, "page.md"), ["# Top", "Intro prose.", "## A", "Body A.", "## B", "Body B.", "## C", "Body C.", "## D", "Body D."].join("\n"));
|
|
209
|
+
return [{ source: "brane", dir }];
|
|
210
|
+
}
|
|
211
|
+
describe("readWikiSection (neighbor-aware read)", () => {
|
|
212
|
+
it("starts at the page top with no anchor: empty prev, forward window", () => {
|
|
213
|
+
const res = readWikiSection(navFixture(), "brane", "page", { window: 2 });
|
|
214
|
+
assert.ok(!("error" in res));
|
|
215
|
+
if ("error" in res)
|
|
216
|
+
return;
|
|
217
|
+
assert.equal(res.current.anchor, "top");
|
|
218
|
+
assert.equal(res.citation, "page.md#top");
|
|
219
|
+
assert.deepEqual(res.prev, []);
|
|
220
|
+
assert.deepEqual(res.next.map((n) => n.anchor), ["a", "b"]);
|
|
221
|
+
assert.deepEqual(res.position, { index: 0, total: 5 });
|
|
222
|
+
assert.ok(res.current.content.includes("Intro prose."));
|
|
223
|
+
});
|
|
224
|
+
it("windows prev/next nearest-first around a mid-page anchor, with previews", () => {
|
|
225
|
+
const res = readWikiSection(navFixture(), "brane", "page", { anchor: "c", window: 2 });
|
|
226
|
+
assert.ok(!("error" in res));
|
|
227
|
+
if ("error" in res)
|
|
228
|
+
return;
|
|
229
|
+
assert.equal(res.current.anchor, "c");
|
|
230
|
+
assert.ok(res.current.content.includes("Body C."));
|
|
231
|
+
// nearest-first on both sides
|
|
232
|
+
assert.deepEqual(res.prev.map((n) => n.anchor), ["b", "a"]);
|
|
233
|
+
assert.deepEqual(res.next.map((n) => n.anchor), ["d"]);
|
|
234
|
+
assert.deepEqual(res.position, { index: 3, total: 5 });
|
|
235
|
+
// a neighbor carries a one-line preview, not its full content
|
|
236
|
+
assert.equal(res.next[0].heading, "D");
|
|
237
|
+
assert.equal(res.next[0].preview, "Body D.");
|
|
238
|
+
});
|
|
239
|
+
it("caps the window and stays in-bounds at the page edges", () => {
|
|
240
|
+
const res = readWikiSection(navFixture(), "brane", "page", { anchor: "a", window: 50 });
|
|
241
|
+
assert.ok(!("error" in res));
|
|
242
|
+
if ("error" in res)
|
|
243
|
+
return;
|
|
244
|
+
assert.deepEqual(res.prev.map((n) => n.anchor), ["top"]);
|
|
245
|
+
assert.deepEqual(res.next.map((n) => n.anchor), ["b", "c", "d"]);
|
|
246
|
+
});
|
|
247
|
+
it("errors on a bad anchor with the list of valid anchors", () => {
|
|
248
|
+
const res = readWikiSection(navFixture(), "brane", "page", { anchor: "ghost" });
|
|
249
|
+
assert.ok("error" in res);
|
|
250
|
+
if (!("error" in res))
|
|
251
|
+
return;
|
|
252
|
+
assert.match(res.error, /ghost/);
|
|
253
|
+
assert.match(res.error, /top, a, b, c, d/);
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
// ── Index reconciliation + search (deterministic stub embedder) ─────────────
|
|
257
|
+
//
|
|
258
|
+
// The transformers model is heavy and network-dependent, so these tests inject
|
|
259
|
+
// a deterministic stub embedder (constant vectors) into syncWikiIndex/searchWiki
|
|
260
|
+
// — covering the SyncStats bookkeeping and the grouping/scoping/reconciliation
|
|
261
|
+
// logic without any model download. The stub records its calls so a test can
|
|
262
|
+
// assert exactly which content was (re-)embedded.
|
|
263
|
+
/** Constant-vector embedder that records every batch it was asked to embed. */
|
|
264
|
+
function makeStubEmbedder() {
|
|
265
|
+
const batches = [];
|
|
266
|
+
const fn = async (texts) => {
|
|
267
|
+
batches.push(texts);
|
|
268
|
+
return texts.map(() => new Float32Array(VECTOR_DIM).fill(0.1));
|
|
269
|
+
};
|
|
270
|
+
return Object.assign(fn, { batches });
|
|
271
|
+
}
|
|
272
|
+
/** A single-source wiki backed by a fresh temp dir + in-memory DB. */
|
|
273
|
+
function indexFixture() {
|
|
274
|
+
const dir = join(mkdtempSync(join(tmpdir(), "wiki-idx-")), "wiki");
|
|
275
|
+
mkdirSync(dir, { recursive: true });
|
|
276
|
+
return {
|
|
277
|
+
db: new Database(":memory:"),
|
|
278
|
+
sources: [{ source: "brane", dir }],
|
|
279
|
+
write: (slug, body) => writeFileSync(join(dir, slug), body),
|
|
280
|
+
embed: makeStubEmbedder(),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
describe("syncWikiIndex reconciliation", () => {
|
|
284
|
+
it("re-embeds only the changed section on a light edit (hash-skip)", async () => {
|
|
285
|
+
const { db, sources, write, embed } = indexFixture();
|
|
286
|
+
write("doc.md", "# Doc\nintro\n## Alpha\nalpha body\n## Beta\nbeta body\n");
|
|
287
|
+
const first = await syncWikiIndex(db, sources, embed);
|
|
288
|
+
assert.equal(first.chunks_reembedded, 3); // doc + alpha + beta
|
|
289
|
+
assert.equal(first.chunks_removed, 0);
|
|
290
|
+
// Edit only Beta's body; Alpha and the preamble are byte-identical.
|
|
291
|
+
write("doc.md", "# Doc\nintro\n## Alpha\nalpha body\n## Beta\nbeta body EDITED\n");
|
|
292
|
+
embed.batches.length = 0;
|
|
293
|
+
const second = await syncWikiIndex(db, sources, embed);
|
|
294
|
+
assert.equal(second.chunks_reembedded, 1);
|
|
295
|
+
// Exactly one content string was re-embedded, and it's the edited Beta.
|
|
296
|
+
assert.equal(embed.batches.length, 1);
|
|
297
|
+
assert.equal(embed.batches[0].length, 1);
|
|
298
|
+
assert.match(embed.batches[0][0], /EDITED/);
|
|
299
|
+
});
|
|
300
|
+
it("a no-change resync re-embeds nothing and invokes the embedder zero times (empty-tx guard)", async () => {
|
|
301
|
+
const { db, sources, write, embed } = indexFixture();
|
|
302
|
+
write("doc.md", "# Doc\nbody\n## S\nsection\n");
|
|
303
|
+
await syncWikiIndex(db, sources, embed);
|
|
304
|
+
embed.batches.length = 0;
|
|
305
|
+
const again = await syncWikiIndex(db, sources, embed);
|
|
306
|
+
assert.equal(again.chunks_reembedded, 0);
|
|
307
|
+
assert.equal(again.chunks_removed, 0);
|
|
308
|
+
assert.equal(embed.batches.length, 0); // no embedding work → no write tx
|
|
309
|
+
});
|
|
310
|
+
it("removes chunks for a deleted or renamed section so they cannot resurface", async () => {
|
|
311
|
+
const { db, sources, write, embed } = indexFixture();
|
|
312
|
+
write("doc.md", "# Doc\nintro\n## Alpha\nalpha about penguins\n## Beta\nbeta about glaciers\n");
|
|
313
|
+
await syncWikiIndex(db, sources, embed);
|
|
314
|
+
// Rename Alpha's heading (anchor changes) and delete Beta entirely.
|
|
315
|
+
write("doc.md", "# Doc\nintro\n## Renamed Alpha\nalpha about walruses\n");
|
|
316
|
+
const stats = await syncWikiIndex(db, sources, embed);
|
|
317
|
+
// Old #alpha (content changed → id changed via anchor) + #beta both gone.
|
|
318
|
+
assert.equal(stats.chunks_removed, 2);
|
|
319
|
+
const rows = db.prepare("SELECT id FROM wiki_chunks").all();
|
|
320
|
+
// id = source\0slug\0anchor\0part — pull out the anchor field.
|
|
321
|
+
const anchors = rows.map((r) => r.id.split("\0")[2]).sort();
|
|
322
|
+
assert.deepEqual(anchors, ["doc", "renamed-alpha"]);
|
|
323
|
+
assert.ok(!anchors.includes("alpha"), "old #alpha anchor must be gone");
|
|
324
|
+
assert.ok(!anchors.includes("beta"), "deleted #beta anchor must be gone");
|
|
325
|
+
// The removed section never appears in a subsequent search.
|
|
326
|
+
const res = await searchWiki(db, sources, "glaciers", 10, embed);
|
|
327
|
+
const citations = res.flatMap((a) => a.hits.map((h) => h.citation));
|
|
328
|
+
assert.ok(!citations.includes("doc.md#beta"), "stale #beta must not resurface");
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
describe("searchWiki grouping + scoping", () => {
|
|
332
|
+
it("groups chunk hits under their article with article score = best hit", async () => {
|
|
333
|
+
const { db, sources, write, embed } = indexFixture();
|
|
334
|
+
write("a.md", "# A\n## One\nshared keyword here\n## Two\nshared keyword again plus more\n");
|
|
335
|
+
write("b.md", "# B\nshared keyword once\n");
|
|
336
|
+
const res = await searchWiki(db, sources, "keyword", 10, embed);
|
|
337
|
+
// Results are grouped by article, each carrying its section/chunk hits.
|
|
338
|
+
const a = res.find((x) => x.slug === "a.md");
|
|
339
|
+
assert.ok(a, "article a.md present");
|
|
340
|
+
assert.ok(a.hits.length >= 2, "a.md groups multiple section hits");
|
|
341
|
+
// Article score is the max of its hits' scores; hits are ranked within.
|
|
342
|
+
assert.equal(a.score, Math.max(...a.hits.map((h) => h.score)));
|
|
343
|
+
for (let i = 1; i < a.hits.length; i++) {
|
|
344
|
+
assert.ok(a.hits[i - 1].score >= a.hits[i].score, "hits ranked desc");
|
|
345
|
+
}
|
|
346
|
+
// Every hit carries a slug#anchor (or slug) citation stamped to the article.
|
|
347
|
+
assert.ok(a.hits.every((h) => h.citation.startsWith("a.md")));
|
|
348
|
+
});
|
|
349
|
+
it("caps the number of chunk hits, not the number of articles", async () => {
|
|
350
|
+
const { db, sources, write, embed } = indexFixture();
|
|
351
|
+
// Three articles, each with four sections that all match the query — 12
|
|
352
|
+
// matching chunks total, spread across only 3 articles.
|
|
353
|
+
for (const slug of ["a.md", "b.md", "c.md"]) {
|
|
354
|
+
write(slug, `# ${slug}\n## One\nshared keyword\n## Two\nshared keyword\n` +
|
|
355
|
+
`## Three\nshared keyword\n## Four\nshared keyword\n`);
|
|
356
|
+
}
|
|
357
|
+
const res = await searchWiki(db, sources, "keyword", 5, embed);
|
|
358
|
+
// limit bounds chunk hits (like narrative_search bounds messages), so the
|
|
359
|
+
// total hits across all returned articles is exactly 5 — even though far
|
|
360
|
+
// more chunks matched and only 3 articles exist.
|
|
361
|
+
const totalHits = res.reduce((n, a) => n + a.hits.length, 0);
|
|
362
|
+
assert.equal(totalHits, 5, "total chunk hits is capped at limit");
|
|
363
|
+
assert.ok(res.length <= 3, "hits stay grouped under their articles");
|
|
364
|
+
});
|
|
365
|
+
it("scopes results to the requested sources (dormant chunks from other sources don't leak)", async () => {
|
|
366
|
+
const dirA = join(mkdtempSync(join(tmpdir(), "wiki-a-")), "wiki");
|
|
367
|
+
const dirB = join(mkdtempSync(join(tmpdir(), "wiki-b-")), "wiki");
|
|
368
|
+
mkdirSync(dirA, { recursive: true });
|
|
369
|
+
mkdirSync(dirB, { recursive: true });
|
|
370
|
+
writeFileSync(join(dirA, "x.md"), "# X\nshared token alpha\n");
|
|
371
|
+
writeFileSync(join(dirB, "y.md"), "# Y\nshared token beta\n");
|
|
372
|
+
const A = { source: "A", dir: dirA };
|
|
373
|
+
const B = { source: "B", dir: dirB };
|
|
374
|
+
const db = new Database(":memory:");
|
|
375
|
+
const embed = makeStubEmbedder();
|
|
376
|
+
// Index both sources, then search only A — B's chunks are dormant in the DB.
|
|
377
|
+
await syncWikiIndex(db, [A, B], embed);
|
|
378
|
+
const res = await searchWiki(db, [A], "shared token", 10, embed);
|
|
379
|
+
assert.ok(res.length > 0, "A has results");
|
|
380
|
+
assert.ok(res.every((a) => a.source === "A"), "no B results leak through");
|
|
381
|
+
});
|
|
382
|
+
it("drops hits below the relevance floor instead of padding with noise", async () => {
|
|
383
|
+
const { db, sources, write } = indexFixture();
|
|
384
|
+
// Embedder puts the "noise" page on a different axis from everything else,
|
|
385
|
+
// so its cosine score is ~0 (below the floor); the query and the "match"
|
|
386
|
+
// page share an axis (score ~1). No shared query term → no keyword boost,
|
|
387
|
+
// isolating the vector score.
|
|
388
|
+
const embed = async (texts) => texts.map((t) => {
|
|
389
|
+
const v = new Float32Array(VECTOR_DIM);
|
|
390
|
+
v[t.includes("NOISEONLY") ? 1 : 0] = 1;
|
|
391
|
+
return v;
|
|
392
|
+
});
|
|
393
|
+
write("match.md", "# Match\nrelevant material on the subject.\n");
|
|
394
|
+
write("noise.md", "# Noise\nNOISEONLY off-topic filler.\n");
|
|
395
|
+
const res = await searchWiki(db, sources, "xyzzy", 10, embed);
|
|
396
|
+
assert.deepEqual(res.map((a) => a.slug), ["match.md"], "only the above-floor hit survives; the orthogonal noise page is dropped");
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
//# sourceMappingURL=wiki.test.js.map
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { WordPieceTokenizer } from "../memory/wordpiece.js";
|
|
4
|
+
import { MODEL_REPO, ensureFile } from "../memory/embedder.js";
|
|
5
|
+
import { WORDPIECE_GOLDEN } from "./fixtures/wordpiece-golden.js";
|
|
6
|
+
/**
|
|
7
|
+
* `src/memory/wordpiece.ts` exists to reproduce transformers.js's tokenizer
|
|
8
|
+
* exactly, so these tests assert *agreement*, not reasonableness. The golden
|
|
9
|
+
* ids in the fixture came from `AutoTokenizer` while that package was still
|
|
10
|
+
* installed; a mismatch here means query vectors have drifted away from what
|
|
11
|
+
* `memory_vec` already holds, which nothing else would surface.
|
|
12
|
+
*
|
|
13
|
+
* `tokenizer.json` (~700 KB) is fetched into the model cache on a cold run
|
|
14
|
+
* rather than skip-gating the suite: this assertion is the one the embedder
|
|
15
|
+
* swap rests on, so it has to run everywhere, CI included.
|
|
16
|
+
*/
|
|
17
|
+
const tok = WordPieceTokenizer.fromFile(await ensureFile(MODEL_REPO, "tokenizer.json"));
|
|
18
|
+
describe("WordPieceTokenizer", () => {
|
|
19
|
+
it("reproduces transformers.js token ids exactly", () => {
|
|
20
|
+
for (const { text, ids } of WORDPIECE_GOLDEN) {
|
|
21
|
+
assert.deepEqual(tok.encode(text).input_ids, ids, `token ids differ for: ${JSON.stringify(text.slice(0, 60))}`);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
it("wraps in [CLS]/[SEP] and reports a matching attention mask", () => {
|
|
25
|
+
const enc = tok.encode("hello world");
|
|
26
|
+
assert.equal(enc.input_ids[0], tok.clsId);
|
|
27
|
+
assert.equal(enc.input_ids.at(-1), tok.sepId);
|
|
28
|
+
assert.equal(enc.attention_mask.length, enc.input_ids.length);
|
|
29
|
+
assert.ok(enc.attention_mask.every((m) => m === 1));
|
|
30
|
+
assert.ok(enc.token_type_ids.every((t) => t === 0));
|
|
31
|
+
});
|
|
32
|
+
it("truncates the post-processed sequence, dropping [SEP] rather than reserving room", () => {
|
|
33
|
+
// Deliberately pinned: HuggingFace truncates *after* adding both special
|
|
34
|
+
// tokens, so an over-long text ends on an ordinary token. Reserving two
|
|
35
|
+
// slots to keep [SEP] is the intuitive reading and disagrees with the
|
|
36
|
+
// reference on every long input — see the comment in wordpiece.ts.
|
|
37
|
+
const enc = tok.encode("one two three ".repeat(500));
|
|
38
|
+
assert.equal(enc.input_ids.length, tok.modelMaxLength);
|
|
39
|
+
assert.equal(enc.input_ids[0], tok.clsId);
|
|
40
|
+
assert.notEqual(enc.input_ids.at(-1), tok.sepId);
|
|
41
|
+
});
|
|
42
|
+
it("pads a batch to its longest member and masks the padding", () => {
|
|
43
|
+
const batch = tok.encodeBatch(["hi", "a considerably longer sentence here"]);
|
|
44
|
+
const width = batch.input_ids[0].length;
|
|
45
|
+
assert.equal(batch.input_ids[1].length, width);
|
|
46
|
+
assert.equal(batch.attention_mask[0].length, width);
|
|
47
|
+
const short = batch.input_ids[0];
|
|
48
|
+
const mask = batch.attention_mask[0];
|
|
49
|
+
// Every padded position must be [PAD] with mask 0, or mean pooling would
|
|
50
|
+
// fold padding into the vector and a batched text would embed differently
|
|
51
|
+
// from the same text alone.
|
|
52
|
+
for (let i = 0; i < width; i++) {
|
|
53
|
+
if (mask[i] === 0)
|
|
54
|
+
assert.equal(short[i], tok.padId);
|
|
55
|
+
}
|
|
56
|
+
assert.equal(mask.reduce((a, b) => a + b, 0), tok.encode("hi").input_ids.length);
|
|
57
|
+
});
|
|
58
|
+
it("treats a whole word as unknown when any piece is unmatchable", () => {
|
|
59
|
+
// BERT's rule, and load-bearing: a per-piece fallback would produce
|
|
60
|
+
// different ids for any word containing an unseen character.
|
|
61
|
+
const ids = tok.tokenize("\u{10FFFF}\u{10FFFE}");
|
|
62
|
+
assert.deepEqual(ids, [tok.unkId]);
|
|
63
|
+
});
|
|
64
|
+
it("encodes an empty string as just the special tokens", () => {
|
|
65
|
+
assert.deepEqual(tok.encode("").input_ids, [tok.clsId, tok.sepId]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
//# sourceMappingURL=wordpiece.test.js.map
|