brane 0.1.1 → 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 (348) 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 +79 -27
  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/.travis.yml +0 -7
  336. package/lib/brane.cjs.js +0 -12
  337. package/lib/brane.cjs.js.map +0 -1
  338. package/lib/brane.d.ts +0 -11
  339. package/lib/brane.js +0 -6
  340. package/lib/brane.js.map +0 -1
  341. package/lib/tests.d.ts +0 -1
  342. package/lib/tests.js +0 -8
  343. package/lib/tests.js.map +0 -1
  344. package/rollup.config.js +0 -21
  345. package/scripts/test.sh +0 -8
  346. package/src/brane.ts +0 -21
  347. package/src/tests.ts +0 -8
  348. package/tsconfig.json +0 -16
@@ -0,0 +1,265 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { createTestDb } from "./helpers.js";
4
+ import { announceEvent, createMentionRecorder, startMcpListen, wakeListenWaiters, } from "../daemon/server.js";
5
+ import { appendEvent } from "../db/events.js";
6
+ import { recordMentions } from "../db/mentions.js";
7
+ // The regression guard for the failure that would make this feature worse than
8
+ // not having it.
9
+ //
10
+ // A mention has to resolve a parked `listen` — otherwise the daemon's wake
11
+ // finds no *events*, parks again, and the mention waits out the full hold (up
12
+ // to 3400s for claude). But the moment mentions can resolve a listen, they can
13
+ // also resolve *every* listen: if the resolve doesn't settle durable state in
14
+ // the same synchronous block as the send, the agent's next listen returns
15
+ // instantly, forever, at inference speed, with no operator in the loop.
16
+ //
17
+ // So: exactly once. That is what these pin.
18
+ const SELF = "claude-aaaaaaaa";
19
+ const OTHER = "claude-bbbbbbbb";
20
+ const BRANE = "brane-home";
21
+ /** A bridge client whose socket records what it was sent. */
22
+ function fakeClient() {
23
+ const sent = [];
24
+ const ws = {
25
+ readyState: 1, // WebSocket.OPEN
26
+ send: (raw) => {
27
+ const msg = JSON.parse(raw);
28
+ if (msg.type === "tool_result")
29
+ sent.push({ callId: msg.id, result: msg.result });
30
+ },
31
+ };
32
+ return {
33
+ client: {
34
+ ws: ws,
35
+ narrativeId: SELF,
36
+ agentType: "claude",
37
+ pendingListens: new Map(),
38
+ },
39
+ sent,
40
+ };
41
+ }
42
+ function db() {
43
+ const d = createTestDb();
44
+ const ins = d.prepare("INSERT INTO narratives (id, brane_id, created_at, status) VALUES (?, ?, 0, 'active')");
45
+ ins.run(SELF, BRANE);
46
+ ins.run(OTHER, BRANE);
47
+ return d;
48
+ }
49
+ /** OTHER mentions SELF, exactly as the daemon's recorder would. */
50
+ function mentionSelf(d, content = `ping @${SELF}`) {
51
+ const event = appendEvent(d, OTHER, "narration", { content, source: OTHER });
52
+ recordMentions(d, {
53
+ mentionedNids: [SELF],
54
+ srcNarrative: OTHER,
55
+ srcSeq: event.seq,
56
+ author: OTHER,
57
+ time: event.time,
58
+ });
59
+ return event.seq;
60
+ }
61
+ function listen(d, c, callId, args = {}) {
62
+ startMcpListen(c, d, callId, { after: { self: 0 }, ...args }, new Map(), () => { }, BRANE);
63
+ }
64
+ describe("a mention resolves a listen exactly once", () => {
65
+ it("resolves the first listen and blocks the next", () => {
66
+ const d = db();
67
+ const { client, sent } = fakeClient();
68
+ mentionSelf(d);
69
+ listen(d, client, "1");
70
+ assert.equal(sent.length, 1, "a pending mention must resolve a parked listen");
71
+ assert.equal(sent[0].result.mentions.length, 1);
72
+ assert.equal(sent[0].result.mentions[0].src, OTHER);
73
+ listen(d, client, "2");
74
+ assert.equal(sent.length, 1, "SPIN LOOP: the same mention resolved a second listen");
75
+ assert.equal(client.pendingListens.size, 1, "the second listen should be parked");
76
+ });
77
+ it("keeps blocking across many listens while the mention stays unread", () => {
78
+ const d = db();
79
+ const { client, sent } = fakeClient();
80
+ mentionSelf(d);
81
+ listen(d, client, "1");
82
+ for (let i = 2; i <= 10; i++)
83
+ listen(d, client, `${i}`);
84
+ assert.equal(sent.length, 1);
85
+ });
86
+ it("resolves again for a *new* mention, even with the first still unread", () => {
87
+ const d = db();
88
+ const { client, sent } = fakeClient();
89
+ mentionSelf(d);
90
+ listen(d, client, "1");
91
+ assert.equal(sent.length, 1);
92
+ mentionSelf(d, `again @${SELF}`);
93
+ listen(d, client, "2");
94
+ assert.equal(sent.length, 2, "a fresh mention must wake the agent");
95
+ assert.equal(sent[1].result.mentions.length, 2, "the unread one is still owed");
96
+ });
97
+ it("settles in the same call that sends, so a crash after cannot double-deliver", () => {
98
+ // `markSent` runs inside `mentionReceipt`, which is spread into the very
99
+ // object handed to `sendToolResult`. Nothing between them can throw and
100
+ // leave the mention both sent and pending.
101
+ const d = db();
102
+ const { client, sent } = fakeClient();
103
+ mentionSelf(d);
104
+ listen(d, client, "1");
105
+ const unsent = d
106
+ .prepare("SELECT COUNT(*) c FROM mentions WHERE mentioned_nid = ? AND sent_at IS NULL")
107
+ .get(SELF);
108
+ assert.equal(unsent.c, 0);
109
+ assert.equal(sent.length, 1);
110
+ });
111
+ });
112
+ describe("the receipt", () => {
113
+ it("is absent entirely when nothing is in play", () => {
114
+ const d = db();
115
+ const { client, sent } = fakeClient();
116
+ appendEvent(d, SELF, "human_signal", { content: "hello", source: "web" });
117
+ listen(d, client, "1");
118
+ assert.equal(sent.length, 1);
119
+ assert.equal("mentions" in sent[0].result, false, "an ordinary listen must be unchanged");
120
+ assert.equal("mentions_pending" in sent[0].result, false);
121
+ });
122
+ it("does not resolve a listen on its own when only ordinary events arrive", () => {
123
+ const d = db();
124
+ const { client, sent } = fakeClient();
125
+ listen(d, client, "1");
126
+ assert.equal(sent.length, 0, "nothing to return: must park");
127
+ assert.equal(client.pendingListens.size, 1);
128
+ });
129
+ it("carries pointers, never the prose", () => {
130
+ const d = db();
131
+ const { client, sent } = fakeClient();
132
+ const seq = mentionSelf(d, `a long and private message @${SELF} with secrets`);
133
+ listen(d, client, "1");
134
+ const row = sent[0].result.mentions[0];
135
+ assert.deepEqual(Object.keys(row).sort(), ["author", "id", "seq", "src", "time"]);
136
+ assert.equal(row.seq, seq);
137
+ assert.equal(JSON.stringify(row).includes("secrets"), false);
138
+ });
139
+ });
140
+ describe("reading the source settles it", () => {
141
+ it("annotates the delivered event and stops owing the mention", () => {
142
+ const d = db();
143
+ const { client, sent } = fakeClient();
144
+ const seq = mentionSelf(d);
145
+ // The agent goes and reads OTHER, which is what interpreting a mention
146
+ // means — and the cursor it already passes is the evidence.
147
+ listen(d, client, "1", { after: { self: 0, [OTHER]: 0 } });
148
+ const events = sent[0].result.events[OTHER];
149
+ assert.equal(events.length, 1);
150
+ assert.deepEqual(events[0].mentions, [SELF], "the event says what it addresses");
151
+ // Settled by the read: a later listen owes nothing.
152
+ listen(d, client, "2", { after: { self: 0 } });
153
+ assert.equal(sent.length, 1, "nothing left to wake for");
154
+ });
155
+ it("does not settle a mention the read never showed", () => {
156
+ const d = db();
157
+ const { client, sent } = fakeClient();
158
+ const first = mentionSelf(d);
159
+ mentionSelf(d, `later @${SELF}`);
160
+ // `before` truncates: this read returns the first event only.
161
+ listen(d, client, "1", { after: { self: 0, [OTHER]: 0 }, before: { [OTHER]: first + 1 } });
162
+ assert.equal(sent[0].result.events[OTHER].length, 1);
163
+ // The receipt acks before it reports, so the mention delivered in this
164
+ // very batch is already settled and only the untouched one is listed.
165
+ assert.equal(sent[0].result.mentions.length, 1);
166
+ assert.notEqual(sent[0].result.mentions[0].seq, first);
167
+ const owed = d
168
+ .prepare("SELECT COUNT(*) c FROM mentions WHERE mentioned_nid = ? AND seen_at IS NULL")
169
+ .get(SELF);
170
+ assert.equal(owed.c, 1, "the event that was never returned is still owed");
171
+ });
172
+ });
173
+ describe("the escape hatches", () => {
174
+ it('"dismiss" settles an irrelevant mention so it stops reappearing', () => {
175
+ const d = db();
176
+ const { client, sent } = fakeClient();
177
+ mentionSelf(d);
178
+ listen(d, client, "1", { mentions: "dismiss" });
179
+ assert.equal(sent.length, 1);
180
+ listen(d, client, "2");
181
+ assert.equal(sent.length, 1, "dismissed: must not wake or be re-reported");
182
+ });
183
+ it('"all" replays settled mentions without un-settling them', () => {
184
+ const d = db();
185
+ const { client, sent } = fakeClient();
186
+ mentionSelf(d);
187
+ listen(d, client, "1", { after: { self: 0, [OTHER]: 0 } }); // read + settle
188
+ listen(d, client, "2", { mentions: "all" });
189
+ assert.equal(sent.length, 2);
190
+ assert.equal(sent[1].result.mentions.length, 1, "recovery shows what was settled");
191
+ listen(d, client, "3");
192
+ assert.equal(sent.length, 2, "and a recovery read must not resurrect it");
193
+ });
194
+ });
195
+ describe("announceEvent records before it wakes", () => {
196
+ // The ordering bug this pins, observed live at claude-9o0vgfvb:14–:17.
197
+ //
198
+ // Every test above records the mention *before* any listen is parked, so
199
+ // none of them can see it: the failure needs a waiter that is already
200
+ // parked when the event lands. `notifyListenWaiters` resolves that waiter
201
+ // synchronously, and the receipt it builds acks by read — so if the wake
202
+ // runs before the insert, the ack updates zero rows, the mention lands
203
+ // unseen, and the agent's cursor has already moved past the only event
204
+ // that could ever settle it.
205
+ //
206
+ // Driving the real `announceEvent` and the real `createMentionRecorder` is
207
+ // the point: a test that re-implemented the order would pass no matter what
208
+ // `server.ts` did.
209
+ const harness = () => {
210
+ const d = db();
211
+ const { client, sent } = fakeClient();
212
+ const waiters = new Map();
213
+ // Stands in for the daemon's transport only. The order under test lives
214
+ // entirely inside `announceEvent`.
215
+ const notify = (nid) => wakeListenWaiters(waiters, nid);
216
+ const record = createMentionRecorder(d, notify);
217
+ const say = (content) => {
218
+ const event = appendEvent(d, OTHER, "narration", { content, source: OTHER });
219
+ announceEvent(OTHER, event, OTHER, notify, record);
220
+ return event.seq;
221
+ };
222
+ const unseen = () => d
223
+ .prepare("SELECT COUNT(*) c FROM mentions WHERE mentioned_nid = ? AND seen_at IS NULL")
224
+ .get(SELF).c;
225
+ return { d, client, sent, waiters, say, unseen };
226
+ };
227
+ it("settles a mention delivered to a listen that was already parked", () => {
228
+ const { d, client, sent, waiters, say, unseen } = harness();
229
+ // SELF is watching OTHER — the twin's cursor, and the case that breaks.
230
+ startMcpListen(client, d, "1", { after: { self: 0, [OTHER]: 0 } }, waiters, () => { }, BRANE);
231
+ assert.equal(sent.length, 0, "nothing to return yet: must park");
232
+ const seq = say(`ping @${SELF}`);
233
+ assert.equal(sent.length, 1, "the parked listen must be woken by the event");
234
+ const events = sent[0].result.events[OTHER];
235
+ assert.equal(events.length, 1);
236
+ assert.equal(events[0].seq, seq);
237
+ assert.deepEqual(events[0].mentions, [SELF], "the row existed when the receipt was built");
238
+ assert.equal(unseen(), 0, "reading the event that carried it must settle it");
239
+ });
240
+ it("does not re-report a mention the parked listen already read", () => {
241
+ // The user-visible symptom: it came back in every later receipt, and only
242
+ // a deliberate backward read cleared it.
243
+ const { d, client, sent, waiters, say } = harness();
244
+ startMcpListen(client, d, "1", { after: { self: 0, [OTHER]: 0 } }, waiters, () => { }, BRANE);
245
+ const seq = say(`ping @${SELF}`);
246
+ assert.equal(sent.length, 1);
247
+ // A forward cursor past that seq — exactly what an agent passes next.
248
+ startMcpListen(client, d, "2", { after: { self: 0, [OTHER]: seq } }, waiters, () => { }, BRANE);
249
+ assert.equal(sent.length, 1, "settled: must not wake again");
250
+ assert.equal(client.pendingListens.size, 1, "the second listen should be parked");
251
+ });
252
+ it("still wakes a mentioned agent that is not watching the source", () => {
253
+ // The other half of the contract: recording first must not cost the wake
254
+ // path, which is the only way a mention reaches someone watching only
255
+ // their own narrative.
256
+ const { d, client, sent, waiters, say } = harness();
257
+ startMcpListen(client, d, "1", { after: { self: 0 } }, waiters, () => { }, BRANE);
258
+ assert.equal(sent.length, 0);
259
+ say(`ping @${SELF}`);
260
+ assert.equal(sent.length, 1, "the mention itself must resolve the listen");
261
+ assert.equal(sent[0].result.mentions.length, 1);
262
+ assert.equal(sent[0].result.mentions[0].src, OTHER);
263
+ });
264
+ });
265
+ //# sourceMappingURL=mentions-listen.test.js.map
@@ -0,0 +1,238 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { fromMarkdown } from "mdast-util-from-markdown";
4
+ import { gfmAutolinkLiteral } from "micromark-extension-gfm-autolink-literal";
5
+ import { gfmAutolinkLiteralFromMarkdown } from "mdast-util-gfm-autolink-literal";
6
+ import { narrativeIdLinks } from "../shared/markdown-nids.js";
7
+ import { mentionLinks } from "../shared/markdown-mentions.js";
8
+ import { extractMentions } from "../mentions-scan.js";
9
+ // Two things are under test here, and the second matters more than the first.
10
+ //
11
+ // 1. That `@claude-4c7289d1` links as a mention and a bare `claude-4c7289d1`
12
+ // still links as a citation — the distinction the whole feature rests on.
13
+ // 2. That `extractMentions` agrees with the renderer on every input. A handle
14
+ // must fire a notification *iff* the operator sees it as a mention. A
15
+ // scanner that disagrees with the view produces wakeups nobody can explain,
16
+ // so `agrees()` below re-runs every interesting case through both.
17
+ const KNOWN = new Set(["claude-4c7289d1", "goose-aa11bb22"]);
18
+ const isKnown = (nid) => KNOWN.has(nid);
19
+ /** Parses the way the app does: autolinks, then mentions, then citations. The
20
+ * order is load-bearing — see `mentionLinks`. */
21
+ function render(md, knownIds = KNOWN) {
22
+ return fromMarkdown(md, {
23
+ extensions: [gfmAutolinkLiteral()],
24
+ mdastExtensions: [
25
+ gfmAutolinkLiteralFromMarkdown(),
26
+ mentionLinks(knownIds),
27
+ narrativeIdLinks(knownIds),
28
+ ],
29
+ });
30
+ }
31
+ function leaves(node, out = []) {
32
+ if (node.type === "link") {
33
+ out.push(["link", node.url]);
34
+ return out;
35
+ }
36
+ if (node.type === "inlineCode" || node.type === "code" || node.type === "text") {
37
+ out.push([node.type, node.value]);
38
+ return out;
39
+ }
40
+ for (const child of node.children ?? [])
41
+ leaves(child, out);
42
+ return out;
43
+ }
44
+ function urls(md, knownIds) {
45
+ return leaves(render(md, knownIds)).filter(([t]) => t === "link").map(([, u]) => u);
46
+ }
47
+ /** The link labels a reader ends up seeing, which is how a mention is told
48
+ * apart from a citation: the `@` survives into the label. */
49
+ function labels(md, knownIds) {
50
+ const out = [];
51
+ const walk = (n) => {
52
+ if (n.type === "link") {
53
+ out.push(n.children?.map((c) => c.value).join("") ?? "");
54
+ return;
55
+ }
56
+ for (const c of n.children ?? [])
57
+ walk(c);
58
+ };
59
+ walk(render(md, knownIds));
60
+ return out;
61
+ }
62
+ /** The nids the *renderer* turned into mentions.
63
+ *
64
+ * A link counts only if this pipeline created it: an author-written
65
+ * `[@claude-4c7289d1](https://example.com/x)` has a mention-shaped label but
66
+ * is someone else's link, and the walker never looked inside it. */
67
+ function renderedMentions(md, knownIds) {
68
+ const out = [];
69
+ const walk = (n) => {
70
+ if (n.type === "link") {
71
+ const label = n.children?.map((c) => c.value).join("") ?? "";
72
+ if (label.startsWith("@") && String(n.url).startsWith("brane://nid/")) {
73
+ out.push(label.slice(1).split(":")[0]);
74
+ }
75
+ return;
76
+ }
77
+ for (const c of n.children ?? [])
78
+ walk(c);
79
+ };
80
+ walk(render(md, knownIds));
81
+ return out;
82
+ }
83
+ function mentioned(md) {
84
+ return extractMentions(md, isKnown);
85
+ }
86
+ /** Asserts the scanner and the renderer say the same thing about `md`. */
87
+ function agrees(md) {
88
+ assert.deepEqual(mentioned(md).sort(), [...new Set(renderedMentions(md))].sort(), `scanner and renderer disagree on: ${JSON.stringify(md)}`);
89
+ }
90
+ describe("mention linkification", () => {
91
+ it("links a mention and keeps the @ in the label", () => {
92
+ assert.deepEqual(labels("ping @claude-4c7289d1 please"), ["@claude-4c7289d1"]);
93
+ assert.deepEqual(leaves(render("ping @claude-4c7289d1 please")), [
94
+ ["text", "ping "],
95
+ ["link", "brane://nid/claude-4c7289d1"],
96
+ ["text", " please"],
97
+ ]);
98
+ });
99
+ it("still links a bare id as a citation", () => {
100
+ assert.deepEqual(labels("see claude-4c7289d1"), ["claude-4c7289d1"]);
101
+ });
102
+ it("carries a :seq suffix into the link instead of stranding it", () => {
103
+ // The citation spec would otherwise eat the id and leave ":42" as text.
104
+ assert.deepEqual(leaves(render("@claude-4c7289d1:42")), [
105
+ ["link", "brane://nid/claude-4c7289d1/42"],
106
+ ]);
107
+ assert.deepEqual(labels("@claude-4c7289d1:42"), ["@claude-4c7289d1:42"]);
108
+ // ...and the seq says where to look, not who to notify.
109
+ assert.deepEqual(mentioned("@claude-4c7289d1:42"), ["claude-4c7289d1"]);
110
+ });
111
+ it("mentions and citations coexist in one run", () => {
112
+ assert.deepEqual(labels("@claude-4c7289d1 re goose-aa11bb22:3"), [
113
+ "@claude-4c7289d1",
114
+ "goose-aa11bb22:3",
115
+ ]);
116
+ assert.deepEqual(mentioned("@claude-4c7289d1 re goose-aa11bb22:3"), ["claude-4c7289d1"]);
117
+ });
118
+ it("does not mention an id that names no narrative", () => {
119
+ assert.deepEqual(mentioned("@claude-deadbeef hello"), []);
120
+ assert.deepEqual(labels("@claude-deadbeef hello"), []);
121
+ });
122
+ it("is case-sensitive, so a capitalized handle is inert", () => {
123
+ // `[a-z]+-[0-9a-z]{8}` by construction. Pinned so it stays deliberate.
124
+ assert.deepEqual(mentioned("@Claude-4C7289D1"), []);
125
+ });
126
+ it("collapses a repeated handle to one mention", () => {
127
+ assert.deepEqual(mentioned("@claude-4c7289d1 and again @claude-4c7289d1"), [
128
+ "claude-4c7289d1",
129
+ ]);
130
+ });
131
+ it("returns several distinct handles", () => {
132
+ assert.deepEqual(mentioned("@claude-4c7289d1 @goose-aa11bb22").sort(), [
133
+ "claude-4c7289d1",
134
+ "goose-aa11bb22",
135
+ ]);
136
+ });
137
+ });
138
+ describe("mentions the parser refuses", () => {
139
+ it("ignores a handle inside a fenced block", () => {
140
+ // The case that makes a source-level regex unusable: a pasted log.
141
+ const md = "```\n@claude-4c7289d1\n```";
142
+ assert.deepEqual(mentioned(md), []);
143
+ assert.deepEqual(labels(md), []);
144
+ });
145
+ it("ignores a handle embedded in a code span", () => {
146
+ assert.deepEqual(mentioned("`brane revive @claude-4c7289d1`"), []);
147
+ });
148
+ it("promotes a code span that is exactly one mention", () => {
149
+ // Matches how the citation spec treats `` `claude-4c7289d1:42` ``.
150
+ assert.deepEqual(labels("`@claude-4c7289d1`"), ["@claude-4c7289d1"]);
151
+ assert.deepEqual(mentioned("`@claude-4c7289d1`"), ["claude-4c7289d1"]);
152
+ });
153
+ it("ignores a handle inside an existing link", () => {
154
+ assert.deepEqual(mentioned("[@claude-4c7289d1](https://example.com/x)"), []);
155
+ assert.deepEqual(mentioned("https://example.com/@claude-4c7289d1"), []);
156
+ });
157
+ it("leaves an email address alone", () => {
158
+ assert.deepEqual(urls("mail ben@example.com now"), ["mailto:ben@example.com"]);
159
+ assert.deepEqual(mentioned("mail ben@example.com now"), []);
160
+ });
161
+ it("does not fire mid-word", () => {
162
+ // No dot, so GFM does not claim it as an email — the boundary is the only
163
+ // thing standing in front of this one.
164
+ assert.deepEqual(mentioned("ben@claude-4c7289d1"), []);
165
+ assert.deepEqual(mentioned("path/@claude-4c7289d1"), []);
166
+ assert.deepEqual(mentioned("v1.@claude-4c7289d1"), []);
167
+ assert.deepEqual(mentioned("@@claude-4c7289d1"), []);
168
+ });
169
+ it("fires at a text-node edge and after punctuation", () => {
170
+ assert.deepEqual(mentioned("@claude-4c7289d1"), ["claude-4c7289d1"]);
171
+ assert.deepEqual(mentioned("(@claude-4c7289d1)"), ["claude-4c7289d1"]);
172
+ assert.deepEqual(mentioned("cc: @claude-4c7289d1."), ["claude-4c7289d1"]);
173
+ assert.deepEqual(mentioned("*@claude-4c7289d1*"), ["claude-4c7289d1"]);
174
+ assert.deepEqual(mentioned("> @claude-4c7289d1"), ["claude-4c7289d1"]);
175
+ assert.deepEqual(mentioned("- @claude-4c7289d1"), ["claude-4c7289d1"]);
176
+ });
177
+ it("does not fire on @ followed by a space", () => {
178
+ assert.deepEqual(mentioned("@ claude-4c7289d1"), []);
179
+ });
180
+ });
181
+ describe("scanner and renderer agree", () => {
182
+ // The invariant the feature depends on: a wakeup happens iff the operator
183
+ // can see why. Every input above, run through both paths at once.
184
+ const CASES = [
185
+ "ping @claude-4c7289d1 please",
186
+ "see claude-4c7289d1",
187
+ "@claude-4c7289d1:42",
188
+ "@claude-4c7289d1 re goose-aa11bb22:3",
189
+ "@claude-deadbeef hello",
190
+ "@Claude-4C7289D1",
191
+ "@claude-4c7289d1 and again @claude-4c7289d1",
192
+ "@claude-4c7289d1 @goose-aa11bb22",
193
+ "```\n@claude-4c7289d1\n```",
194
+ "`brane revive @claude-4c7289d1`",
195
+ "`@claude-4c7289d1`",
196
+ "[@claude-4c7289d1](https://example.com/x)",
197
+ "https://example.com/@claude-4c7289d1",
198
+ "mail ben@example.com now",
199
+ "ben@claude-4c7289d1",
200
+ "path/@claude-4c7289d1",
201
+ "@@claude-4c7289d1",
202
+ "(@claude-4c7289d1)",
203
+ "cc: @claude-4c7289d1.",
204
+ "*@claude-4c7289d1*",
205
+ "> @claude-4c7289d1",
206
+ "- @claude-4c7289d1",
207
+ "@ claude-4c7289d1",
208
+ "# @claude-4c7289d1",
209
+ "| @claude-4c7289d1 |",
210
+ "@claude-4c7289d1\n\n@goose-aa11bb22",
211
+ ];
212
+ for (const md of CASES) {
213
+ it(`agrees on ${JSON.stringify(md)}`, () => agrees(md));
214
+ }
215
+ });
216
+ describe("citation spec still behaves without the mention spec", () => {
217
+ // `narrativeIdLinks` is used alone by the HTML exporter. The boundary added
218
+ // for mentions must not change what it does to ordinary prose.
219
+ function citeOnly(md) {
220
+ const tree = fromMarkdown(md, {
221
+ extensions: [gfmAutolinkLiteral()],
222
+ mdastExtensions: [gfmAutolinkLiteralFromMarkdown(), narrativeIdLinks(KNOWN)],
223
+ });
224
+ return leaves(tree).filter(([t]) => t === "link").map(([, u]) => u);
225
+ }
226
+ it("links a bare id and an id:seq", () => {
227
+ assert.deepEqual(citeOnly("see claude-4c7289d1 and claude-4c7289d1:42"), [
228
+ "brane://nid/claude-4c7289d1",
229
+ "brane://nid/claude-4c7289d1/42",
230
+ ]);
231
+ });
232
+ it("declines an @-prefixed id rather than half-linking it", () => {
233
+ // Before mentions existed this produced a stranded "@" beside a citation
234
+ // link. With no mention spec installed it is now plain prose.
235
+ assert.deepEqual(citeOnly("@claude-4c7289d1"), []);
236
+ });
237
+ });
238
+ //# sourceMappingURL=mentions.test.js.map
@@ -0,0 +1,114 @@
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { MessageSelection } from "../shared/message-selection.js";
4
+ /** Records which rows were woken, so a test can assert what a tap *didn't*
5
+ * touch — which is the entire reason this store exists. */
6
+ function watch(sel, seqs) {
7
+ const woken = [];
8
+ const unsubs = seqs.map((seq) => sel.subscribeSeq(seq, () => woken.push(seq)));
9
+ return { woken, unsubscribe: () => unsubs.forEach((u) => u()) };
10
+ }
11
+ describe("MessageSelection", () => {
12
+ it("toggles one seq on and off", () => {
13
+ const sel = new MessageSelection();
14
+ assert.equal(sel.has(4), false);
15
+ assert.equal(sel.toggle(4), true);
16
+ assert.equal(sel.has(4), true);
17
+ assert.equal(sel.size, 1);
18
+ assert.equal(sel.toggle(4), false);
19
+ assert.equal(sel.has(4), false);
20
+ });
21
+ it("keeps the selection ascending whatever order it was tapped in", () => {
22
+ const sel = new MessageSelection();
23
+ for (const seq of [9, 2, 5])
24
+ sel.toggle(seq);
25
+ assert.deepEqual([...sel.getSelected()], [2, 5, 9]);
26
+ });
27
+ it("holds the snapshot's identity steady between changes", () => {
28
+ // useSyncExternalStore compares snapshots by identity; a getter that
29
+ // allocates on every read re-renders forever.
30
+ const sel = new MessageSelection();
31
+ assert.equal(sel.getSelected(), sel.getSelected());
32
+ sel.toggle(3);
33
+ const after = sel.getSelected();
34
+ assert.equal(sel.getSelected(), after);
35
+ sel.toggle(4);
36
+ assert.notEqual(sel.getSelected(), after);
37
+ });
38
+ it("wakes only the row that changed", () => {
39
+ const sel = new MessageSelection();
40
+ const { woken } = watch(sel, [1, 2, 3, 4, 5]);
41
+ sel.toggle(3);
42
+ assert.deepEqual(woken, [3], "a tap is not a broadcast");
43
+ sel.toggle(3);
44
+ assert.deepEqual(woken, [3, 3]);
45
+ });
46
+ it("wakes only the rows a clear actually cleared", () => {
47
+ const sel = new MessageSelection();
48
+ sel.toggle(2);
49
+ sel.toggle(4);
50
+ const { woken } = watch(sel, [1, 2, 3, 4, 5]);
51
+ assert.deepEqual(sel.clear(), [2, 4]);
52
+ assert.deepEqual(woken.sort(), [2, 4]);
53
+ assert.equal(sel.size, 0);
54
+ });
55
+ it("does nothing at all on a clear with nothing selected", () => {
56
+ const sel = new MessageSelection();
57
+ const { woken } = watch(sel, [1, 2, 3]);
58
+ let wholeCalls = 0;
59
+ sel.subscribe(() => { wholeCalls++; });
60
+ assert.deepEqual(sel.clear(), []);
61
+ assert.deepEqual(woken, []);
62
+ assert.equal(wholeCalls, 0, "an empty clear is not a change");
63
+ });
64
+ it("replace wakes the symmetric difference and nothing else", () => {
65
+ const sel = new MessageSelection();
66
+ sel.replace([1, 2, 3]);
67
+ const { woken } = watch(sel, [1, 2, 3, 4]);
68
+ sel.replace([2, 3, 4]);
69
+ assert.deepEqual(woken.sort(), [1, 4], "2 and 3 were selected before and after");
70
+ assert.deepEqual([...sel.getSelected()], [2, 3, 4]);
71
+ });
72
+ it("replace with the same set is not a change", () => {
73
+ const sel = new MessageSelection();
74
+ sel.replace([5, 6]);
75
+ let wholeCalls = 0;
76
+ sel.subscribe(() => { wholeCalls++; });
77
+ sel.replace([6, 5]);
78
+ assert.equal(wholeCalls, 0);
79
+ });
80
+ it("ignores values that are not seqs", () => {
81
+ const sel = new MessageSelection();
82
+ sel.replace([0, -1, 2.5, 7]);
83
+ assert.deepEqual([...sel.getSelected()], [7]);
84
+ });
85
+ it("notifies whole-selection watchers on every change", () => {
86
+ const sel = new MessageSelection();
87
+ const seen = [];
88
+ sel.subscribe(() => seen.push([...sel.getSelected()]));
89
+ sel.toggle(1);
90
+ sel.toggle(2);
91
+ sel.clear();
92
+ // Each listener sees the value it was woken about, not the previous one.
93
+ assert.deepEqual(seen, [[1], [1, 2], []]);
94
+ });
95
+ it("forgets a row's bucket once nothing is watching it", () => {
96
+ // Rows unmount as the list scrolls; buckets left behind would grow the map
97
+ // by every message ever scrolled past.
98
+ const sel = new MessageSelection();
99
+ const { unsubscribe } = watch(sel, [42]);
100
+ unsubscribe();
101
+ const { woken } = watch(sel, [42]);
102
+ sel.toggle(42);
103
+ assert.deepEqual(woken, [42], "re-subscribing still works after the bucket was dropped");
104
+ });
105
+ it("survives a listener that unsubscribes while being notified", () => {
106
+ const sel = new MessageSelection();
107
+ let calls = 0;
108
+ const off = sel.subscribeSeq(8, () => { calls++; off(); });
109
+ sel.toggle(8);
110
+ sel.toggle(8);
111
+ assert.equal(calls, 1);
112
+ });
113
+ });
114
+ //# sourceMappingURL=message-selection.test.js.map
@@ -0,0 +1,22 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { MO_GLYPH_BOX, MO_GLYPH_PATH } from "../shared/mo-glyph.js";
4
+ test("mo-glyph: closed subpaths, absolute commands only, every point inside the box", () => {
5
+ assert.match(MO_GLYPH_PATH, /^M/);
6
+ assert.match(MO_GLYPH_PATH, /Z$/);
7
+ // Only the absolute commands the extractor emits; a relative command or an
8
+ // arc would mean the data was regenerated by something else.
9
+ assert.equal(MO_GLYPH_PATH.replace(/[MLCQZ0-9. -]/g, ""), "");
10
+ const nums = MO_GLYPH_PATH.match(/-?\d+(?:\.\d+)?/g).map(Number);
11
+ assert.ok(nums.length > 300, `expected a real outline, got ${nums.length} numbers`);
12
+ for (const n of nums) {
13
+ assert.ok(n >= 0 && n <= MO_GLYPH_BOX, `${n} falls outside [0, ${MO_GLYPH_BOX}]`);
14
+ }
15
+ // The glyph is centered: its extent should be symmetric about the box center.
16
+ const xs = nums.filter((_, i) => i % 2 === 0);
17
+ const ys = nums.filter((_, i) => i % 2 === 1);
18
+ const mid = MO_GLYPH_BOX / 2;
19
+ assert.ok(Math.abs(Math.min(...xs) + Math.max(...xs) - 2 * mid) < 2);
20
+ assert.ok(Math.abs(Math.min(...ys) + Math.max(...ys) - 2 * mid) < 2);
21
+ });
22
+ //# sourceMappingURL=mo-glyph.test.js.map