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.
Files changed (352) hide show
  1. package/AGENTS.md +367 -0
  2. package/CHANGELOG.md +66 -0
  3. package/CONTRIBUTING.md +101 -0
  4. package/DESIGN.md +405 -0
  5. package/Dockerfile +61 -0
  6. package/LICENSE +656 -17
  7. package/README.md +174 -2
  8. package/SECURITY.md +213 -0
  9. package/dist/absorb/brane-id.js +11 -0
  10. package/dist/absorb/chatgpt.js +161 -0
  11. package/dist/absorb/claude-code.js +504 -0
  12. package/dist/absorb/index.js +543 -0
  13. package/dist/absorb/organelle.js +345 -0
  14. package/dist/absorb/run.js +520 -0
  15. package/dist/absorb/sources.js +168 -0
  16. package/dist/absorb/types.js +3 -0
  17. package/dist/agent-env.js +45 -0
  18. package/dist/agents.js +1168 -0
  19. package/dist/claude-live-session.js +124 -0
  20. package/dist/cli.js +50 -0
  21. package/dist/codex-tool-log.js +144 -0
  22. package/dist/commands/absorb.js +291 -0
  23. package/dist/commands/attach.js +31 -0
  24. package/dist/commands/certify.js +62 -0
  25. package/dist/commands/config.js +133 -0
  26. package/dist/commands/cost.js +126 -0
  27. package/dist/commands/daemon.js +350 -0
  28. package/dist/commands/doctor.js +262 -0
  29. package/dist/commands/fold.js +95 -0
  30. package/dist/commands/list.js +89 -0
  31. package/dist/commands/mcp.js +84 -0
  32. package/dist/commands/meld.js +360 -0
  33. package/dist/commands/memory.js +215 -0
  34. package/dist/commands/revive.js +74 -0
  35. package/dist/commands/run.js +85 -0
  36. package/dist/commands/spawn.js +1101 -0
  37. package/dist/commands/token.js +154 -0
  38. package/dist/copilot-tool-log.js +145 -0
  39. package/dist/cost/pricing.js +116 -0
  40. package/dist/cost/reprice.js +136 -0
  41. package/dist/crush-tool-log.js +293 -0
  42. package/dist/daemon/agent-scope.js +116 -0
  43. package/dist/daemon/awaiting.js +54 -0
  44. package/dist/daemon/cert-manager.js +349 -0
  45. package/dist/daemon/client.js +262 -0
  46. package/dist/daemon/gated-routes.js +112 -0
  47. package/dist/daemon/keep-awake.js +103 -0
  48. package/dist/daemon/local-socket.js +145 -0
  49. package/dist/daemon/presence-gate.js +206 -0
  50. package/dist/daemon/server.js +6353 -0
  51. package/dist/daemon/signal-source.js +35 -0
  52. package/dist/daemon/tailscale-rebind.js +58 -0
  53. package/dist/daemon/token-ring.js +263 -0
  54. package/dist/daemon/token-routes.js +190 -0
  55. package/dist/db/events.js +152 -0
  56. package/dist/db/mentions.js +179 -0
  57. package/dist/db/schema.js +643 -0
  58. package/dist/db/tokens.js +624 -0
  59. package/dist/db/tool-uses.js +647 -0
  60. package/dist/diagram/index.js +85 -0
  61. package/dist/diagram/label.js +455 -0
  62. package/dist/diagram/layout.js +476 -0
  63. package/dist/diagram/links.js +50 -0
  64. package/dist/diagram/metrics.js +152 -0
  65. package/dist/diagram/parse.js +487 -0
  66. package/dist/diagram/svg.js +389 -0
  67. package/dist/diagram/types.js +9 -0
  68. package/dist/export.js +629 -0
  69. package/dist/files/archive-types.js +2 -0
  70. package/dist/files/archive.js +182 -0
  71. package/dist/files/browse.js +327 -0
  72. package/dist/files/bytes.js +162 -0
  73. package/dist/files/codecs.js +33 -0
  74. package/dist/files/find.js +212 -0
  75. package/dist/files/tar.js +223 -0
  76. package/dist/files/zip.js +209 -0
  77. package/dist/folds.js +72 -0
  78. package/dist/grammars/ebnf.js +92 -0
  79. package/dist/highlight.js +250 -0
  80. package/dist/ids.js +24 -0
  81. package/dist/markdown-diagnostics.js +233 -0
  82. package/dist/math.js +168 -0
  83. package/dist/mcp/server.js +886 -0
  84. package/dist/memory/embedder.js +145 -0
  85. package/dist/memory/embedding-worker.js +71 -0
  86. package/dist/memory/embeddings.js +91 -0
  87. package/dist/memory/index.js +232 -0
  88. package/dist/memory/storage.js +587 -0
  89. package/dist/memory/wiki.js +688 -0
  90. package/dist/memory/wordpiece.js +247 -0
  91. package/dist/mentions-scan.js +40 -0
  92. package/dist/presence.js +259 -0
  93. package/dist/session-affinity.js +185 -0
  94. package/dist/shared/api-request.js +124 -0
  95. package/dist/shared/app-origin.js +26 -0
  96. package/dist/shared/block-glyphs.js +114 -0
  97. package/dist/shared/diagram-css.js +17 -0
  98. package/dist/shared/export-selection.js +101 -0
  99. package/dist/shared/find-in-text.js +110 -0
  100. package/dist/shared/glow-hues.js +225 -0
  101. package/dist/shared/hex-dump.js +62 -0
  102. package/dist/shared/hex-pages.js +52 -0
  103. package/dist/shared/hit-drum.js +79 -0
  104. package/dist/shared/known-nids.js +58 -0
  105. package/dist/shared/list-scroll-keys.js +56 -0
  106. package/dist/shared/markdown-lists.js +72 -0
  107. package/dist/shared/markdown-math.js +485 -0
  108. package/dist/shared/markdown-mentions.js +79 -0
  109. package/dist/shared/markdown-nids.js +41 -0
  110. package/dist/shared/markdown-protocols.js +53 -0
  111. package/dist/shared/markdown-refs.js +123 -0
  112. package/dist/shared/membrane-invasion.js +342 -0
  113. package/dist/shared/membrane-motion.js +83 -0
  114. package/dist/shared/membrane-palette.js +253 -0
  115. package/dist/shared/membrane-terrain.js +212 -0
  116. package/dist/shared/message-selection.js +123 -0
  117. package/dist/shared/mo-glyph.js +57 -0
  118. package/dist/shared/narrative-format.js +237 -0
  119. package/dist/shared/narrative-menu.js +84 -0
  120. package/dist/shared/narrative-sort.js +47 -0
  121. package/dist/shared/narrative-status.js +52 -0
  122. package/dist/shared/no-pan.js +85 -0
  123. package/dist/shared/pane-layout.js +50 -0
  124. package/dist/shared/payload-inline.js +83 -0
  125. package/dist/shared/speaker-color.js +61 -0
  126. package/dist/shared/token-rotation.js +14 -0
  127. package/dist/shared/view-url.js +216 -0
  128. package/dist/shared/wiki-links.js +70 -0
  129. package/dist/shared/wiki-window.js +4 -0
  130. package/dist/tailscale.js +55 -0
  131. package/dist/test/absorb-claude.test.js +892 -0
  132. package/dist/test/agent-scope.test.js +193 -0
  133. package/dist/test/agents.test.js +55 -0
  134. package/dist/test/api-request.test.js +122 -0
  135. package/dist/test/app-host-literal.test.js +53 -0
  136. package/dist/test/awaiting-glow.test.js +54 -0
  137. package/dist/test/awaiting.test.js +122 -0
  138. package/dist/test/bind-token.test.js +88 -0
  139. package/dist/test/block-glyphs.test.js +68 -0
  140. package/dist/test/cert-manager.test.js +262 -0
  141. package/dist/test/claude-live-session.test.js +110 -0
  142. package/dist/test/codex-tool-log.test.js +568 -0
  143. package/dist/test/config.test.js +71 -0
  144. package/dist/test/copilot-tool-log.test.js +93 -0
  145. package/dist/test/cors-preflight.test.js +56 -0
  146. package/dist/test/cost-linger.test.js +31 -0
  147. package/dist/test/cost-pricing.test.js +72 -0
  148. package/dist/test/cost-reprice.test.js +139 -0
  149. package/dist/test/cost-sessions.test.js +301 -0
  150. package/dist/test/crush-tool-log.test.js +289 -0
  151. package/dist/test/daemon-client.test.js +109 -0
  152. package/dist/test/daemon-socket.test.js +293 -0
  153. package/dist/test/diagram-arrows.test.js +205 -0
  154. package/dist/test/diagram-export.test.js +210 -0
  155. package/dist/test/diagram-labels.test.js +313 -0
  156. package/dist/test/diagram-parse.test.js +191 -0
  157. package/dist/test/diagram-render.test.js +175 -0
  158. package/dist/test/diagram-review.test.js +288 -0
  159. package/dist/test/diagram-self-loop-labels.test.js +303 -0
  160. package/dist/test/embedder.test.js +84 -0
  161. package/dist/test/events.test.js +123 -0
  162. package/dist/test/export-selection.test.js +102 -0
  163. package/dist/test/export.test.js +505 -0
  164. package/dist/test/files-browse.test.js +393 -0
  165. package/dist/test/files-find.test.js +272 -0
  166. package/dist/test/find-in-text.test.js +86 -0
  167. package/dist/test/first-paint-viewport.test.js +59 -0
  168. package/dist/test/fixtures/embedding-golden.js +23 -0
  169. package/dist/test/fixtures/wordpiece-golden.js +167 -0
  170. package/dist/test/glow-hues.test.js +202 -0
  171. package/dist/test/harness-cost.test.js +423 -0
  172. package/dist/test/helpers.js +21 -0
  173. package/dist/test/hex-pages.test.js +68 -0
  174. package/dist/test/highlight.test.js +69 -0
  175. package/dist/test/hit-drum.test.js +71 -0
  176. package/dist/test/hook-config.test.js +299 -0
  177. package/dist/test/json-gzip.test.js +70 -0
  178. package/dist/test/key-routing.test.js +58 -0
  179. package/dist/test/known-nids.test.js +106 -0
  180. package/dist/test/link-preview.test.js +67 -0
  181. package/dist/test/list-scroll-keys.test.js +66 -0
  182. package/dist/test/listen-own-words.test.js +147 -0
  183. package/dist/test/listen-timeout.test.js +33 -0
  184. package/dist/test/markdown-diagnostics.test.js +121 -0
  185. package/dist/test/markdown-lists.test.js +182 -0
  186. package/dist/test/markdown-math.test.js +256 -0
  187. package/dist/test/markdown-nids.test.js +146 -0
  188. package/dist/test/markdown-protocols.test.js +139 -0
  189. package/dist/test/mcp-bridge-cancel.test.js +98 -0
  190. package/dist/test/mcp-bridge-reconnect.test.js +121 -0
  191. package/dist/test/mcp-narrative.test.js +193 -0
  192. package/dist/test/mcp.test.js +548 -0
  193. package/dist/test/meld.test.js +143 -0
  194. package/dist/test/membrane-invasion.test.js +482 -0
  195. package/dist/test/membrane-motion.test.js +101 -0
  196. package/dist/test/membrane-palette.test.js +166 -0
  197. package/dist/test/membrane-terrain.test.js +170 -0
  198. package/dist/test/memory-log.test.js +202 -0
  199. package/dist/test/mentions-e2e.test.js +261 -0
  200. package/dist/test/mentions-ledger.test.js +265 -0
  201. package/dist/test/mentions-listen.test.js +265 -0
  202. package/dist/test/mentions.test.js +238 -0
  203. package/dist/test/message-selection.test.js +114 -0
  204. package/dist/test/mo-glyph.test.js +22 -0
  205. package/dist/test/model-resolution.test.js +166 -0
  206. package/dist/test/narrate-validate.test.js +77 -0
  207. package/dist/test/narrate-wait.test.js +160 -0
  208. package/dist/test/narrative-format.test.js +219 -0
  209. package/dist/test/narrative-menu.test.js +150 -0
  210. package/dist/test/narrative-sort.test.js +75 -0
  211. package/dist/test/no-pan.test.js +60 -0
  212. package/dist/test/pane-layout.test.js +153 -0
  213. package/dist/test/paths.test.js +37 -0
  214. package/dist/test/payload-inline.test.js +91 -0
  215. package/dist/test/precache-fonts.test.js +72 -0
  216. package/dist/test/presence-gate.test.js +440 -0
  217. package/dist/test/presence.test.js +160 -0
  218. package/dist/test/read-events.test.js +220 -0
  219. package/dist/test/remote-meld.test.js +128 -0
  220. package/dist/test/revive-cli.test.js +93 -0
  221. package/dist/test/search-floor.test.js +206 -0
  222. package/dist/test/search-fusion.test.js +168 -0
  223. package/dist/test/search.test.js +205 -0
  224. package/dist/test/security.test.js +48 -0
  225. package/dist/test/selection-wiring.test.js +61 -0
  226. package/dist/test/session-affinity.test.js +295 -0
  227. package/dist/test/signal-source.test.js +32 -0
  228. package/dist/test/socket-account.test.js +268 -0
  229. package/dist/test/spawn-toml.test.js +72 -0
  230. package/dist/test/speaker-color.test.js +90 -0
  231. package/dist/test/tailscale-rebind.test.js +79 -0
  232. package/dist/test/terminal-cursor.test.js +61 -0
  233. package/dist/test/terminal-resync.test.js +23 -0
  234. package/dist/test/terminal-size.test.js +36 -0
  235. package/dist/test/thin-cli.test.js +382 -0
  236. package/dist/test/token-bootstrap.test.js +272 -0
  237. package/dist/test/token-devices.test.js +646 -0
  238. package/dist/test/token-lapse.test.js +94 -0
  239. package/dist/test/token-ring.test.js +188 -0
  240. package/dist/test/token-rotate-cli.test.js +58 -0
  241. package/dist/test/token-routes.test.js +95 -0
  242. package/dist/test/tool-uses.test.js +1006 -0
  243. package/dist/test/usage-ledger.test.js +126 -0
  244. package/dist/test/view-url.test.js +627 -0
  245. package/dist/test/wiki-links.test.js +57 -0
  246. package/dist/test/wiki-sources.test.js +85 -0
  247. package/dist/test/wiki-window.test.js +31 -0
  248. package/dist/test/wiki.test.js +399 -0
  249. package/dist/test/wordpiece.test.js +68 -0
  250. package/dist/test/wrangler-route.test.js +27 -0
  251. package/dist/tls.js +148 -0
  252. package/dist/tmux.js +213 -0
  253. package/dist/user-agent.js +46 -0
  254. package/docker-entrypoint.sh +154 -0
  255. package/docs/README.md +3 -0
  256. package/docs/absorb.md +231 -0
  257. package/docs/cost-ledger.md +125 -0
  258. package/docs/daemon-api.md +189 -0
  259. package/docs/diagrams.md +163 -0
  260. package/docs/doctor.md +36 -0
  261. package/docs/export-scope.md +69 -0
  262. package/docs/files-view.md +133 -0
  263. package/docs/link-previews.md +38 -0
  264. package/docs/math.md +32 -0
  265. package/docs/mcp.md +69 -0
  266. package/docs/memory-search.md +75 -0
  267. package/docs/mentions.md +101 -0
  268. package/docs/multi-brane.md +14 -0
  269. package/docs/passkeys.md +165 -0
  270. package/docs/presence.md +202 -0
  271. package/docs/releasing.md +104 -0
  272. package/docs/rendering-warnings.md +22 -0
  273. package/docs/request-layer.md +72 -0
  274. package/docs/selection-store.md +50 -0
  275. package/docs/session-affinity.md +61 -0
  276. package/docs/terminal-mirroring.md +243 -0
  277. package/docs/token-rotation.md +431 -0
  278. package/docs/view-urls.md +134 -0
  279. package/docs/web-app.md +23 -0
  280. package/docs/wiki.md +43 -0
  281. package/expo/dist/_expo/static/js/web/entry-e94247f119fdcd3b424a1764233a41d2.js +1099 -0
  282. package/expo/dist/apple-touch-icon.png +0 -0
  283. package/expo/dist/favicon.svg +3 -0
  284. package/expo/dist/index.html +177 -0
  285. package/expo/dist/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  286. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  287. package/expo/dist/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  288. package/expo/dist/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  289. package/expo/dist/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  290. package/expo/dist/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  291. package/expo/dist/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  292. package/expo/dist/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  293. package/expo/dist/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  294. package/expo/dist/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  295. package/expo/dist/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  296. package/expo/dist/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  297. package/expo/dist/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  298. package/expo/dist/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  299. package/expo/dist/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  300. package/expo/dist/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  301. package/expo/dist/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  302. package/expo/dist/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  303. package/expo/dist/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  304. package/expo/dist/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  305. package/expo/dist/katex/katex.min.css +1 -0
  306. package/expo/dist/manifest.json +16 -0
  307. package/expo/dist/metadata.json +1 -0
  308. package/expo/dist/og/membrane.v1.png +0 -0
  309. package/expo/dist/pdf.min.mjs +28 -0
  310. package/expo/dist/pdf.worker.min.mjs +28 -0
  311. package/expo/dist/sw.js +154 -0
  312. package/package.json +79 -27
  313. package/protocols/LIVE_loop.md +407 -0
  314. package/protocols/bebrief.md +41 -0
  315. package/protocols/digest.md +231 -0
  316. package/protocols/fly.md +266 -0
  317. package/protocols/idle.md +81 -0
  318. package/protocols/keeper.md +829 -0
  319. package/protocols/markdown.md +80 -0
  320. package/protocols/memory.md +419 -0
  321. package/protocols/mentions.md +93 -0
  322. package/protocols/payloads.md +53 -0
  323. package/protocols/prelude.md +91 -0
  324. package/protocols/ramify.md +105 -0
  325. package/protocols/review.md +356 -0
  326. package/protocols/rrr.md +232 -0
  327. package/protocols/scribe.md +429 -0
  328. package/protocols/summarize.md +203 -0
  329. package/protocols/twin.md +174 -0
  330. package/protocols/wiki.md +438 -0
  331. package/protocols/worker.md +38 -0
  332. package/wiki/shared/append-only-event-log.md +148 -0
  333. package/wiki/shared/index.md +53 -0
  334. package/wiki/shared/owning-your-narratives.md +161 -0
  335. package/wiki/shared/premature-ontologies.md +435 -0
  336. package/wiki/shared/sedimentary-superintelligence.md +128 -0
  337. package/wiki/shared/sequestering-the-narrative.md +143 -0
  338. package/wiki/shared/why-wikis-work.md +216 -0
  339. package/.travis.yml +0 -7
  340. package/lib/brane.cjs.js +0 -12
  341. package/lib/brane.cjs.js.map +0 -1
  342. package/lib/brane.d.ts +0 -11
  343. package/lib/brane.js +0 -6
  344. package/lib/brane.js.map +0 -1
  345. package/lib/tests.d.ts +0 -1
  346. package/lib/tests.js +0 -8
  347. package/lib/tests.js.map +0 -1
  348. package/rollup.config.js +0 -21
  349. package/scripts/test.sh +0 -8
  350. package/src/brane.ts +0 -21
  351. package/src/tests.ts +0 -8
  352. 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