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,587 @@
|
|
|
1
|
+
import * as sqliteVec from "sqlite-vec";
|
|
2
|
+
import { VECTOR_DIM } from "./embeddings.js";
|
|
3
|
+
/** Convert Float32Array to Node Buffer for better-sqlite3 binding. */
|
|
4
|
+
function vecToBuffer(vec) {
|
|
5
|
+
return Buffer.from(vec.buffer, vec.byteOffset, vec.byteLength);
|
|
6
|
+
}
|
|
7
|
+
/** Minimum embedding similarity for a row to qualify on semantic grounds alone.
|
|
8
|
+
*
|
|
9
|
+
* Calibrated like the wiki's floor (`wiki.ts`): unrelated text sits around the
|
|
10
|
+
* 0.5–0.6 similarity baseline this embedder returns for *anything*, so a floor
|
|
11
|
+
* must sit above that band to reject rather than merely reorder.
|
|
12
|
+
*
|
|
13
|
+
* This gates the **semantic** score, never the fused one, and only for rows
|
|
14
|
+
* with no lexical evidence — exact phrases and FTS hits (which require every
|
|
15
|
+
* query term, see {@link prepareFtsQuery}) bypass it entirely. The failure it
|
|
16
|
+
* prevents is specific: a bare term co-occurring in unrelated text earns +0.5
|
|
17
|
+
* at {@link searchHybrid}, which used to outrank genuinely relevant matches. */
|
|
18
|
+
export const MIN_SEMANTIC_SCORE = 0.7;
|
|
19
|
+
/** Reciprocal-rank fusion constant: an arm's row at rank `i` contributes
|
|
20
|
+
* `weight / (RRF_K + i + 1)`.
|
|
21
|
+
*
|
|
22
|
+
* Fusing by **rank** rather than by score is the point. The arms produce
|
|
23
|
+
* numbers on incomparable scales — the vector arm's is a cosine (in practice
|
|
24
|
+
* 0.62–0.73 across this whole corpus, a band so narrow it barely orders
|
|
25
|
+
* anything), the lexical arms' a squashed bm25 topping out near 0.7 for an
|
|
26
|
+
* excellent hit. Adding or blending those meant the vector arm won by
|
|
27
|
+
* construction: a topically-adjacent one-line chunk outscored the only chunk
|
|
28
|
+
* in 43,876 that contained the operator's entire sentence. Ranks have no such
|
|
29
|
+
* units problem, which is why RRF is the standard answer here.
|
|
30
|
+
*
|
|
31
|
+
* 60 is the value from the original RRF paper (Cormack et al. 2009) and it is
|
|
32
|
+
* deliberately large: it flattens the gap between ranks 1 and 2 so that
|
|
33
|
+
* *agreement across arms* outweighs any single arm's confidence. */
|
|
34
|
+
const RRF_K = 60;
|
|
35
|
+
/** Per-arm fusion weights.
|
|
36
|
+
*
|
|
37
|
+
* ALL outranks ANY because containing every query term is strictly stronger
|
|
38
|
+
* evidence than containing some of them; the two are the same index queried
|
|
39
|
+
* two ways, so a row matching both collects from both. The vector arm is
|
|
40
|
+
* weighted lowest because that is what it earns on this corpus — ablating it
|
|
41
|
+
* from this fused design costs one probe in sixty. It is kept because it is
|
|
42
|
+
* the only arm that can rank a row sharing no terms with the query at all. */
|
|
43
|
+
const ARM_WEIGHTS = { all: 2.0, any: 1.0, vector: 1.0 };
|
|
44
|
+
/** Multipliers on a row's fused rank for how much of the query it contains:
|
|
45
|
+
* `× (1 + COVERAGE_WEIGHT × termFraction + PHRASE_COVERAGE_WEIGHT × phraseFraction)`.
|
|
46
|
+
*
|
|
47
|
+
* This replaced a flat `+0.5` awarded for *any single* term appearing, under
|
|
48
|
+
* which a row holding one of eight query words scored identically to a row
|
|
49
|
+
* holding all eight. Coverage is what bm25 was already measuring and the
|
|
50
|
+
* fused score was already throwing away. */
|
|
51
|
+
const COVERAGE_WEIGHT = 1.5;
|
|
52
|
+
const PHRASE_COVERAGE_WEIGHT = 2.0;
|
|
53
|
+
/** Distinct content terms a row must contain to count as lexical evidence when
|
|
54
|
+
* it did *not* match every term. Two, so a single common word co-occurring in
|
|
55
|
+
* unrelated text still doesn't qualify — that noise is what the relevance
|
|
56
|
+
* floor exists to drop — while a genuine partial match does. */
|
|
57
|
+
const MIN_TERM_HITS = 2;
|
|
58
|
+
/** Minimum candidates fetched per arm regardless of `limit`. Fusion can only
|
|
59
|
+
* rank what the arms returned. */
|
|
60
|
+
const CANDIDATE_FLOOR = 100;
|
|
61
|
+
/** In the last-resort `weak` pass, keep rows scoring at least this fraction of
|
|
62
|
+
* the best surviving row.
|
|
63
|
+
*
|
|
64
|
+
* Relative because a fused rank has no absolute meaning to threshold against —
|
|
65
|
+
* it depends on how many arms returned the row and how deep each went, so the
|
|
66
|
+
* old `score >= 0.35` constant would pass everything or nothing depending on
|
|
67
|
+
* `limit`. Loose rather than tight: nothing qualified, so the operator is
|
|
68
|
+
* being offered guesses and deserves a few to choose between rather than one
|
|
69
|
+
* arbitrary winner. */
|
|
70
|
+
const WEAK_RELATIVE_FLOOR = 0.25;
|
|
71
|
+
/** Terms carrying no retrieval signal. Deliberately the closed class of English
|
|
72
|
+
* function words rather than a tuned list. Used by the ANY arm (ORed, `the`
|
|
73
|
+
* would match most of the corpus and swamp bm25) and by the coverage weight (a
|
|
74
|
+
* row earns no credit for containing `of`). The ALL arm keeps them: there every
|
|
75
|
+
* term is a *restriction*, so a stopword narrows correctly and costs nothing. */
|
|
76
|
+
const STOPWORDS = new Set(("a an and are as at be been being but by can could did do does doing done for from " +
|
|
77
|
+
"had has have having he her hers him his how i if in into is it its me my no nor not " +
|
|
78
|
+
"of on or our ours out over she should so than that the their theirs them then there " +
|
|
79
|
+
"these they this those to up us was we were what when where which while who whom why " +
|
|
80
|
+
"will with would you your yours").split(" "));
|
|
81
|
+
/** Does this term carry retrieval signal? */
|
|
82
|
+
function isContentTerm(term) {
|
|
83
|
+
return term.length > 2 && !STOPWORDS.has(term.toLowerCase());
|
|
84
|
+
}
|
|
85
|
+
const SCHEMA_SQL = `
|
|
86
|
+
-- Embedded chunks metadata
|
|
87
|
+
CREATE TABLE IF NOT EXISTS memory_chunks (
|
|
88
|
+
id TEXT PRIMARY KEY,
|
|
89
|
+
narrative_id TEXT NOT NULL,
|
|
90
|
+
seq INTEGER NOT NULL,
|
|
91
|
+
content TEXT NOT NULL,
|
|
92
|
+
time INTEGER NOT NULL
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
-- vec0 virtual table for vector KNN search (cosine distance)
|
|
96
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_vec USING vec0(
|
|
97
|
+
chunk_id TEXT PRIMARY KEY,
|
|
98
|
+
embedding float[${VECTOR_DIM}] distance_metric=cosine
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
-- FTS5 for keyword search
|
|
102
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS memory_fts USING fts5(
|
|
103
|
+
content,
|
|
104
|
+
content='memory_chunks',
|
|
105
|
+
content_rowid='rowid',
|
|
106
|
+
tokenize='porter unicode61'
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
-- Keep FTS in sync
|
|
110
|
+
CREATE TRIGGER IF NOT EXISTS memory_chunks_ai AFTER INSERT ON memory_chunks BEGIN
|
|
111
|
+
INSERT INTO memory_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
112
|
+
END;
|
|
113
|
+
|
|
114
|
+
CREATE TRIGGER IF NOT EXISTS memory_chunks_ad AFTER DELETE ON memory_chunks BEGIN
|
|
115
|
+
INSERT INTO memory_fts(memory_fts, rowid, content) VALUES('delete', old.rowid, old.content);
|
|
116
|
+
END;
|
|
117
|
+
|
|
118
|
+
CREATE TRIGGER IF NOT EXISTS memory_chunks_au AFTER UPDATE ON memory_chunks BEGIN
|
|
119
|
+
INSERT INTO memory_fts(memory_fts, rowid, content) VALUES('delete', old.rowid, old.content);
|
|
120
|
+
INSERT INTO memory_fts(rowid, content) VALUES (new.rowid, new.content);
|
|
121
|
+
END;
|
|
122
|
+
|
|
123
|
+
CREATE INDEX IF NOT EXISTS idx_memory_chunks_narrative ON memory_chunks(narrative_id);
|
|
124
|
+
|
|
125
|
+
-- Document frequency for IDF filtering
|
|
126
|
+
CREATE TABLE IF NOT EXISTS memory_content_df (
|
|
127
|
+
content_key TEXT PRIMARY KEY,
|
|
128
|
+
narrative_count INTEGER NOT NULL DEFAULT 1
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
-- Index cursors: tracks last indexed seq per narrative
|
|
132
|
+
CREATE TABLE IF NOT EXISTS memory_cursors (
|
|
133
|
+
narrative_id TEXT PRIMARY KEY,
|
|
134
|
+
last_seq INTEGER NOT NULL
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
-- Aggregate stats
|
|
138
|
+
CREATE TABLE IF NOT EXISTS memory_stats (
|
|
139
|
+
key TEXT PRIMARY KEY,
|
|
140
|
+
value INTEGER NOT NULL
|
|
141
|
+
);
|
|
142
|
+
`;
|
|
143
|
+
/** Load sqlite-vec extension and create memory tables. */
|
|
144
|
+
export function initMemorySchema(db) {
|
|
145
|
+
sqliteVec.load(db);
|
|
146
|
+
db.exec(SCHEMA_SQL);
|
|
147
|
+
}
|
|
148
|
+
/** Get the last indexed seq for a narrative (0 if never indexed). */
|
|
149
|
+
export function getLastIndexedSeq(db, narrativeId) {
|
|
150
|
+
const row = db
|
|
151
|
+
.prepare("SELECT last_seq FROM memory_cursors WHERE narrative_id = ?")
|
|
152
|
+
.get(narrativeId);
|
|
153
|
+
return row?.last_seq ?? 0;
|
|
154
|
+
}
|
|
155
|
+
/** Update the cursor after indexing. */
|
|
156
|
+
export function setLastIndexedSeq(db, narrativeId, seq) {
|
|
157
|
+
db.prepare("INSERT OR REPLACE INTO memory_cursors (narrative_id, last_seq) VALUES (?, ?)").run(narrativeId, seq);
|
|
158
|
+
}
|
|
159
|
+
/** Upsert embedded chunks into storage within a transaction. */
|
|
160
|
+
export function upsertChunks(db, chunks) {
|
|
161
|
+
if (chunks.length === 0)
|
|
162
|
+
return;
|
|
163
|
+
const insertChunk = db.prepare(`INSERT OR REPLACE INTO memory_chunks (id, narrative_id, seq, content, time)
|
|
164
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
165
|
+
const deleteVec = db.prepare("DELETE FROM memory_vec WHERE chunk_id = ?");
|
|
166
|
+
const insertVec = db.prepare("INSERT INTO memory_vec (chunk_id, embedding) VALUES (?, ?)");
|
|
167
|
+
const upsertDf = db.prepare(`INSERT INTO memory_content_df (content_key, narrative_count)
|
|
168
|
+
VALUES (?, 1)
|
|
169
|
+
ON CONFLICT(content_key) DO UPDATE SET narrative_count = narrative_count + 1`);
|
|
170
|
+
const seen = new Set();
|
|
171
|
+
const tx = db.transaction(() => {
|
|
172
|
+
for (const chunk of chunks) {
|
|
173
|
+
insertChunk.run(chunk.id, chunk.narrative_id, chunk.seq, chunk.content, chunk.time);
|
|
174
|
+
deleteVec.run(chunk.id);
|
|
175
|
+
insertVec.run(chunk.id, vecToBuffer(chunk.vector));
|
|
176
|
+
// Track DF per unique (content_key, narrative_id)
|
|
177
|
+
const key = normalizeForDf(chunk.content);
|
|
178
|
+
const pair = `${key}\0${chunk.narrative_id}`;
|
|
179
|
+
if (!seen.has(pair)) {
|
|
180
|
+
seen.add(pair);
|
|
181
|
+
upsertDf.run(key);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// Update total narratives count
|
|
185
|
+
const total = db
|
|
186
|
+
.prepare("SELECT COUNT(DISTINCT narrative_id) FROM memory_chunks")
|
|
187
|
+
.get();
|
|
188
|
+
db.prepare("INSERT OR REPLACE INTO memory_stats (key, value) VALUES ('total_narratives', ?)").run(total["COUNT(DISTINCT narrative_id)"]);
|
|
189
|
+
});
|
|
190
|
+
tx();
|
|
191
|
+
}
|
|
192
|
+
/** Hybrid search: three ranked arms fused by reciprocal rank, then weighted by
|
|
193
|
+
* how much of the query each row actually contains.
|
|
194
|
+
* When braneIds is provided, results are scoped to narratives in those branes.
|
|
195
|
+
*
|
|
196
|
+
* Quoted phrases in `queryText` are **requirements** — every phrase atom must
|
|
197
|
+
* appear in a row's content, and this is the one filter permitted to return
|
|
198
|
+
* nothing. Everything else is ranking: see {@link MIN_SEMANTIC_SCORE} for the
|
|
199
|
+
* relevance floor, which by contrast never empties a result set.
|
|
200
|
+
*
|
|
201
|
+
* See {@link RRF_K} for why the arms are fused by rank rather than by score,
|
|
202
|
+
* and {@link ARM_WEIGHTS} for what each arm is for. */
|
|
203
|
+
export function searchHybrid(db, queryVector, queryText, limit, braneIds) {
|
|
204
|
+
// Build brane filter set if scoping is requested
|
|
205
|
+
let braneNids;
|
|
206
|
+
if (braneIds && braneIds.length > 0) {
|
|
207
|
+
const placeholders = braneIds.map(() => "?").join(", ");
|
|
208
|
+
const rows = db
|
|
209
|
+
.prepare(`SELECT id FROM narratives WHERE brane_id IN (${placeholders})`)
|
|
210
|
+
.all(...braneIds);
|
|
211
|
+
braneNids = new Set(rows.map((r) => r.id));
|
|
212
|
+
}
|
|
213
|
+
// Fetch depth. Fusion only sees what each arm returned, so a candidate the
|
|
214
|
+
// vector arm ranked 60th can still win on lexical evidence — but only if it
|
|
215
|
+
// was fetched. The old `max(limit * 4, 20)` was far too shallow: a long exact
|
|
216
|
+
// match is routinely outside the top 40 neighbours of its own quotation.
|
|
217
|
+
const fetchLimit = Math.max(limit * (braneNids ? 20 : 10), CANDIDATE_FLOOR);
|
|
218
|
+
const tokens = tokenizeQuery(queryText);
|
|
219
|
+
const phraseNeedles = tokens
|
|
220
|
+
.filter((t) => t.kind === "phrase")
|
|
221
|
+
.map((t) => t.text.toLowerCase());
|
|
222
|
+
const termNeedles = tokens
|
|
223
|
+
.filter((t) => t.kind === "term")
|
|
224
|
+
.map((t) => t.text.toLowerCase());
|
|
225
|
+
// Terms that carry retrieval signal. Stopwords are dropped for the ANY arm
|
|
226
|
+
// (ORed, they would match most of the corpus and swamp bm25) and for the
|
|
227
|
+
// coverage weight (a row shouldn't earn credit for containing "the").
|
|
228
|
+
const contentNeedles = termNeedles.filter(isContentTerm);
|
|
229
|
+
// ── Three arms, each a ranked list ────────────────────────────────────────
|
|
230
|
+
//
|
|
231
|
+
// ALL and ANY are the same index queried two ways, and they fail in opposite
|
|
232
|
+
// directions: ALL (FTS5 implicit AND) is precise but returns *nothing* for a
|
|
233
|
+
// conversational question, since no chunk contains all thirteen words of it;
|
|
234
|
+
// ANY always returns something but ranks loosely. Running both means a query
|
|
235
|
+
// is never silently unanswerable, and agreement between them is itself signal
|
|
236
|
+
// — a row that both arms rank highly collects from both.
|
|
237
|
+
const allRows = searchFts(db, prepareFtsQuery(queryText), fetchLimit);
|
|
238
|
+
const anyRows = searchFts(db, prepareFtsAnyQuery(queryText), fetchLimit);
|
|
239
|
+
const vecRows = searchVector(db, queryVector, fetchLimit);
|
|
240
|
+
// Rows the ALL arm matched — every query term present. That's the strong
|
|
241
|
+
// lexical evidence the relevance floor exempts.
|
|
242
|
+
const ftsHits = new Set(allRows.map((r) => `${r.narrative_id}:${r.seq}`));
|
|
243
|
+
// ── Reciprocal-rank fusion ────────────────────────────────────────────────
|
|
244
|
+
const merged = new Map();
|
|
245
|
+
const contribute = (rows, weight) => {
|
|
246
|
+
rows.forEach((row, i) => {
|
|
247
|
+
const key = `${row.narrative_id}:${row.seq}`;
|
|
248
|
+
let entry = merged.get(key);
|
|
249
|
+
if (!entry) {
|
|
250
|
+
entry = {
|
|
251
|
+
narrative_id: row.narrative_id,
|
|
252
|
+
seq: row.seq,
|
|
253
|
+
content: row.content,
|
|
254
|
+
score: 0,
|
|
255
|
+
};
|
|
256
|
+
merged.set(key, entry);
|
|
257
|
+
}
|
|
258
|
+
entry.score += weight / (RRF_K + i + 1);
|
|
259
|
+
// Keep each arm's own measurement intact beside the fused rank: the floor
|
|
260
|
+
// reads `semantic`, and a caller can tell a keyword hit from a guess.
|
|
261
|
+
if (row.semantic !== undefined)
|
|
262
|
+
entry.semantic = row.semantic;
|
|
263
|
+
if (row.lexical !== undefined) {
|
|
264
|
+
entry.lexical = Math.max(entry.lexical ?? 0, row.lexical);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
};
|
|
268
|
+
contribute(allRows, ARM_WEIGHTS.all);
|
|
269
|
+
contribute(anyRows, ARM_WEIGHTS.any);
|
|
270
|
+
contribute(vecRows, ARM_WEIGHTS.vector);
|
|
271
|
+
// A quoted phrase is a requirement, not a preference. FTS5 already enforces
|
|
272
|
+
// it on its own arm, but the vector arm runs independently and unions in
|
|
273
|
+
// rows that never contained the phrase — so quoting used to rank rather than
|
|
274
|
+
// restrict. Every phrase atom must be present: two quoted phrases narrow,
|
|
275
|
+
// they don't widen.
|
|
276
|
+
//
|
|
277
|
+
// This is the one filter allowed to return nothing. The relevance floor below
|
|
278
|
+
// deliberately never empties a result set, because a loosely-remembered query
|
|
279
|
+
// deserves best-effort results. Quoting is the opposite: an exact, unambiguous
|
|
280
|
+
// instruction, where answering with rows that don't contain the string is
|
|
281
|
+
// worse than answering with none.
|
|
282
|
+
const results = phraseNeedles.length > 0
|
|
283
|
+
? [...merged.values()].filter((r) => {
|
|
284
|
+
const lower = r.content.toLowerCase();
|
|
285
|
+
return phraseNeedles.every((p) => lower.includes(p));
|
|
286
|
+
})
|
|
287
|
+
: [...merged.values()];
|
|
288
|
+
for (const result of results) {
|
|
289
|
+
const lower = result.content.toLowerCase();
|
|
290
|
+
const phraseHits = phraseNeedles.filter((p) => lower.includes(p)).length;
|
|
291
|
+
const termHits = contentNeedles.filter((t) => lower.includes(t)).length;
|
|
292
|
+
// Coverage, not presence. The old rule added a flat +0.5 for *any* single
|
|
293
|
+
// term appearing, so a row with one of eight query words scored exactly
|
|
294
|
+
// like a row with all eight — which is how "Restart* daemon" came to
|
|
295
|
+
// outrank the only chunk in the corpus containing the whole sentence.
|
|
296
|
+
const termCoverage = contentNeedles.length
|
|
297
|
+
? termHits / contentNeedles.length
|
|
298
|
+
: 0;
|
|
299
|
+
const phraseCoverage = phraseNeedles.length
|
|
300
|
+
? phraseHits / phraseNeedles.length
|
|
301
|
+
: 0;
|
|
302
|
+
result.score *=
|
|
303
|
+
1 + COVERAGE_WEIGHT * termCoverage + PHRASE_COVERAGE_WEIGHT * phraseCoverage;
|
|
304
|
+
if (phraseHits > 0) {
|
|
305
|
+
result.match = "phrase";
|
|
306
|
+
}
|
|
307
|
+
else if (ftsHits.has(`${result.narrative_id}:${result.seq}`)) {
|
|
308
|
+
// Every query term matched.
|
|
309
|
+
result.match = "keyword";
|
|
310
|
+
}
|
|
311
|
+
else if (termHits >= MIN_TERM_HITS) {
|
|
312
|
+
// Not every term, but more than one — which is lexical evidence rather
|
|
313
|
+
// than an incidental word, and what carries a paraphrased question where
|
|
314
|
+
// no chunk contains the whole sentence and the ALL arm comes back empty.
|
|
315
|
+
//
|
|
316
|
+
// Deliberately a *count*, not a fraction. A fraction punishes long
|
|
317
|
+
// queries for being long: the narration this rewrite was tested against
|
|
318
|
+
// matches 2 of the 8 content terms in "can we remove unused code from the
|
|
319
|
+
// bundle to shrink the machine learning runtime" — real evidence, and a
|
|
320
|
+
// 50% coverage gate discarded it while admitting a 2-of-3 coincidence.
|
|
321
|
+
// Coverage still *ranks* (see COVERAGE_WEIGHT); it no longer gates.
|
|
322
|
+
result.match = "keyword";
|
|
323
|
+
}
|
|
324
|
+
// Rows with no lexical evidence at all still qualify on similarity.
|
|
325
|
+
if (!result.match && (result.semantic ?? 0) >= MIN_SEMANTIC_SCORE) {
|
|
326
|
+
result.match = "semantic";
|
|
327
|
+
}
|
|
328
|
+
// Length penalty, for rows with nothing lexical to stand on.
|
|
329
|
+
//
|
|
330
|
+
// Gated on lexical evidence, not on `match`. Those differ for exactly the
|
|
331
|
+
// rows this exists to catch: a two-character chunk sitting close to the
|
|
332
|
+
// query in embedding space clears MIN_SEMANTIC_SCORE, gets tagged
|
|
333
|
+
// `semantic`, and would sail past a `!result.match` guard — which is how
|
|
334
|
+
// "Restart* daemon" outranked a paragraph. Embedding similarity to a very
|
|
335
|
+
// short string is cheap; it is not evidence that the string answers
|
|
336
|
+
// anything. (It used to sit in the same `else` as the keyword boost, so a
|
|
337
|
+
// short chunk holding one query term skipped the penalty *and* took the
|
|
338
|
+
// boost — the two halves of the same bug.)
|
|
339
|
+
if (phraseHits === 0 && termHits === 0) {
|
|
340
|
+
result.score *= Math.min(1.0, Math.max(0.5, result.content.length / 50));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
// Apply brane scope filter
|
|
344
|
+
const scoped = braneNids
|
|
345
|
+
? results.filter((r) => braneNids.has(r.narrative_id))
|
|
346
|
+
: results;
|
|
347
|
+
scoped.sort((a, b) => b.score - a.score);
|
|
348
|
+
// Relevance floor, applied *relatively* so it can never empty a result set.
|
|
349
|
+
//
|
|
350
|
+
// A row qualifies on lexical evidence (an exact phrase, or an FTS hit — which
|
|
351
|
+
// means every query term matched) or on embedding similarity clearing
|
|
352
|
+
// MIN_SEMANTIC_SCORE. Rows with neither are the noise this exists to drop: a
|
|
353
|
+
// single common word co-occurring in unrelated text, boosted above genuinely
|
|
354
|
+
// relevant matches.
|
|
355
|
+
//
|
|
356
|
+
// But dropping them is only safe when something better takes their place. If
|
|
357
|
+
// *nothing* qualifies, the query is loosely-remembered rather than off-topic,
|
|
358
|
+
// and an empty page is the worse answer — so the unqualified rows come back
|
|
359
|
+
// marked `weak`. The guarantee that buys: a result is only ever dropped when a
|
|
360
|
+
// better-qualified one survived to replace it.
|
|
361
|
+
const qualified = scoped.filter((r) => r.match !== undefined);
|
|
362
|
+
if (qualified.length > 0)
|
|
363
|
+
return qualified.slice(0, limit);
|
|
364
|
+
// Nothing qualified. The surviving rows are ranked by fused position, and a
|
|
365
|
+
// fused score has no absolute meaning to threshold against — it depends on
|
|
366
|
+
// how many arms returned the row and how deep they went. So the fallback is
|
|
367
|
+
// relative: keep what is close to the best of a bad lot, rather than testing
|
|
368
|
+
// against a constant that would pass everything or nothing as `limit` moves.
|
|
369
|
+
const best = scoped[0]?.score ?? 0;
|
|
370
|
+
return scoped
|
|
371
|
+
.filter((r) => r.score >= best * WEAK_RELATIVE_FLOOR)
|
|
372
|
+
.slice(0, limit)
|
|
373
|
+
.map((r) => ({ ...r, match: "weak" }));
|
|
374
|
+
}
|
|
375
|
+
function searchVector(db, queryVector, limit) {
|
|
376
|
+
const totalNarratives = getTotalNarratives(db);
|
|
377
|
+
const dfMap = loadDfMap(db);
|
|
378
|
+
const rows = db
|
|
379
|
+
.prepare(`SELECT v.chunk_id, v.distance, c.narrative_id, c.seq, c.content
|
|
380
|
+
FROM memory_vec v
|
|
381
|
+
JOIN memory_chunks c ON c.id = v.chunk_id
|
|
382
|
+
WHERE v.embedding MATCH ? AND k = ?
|
|
383
|
+
ORDER BY v.distance`)
|
|
384
|
+
.all(vecToBuffer(queryVector), limit);
|
|
385
|
+
return rows.map((row) => {
|
|
386
|
+
const rawScore = Math.max(0, 1.0 - row.distance);
|
|
387
|
+
const contentKey = normalizeForDf(row.content);
|
|
388
|
+
const df = dfMap.get(contentKey) ?? 1;
|
|
389
|
+
const idf = idfWeight(df, totalNarratives);
|
|
390
|
+
return {
|
|
391
|
+
narrative_id: row.narrative_id,
|
|
392
|
+
seq: row.seq,
|
|
393
|
+
content: row.content,
|
|
394
|
+
score: rawScore * idf,
|
|
395
|
+
// Kept unweighted: IDF is a ranking adjustment for how *common* a chunk's
|
|
396
|
+
// text is across narratives, which says nothing about whether this chunk
|
|
397
|
+
// means what the query means. The floor needs the latter.
|
|
398
|
+
semantic: rawScore,
|
|
399
|
+
};
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
/** Map FTS5's bm25 (negative, more negative = better) onto 0–1 without ever
|
|
403
|
+
* reaching either end.
|
|
404
|
+
*
|
|
405
|
+
* The previous form, `min(1, -bm25/10)`, clipped: on a real corpus, matches
|
|
406
|
+
* from -10 through -20 all came back as exactly 1.0, so a tight hit in a short
|
|
407
|
+
* chunk and an incidental all-terms hit in a 6,000-character essay were
|
|
408
|
+
* indistinguishable by the time anything downstream saw them. bm25 already
|
|
409
|
+
* discounts for document length — that's the signal that separates the two —
|
|
410
|
+
* and clipping discarded it.
|
|
411
|
+
*
|
|
412
|
+
* This rational curve is monotonic over the whole range, so ordering survives
|
|
413
|
+
* into the fused score. HALF is the bm25 magnitude that maps to 0.5; above it
|
|
414
|
+
* the curve flattens without ever saturating. */
|
|
415
|
+
const BM25_HALF = 10;
|
|
416
|
+
function normalizeBm25(bm25) {
|
|
417
|
+
const strength = Math.max(0, -bm25);
|
|
418
|
+
return strength / (strength + BM25_HALF);
|
|
419
|
+
}
|
|
420
|
+
/** Run one FTS5 arm. Takes an already-prepared MATCH expression rather than raw
|
|
421
|
+
* text, because the same index is queried two ways — see
|
|
422
|
+
* {@link prepareFtsQuery} (all terms) and {@link prepareFtsAnyQuery} (any). */
|
|
423
|
+
function searchFts(db, ftsQuery, limit) {
|
|
424
|
+
if (!ftsQuery)
|
|
425
|
+
return [];
|
|
426
|
+
try {
|
|
427
|
+
const rows = db
|
|
428
|
+
.prepare(`SELECT c.narrative_id, c.seq, c.content, bm25(memory_fts) as bm25_score
|
|
429
|
+
FROM memory_fts
|
|
430
|
+
JOIN memory_chunks c ON memory_fts.rowid = c.rowid
|
|
431
|
+
WHERE memory_fts MATCH ?
|
|
432
|
+
ORDER BY bm25_score
|
|
433
|
+
LIMIT ?`)
|
|
434
|
+
.all(ftsQuery, limit);
|
|
435
|
+
return rows.map((row) => ({
|
|
436
|
+
narrative_id: row.narrative_id,
|
|
437
|
+
seq: row.seq,
|
|
438
|
+
content: row.content,
|
|
439
|
+
score: normalizeBm25(row.bm25_score),
|
|
440
|
+
lexical: normalizeBm25(row.bm25_score),
|
|
441
|
+
}));
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
// FTS query might fail on special chars
|
|
445
|
+
return [];
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function getTotalNarratives(db) {
|
|
449
|
+
const row = db
|
|
450
|
+
.prepare("SELECT value FROM memory_stats WHERE key = 'total_narratives'")
|
|
451
|
+
.get();
|
|
452
|
+
return row?.value ?? 1;
|
|
453
|
+
}
|
|
454
|
+
function loadDfMap(db) {
|
|
455
|
+
const rows = db
|
|
456
|
+
.prepare("SELECT content_key, narrative_count FROM memory_content_df")
|
|
457
|
+
.all();
|
|
458
|
+
return new Map(rows.map((r) => [r.content_key, r.narrative_count]));
|
|
459
|
+
}
|
|
460
|
+
function normalizeForDf(content) {
|
|
461
|
+
return content.toLowerCase().split(/\s+/).join(" ");
|
|
462
|
+
}
|
|
463
|
+
function idfWeight(df, totalDocs) {
|
|
464
|
+
if (totalDocs === 0 || df === 0)
|
|
465
|
+
return 1.0;
|
|
466
|
+
const ratio = Math.log(1.0 + totalDocs / df);
|
|
467
|
+
const maxRatio = Math.log(1.0 + totalDocs);
|
|
468
|
+
return ratio / maxRatio;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Phrase delimiter pairs: opener → closer.
|
|
472
|
+
*
|
|
473
|
+
* - ASCII `"…"` — straight double quote, paired with itself.
|
|
474
|
+
* - `"…"` (U+201C/U+201D) — typographic double quote, e.g. iOS smart-quote
|
|
475
|
+
* substitution.
|
|
476
|
+
* - `'…'` (U+2018/U+2019) — typographic single quote, ditto.
|
|
477
|
+
*
|
|
478
|
+
* ASCII `'` is deliberately **not** a delimiter: it appears inside common
|
|
479
|
+
* English contractions (`don't`, `it's`) and treating it as a phrase
|
|
480
|
+
* boundary would shred bare-token queries that contain apostrophes.
|
|
481
|
+
*/
|
|
482
|
+
const PHRASE_DELIMITERS = {
|
|
483
|
+
'"': '"',
|
|
484
|
+
"“": "”",
|
|
485
|
+
"‘": "’",
|
|
486
|
+
};
|
|
487
|
+
/** A character that opens (or in the case of self-paired `"`, also closes) a phrase. */
|
|
488
|
+
function isPhraseBoundary(c) {
|
|
489
|
+
return c in PHRASE_DELIMITERS || c === "”" || c === "’";
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Tokenize a user query into phrase and term atoms.
|
|
493
|
+
*
|
|
494
|
+
* Recognizes the quote pairs in {@link PHRASE_DELIMITERS} for phrases.
|
|
495
|
+
* Phrase contents are preserved verbatim (whitespace inside the phrase
|
|
496
|
+
* is internal-tokenizer business). Bare terms shorter than 2 chars are
|
|
497
|
+
* dropped as noise; tokens inside a phrase are kept regardless of length
|
|
498
|
+
* since the operator quoted them deliberately. Unterminated phrases
|
|
499
|
+
* (opener with no matching closer) are read to end-of-input — lenient
|
|
500
|
+
* on operator typos.
|
|
501
|
+
*
|
|
502
|
+
* There is no escape mechanism for the closer inside a phrase: the
|
|
503
|
+
* first matching closer ends the phrase.
|
|
504
|
+
*/
|
|
505
|
+
export function tokenizeQuery(query) {
|
|
506
|
+
const tokens = [];
|
|
507
|
+
const len = query.length;
|
|
508
|
+
let i = 0;
|
|
509
|
+
while (i < len) {
|
|
510
|
+
while (i < len && /\s/.test(query[i]))
|
|
511
|
+
i++;
|
|
512
|
+
if (i >= len)
|
|
513
|
+
break;
|
|
514
|
+
const closer = PHRASE_DELIMITERS[query[i]];
|
|
515
|
+
if (closer !== undefined) {
|
|
516
|
+
i++;
|
|
517
|
+
const start = i;
|
|
518
|
+
while (i < len && query[i] !== closer)
|
|
519
|
+
i++;
|
|
520
|
+
const text = query.slice(start, i).trim();
|
|
521
|
+
if (i < len)
|
|
522
|
+
i++;
|
|
523
|
+
if (text)
|
|
524
|
+
tokens.push({ kind: "phrase", text });
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
const start = i;
|
|
528
|
+
while (i < len && !/\s/.test(query[i]) && !isPhraseBoundary(query[i]))
|
|
529
|
+
i++;
|
|
530
|
+
const text = query.slice(start, i);
|
|
531
|
+
if (text.length >= 2)
|
|
532
|
+
tokens.push({ kind: "term", text });
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
return tokens;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Translate a user query into an FTS5 MATCH expression.
|
|
539
|
+
*
|
|
540
|
+
* Thin wrapper over {@link tokenizeQuery}: phrases become FTS5 phrase
|
|
541
|
+
* literals, terms become prefix-matched literals, all ANDed together.
|
|
542
|
+
* Returns null if the query yields no usable tokens.
|
|
543
|
+
*/
|
|
544
|
+
export function prepareFtsQuery(query) {
|
|
545
|
+
const tokens = tokenizeQuery(query);
|
|
546
|
+
if (tokens.length === 0)
|
|
547
|
+
return null;
|
|
548
|
+
// FTS5 phrase syntax always uses ASCII double quotes.
|
|
549
|
+
const parts = tokens.map((t) => t.kind === "phrase" ? `"${t.text}"` : `"${t.text}"*`);
|
|
550
|
+
return parts.join(" ");
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Translate a user query into an FTS5 MATCH expression that requires only
|
|
554
|
+
* *some* of it — the recall counterpart to {@link prepareFtsQuery}.
|
|
555
|
+
*
|
|
556
|
+
* The AND that `prepareFtsQuery` builds is right for a remembered fragment and
|
|
557
|
+
* catastrophic for a question. "which thread setting made embedding six times
|
|
558
|
+
* slower than the code it replaced" contains thirteen terms; no chunk in the
|
|
559
|
+
* archive contains all thirteen, so the strict arm returns **zero** candidates
|
|
560
|
+
* and the lexical half of a hybrid search silently switches itself off at
|
|
561
|
+
* exactly the moment a natural-language query needs it. Measured on this
|
|
562
|
+
* corpus, ORing the content terms instead put the intended event at rank 2.
|
|
563
|
+
*
|
|
564
|
+
* Quoted phrases stay conjunctive: quoting is an explicit instruction and
|
|
565
|
+
* {@link searchHybrid} enforces it as a hard filter regardless. Stopwords are
|
|
566
|
+
* dropped — ORed, `the` matches most of the archive and drowns bm25 in noise.
|
|
567
|
+
*
|
|
568
|
+
* Returns null when nothing usable survives, in which case the caller simply
|
|
569
|
+
* has no ANY arm — the strict arm and the vector arm still run.
|
|
570
|
+
*/
|
|
571
|
+
export function prepareFtsAnyQuery(query) {
|
|
572
|
+
const tokens = tokenizeQuery(query);
|
|
573
|
+
const phrases = tokens
|
|
574
|
+
.filter((t) => t.kind === "phrase")
|
|
575
|
+
.map((t) => `"${t.text}"`);
|
|
576
|
+
const terms = tokens
|
|
577
|
+
.filter((t) => t.kind === "term")
|
|
578
|
+
.filter((t) => isContentTerm(t.text))
|
|
579
|
+
.map((t) => `"${t.text}"*`);
|
|
580
|
+
// A single content term ORed with nothing is just that term — let the strict
|
|
581
|
+
// arm handle it rather than running an identical query twice.
|
|
582
|
+
if (terms.length < 2)
|
|
583
|
+
return phrases.length > 0 ? phrases.join(" ") : null;
|
|
584
|
+
const any = `(${terms.join(" OR ")})`;
|
|
585
|
+
return phrases.length > 0 ? `${phrases.join(" ")} ${any}` : any;
|
|
586
|
+
}
|
|
587
|
+
//# sourceMappingURL=storage.js.map
|