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,168 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createTestDb } from "./helpers.js";
4
+ import { initMemorySchema, prepareFtsAnyQuery, prepareFtsQuery, searchHybrid, upsertChunks, } from "../memory/storage.js";
5
+ // What this file pins is *ranking*, which had no test at all and so drifted
6
+ // until an exact quotation from a real message came back at rank 1 less than
7
+ // half the time. Measured on the operator's 43,876-chunk archive, the fusion
8
+ // this suite describes took that from 48% to 95%.
9
+ //
10
+ // Those corpus percentages can't live in a unit test — they move with the
11
+ // archive (the same probe read 52%, 47% and 37% across one afternoon as new
12
+ // events were indexed). So each case below is instead the *smallest corpus
13
+ // that reproduces one defect*, with vectors chosen by hand. A percentage tells
14
+ // you something regressed; these tell you which thing.
15
+ //
16
+ // Vectors are synthetic for the same reason search-floor.test.ts uses them: a
17
+ // real embedder won't give you the same similarity twice, and every case here
18
+ // turns on the vector arm being deliberately *wrong* — that's the situation the
19
+ // lexical arms exist to survive.
20
+ const DIM = 384;
21
+ /** A unit vector whose cosine similarity to {@link QUERY} is `target`. */
22
+ function withSimilarity(target, axis = 1) {
23
+ const v = new Float32Array(DIM);
24
+ v[0] = target;
25
+ v[axis] = Math.sqrt(Math.max(0, 1 - target * target));
26
+ return v;
27
+ }
28
+ const QUERY = withSimilarity(1);
29
+ function seed(rows) {
30
+ const db = createTestDb();
31
+ initMemorySchema(db);
32
+ db.prepare(`INSERT OR IGNORE INTO narratives (id, brane_id, agent_type, role, created_at, status)
33
+ VALUES ('claude-test', 'brane-test', 'claude', 'worker', 0, 'active')`).run();
34
+ upsertChunks(db, rows.map((r, i) => ({
35
+ id: `claude-test:${i + 1}`,
36
+ narrative_id: "claude-test",
37
+ seq: i + 1,
38
+ content: r.content,
39
+ time: i,
40
+ vector: withSimilarity(r.similarity, (i % (DIM - 1)) + 1),
41
+ })));
42
+ return db;
43
+ }
44
+ const contents = (db, query, limit = 10) => searchHybrid(db, QUERY, query, limit).map((r) => r.content);
45
+ describe("search fusion", () => {
46
+ // The case that motivated all of this. On the real archive, this query's only
47
+ // all-terms match in 43,876 chunks came back 11th, behind ten one-line
48
+ // operator signals that each contained the single word "daemon" and happened
49
+ // to sit near the query in embedding space.
50
+ //
51
+ // Two compounding causes, both reproduced here: a flat +0.5 for *any* term
52
+ // appearing (so 1-of-8 scored like 8-of-8), and score-blending two arms whose
53
+ // numbers aren't on the same scale (so the vector arm won by construction).
54
+ it("ranks the one chunk containing the whole query above topical one-liners", () => {
55
+ const db = seed([
56
+ {
57
+ content: "The self-inflicted instance restarted the daemon four times today, " +
58
+ "each time because a config write raced the supervisor's health check.",
59
+ similarity: 0.64, // *below* the noise: the vector arm is no help here
60
+ },
61
+ { content: "Daemon is restarted", similarity: 0.82 },
62
+ { content: "Restarting daemon now.", similarity: 0.79 },
63
+ { content: "Restart* daemon", similarity: 0.78 },
64
+ { content: "Now restart the daemon and test it yourself", similarity: 0.77 },
65
+ { content: "daemon restarted", similarity: 0.76 },
66
+ ]);
67
+ const top = contents(db, "self-inflicted instance restarted the daemon four times today")[0];
68
+ assert.match(top, /self-inflicted instance restarted/);
69
+ });
70
+ // Coverage has to *scale*, not merely exist. A row holding every query term
71
+ // must beat one holding a single term, however similar the latter looks.
72
+ it("scores partial keyword matches below complete ones", () => {
73
+ const db = seed([
74
+ { content: "one term only: embedding", similarity: 0.9 },
75
+ { content: "tokenizer embedding pooling normalization vectors", similarity: 0.5 },
76
+ ]);
77
+ assert.match(contents(db, "tokenizer embedding pooling normalization vectors")[0], /tokenizer embedding pooling/);
78
+ });
79
+ // The ANY arm's reason to exist. FTS5's implicit AND requires every term, so
80
+ // a natural-language question — which no single chunk ever contains in full —
81
+ // returned *zero* lexical candidates, silently disabling half of a "hybrid"
82
+ // search at precisely the moment it was asked a hard question.
83
+ it("finds the best partial match when no chunk contains every query term", () => {
84
+ const db = seed([
85
+ { content: "The intraOpNumThreads cap made embedding six times slower.", similarity: 0.6 },
86
+ { content: "Unrelated note about terminal cursor rendering.", similarity: 0.61 },
87
+ { content: "Another unrelated note about payload storage.", similarity: 0.62 },
88
+ ]);
89
+ const query = "which thread setting made embedding six times slower than before";
90
+ // Precondition, asserted rather than assumed: the strict arm really does
91
+ // come back empty here. If it ever stops doing so, this test is measuring
92
+ // something else and should be rewritten rather than silently passing.
93
+ const strict = db
94
+ .prepare("SELECT count(*) AS n FROM memory_fts WHERE memory_fts MATCH ?")
95
+ .get(prepareFtsQuery(query));
96
+ assert.equal(strict.n, 0);
97
+ assert.match(contents(db, query)[0], /intraOpNumThreads/);
98
+ });
99
+ // A quoted phrase is an instruction, not a preference — the one filter
100
+ // allowed to return nothing. The vector arm unions in rows independently, so
101
+ // without this it would rank rather than restrict.
102
+ it("treats a quoted phrase as a requirement", () => {
103
+ const db = seed([
104
+ { content: "the exact phrase we want lives here", similarity: 0.3 },
105
+ { content: "a much closer neighbour that lacks it", similarity: 0.99 },
106
+ ]);
107
+ const results = searchHybrid(db, QUERY, '"exact phrase we want"', 10);
108
+ assert.equal(results.length, 1);
109
+ assert.match(results[0].content, /exact phrase we want/);
110
+ });
111
+ // ...but only quoting may empty the page. A loosely-remembered query gets
112
+ // best-effort guesses, marked `weak`, rather than nothing.
113
+ it("never returns an empty page for an unquoted query", () => {
114
+ const db = seed([
115
+ { content: "something about terminals", similarity: 0.4 },
116
+ { content: "something about payloads", similarity: 0.39 },
117
+ ]);
118
+ const results = searchHybrid(db, QUERY, "zzqqxx blorptangle wibblefrotz", 10);
119
+ assert.ok(results.length > 0);
120
+ assert.ok(results.every((r) => r.match === "weak"));
121
+ });
122
+ // The length penalty used to sit in the same branch as the missing keyword
123
+ // boost, so a 15-character chunk holding one query word skipped the penalty
124
+ // *and* collected the bonus. It now applies to rows with no lexical evidence,
125
+ // which is the only place it was ever meant to.
126
+ it("discounts very short chunks that match nothing lexically", () => {
127
+ const db = seed([
128
+ { content: "ok", similarity: 0.88 },
129
+ {
130
+ content: "A full paragraph of genuinely relevant prose that does not repeat " +
131
+ "the query's vocabulary but is long enough to be a real answer.",
132
+ similarity: 0.86,
133
+ },
134
+ ]);
135
+ assert.match(contents(db, "some unrelated wording entirely")[0], /full paragraph/);
136
+ });
137
+ // A single rare identifier is the query most at risk from adding an OR arm:
138
+ // it must not get diluted by loosely-related rows.
139
+ it("keeps a rare identifier's exact match at rank 1", () => {
140
+ const db = seed([
141
+ { content: "Set intraOpNumThreads to 8; it saturates there.", similarity: 0.5 },
142
+ { content: "thread pool sizing notes", similarity: 0.9 },
143
+ { content: "more about threads and pools", similarity: 0.89 },
144
+ ]);
145
+ assert.match(contents(db, "intraOpNumThreads")[0], /intraOpNumThreads/);
146
+ });
147
+ });
148
+ describe("prepareFtsAnyQuery", () => {
149
+ it("ORs content terms and drops stopwords", () => {
150
+ const q = prepareFtsAnyQuery("can we remove the unused code from the bundle");
151
+ assert.equal(q, '("remove"* OR "unused"* OR "code"* OR "bundle"*)');
152
+ });
153
+ it("keeps quoted phrases conjunctive alongside the OR group", () => {
154
+ const q = prepareFtsAnyQuery('"exact thing" alpha beta');
155
+ assert.equal(q, '"exact thing" ("alpha"* OR "beta"*)');
156
+ });
157
+ // One term ORed with nothing is just that term — the strict arm already runs
158
+ // it, and a duplicate query is pure cost.
159
+ it("declines a single-term query, leaving it to the strict arm", () => {
160
+ assert.equal(prepareFtsAnyQuery("intraOpNumThreads"), null);
161
+ assert.equal(prepareFtsAnyQuery("the of and"), null);
162
+ });
163
+ it("returns null for an empty query", () => {
164
+ assert.equal(prepareFtsAnyQuery(""), null);
165
+ assert.equal(prepareFtsAnyQuery(" "), null);
166
+ });
167
+ });
168
+ //# sourceMappingURL=search-fusion.test.js.map
@@ -0,0 +1,205 @@
1
+ import { after, describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createTestDb } from "./helpers.js";
4
+ import { appendEvent, readEvents } from "../db/events.js";
5
+ import { initMemorySchema, prepareFtsQuery, searchHybrid, tokenizeQuery } from "../memory/storage.js";
6
+ import { runIndex, hasUnindexedEvents } from "../memory/index.js";
7
+ import { disposeEmbeddingModel } from "../memory/embeddings.js";
8
+ // The embedding pipeline runs in a worker_threads Worker that keeps the
9
+ // test process alive until terminated. Tear it down after this suite so
10
+ // `node --test` can exit cleanly.
11
+ after(async () => {
12
+ await disposeEmbeddingModel();
13
+ });
14
+ /** Create a test DB with migrations applied (adds archived column etc.) */
15
+ function createSearchTestDb() {
16
+ const db = createTestDb();
17
+ // Apply migrations that createTestDb doesn't run
18
+ const cols = db.pragma("table_info(narratives)");
19
+ const colNames = new Set(cols.map((c) => c.name));
20
+ if (!colNames.has("archived")) {
21
+ db.exec("ALTER TABLE narratives ADD COLUMN archived INTEGER DEFAULT 0");
22
+ }
23
+ return db;
24
+ }
25
+ function insertNarrative(db, id, opts = {}) {
26
+ const hasArchived = db.pragma("table_info(narratives)")
27
+ .some((c) => c.name === "archived");
28
+ if (hasArchived) {
29
+ db.prepare(`INSERT INTO narratives (id, brane_id, agent_type, role, status, summary, archived, created_at)
30
+ VALUES (?, 'brane-test', ?, ?, ?, ?, ?, ?)`).run(id, opts.agent_type ?? "claude", opts.role ?? "worker", opts.status ?? "active", opts.summary ?? null, opts.archived ? 1 : 0, Date.now());
31
+ }
32
+ else {
33
+ db.prepare(`INSERT INTO narratives (id, brane_id, agent_type, role, status, summary, created_at)
34
+ VALUES (?, 'brane-test', ?, ?, ?, ?, ?)`).run(id, opts.agent_type ?? "claude", opts.role ?? "worker", opts.status ?? "active", opts.summary ?? null, Date.now());
35
+ }
36
+ }
37
+ describe("events endpoint limit with after", () => {
38
+ it("readEvents with after returns all events after cursor", () => {
39
+ const db = createSearchTestDb();
40
+ insertNarrative(db, "n1");
41
+ for (let i = 0; i < 20; i++) {
42
+ appendEvent(db, "n1", "narration", { content: `msg-${i}` });
43
+ }
44
+ // Without limit, returns all 15 events after seq 5
45
+ const all = readEvents(db, "n1", { after: 5 });
46
+ assert.equal(all.length, 15);
47
+ });
48
+ it("readEvents with after + tail caps results", () => {
49
+ const db = createSearchTestDb();
50
+ insertNarrative(db, "n1");
51
+ for (let i = 0; i < 20; i++) {
52
+ appendEvent(db, "n1", "narration", { content: `msg-${i}` });
53
+ }
54
+ // With tail, returns last N events after cursor
55
+ const capped = readEvents(db, "n1", { after: 5, tail: 5 });
56
+ assert.equal(capped.length, 5);
57
+ // Should be the last 5 events (seq 16-20)
58
+ assert.equal(capped[0].seq, 16);
59
+ assert.equal(capped[4].seq, 20);
60
+ });
61
+ it("readEvents with after + before provides a window", () => {
62
+ const db = createSearchTestDb();
63
+ insertNarrative(db, "n1");
64
+ for (let i = 0; i < 20; i++) {
65
+ appendEvent(db, "n1", "narration", { content: `msg-${i}` });
66
+ }
67
+ // Window: events with 5 < seq < 15
68
+ const windowed = readEvents(db, "n1", { after: 5, before: 15 });
69
+ assert.equal(windowed.length, 9); // seq 6-14
70
+ assert.equal(windowed[0].seq, 6);
71
+ assert.equal(windowed[8].seq, 14);
72
+ });
73
+ });
74
+ describe("tokenizeQuery", () => {
75
+ it("returns terms for bare tokens", () => {
76
+ assert.deepEqual(tokenizeQuery("foo bar"), [
77
+ { kind: "term", text: "foo" },
78
+ { kind: "term", text: "bar" },
79
+ ]);
80
+ });
81
+ it("returns a phrase for a quoted span", () => {
82
+ assert.deepEqual(tokenizeQuery('"foo bar"'), [
83
+ { kind: "phrase", text: "foo bar" },
84
+ ]);
85
+ });
86
+ it("mixes phrase and term tokens preserving order", () => {
87
+ assert.deepEqual(tokenizeQuery('"foo bar" baz'), [
88
+ { kind: "phrase", text: "foo bar" },
89
+ { kind: "term", text: "baz" },
90
+ ]);
91
+ });
92
+ it("recognizes smart-quote phrase pairs", () => {
93
+ assert.deepEqual(tokenizeQuery("“foo bar” ‘baz qux’"), [
94
+ { kind: "phrase", text: "foo bar" },
95
+ { kind: "phrase", text: "baz qux" },
96
+ ]);
97
+ });
98
+ it("returns empty array for empty input", () => {
99
+ assert.deepEqual(tokenizeQuery(""), []);
100
+ assert.deepEqual(tokenizeQuery(" "), []);
101
+ });
102
+ });
103
+ describe("prepareFtsQuery", () => {
104
+ it("bare tokens become AND of prefix-matched terms", () => {
105
+ assert.equal(prepareFtsQuery("foo bar"), '"foo"* "bar"*');
106
+ });
107
+ it("drops bare tokens shorter than 2 chars", () => {
108
+ assert.equal(prepareFtsQuery("a foo b"), '"foo"*');
109
+ });
110
+ it("returns null for empty or all-too-short input", () => {
111
+ assert.equal(prepareFtsQuery(""), null);
112
+ assert.equal(prepareFtsQuery(" "), null);
113
+ assert.equal(prepareFtsQuery("a b c"), null);
114
+ });
115
+ it("quoted phrase becomes an FTS5 phrase (no prefix)", () => {
116
+ assert.equal(prepareFtsQuery('"foo bar"'), '"foo bar"');
117
+ });
118
+ it("phrase + bare term ANDs both", () => {
119
+ assert.equal(prepareFtsQuery('"foo bar" baz'), '"foo bar" "baz"*');
120
+ });
121
+ it("phrase preserves short tokens that bare-token rules would drop", () => {
122
+ assert.equal(prepareFtsQuery('"a b"'), '"a b"');
123
+ });
124
+ it("unterminated phrase reads to end-of-input", () => {
125
+ assert.equal(prepareFtsQuery('foo "bar baz'), '"foo"* "bar baz"');
126
+ });
127
+ it("empty quoted span is dropped", () => {
128
+ assert.equal(prepareFtsQuery('"" foo'), '"foo"*');
129
+ });
130
+ it("recognizes smart double quotes as a phrase pair", () => {
131
+ assert.equal(prepareFtsQuery("“foo bar” baz"), '"foo bar" "baz"*');
132
+ });
133
+ it("recognizes smart single quotes as a phrase pair", () => {
134
+ assert.equal(prepareFtsQuery("‘foo bar’"), '"foo bar"');
135
+ });
136
+ it("does not treat ASCII apostrophe as a delimiter (preserves contractions)", () => {
137
+ assert.equal(prepareFtsQuery("don't stop"), `"don't"* "stop"*`);
138
+ });
139
+ it("smart-quote opener without matching closer reads to EOF", () => {
140
+ assert.equal(prepareFtsQuery("foo “bar baz"), '"foo"* "bar baz"');
141
+ });
142
+ it("treats `\"` as a phrase boundary even when adjacent (no escape syntax)", () => {
143
+ // `""hi""` is parsed as: empty phrase, then bare token `hi` (the trailing
144
+ // `"` ends the bare token), then another empty phrase. Empties drop out;
145
+ // `hi` is treated as a regular bare term.
146
+ assert.equal(prepareFtsQuery('""hi""'), '"hi"*');
147
+ });
148
+ });
149
+ describe("memory search", () => {
150
+ it("hasUnindexedEvents detects unindexed events", () => {
151
+ const db = createSearchTestDb();
152
+ initMemorySchema(db);
153
+ insertNarrative(db, "n1");
154
+ appendEvent(db, "n1", "narration", { content: "hello world" });
155
+ assert.equal(hasUnindexedEvents(db), true);
156
+ });
157
+ it("indexing and search finds matching content", async () => {
158
+ const db = createSearchTestDb();
159
+ initMemorySchema(db);
160
+ insertNarrative(db, "n1");
161
+ appendEvent(db, "n1", "narration", { content: "The mitochondria is the powerhouse of the cell" });
162
+ appendEvent(db, "n1", "narration", { content: "JavaScript is a programming language" });
163
+ await runIndex(db, false);
164
+ assert.equal(hasUnindexedEvents(db), false);
165
+ // Import embedText dynamically since it loads a model
166
+ const { embedText } = await import("../memory/embeddings.js");
167
+ const queryVector = await embedText("mitochondria cell biology");
168
+ const results = searchHybrid(db, queryVector, "mitochondria cell biology", 10);
169
+ assert.ok(results.length > 0, "Should find at least one result");
170
+ assert.ok(results[0].content.includes("mitochondria"), "Top result should be about mitochondria");
171
+ });
172
+ it("phrase match outranks bare-keyword match", async () => {
173
+ const db = createSearchTestDb();
174
+ initMemorySchema(db);
175
+ insertNarrative(db, "n-adj");
176
+ appendEvent(db, "n-adj", "narration", { content: "context compaction recovery happens after long sessions" });
177
+ insertNarrative(db, "n-split");
178
+ appendEvent(db, "n-split", "narration", { content: "context window state and unrelated compaction logic" });
179
+ await runIndex(db, false);
180
+ const { embedText } = await import("../memory/embeddings.js");
181
+ const queryVector = await embedText("context compaction");
182
+ const phrase = searchHybrid(db, queryVector, '"context compaction"', 10);
183
+ assert.ok(phrase.length > 0, "phrase query should return results");
184
+ const adj = phrase.find((r) => r.narrative_id === "n-adj");
185
+ const split = phrase.find((r) => r.narrative_id === "n-split");
186
+ assert.ok(adj, "phrase query should surface the adjacent-tokens narrative");
187
+ if (split) {
188
+ assert.ok(adj.score > split.score, `adjacent-phrase narrative should outrank split-tokens narrative ` +
189
+ `(adj=${adj.score}, split=${split.score})`);
190
+ }
191
+ });
192
+ it("search surfaces archived narratives", async () => {
193
+ const db = createSearchTestDb();
194
+ initMemorySchema(db);
195
+ insertNarrative(db, "n-archived", { archived: true, summary: "Old conversation" });
196
+ appendEvent(db, "n-archived", "narration", { content: "Quantum computing uses qubits for parallel computation" });
197
+ await runIndex(db, false);
198
+ const { embedText } = await import("../memory/embeddings.js");
199
+ const queryVector = await embedText("quantum computing qubits");
200
+ const results = searchHybrid(db, queryVector, "quantum computing qubits", 10);
201
+ assert.ok(results.length > 0, "Should find results from archived narrative");
202
+ assert.equal(results[0].narrative_id, "n-archived");
203
+ });
204
+ });
205
+ //# sourceMappingURL=search.test.js.map
@@ -0,0 +1,48 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolve } from "node:path";
4
+ describe("agent name validation", () => {
5
+ // Regex from doSpawn
6
+ const VALID_AGENT = /^[a-zA-Z0-9_-]+$/;
7
+ it("accepts valid agent names", () => {
8
+ for (const name of ["claude", "goose", "codex", "my-agent", "agent_v2"]) {
9
+ assert.ok(VALID_AGENT.test(name), `${name} should be valid`);
10
+ }
11
+ });
12
+ it("rejects shell metacharacters", () => {
13
+ for (const name of [
14
+ "agent; rm -rf /",
15
+ "$(whoami)",
16
+ "`id`",
17
+ "agent | cat /etc/passwd",
18
+ "../../../etc/passwd",
19
+ "agent name",
20
+ "agent\nnewline",
21
+ ]) {
22
+ assert.ok(!VALID_AGENT.test(name), `${name} should be rejected`);
23
+ }
24
+ });
25
+ });
26
+ describe("static file path traversal", () => {
27
+ const STATIC_DIR = "/app/expo/dist";
28
+ function isAllowed(urlPath) {
29
+ const filePath = resolve(STATIC_DIR, urlPath === "/" ? "index.html" : urlPath.replace(/^\//, ""));
30
+ return filePath === STATIC_DIR || filePath.startsWith(STATIC_DIR + "/");
31
+ }
32
+ it("allows normal paths", () => {
33
+ assert.ok(isAllowed("/"));
34
+ assert.ok(isAllowed("/index.html"));
35
+ assert.ok(isAllowed("/assets/main.js"));
36
+ });
37
+ it("blocks path traversal", () => {
38
+ assert.ok(!isAllowed("/../../../etc/passwd"));
39
+ assert.ok(!isAllowed("/.."));
40
+ });
41
+ it("blocks prefix collision", () => {
42
+ // If STATIC_DIR is /app/expo/dist, a path resolving to /app/expo/distevil should be blocked
43
+ const evil = resolve(STATIC_DIR, "../distevil/file.txt");
44
+ assert.ok(evil.startsWith(STATIC_DIR.slice(0, -1))); // would pass naive startsWith
45
+ assert.ok(!isAllowed("/../distevil/file.txt")); // but our check blocks it
46
+ });
47
+ });
48
+ //# sourceMappingURL=security.test.js.map
@@ -0,0 +1,61 @@
1
+ import { test, describe } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname, join } from "node:path";
6
+ // The chat's message selection lives in a store (shared/message-selection), not
7
+ // in the screen's React state, and the difference is invisible to every test
8
+ // that can run in node: both spellings select messages. What the store buys is
9
+ // two things a rendered test would have to catch — a tap that wakes only the
10
+ // bubble it changed, and two taps in the same frame that both land. The
11
+ // `useState` spelling failed the second outright: `handleTimestampPress` closed
12
+ // over `selectedSeqs`, so the second tap computed from the set the first had
13
+ // already replaced and silently undid it (observed in a headless browser: two
14
+ // rapid taps left one message selected).
15
+ //
16
+ // So this reads the source, the way key-routing.test.ts does, and holds the
17
+ // screen to the wiring.
18
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
19
+ const SCREEN = join("expo", "app", "[nid].tsx");
20
+ const read = (rel) => readFileSync(join(repoRoot, rel), "utf8");
21
+ // Lints source the tarball doesn't ship, so an installed package skips rather
22
+ // than failing on ENOENT (`brane doctor` runs this suite).
23
+ const skip = !existsSync(join(repoRoot, SCREEN)) ? "expo source not shipped" : false;
24
+ describe("the chat's message selection is a store, not screen state", () => {
25
+ test("the screen holds no selection state", { skip }, () => {
26
+ const src = read(SCREEN);
27
+ assert.ok(!/useState<Set<number>>/.test(src), "a Set in useState is the shape this replaced: it re-renders the screen, which rebuilds renderItem, which re-renders every bubble");
28
+ assert.match(src, /useMessageSelection\(/, "the screen creates the selection store");
29
+ });
30
+ test("the toggle closes over the store, not over the selection", { skip }, () => {
31
+ const src = read(SCREEN);
32
+ const press = src.match(/const handleTimestampPress = useCallback\([\s\S]*?\}, \[([^\]]*)\]\);/);
33
+ assert.ok(press, "handleTimestampPress must be a useCallback with a dependency list");
34
+ const deps = press[1].split(",").map((d) => d.trim()).filter(Boolean);
35
+ assert.deepEqual(deps.filter((d) => d !== "router" && d !== "selection"), [], "any dependency that changes with the selection rebuilds every bubble's callback prop");
36
+ });
37
+ test("renderItem hands the bubble the store, not a boolean", { skip }, () => {
38
+ const src = read(SCREEN);
39
+ assert.match(src, /selection=\{selection\}/, "the bubble subscribes for itself");
40
+ assert.ok(!/selected=\{[^}]*\.has\(/.test(src), "a computed `selected` prop puts the whole list back on the selection's re-render path");
41
+ });
42
+ test("the bubble subscribes to its own seq", { skip }, () => {
43
+ const src = read(SCREEN);
44
+ assert.match(src, /useIsSelected\(selection, item\.seq\)/);
45
+ });
46
+ test("a send consumes the selection through handleDeselect, URL included", { skip }, () => {
47
+ // `?scrollToSeq=` is what makes a selection survive a reload, so a send
48
+ // that clears only the store leaves the param behind: a reload, Isolate or
49
+ // Meld re-selects the message just answered, and the next signal silently
50
+ // carries the old `re` again. `handleDeselect` clears both.
51
+ const src = read(SCREEN);
52
+ const send = src.match(/const handleSend = useCallback\([\s\S]*?\n \}, \[[^\]]*\]\);/);
53
+ assert.ok(send, "handleSend must be a useCallback with a dependency list");
54
+ assert.match(send[0], /onDeselect\(\)/, "the send hands the clear to the screen's handleDeselect");
55
+ assert.ok(!/selection\.clear\(\)/.test(send[0]), "clearing the store alone strands ?scrollToSeq=");
56
+ assert.match(src, /<InputBar[\s\S]*?onDeselect=\{handleDeselect\}/, "the composer is given handleDeselect");
57
+ const deselect = src.match(/const handleDeselect = useCallback\([\s\S]*?\}, \[[^\]]*\]\);/);
58
+ assert.ok(deselect && /scrollToSeq: undefined/.test(deselect[0]), "handleDeselect clears the param");
59
+ });
60
+ });
61
+ //# sourceMappingURL=selection-wiring.test.js.map