brane 0.1.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +367 -0
- package/CHANGELOG.md +66 -0
- package/CONTRIBUTING.md +101 -0
- package/DESIGN.md +405 -0
- package/Dockerfile +61 -0
- package/LICENSE +656 -17
- package/README.md +174 -2
- package/SECURITY.md +213 -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 +133 -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 +360 -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 +1101 -0
- package/dist/commands/token.js +154 -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 +6353 -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 +643 -0
- package/dist/db/tokens.js +624 -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/app-origin.js +26 -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 +84 -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 +216 -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/app-host-literal.test.js +53 -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 +56 -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 +299 -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 +143 -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 +150 -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 +128 -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/terminal-size.test.js +36 -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 +646 -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 +627 -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/test/wrangler-route.test.js +27 -0
- package/dist/tls.js +148 -0
- package/dist/tmux.js +213 -0
- package/dist/user-agent.js +46 -0
- package/docker-entrypoint.sh +154 -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 +104 -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 +243 -0
- package/docs/token-rotation.md +431 -0
- package/docs/view-urls.md +134 -0
- package/docs/web-app.md +23 -0
- package/docs/wiki.md +43 -0
- package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -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 +79 -27
- 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 +53 -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/.travis.yml +0 -7
- package/lib/brane.cjs.js +0 -12
- package/lib/brane.cjs.js.map +0 -1
- package/lib/brane.d.ts +0 -11
- package/lib/brane.js +0 -6
- package/lib/brane.js.map +0 -1
- package/lib/tests.d.ts +0 -1
- package/lib/tests.js +0 -8
- package/lib/tests.js.map +0 -1
- package/rollup.config.js +0 -21
- package/scripts/test.sh +0 -8
- package/src/brane.ts +0 -21
- package/src/tests.ts +0 -8
- package/tsconfig.json +0 -16
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
import * as sqliteVec from "sqlite-vec";
|
|
5
|
+
import { VECTOR_DIM, embedBatch } from "./embeddings.js";
|
|
6
|
+
import { prepareFtsQuery, tokenizeQuery } from "./storage.js";
|
|
7
|
+
import { WIKI_SHARED_DIR, isSharedSlug, normalizeWikiSlug } from "../shared/wiki-links.js";
|
|
8
|
+
/** Soft cap on chunk size (characters). Sections larger than this are split on
|
|
9
|
+
* paragraph boundaries into parts. bge-small's useful window is ~512 tokens;
|
|
10
|
+
* ~1800 chars keeps us comfortably under that with headroom for context. */
|
|
11
|
+
const MAX_CHUNK_CHARS = 1800;
|
|
12
|
+
// ── Markdown parsing ────────────────────────────────────────────────────────
|
|
13
|
+
/** GitHub-style heading → anchor slug. Duplicate slugs on one page get a
|
|
14
|
+
* numeric suffix (-1, -2, …), matching GitHub's disambiguation. */
|
|
15
|
+
function slugify(heading, seen) {
|
|
16
|
+
const base = heading
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/[^\w\s-]/g, "")
|
|
20
|
+
.replace(/\s+/g, "-")
|
|
21
|
+
.replace(/-+/g, "-")
|
|
22
|
+
.replace(/^-|-$/g, "");
|
|
23
|
+
const count = seen.get(base) ?? 0;
|
|
24
|
+
seen.set(base, count + 1);
|
|
25
|
+
return count === 0 ? base : `${base}-${count}`;
|
|
26
|
+
}
|
|
27
|
+
/** Parse `---`-delimited YAML-ish frontmatter into a flat key→value map.
|
|
28
|
+
* Only simple `key: value` lines are supported (no nesting); that's all the
|
|
29
|
+
* wiki convention needs. Returns the map plus the body with frontmatter
|
|
30
|
+
* stripped. */
|
|
31
|
+
function parseFrontmatter(content) {
|
|
32
|
+
const frontmatter = {};
|
|
33
|
+
if (!content.startsWith("---\n") && !content.startsWith("---\r\n")) {
|
|
34
|
+
return { frontmatter, body: content };
|
|
35
|
+
}
|
|
36
|
+
const lines = content.split("\n");
|
|
37
|
+
// lines[0] is the opening "---"; find the closing fence.
|
|
38
|
+
let end = -1;
|
|
39
|
+
for (let i = 1; i < lines.length; i++) {
|
|
40
|
+
if (lines[i].trim() === "---") {
|
|
41
|
+
end = i;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (end === -1)
|
|
46
|
+
return { frontmatter, body: content };
|
|
47
|
+
for (let i = 1; i < end; i++) {
|
|
48
|
+
const line = lines[i];
|
|
49
|
+
const m = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/.exec(line);
|
|
50
|
+
if (m) {
|
|
51
|
+
let value = m[2].trim();
|
|
52
|
+
// Strip matching surrounding quotes.
|
|
53
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
54
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
55
|
+
value = value.slice(1, -1);
|
|
56
|
+
}
|
|
57
|
+
frontmatter[m[1]] = value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const body = lines.slice(end + 1).join("\n");
|
|
61
|
+
return { frontmatter, body };
|
|
62
|
+
}
|
|
63
|
+
const HEADING_RE = /^(#{1,6})\s+(.*)$/;
|
|
64
|
+
const FENCE_RE = /^(```|~~~)/;
|
|
65
|
+
/** Split body into heading-rooted sections, respecting code fences. Content
|
|
66
|
+
* above the first heading becomes a preamble section (anchor ""). */
|
|
67
|
+
function splitSections(body) {
|
|
68
|
+
const lines = body.split("\n");
|
|
69
|
+
const sections = [];
|
|
70
|
+
const toc = [];
|
|
71
|
+
const seenAnchors = new Map();
|
|
72
|
+
let current = { anchor: "", heading: null, level: 0, lines: [] };
|
|
73
|
+
let inFence = false;
|
|
74
|
+
for (const line of lines) {
|
|
75
|
+
if (FENCE_RE.test(line.trim())) {
|
|
76
|
+
inFence = !inFence;
|
|
77
|
+
current.lines.push(line);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const m = !inFence ? HEADING_RE.exec(line) : null;
|
|
81
|
+
if (m) {
|
|
82
|
+
// Close the current section if it has any content (or is a heading).
|
|
83
|
+
if (current.heading !== null || current.lines.some((l) => l.trim().length > 0)) {
|
|
84
|
+
sections.push(current);
|
|
85
|
+
}
|
|
86
|
+
const level = m[1].length;
|
|
87
|
+
const heading = m[2].trim();
|
|
88
|
+
const anchor = slugify(heading, seenAnchors);
|
|
89
|
+
toc.push({ anchor, heading, level });
|
|
90
|
+
current = { anchor, heading, level, lines: [line] };
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
current.lines.push(line);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (current.heading !== null || current.lines.some((l) => l.trim().length > 0)) {
|
|
97
|
+
sections.push(current);
|
|
98
|
+
}
|
|
99
|
+
return { sections, toc };
|
|
100
|
+
}
|
|
101
|
+
function sha256(text) {
|
|
102
|
+
return createHash("sha256").update(text).digest("hex");
|
|
103
|
+
}
|
|
104
|
+
/** Split an oversized section body on paragraph (blank-line) boundaries into
|
|
105
|
+
* pieces no larger than MAX_CHUNK_CHARS where possible. A single paragraph
|
|
106
|
+
* bigger than the cap is emitted whole rather than cut mid-sentence. */
|
|
107
|
+
function splitOversized(content) {
|
|
108
|
+
if (content.length <= MAX_CHUNK_CHARS)
|
|
109
|
+
return [content];
|
|
110
|
+
const paras = content.split(/\n{2,}/);
|
|
111
|
+
const parts = [];
|
|
112
|
+
let buf = "";
|
|
113
|
+
for (const para of paras) {
|
|
114
|
+
if (buf && buf.length + para.length + 2 > MAX_CHUNK_CHARS) {
|
|
115
|
+
parts.push(buf);
|
|
116
|
+
buf = para;
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
buf = buf ? `${buf}\n\n${para}` : para;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (buf)
|
|
123
|
+
parts.push(buf);
|
|
124
|
+
return parts.length > 0 ? parts : [content];
|
|
125
|
+
}
|
|
126
|
+
/** Parse a wiki page into frontmatter, a table of contents, and chunks. */
|
|
127
|
+
export function parseWikiPage(content) {
|
|
128
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
129
|
+
const { sections, toc } = splitSections(body);
|
|
130
|
+
const chunks = [];
|
|
131
|
+
for (const section of sections) {
|
|
132
|
+
const text = section.lines.join("\n").trim();
|
|
133
|
+
if (!text)
|
|
134
|
+
continue;
|
|
135
|
+
const pieces = splitOversized(text);
|
|
136
|
+
pieces.forEach((piece, idx) => {
|
|
137
|
+
chunks.push({ anchor: section.anchor, part: idx, content: piece, hash: sha256(piece) });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return { frontmatter, toc, chunks };
|
|
141
|
+
}
|
|
142
|
+
/** First non-empty, non-heading, non-frontmatter line — the description
|
|
143
|
+
* fallback when a page carries no `description:` frontmatter. Mirrors
|
|
144
|
+
* `handleListProtocols`. */
|
|
145
|
+
function firstProseLine(body) {
|
|
146
|
+
const line = body
|
|
147
|
+
.split("\n")
|
|
148
|
+
.find((l) => l.trim().length > 0 && !l.startsWith("#") && !l.startsWith("---"));
|
|
149
|
+
return line?.trim();
|
|
150
|
+
}
|
|
151
|
+
// ── Source resolution ─────────────────────────────────────────────────────
|
|
152
|
+
/** Normalize a slug to its `.md` filename form. */
|
|
153
|
+
export function normalizeSlug(slug) {
|
|
154
|
+
return normalizeWikiSlug(slug);
|
|
155
|
+
}
|
|
156
|
+
/** Markdown filenames directly in `dir`, sorted. */
|
|
157
|
+
function mdFiles(dir) {
|
|
158
|
+
try {
|
|
159
|
+
return readdirSync(dir)
|
|
160
|
+
.filter((f) => f.endsWith(".md"))
|
|
161
|
+
.sort();
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return [];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** List page slugs (with `.md`, relative to the wiki root) in a wiki
|
|
168
|
+
* directory: the local pages directly under it plus the shared pages under
|
|
169
|
+
* `shared/`, which carry that prefix. No other subdirectory is a page
|
|
170
|
+
* location — the layout is flat by protocol, with `shared/` as the one
|
|
171
|
+
* exception. */
|
|
172
|
+
function listSlugs(dir) {
|
|
173
|
+
const shared = mdFiles(join(dir, WIKI_SHARED_DIR)).map((f) => `${WIKI_SHARED_DIR}/${f}`);
|
|
174
|
+
return [...mdFiles(dir), ...shared].sort();
|
|
175
|
+
}
|
|
176
|
+
/** Whether `slug` is visible through `src`: everything for an owned source,
|
|
177
|
+
* only `shared/` pages for a `shared_only` one. */
|
|
178
|
+
function visibleIn(src, slug) {
|
|
179
|
+
return !src.shared_only || isSharedSlug(slug);
|
|
180
|
+
}
|
|
181
|
+
/** Where a requested slug lands in a source. `ok` carries the canonical
|
|
182
|
+
* filename; `local` means the page exists but is not shared and the source
|
|
183
|
+
* is `shared_only`; `missing` means no such page (an escaping `..` slug is
|
|
184
|
+
* treated as missing rather than resolved). A bare `foo` that does not exist
|
|
185
|
+
* at the root falls through to `shared/foo.md`, so the short form keeps
|
|
186
|
+
* working for shipped pages. */
|
|
187
|
+
export function locatePage(src, slug) {
|
|
188
|
+
const filename = normalizeSlug(slug);
|
|
189
|
+
if (filename.split("/").some((seg) => seg === ".."))
|
|
190
|
+
return { status: "missing", filename };
|
|
191
|
+
const candidates = [filename];
|
|
192
|
+
if (!filename.includes("/"))
|
|
193
|
+
candidates.push(`${WIKI_SHARED_DIR}/${filename}`);
|
|
194
|
+
for (const candidate of candidates) {
|
|
195
|
+
if (existsSync(join(src.dir, candidate))) {
|
|
196
|
+
return { status: visibleIn(src, candidate) ? "ok" : "local", filename: candidate };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return { status: "missing", filename };
|
|
200
|
+
}
|
|
201
|
+
/** The error a non-owner gets for a page that exists but is not shared. */
|
|
202
|
+
function localPageError(source, filename) {
|
|
203
|
+
return {
|
|
204
|
+
error: `"${filename}" is local to wiki source "${source}", not shared — only pages under ` +
|
|
205
|
+
`${WIKI_SHARED_DIR}/ are readable from another fold.`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
export function listWiki(sources, opts = {}) {
|
|
209
|
+
const wanted = opts.source && opts.source !== "all" ? opts.source : undefined;
|
|
210
|
+
const entries = [];
|
|
211
|
+
for (const src of sources) {
|
|
212
|
+
if (wanted && src.source !== wanted)
|
|
213
|
+
continue;
|
|
214
|
+
for (const slug of listSlugs(src.dir)) {
|
|
215
|
+
if (!visibleIn(src, slug))
|
|
216
|
+
continue;
|
|
217
|
+
const path = join(src.dir, slug);
|
|
218
|
+
let content;
|
|
219
|
+
let mtime = 0;
|
|
220
|
+
try {
|
|
221
|
+
content = readFileSync(path, "utf-8");
|
|
222
|
+
mtime = statSync(path).mtimeMs;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const { frontmatter, body } = parseFrontmatter(content);
|
|
228
|
+
const description = frontmatter.description ?? firstProseLine(body) ?? null;
|
|
229
|
+
const title = frontmatter.title ?? basename(slug, ".md");
|
|
230
|
+
entries.push({
|
|
231
|
+
slug,
|
|
232
|
+
title,
|
|
233
|
+
description,
|
|
234
|
+
source: src.source,
|
|
235
|
+
fold_path: src.fold_path,
|
|
236
|
+
shared: isSharedSlug(slug),
|
|
237
|
+
mtime,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if (opts.filter) {
|
|
242
|
+
const needle = opts.filter.toLowerCase();
|
|
243
|
+
const matched = entries.filter((e) => e.slug.toLowerCase().includes(needle) ||
|
|
244
|
+
e.title.toLowerCase().includes(needle) ||
|
|
245
|
+
(e.description?.toLowerCase().includes(needle) ?? false));
|
|
246
|
+
entries.length = 0;
|
|
247
|
+
entries.push(...matched);
|
|
248
|
+
}
|
|
249
|
+
const order = opts.order ?? "slug";
|
|
250
|
+
entries.sort((a, b) => {
|
|
251
|
+
if (order === "mtime")
|
|
252
|
+
return b.mtime - a.mtime;
|
|
253
|
+
if (order === "title")
|
|
254
|
+
return a.title.localeCompare(b.title);
|
|
255
|
+
return a.slug.localeCompare(b.slug);
|
|
256
|
+
});
|
|
257
|
+
return entries;
|
|
258
|
+
}
|
|
259
|
+
export function readWiki(sources, source, slug, opts = {}) {
|
|
260
|
+
const src = sources.find((s) => s.source === source);
|
|
261
|
+
if (!src) {
|
|
262
|
+
return { error: `Unknown wiki source: ${source}. Use list_wiki to see available sources.` };
|
|
263
|
+
}
|
|
264
|
+
const located = locatePage(src, slug);
|
|
265
|
+
if (located.status === "missing") {
|
|
266
|
+
return { error: `Wiki page not found: ${source}/${located.filename}. Use list_wiki to see pages.` };
|
|
267
|
+
}
|
|
268
|
+
if (located.status === "local")
|
|
269
|
+
return localPageError(source, located.filename);
|
|
270
|
+
const filename = located.filename;
|
|
271
|
+
const content = readFileSync(join(src.dir, filename), "utf-8");
|
|
272
|
+
const parsed = parseWikiPage(content);
|
|
273
|
+
let chunks = parsed.chunks;
|
|
274
|
+
let citation = filename;
|
|
275
|
+
if (opts.anchor) {
|
|
276
|
+
const anchor = opts.anchor.replace(/^#/, "");
|
|
277
|
+
chunks = parsed.chunks.filter((c) => c.anchor === anchor);
|
|
278
|
+
citation = `${filename}#${anchor}`;
|
|
279
|
+
if (chunks.length === 0) {
|
|
280
|
+
const anchors = parsed.toc.map((t) => t.anchor).join(", ");
|
|
281
|
+
return { error: `No section "#${anchor}" in ${source}/${filename}. Anchors: ${anchors || "(none)"}` };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const total = chunks.length;
|
|
285
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
286
|
+
const limit = opts.limit ?? total;
|
|
287
|
+
const page = chunks.slice(offset, offset + limit);
|
|
288
|
+
return {
|
|
289
|
+
source: src.source,
|
|
290
|
+
slug: filename,
|
|
291
|
+
fold_path: src.fold_path,
|
|
292
|
+
frontmatter: parsed.frontmatter,
|
|
293
|
+
toc: parsed.toc,
|
|
294
|
+
citation,
|
|
295
|
+
content: page.map((c) => c.content).join("\n\n"),
|
|
296
|
+
total_chunks: total,
|
|
297
|
+
offset,
|
|
298
|
+
returned: page.length,
|
|
299
|
+
has_more: offset + page.length < total,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/** Fold a parsed page's chunks back into ordered sections by anchor. Chunks
|
|
303
|
+
* arrive in document order and an oversized section's parts are consecutive,
|
|
304
|
+
* so grouping consecutive same-anchor chunks reconstructs each section. */
|
|
305
|
+
function pageSections(parsed) {
|
|
306
|
+
const tocByAnchor = new Map(parsed.toc.map((t) => [t.anchor, t]));
|
|
307
|
+
const sections = [];
|
|
308
|
+
for (const ch of parsed.chunks) {
|
|
309
|
+
const last = sections[sections.length - 1];
|
|
310
|
+
if (last && last.anchor === ch.anchor) {
|
|
311
|
+
last.content += `\n\n${ch.content}`;
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
const t = tocByAnchor.get(ch.anchor);
|
|
315
|
+
sections.push({
|
|
316
|
+
anchor: ch.anchor,
|
|
317
|
+
heading: t?.heading ?? null,
|
|
318
|
+
level: t?.level ?? 0,
|
|
319
|
+
content: ch.content,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return sections;
|
|
324
|
+
}
|
|
325
|
+
/** First prose line of a section (heading and blank lines skipped), truncated
|
|
326
|
+
* — a "should I read on?" hint for a neighbor, never the whole section. */
|
|
327
|
+
function sectionPreview(content) {
|
|
328
|
+
const line = content
|
|
329
|
+
.split("\n")
|
|
330
|
+
.map((l) => l.trim())
|
|
331
|
+
.find((l) => l.length > 0 && !l.startsWith("#"));
|
|
332
|
+
if (!line)
|
|
333
|
+
return "";
|
|
334
|
+
return line.length > 120 ? `${line.slice(0, 119)}…` : line;
|
|
335
|
+
}
|
|
336
|
+
/** Default and ceiling for the neighbor window (sections on each side). */
|
|
337
|
+
const DEFAULT_WINDOW = 3;
|
|
338
|
+
const MAX_WINDOW = 10;
|
|
339
|
+
/**
|
|
340
|
+
* Read one section of a wiki page plus a navigational window of the section
|
|
341
|
+
* headings around it, so a reader can walk the page section-by-section ("read
|
|
342
|
+
* on" = fetch `next[0].anchor`) instead of paging blind by offset or pulling
|
|
343
|
+
* the whole page. No `anchor` starts at the page top. Shared by the MCP
|
|
344
|
+
* `read_wiki` (with `window`) and the HTTP `GET /api/wiki/read`.
|
|
345
|
+
*/
|
|
346
|
+
export function readWikiSection(sources, source, slug, opts = {}) {
|
|
347
|
+
const src = sources.find((s) => s.source === source);
|
|
348
|
+
if (!src) {
|
|
349
|
+
return { error: `Unknown wiki source: ${source}. Use list_wiki to see available sources.` };
|
|
350
|
+
}
|
|
351
|
+
const located = locatePage(src, slug);
|
|
352
|
+
if (located.status === "missing") {
|
|
353
|
+
return { error: `Wiki page not found: ${source}/${located.filename}. Use list_wiki to see pages.` };
|
|
354
|
+
}
|
|
355
|
+
if (located.status === "local")
|
|
356
|
+
return localPageError(source, located.filename);
|
|
357
|
+
const filename = located.filename;
|
|
358
|
+
const parsed = parseWikiPage(readFileSync(join(src.dir, filename), "utf-8"));
|
|
359
|
+
const sections = pageSections(parsed);
|
|
360
|
+
if (sections.length === 0) {
|
|
361
|
+
return { error: `Wiki page has no readable content: ${source}/${filename}` };
|
|
362
|
+
}
|
|
363
|
+
let index = 0;
|
|
364
|
+
if (opts.anchor) {
|
|
365
|
+
const anchor = opts.anchor.replace(/^#/, "");
|
|
366
|
+
const found = sections.findIndex((s) => s.anchor === anchor);
|
|
367
|
+
if (found === -1) {
|
|
368
|
+
const anchors = parsed.toc.map((t) => t.anchor).join(", ");
|
|
369
|
+
return { error: `No section "#${anchor}" in ${source}/${filename}. Anchors: ${anchors || "(none)"}` };
|
|
370
|
+
}
|
|
371
|
+
index = found;
|
|
372
|
+
}
|
|
373
|
+
const window = Math.max(0, Math.min(opts.window ?? DEFAULT_WINDOW, MAX_WINDOW));
|
|
374
|
+
const cur = sections[index];
|
|
375
|
+
const toNeighbor = (s) => ({
|
|
376
|
+
anchor: s.anchor,
|
|
377
|
+
heading: s.heading,
|
|
378
|
+
level: s.level,
|
|
379
|
+
preview: sectionPreview(s.content),
|
|
380
|
+
});
|
|
381
|
+
return {
|
|
382
|
+
source: src.source,
|
|
383
|
+
slug: filename,
|
|
384
|
+
fold_path: src.fold_path,
|
|
385
|
+
frontmatter: parsed.frontmatter,
|
|
386
|
+
toc: parsed.toc,
|
|
387
|
+
citation: cur.anchor ? `${filename}#${cur.anchor}` : filename,
|
|
388
|
+
current: { anchor: cur.anchor, heading: cur.heading, level: cur.level, content: cur.content },
|
|
389
|
+
// nearest-first on both sides
|
|
390
|
+
prev: sections.slice(Math.max(0, index - window), index).reverse().map(toNeighbor),
|
|
391
|
+
next: sections.slice(index + 1, index + 1 + window).map(toNeighbor),
|
|
392
|
+
position: { index, total: sections.length },
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
// ── Semantic search: search_wiki ──────────────────────────────────────────
|
|
396
|
+
const WIKI_SCHEMA_SQL = `
|
|
397
|
+
CREATE TABLE IF NOT EXISTS wiki_chunks (
|
|
398
|
+
id TEXT PRIMARY KEY,
|
|
399
|
+
source TEXT NOT NULL,
|
|
400
|
+
slug TEXT NOT NULL,
|
|
401
|
+
anchor TEXT NOT NULL,
|
|
402
|
+
part INTEGER NOT NULL,
|
|
403
|
+
content TEXT NOT NULL,
|
|
404
|
+
hash TEXT NOT NULL,
|
|
405
|
+
fold_path TEXT,
|
|
406
|
+
time INTEGER NOT NULL
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS wiki_vec USING vec0(
|
|
410
|
+
chunk_id TEXT PRIMARY KEY,
|
|
411
|
+
embedding float[${VECTOR_DIM}] distance_metric=cosine
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS wiki_fts USING fts5(
|
|
415
|
+
content,
|
|
416
|
+
content='wiki_chunks',
|
|
417
|
+
content_rowid='rowid',
|
|
418
|
+
tokenize='porter unicode61'
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
CREATE TRIGGER IF NOT EXISTS wiki_chunks_ai AFTER INSERT ON wiki_chunks BEGIN
|
|
422
|
+
INSERT INTO wiki_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
423
|
+
END;
|
|
424
|
+
|
|
425
|
+
CREATE TRIGGER IF NOT EXISTS wiki_chunks_ad AFTER DELETE ON wiki_chunks BEGIN
|
|
426
|
+
INSERT INTO wiki_fts(wiki_fts, rowid, content) VALUES('delete', old.rowid, old.content);
|
|
427
|
+
END;
|
|
428
|
+
|
|
429
|
+
-- Defensive: sync only does INSERT OR REPLACE / DELETE, so no current path
|
|
430
|
+
-- issues a bare UPDATE. Kept to mirror memory_chunks and stay correct if one
|
|
431
|
+
-- is ever added.
|
|
432
|
+
CREATE TRIGGER IF NOT EXISTS wiki_chunks_au AFTER UPDATE ON wiki_chunks BEGIN
|
|
433
|
+
INSERT INTO wiki_fts(wiki_fts, rowid, content) VALUES('delete', old.rowid, old.content);
|
|
434
|
+
INSERT INTO wiki_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
435
|
+
END;
|
|
436
|
+
|
|
437
|
+
CREATE INDEX IF NOT EXISTS idx_wiki_chunks_doc ON wiki_chunks(source, slug);
|
|
438
|
+
`;
|
|
439
|
+
export function initWikiSchema(db) {
|
|
440
|
+
// sqlite-vec must be loaded for the vec0 virtual table. The memory subsystem
|
|
441
|
+
// loads it on every search too, so repeated loads are an established, safe
|
|
442
|
+
// pattern here.
|
|
443
|
+
sqliteVec.load(db);
|
|
444
|
+
db.exec(WIKI_SCHEMA_SQL);
|
|
445
|
+
}
|
|
446
|
+
function vecToBuffer(vec) {
|
|
447
|
+
return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
|
|
448
|
+
}
|
|
449
|
+
function chunkId(source, slug, anchor, part) {
|
|
450
|
+
return `${source}\0${slug}\0${anchor}\0${part}`;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Reconcile the wiki index with the current on-disk pages for the given
|
|
454
|
+
* sources. Re-embeds only chunks whose content hash changed (or are new) and
|
|
455
|
+
* drops index rows for chunks that no longer exist. This touches only the
|
|
456
|
+
* derived `wiki_*` index tables, never the wiki files themselves.
|
|
457
|
+
*/
|
|
458
|
+
export async function syncWikiIndex(db, sources, embed = embedBatch) {
|
|
459
|
+
initWikiSchema(db);
|
|
460
|
+
const stats = {
|
|
461
|
+
sources: sources.length,
|
|
462
|
+
pages: 0,
|
|
463
|
+
chunks_total: 0,
|
|
464
|
+
chunks_reembedded: 0,
|
|
465
|
+
chunks_removed: 0,
|
|
466
|
+
};
|
|
467
|
+
const now = Date.now();
|
|
468
|
+
const insertChunk = db.prepare(`INSERT OR REPLACE INTO wiki_chunks (id, source, slug, anchor, part, content, hash, fold_path, time)
|
|
469
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
470
|
+
const deleteVec = db.prepare("DELETE FROM wiki_vec WHERE chunk_id = ?");
|
|
471
|
+
const insertVec = db.prepare("INSERT INTO wiki_vec (chunk_id, embedding) VALUES (?, ?)");
|
|
472
|
+
const deleteChunk = db.prepare("DELETE FROM wiki_chunks WHERE id = ?");
|
|
473
|
+
for (const src of sources) {
|
|
474
|
+
const existing = db
|
|
475
|
+
.prepare("SELECT id, hash FROM wiki_chunks WHERE source = ?")
|
|
476
|
+
.all(src.source);
|
|
477
|
+
const existingHash = new Map(existing.map((r) => [r.id, r.hash]));
|
|
478
|
+
const present = new Set();
|
|
479
|
+
const changed = [];
|
|
480
|
+
for (const slug of listSlugs(src.dir)) {
|
|
481
|
+
let content;
|
|
482
|
+
try {
|
|
483
|
+
content = readFileSync(join(src.dir, slug), "utf-8");
|
|
484
|
+
}
|
|
485
|
+
catch {
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
stats.pages++;
|
|
489
|
+
const parsed = parseWikiPage(content);
|
|
490
|
+
for (const ch of parsed.chunks) {
|
|
491
|
+
const id = chunkId(src.source, slug, ch.anchor, ch.part);
|
|
492
|
+
present.add(id);
|
|
493
|
+
stats.chunks_total++;
|
|
494
|
+
if (existingHash.get(id) !== ch.hash) {
|
|
495
|
+
changed.push({
|
|
496
|
+
id,
|
|
497
|
+
source: src.source,
|
|
498
|
+
slug,
|
|
499
|
+
anchor: ch.anchor,
|
|
500
|
+
part: ch.part,
|
|
501
|
+
content: ch.content,
|
|
502
|
+
hash: ch.hash,
|
|
503
|
+
fold_path: src.fold_path,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// Embed changed chunks (outside the write transaction — embedding is async).
|
|
509
|
+
let vectors = [];
|
|
510
|
+
if (changed.length > 0) {
|
|
511
|
+
vectors = await embed(changed.map((c) => c.content));
|
|
512
|
+
}
|
|
513
|
+
const toRemove = [...existingHash.keys()].filter((id) => !present.has(id));
|
|
514
|
+
// Skip the write transaction entirely when nothing changed — otherwise a
|
|
515
|
+
// read-shaped `search_wiki` would still acquire the write lock on an empty
|
|
516
|
+
// BEGIN/COMMIT every call.
|
|
517
|
+
if (changed.length > 0 || toRemove.length > 0) {
|
|
518
|
+
const tx = db.transaction(() => {
|
|
519
|
+
changed.forEach((c, i) => {
|
|
520
|
+
insertChunk.run(c.id, c.source, c.slug, c.anchor, c.part, c.content, c.hash, c.fold_path ?? null, now);
|
|
521
|
+
deleteVec.run(c.id);
|
|
522
|
+
insertVec.run(c.id, vecToBuffer(vectors[i]));
|
|
523
|
+
});
|
|
524
|
+
for (const id of toRemove) {
|
|
525
|
+
deleteVec.run(id);
|
|
526
|
+
deleteChunk.run(id);
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
tx();
|
|
530
|
+
}
|
|
531
|
+
stats.chunks_reembedded += changed.length;
|
|
532
|
+
stats.chunks_removed += toRemove.length;
|
|
533
|
+
}
|
|
534
|
+
return stats;
|
|
535
|
+
}
|
|
536
|
+
/** Minimum fused score for a chunk to count as a real hit. Keyword matches get
|
|
537
|
+
* a +0.5/+1.0 boost so they clear this comfortably; unrelated text sits at the
|
|
538
|
+
* ~0.5–0.6 embedding baseline and is dropped. Tunable. */
|
|
539
|
+
const MIN_WIKI_SCORE = 0.7;
|
|
540
|
+
/** Hybrid vector + FTS search over the wiki index, scoped to `sources`.
|
|
541
|
+
* `limit` bounds the number of chunk hits returned (like narrative_search
|
|
542
|
+
* bounds messages); those hits are then grouped under their article. Hits
|
|
543
|
+
* below {@link MIN_WIKI_SCORE} are dropped, so an off-topic query can return
|
|
544
|
+
* zero results rather than noise. */
|
|
545
|
+
export async function searchWiki(db, sources, query, limit, embed = embedBatch) {
|
|
546
|
+
await syncWikiIndex(db, sources, embed);
|
|
547
|
+
const sourceLabels = sources.map((s) => s.source);
|
|
548
|
+
if (sourceLabels.length === 0)
|
|
549
|
+
return [];
|
|
550
|
+
const bySource = new Map(sources.map((s) => [s.source, s]));
|
|
551
|
+
// A `shared_only` source contributes only its `shared/` chunks; the index
|
|
552
|
+
// holds the whole wiki (its owner searches it too), so visibility is a
|
|
553
|
+
// query-time filter here rather than a property of what got indexed.
|
|
554
|
+
const rowVisible = (r) => {
|
|
555
|
+
const src = bySource.get(r.source);
|
|
556
|
+
return src !== undefined && visibleIn(src, r.slug);
|
|
557
|
+
};
|
|
558
|
+
const placeholders = sourceLabels.map(() => "?").join(", ");
|
|
559
|
+
const fetchLimit = Math.max(limit * 4, 20);
|
|
560
|
+
const queryVector = (await embed([query]))[0];
|
|
561
|
+
const merged = new Map();
|
|
562
|
+
// Vector KNN — fetch a wider candidate set, then scope to sources in JS
|
|
563
|
+
// (vec0 MATCH can't be combined with arbitrary WHERE filters). The `k` is a
|
|
564
|
+
// global top-N across *all* synced sources, so source-scoping happens after
|
|
565
|
+
// the fact; fine for the small wiki corpus (tens–hundreds of chunks), where
|
|
566
|
+
// even a generous `k` spans everything. If a brane ever accumulates very
|
|
567
|
+
// large wikis, this would want a per-source `k` or a pre-filter.
|
|
568
|
+
const vecRows = db
|
|
569
|
+
.prepare(`SELECT v.chunk_id, v.distance, c.source, c.slug, c.anchor, c.content, c.fold_path
|
|
570
|
+
FROM wiki_vec v
|
|
571
|
+
JOIN wiki_chunks c ON c.id = v.chunk_id
|
|
572
|
+
WHERE v.embedding MATCH ? AND k = ?
|
|
573
|
+
ORDER BY v.distance`)
|
|
574
|
+
.all(vecToBuffer(queryVector), fetchLimit * 2);
|
|
575
|
+
for (const r of vecRows) {
|
|
576
|
+
if (!rowVisible(r))
|
|
577
|
+
continue;
|
|
578
|
+
merged.set(r.chunk_id, {
|
|
579
|
+
chunk_id: r.chunk_id,
|
|
580
|
+
source: r.source,
|
|
581
|
+
slug: r.slug,
|
|
582
|
+
anchor: r.anchor,
|
|
583
|
+
content: r.content,
|
|
584
|
+
fold_path: r.fold_path,
|
|
585
|
+
score: Math.max(0, 1.0 - r.distance),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
// FTS keyword search, scoped to sources in SQL.
|
|
589
|
+
const ftsQuery = prepareFtsQuery(query);
|
|
590
|
+
if (ftsQuery) {
|
|
591
|
+
try {
|
|
592
|
+
const ftsRows = db
|
|
593
|
+
.prepare(`SELECT c.id AS chunk_id, c.source, c.slug, c.anchor, c.content, c.fold_path,
|
|
594
|
+
bm25(wiki_fts) AS bm25_score
|
|
595
|
+
FROM wiki_fts
|
|
596
|
+
JOIN wiki_chunks c ON wiki_fts.rowid = c.rowid
|
|
597
|
+
WHERE wiki_fts MATCH ? AND c.source IN (${placeholders})
|
|
598
|
+
ORDER BY bm25_score
|
|
599
|
+
LIMIT ?`)
|
|
600
|
+
.all(ftsQuery, ...sourceLabels, fetchLimit);
|
|
601
|
+
for (const r of ftsRows) {
|
|
602
|
+
if (!rowVisible(r))
|
|
603
|
+
continue;
|
|
604
|
+
const ftsScore = Math.min(1.0, Math.max(0, -r.bm25_score / 10.0));
|
|
605
|
+
const existing = merged.get(r.chunk_id);
|
|
606
|
+
if (existing) {
|
|
607
|
+
existing.score = existing.score * 0.6 + ftsScore * 0.4;
|
|
608
|
+
}
|
|
609
|
+
else {
|
|
610
|
+
merged.set(r.chunk_id, {
|
|
611
|
+
chunk_id: r.chunk_id,
|
|
612
|
+
source: r.source,
|
|
613
|
+
slug: r.slug,
|
|
614
|
+
anchor: r.anchor,
|
|
615
|
+
content: r.content,
|
|
616
|
+
fold_path: r.fold_path,
|
|
617
|
+
score: ftsScore,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
// FTS query can fail on special chars — vector results still stand.
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
// Keyword-presence boost, mirroring searchHybrid.
|
|
627
|
+
const tokens = tokenizeQuery(query);
|
|
628
|
+
const phrases = tokens
|
|
629
|
+
.filter((t) => t.kind === "phrase")
|
|
630
|
+
.map((t) => t.text.toLowerCase());
|
|
631
|
+
const terms = tokens
|
|
632
|
+
.filter((t) => t.kind === "term")
|
|
633
|
+
.map((t) => t.text.toLowerCase());
|
|
634
|
+
const rows = [...merged.values()];
|
|
635
|
+
for (const r of rows) {
|
|
636
|
+
const lower = r.content.toLowerCase();
|
|
637
|
+
if (phrases.some((p) => lower.includes(p)))
|
|
638
|
+
r.score += 1.0;
|
|
639
|
+
else if (terms.some((t) => lower.includes(t)))
|
|
640
|
+
r.score += 0.5;
|
|
641
|
+
}
|
|
642
|
+
rows.sort((a, b) => b.score - a.score);
|
|
643
|
+
// Relevance floor: drop weak hits rather than padding results with noise.
|
|
644
|
+
// A chunk containing a query term gets a +0.5/+1.0 keyword boost, so genuine
|
|
645
|
+
// matches land well above 1.0; pure-semantic baseline similarity for
|
|
646
|
+
// unrelated text sits around 0.5–0.6. A floor of 0.7 keeps every keyword
|
|
647
|
+
// match and strong semantic hit while cutting the baseline tail — so an
|
|
648
|
+
// off-topic query returns few or zero hits (and the UI's Wiki section then
|
|
649
|
+
// hides) instead of thin, unhighlightable filler.
|
|
650
|
+
const relevant = rows.filter((r) => r.score >= MIN_WIKI_SCORE);
|
|
651
|
+
// `limit` caps the number of chunk *hits* — the wiki analogue of
|
|
652
|
+
// narrative_search returning the top-N messages — not the number of
|
|
653
|
+
// articles. We take the top-`limit` chunks globally, then group them under
|
|
654
|
+
// their article (source + slug) the way narrative messages belong to a
|
|
655
|
+
// narrative. This keeps the payload bounded: a query that matches dozens of
|
|
656
|
+
// sections in a handful of pages returns `limit` chunks, not whole pages.
|
|
657
|
+
const topRows = relevant.slice(0, Math.max(0, limit));
|
|
658
|
+
const articles = new Map();
|
|
659
|
+
for (const r of topRows) {
|
|
660
|
+
const key = `${r.source}\0${r.slug}`;
|
|
661
|
+
const score = Math.round(r.score * 100) / 100;
|
|
662
|
+
const hit = {
|
|
663
|
+
anchor: r.anchor,
|
|
664
|
+
citation: r.anchor ? `${r.slug}#${r.anchor}` : r.slug,
|
|
665
|
+
content: r.content,
|
|
666
|
+
score,
|
|
667
|
+
};
|
|
668
|
+
const existing = articles.get(key);
|
|
669
|
+
if (existing) {
|
|
670
|
+
existing.hits.push(hit);
|
|
671
|
+
if (score > existing.score)
|
|
672
|
+
existing.score = score;
|
|
673
|
+
}
|
|
674
|
+
else {
|
|
675
|
+
articles.set(key, {
|
|
676
|
+
source: r.source,
|
|
677
|
+
slug: r.slug,
|
|
678
|
+
fold_path: r.fold_path ?? undefined,
|
|
679
|
+
score,
|
|
680
|
+
hits: [hit],
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
// `topRows` is already score-descending, so each article's `hits` and the
|
|
685
|
+
// insertion order are both ranked; sort articles by their best-hit score.
|
|
686
|
+
return [...articles.values()].sort((a, b) => b.score - a.score);
|
|
687
|
+
}
|
|
688
|
+
//# sourceMappingURL=wiki.js.map
|