brane 0.0.2 → 0.3.0

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