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,643 @@
1
+ import Database from "better-sqlite3";
2
+ import { randomBase36 } from "../ids.js";
3
+ import { join, dirname } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { homedir } from "node:os";
6
+ import { mkdirSync, existsSync, readFileSync, chmodSync } from "node:fs";
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ let _cachedVersion;
9
+ /** Read the package version (major.minor) from package.json */
10
+ export function getPackageVersion() {
11
+ if (!_cachedVersion) {
12
+ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "package.json"), "utf-8"));
13
+ const parts = pkg.version.split(".");
14
+ _cachedVersion = `${parts[0]}.${parts[1]}`;
15
+ }
16
+ return _cachedVersion;
17
+ }
18
+ const BRANE_DIR = join(homedir(), ".brane");
19
+ const DB_PATH = join(BRANE_DIR, "brane.db");
20
+ export const SCHEMA_SQL = `
21
+ -- Brane instance identity and configuration
22
+ CREATE TABLE IF NOT EXISTS meta (
23
+ key TEXT PRIMARY KEY,
24
+ value TEXT NOT NULL
25
+ );
26
+
27
+ -- Registered folds (spawn points)
28
+ CREATE TABLE IF NOT EXISTS folds (
29
+ path TEXT PRIMARY KEY,
30
+ created_at INTEGER NOT NULL,
31
+ last_spawned INTEGER,
32
+ archived INTEGER DEFAULT 0
33
+ );
34
+
35
+ -- Narratives (agent sessions)
36
+ CREATE TABLE IF NOT EXISTS narratives (
37
+ id TEXT PRIMARY KEY,
38
+ brane_id TEXT NOT NULL,
39
+ fold_path TEXT,
40
+ agent_type TEXT,
41
+ role TEXT,
42
+ created_at INTEGER NOT NULL,
43
+ status TEXT DEFAULT 'active',
44
+ agent_session_id TEXT,
45
+ title TEXT,
46
+ summary TEXT,
47
+ launch_args TEXT,
48
+ bind_token TEXT,
49
+ session_ids TEXT
50
+ );
51
+
52
+ -- Narrative events (append-only log)
53
+ CREATE TABLE IF NOT EXISTS events (
54
+ narrative_id TEXT NOT NULL,
55
+ seq INTEGER NOT NULL,
56
+ time INTEGER NOT NULL,
57
+ type TEXT NOT NULL,
58
+ content TEXT,
59
+ content_type TEXT,
60
+ source TEXT,
61
+ re TEXT,
62
+ payloads TEXT,
63
+ meta TEXT,
64
+ PRIMARY KEY (narrative_id, seq)
65
+ );
66
+
67
+ -- Daemon bearer tokens, by hash only (src/db/tokens.ts). Never deleted:
68
+ -- a token is revoked by stamping revoked_at.
69
+ CREATE TABLE IF NOT EXISTS tokens (
70
+ id TEXT PRIMARY KEY,
71
+ hash TEXT NOT NULL UNIQUE,
72
+ kind TEXT NOT NULL,
73
+ label TEXT NOT NULL,
74
+ -- 'minted' by this daemon, or 'adopted' from a plaintext token that
75
+ -- predates hashing (and may have been logged).
76
+ origin TEXT NOT NULL DEFAULT 'minted',
77
+ created_at INTEGER NOT NULL,
78
+ last_used_at INTEGER,
79
+ revoked_at INTEGER
80
+ );
81
+
82
+ -- Index for efficient event polling
83
+ CREATE INDEX IF NOT EXISTS idx_events_narrative_seq
84
+ ON events (narrative_id, seq);
85
+ `;
86
+ function generateBraneId() {
87
+ const slug = randomBase36(6);
88
+ return `brane-${slug}`;
89
+ }
90
+ export function ensureBraneDir() {
91
+ if (!existsSync(BRANE_DIR)) {
92
+ mkdirSync(BRANE_DIR, { recursive: true, mode: 0o700 });
93
+ }
94
+ // Everything here is this user's alone: the database, the daemon's socket,
95
+ // the payloads, the TLS key. `mode` only applies at creation, so the chmod is
96
+ // what tightens a directory an older brane made 0755.
97
+ try {
98
+ chmodSync(BRANE_DIR, 0o700);
99
+ }
100
+ catch { }
101
+ return BRANE_DIR;
102
+ }
103
+ /** `.daemon_token` beside a database: where older branes kept this machine's
104
+ * local token. Retired: the daemon reads it once at start, to revoke what it
105
+ * holds, then empties it, and nothing presents it (`bootstrapTokens`,
106
+ * src/db/tokens.ts). */
107
+ export function getTokenFilePath(db) {
108
+ return join(dirname(db.name), ".daemon_token");
109
+ }
110
+ /**
111
+ * Bring a database created from `SCHEMA_SQL` up to the current shape.
112
+ *
113
+ * `SCHEMA_SQL` is the original table set; everything added since arrives here as
114
+ * an idempotent `ALTER TABLE`/backfill. Kept separate from `openDatabase()` so
115
+ * that *every* database — production and test fixtures alike — is built by the
116
+ * same two steps. A column that exists only in this ladder is otherwise a column
117
+ * that works in production and throws in tests.
118
+ *
119
+ * Safe to run on any database, any number of times.
120
+ */
121
+ export function applyMigrations(db) {
122
+ // Migrate: add title and summary columns if missing
123
+ const cols = db.pragma("table_info(narratives)");
124
+ const colNames = new Set(cols.map((c) => c.name));
125
+ if (!colNames.has("title")) {
126
+ db.exec("ALTER TABLE narratives ADD COLUMN title TEXT");
127
+ }
128
+ if (!colNames.has("summary")) {
129
+ db.exec("ALTER TABLE narratives ADD COLUMN summary TEXT");
130
+ }
131
+ if (!colNames.has("archived")) {
132
+ db.exec("ALTER TABLE narratives ADD COLUMN archived INTEGER DEFAULT 0");
133
+ }
134
+ if (!colNames.has("agent_session_id")) {
135
+ db.exec("ALTER TABLE narratives ADD COLUMN agent_session_id TEXT");
136
+ }
137
+ if (!colNames.has("launch_args")) {
138
+ db.exec("ALTER TABLE narratives ADD COLUMN launch_args TEXT");
139
+ }
140
+ // Per-narrative bind secret: authenticates the MCP bridge's claim to this
141
+ // narrative id on /ws/mcp, so a crossed BRANE_NARRATIVE_ID (or a second
142
+ // process reusing an nid) can't bind as a narrative it doesn't own. Nullable;
143
+ // rows created before this column can no longer bind at all (see
144
+ // resolveMcpBind); revive mints one for such a row before relaunching it.
145
+ if (!colNames.has("bind_token")) {
146
+ db.exec("ALTER TABLE narratives ADD COLUMN bind_token TEXT");
147
+ }
148
+ // Every agent session id a narrative has ever bound (JSON array). A narrative
149
+ // rolls through multiple sessions over its life (revive, twin/agent-switch,
150
+ // context compaction), and total cost is the sum across all of them — but
151
+ // agent_session_id only ever holds the *current* one. session_ids is the
152
+ // cumulative set so cost sampling can aggregate every session's usage.
153
+ if (!colNames.has("session_ids")) {
154
+ db.exec("ALTER TABLE narratives ADD COLUMN session_ids TEXT");
155
+ // Backfill: seed the set from the current session so no existing narrative
156
+ // loses its reading. Session ids are UUIDs ([0-9a-f-]), so the string-built
157
+ // JSON array needs no escaping. Idempotent (guarded on session_ids IS NULL).
158
+ db.exec(`UPDATE narratives SET session_ids = '["' || agent_session_id || '"]'
159
+ WHERE session_ids IS NULL AND agent_session_id IS NOT NULL`);
160
+ }
161
+ // Cumulative session cost, sampled from the agent's own usage log. Unit is
162
+ // agent-defined ("USD", "AIC", …); cost_tokens is an optional JSON breakdown.
163
+ if (!colNames.has("cost_amount")) {
164
+ db.exec("ALTER TABLE narratives ADD COLUMN cost_amount REAL");
165
+ }
166
+ if (!colNames.has("cost_unit")) {
167
+ db.exec("ALTER TABLE narratives ADD COLUMN cost_unit TEXT");
168
+ }
169
+ if (!colNames.has("cost_tokens")) {
170
+ db.exec("ALTER TABLE narratives ADD COLUMN cost_tokens TEXT");
171
+ }
172
+ // Durable per-unit-of-work usage ledger. The transcripts the cost readers
173
+ // parse belong to the harnesses, which may purge them; this table keeps what
174
+ // was read. `key` is the dedup: an entry-level row ("<agent>:<message id>")
175
+ // is inserted once and credited to the first narrative that saw it, so a
176
+ // fork-copied message never counts twice even after the original file is
177
+ // gone. A session-level row ("session:<agent>:<session id>") holds a
178
+ // high-water mark for harnesses that only expose a cumulative total. Rows
179
+ // are never deleted, and never re-priced implicitly: only an explicit
180
+ // `brane cost reprice` rewrites `amount`, so between reprices a narrative's
181
+ // ledger sum is monotonic by construction. narratives.cost_* caches that sum.
182
+ // `cache_write_1h` (of `cache_write`, the 1-hour share) and `speed` are what
183
+ // a reprice needs beyond model and tokens; rows written before they existed
184
+ // have them recovered by the first reprice (src/cost/reprice.ts).
185
+ db.exec(`
186
+ CREATE TABLE IF NOT EXISTS usage_ledger (
187
+ key TEXT PRIMARY KEY,
188
+ narrative_id TEXT NOT NULL,
189
+ session_id TEXT,
190
+ agent_type TEXT,
191
+ unit TEXT NOT NULL,
192
+ amount REAL NOT NULL,
193
+ model TEXT,
194
+ input INTEGER,
195
+ output INTEGER,
196
+ cache_read INTEGER,
197
+ cache_write INTEGER,
198
+ first_seen INTEGER NOT NULL,
199
+ cache_write_1h INTEGER,
200
+ speed TEXT
201
+ );
202
+ CREATE INDEX IF NOT EXISTS usage_ledger_narrative ON usage_ledger(narrative_id);
203
+ `);
204
+ const ledgerCols = new Set(db.pragma("table_info(usage_ledger)").map((c) => c.name));
205
+ if (!ledgerCols.has("model")) {
206
+ db.exec("ALTER TABLE usage_ledger ADD COLUMN model TEXT");
207
+ }
208
+ if (!ledgerCols.has("cache_write_1h")) {
209
+ db.exec("ALTER TABLE usage_ledger ADD COLUMN cache_write_1h INTEGER");
210
+ }
211
+ if (!ledgerCols.has("speed")) {
212
+ db.exec("ALTER TABLE usage_ledger ADD COLUMN speed TEXT");
213
+ }
214
+ // Migrate: add meta column to events if missing
215
+ const eventCols = db.pragma("table_info(events)");
216
+ if (!eventCols.some((c) => c.name === "meta")) {
217
+ db.exec("ALTER TABLE events ADD COLUMN meta TEXT");
218
+ }
219
+ // Migrate: add brane_id to folds if missing
220
+ const foldCols = db.pragma("table_info(folds)");
221
+ if (!foldCols.some((c) => c.name === "brane_id")) {
222
+ db.exec("ALTER TABLE folds ADD COLUMN brane_id TEXT");
223
+ }
224
+ // Migrate: add archived to folds if missing. Archived folds are hidden from
225
+ // the spawn dropdown but never deleted (see Golden Rule) — "prune" is soft.
226
+ if (!foldCols.some((c) => c.name === "archived")) {
227
+ db.exec("ALTER TABLE folds ADD COLUMN archived INTEGER DEFAULT 0");
228
+ }
229
+ // Backfill any folds missing brane_id to home brane (idempotent)
230
+ db.exec("UPDATE folds SET brane_id = (SELECT value FROM meta WHERE key = 'brane_id') WHERE brane_id IS NULL");
231
+ // Fix organelle-imported narratives that kept their original role instead of "absorbed"
232
+ db.exec("UPDATE narratives SET role = 'absorbed' WHERE agent_session_id LIKE 'organelle:%' AND role != 'absorbed'");
233
+ // Tool calls an agent made, alongside the narrative rather than inside it.
234
+ //
235
+ // Deliberately NOT events. `nid:seq` is a published permalink — it appears in
236
+ // `re` back-references from other narratives and in exported HTML anchors that
237
+ // have already left this machine — so existing seqs can never move. Backfilled
238
+ // tool calls (from a session log or an absorb) would need to slot *between*
239
+ // existing seqs, which is impossible without renumbering. Keeping them out of
240
+ // the sequence also means a burst of 200 tool calls can't drown a `tail: 50`
241
+ // context load or wake every blocked `listen`.
242
+ //
243
+ // Three keys, three jobs:
244
+ // after_seq + started_at — where a row renders (between event N and N+1).
245
+ // Anchoring to a seq rather than to wall time keeps placement deterministic
246
+ // when the hook process, the daemon, and an imported vendor file disagree
247
+ // about the clock. Reads the seq space; never writes to it.
248
+ // rev — the sync cursor. A row mutates (pending → completed) without moving,
249
+ // so a client resuming from a display key would miss every update.
250
+ // dedup_key — identity. Vendor tool_use_id where available, else a content
251
+ // hash. In the primary key so the same call arriving twice (live by hook,
252
+ // later by session-log backfill) collapses to one row.
253
+ //
254
+ // Most columns are nullable on purpose: agents report wildly different amounts
255
+ // (some emit only a pre-call event, with no result, duration, or outcome ever).
256
+ // One row shape for every agent, with gaps where a vendor can't fill them in.
257
+ // status: pending | allowed | denied | completed | failed | unknown
258
+ // ("unknown" = started and will never resolve — the renderer must not spin.)
259
+ // source: hook | session_log | absorb — distinguishes "the agent didn't do
260
+ // that" from "this agent can't report it".
261
+ db.exec(`
262
+ CREATE TABLE IF NOT EXISTS tool_uses (
263
+ narrative_id TEXT NOT NULL,
264
+ dedup_key TEXT NOT NULL,
265
+ after_seq INTEGER NOT NULL,
266
+ started_at INTEGER NOT NULL,
267
+ rev INTEGER NOT NULL,
268
+ name TEXT NOT NULL,
269
+ input TEXT,
270
+ status TEXT NOT NULL,
271
+ output TEXT,
272
+ duration_ms INTEGER,
273
+ error TEXT,
274
+ decided_by TEXT,
275
+ decided_at INTEGER,
276
+ decision_note TEXT,
277
+ source TEXT NOT NULL,
278
+ agent_type TEXT,
279
+ parent_id TEXT,
280
+ PRIMARY KEY (narrative_id, dedup_key)
281
+ )
282
+ `);
283
+ db.exec("CREATE INDEX IF NOT EXISTS tool_uses_order ON tool_uses (narrative_id, after_seq, started_at)");
284
+ db.exec("CREATE INDEX IF NOT EXISTS tool_uses_sync ON tool_uses (narrative_id, rev)");
285
+ // Mentions. `@claude-4c7289d1` in one narrative addresses another, and the
286
+ // whole point is to get an agent's attention *without commenting in its
287
+ // narrative* — so a mention is deliberately not an event. That also keeps
288
+ // `nid:seq` clean (see the tool_uses note above), makes a re-scan idempotent,
289
+ // and avoids the recursion an event would invite by quoting the handle that
290
+ // created it.
291
+ //
292
+ // Each row carries both the mention and what became of it:
293
+ //
294
+ // The mention itself — mentioned_nid, src_narrative, src_seq, author, time
295
+ // — is derived data. A re-scan of the event log reproduces it exactly,
296
+ // because UNIQUE makes a second insert a no-op (the same trick as
297
+ // usage_ledger's INSERT OR IGNORE keys). Rebuild freely.
298
+ //
299
+ // sent_at and seen_at are not derivable from anything. They record what
300
+ // passed between the daemon and one agent, and they answer two different
301
+ // questions that a single mark would conflate:
302
+ //
303
+ // sent_at — this mention has been handed over. Governs whether a parked
304
+ // `listen` resolves right now, so a mention wakes an agent exactly
305
+ // once. Without it, "pending" would both resolve the listen and
306
+ // survive the resolve, and the next listen would return instantly,
307
+ // forever, at inference speed.
308
+ //
309
+ // seen_at — the agent has demonstrably attended to it, evidenced by
310
+ // reading the narrative it was written in (ackMentionsByRead in
311
+ // db/mentions.ts). Governs what the receipt keeps showing, so a
312
+ // delivered-but-unread mention is a standing reminder rather than a
313
+ // loop.
314
+ //
315
+ // Per row rather than a high-water mark per narrative, because acknowledgment
316
+ // is per *source*: reading narrative B says nothing about a mention written
317
+ // in C. A scalar watermark over a shared id space settles every lower id
318
+ // regardless of where it came from, silently marking unread mentions read —
319
+ // exactly the drop this design exists to prevent. NULL → timestamp also
320
+ // makes monotonicity structural: there is no value that could rewind.
321
+ //
322
+ // Rows are never deleted (Golden Rule), which is what lets `id` be a plain
323
+ // rowid, and what makes a rebuild safe only when it is *additive*. A
324
+ // delete-and-repopulate would reassign ids and discard sent_at/seen_at,
325
+ // re-delivering settled mentions. Note memory resets itself with
326
+ // `DELETE FROM memory_cursors` (memory/index.ts); the two look alike and
327
+ // must not be reset alike.
328
+ db.exec(`
329
+ CREATE TABLE IF NOT EXISTS mentions (
330
+ id INTEGER PRIMARY KEY,
331
+ mentioned_nid TEXT NOT NULL,
332
+ src_narrative TEXT NOT NULL,
333
+ src_seq INTEGER NOT NULL,
334
+ author TEXT NOT NULL,
335
+ time INTEGER NOT NULL,
336
+ sent_at INTEGER,
337
+ seen_at INTEGER,
338
+ UNIQUE (mentioned_nid, src_narrative, src_seq)
339
+ )
340
+ `);
341
+ db.exec("CREATE INDEX IF NOT EXISTS mentions_pending ON mentions (mentioned_nid, id)");
342
+ // The reverse lookup: which mentions ride in these specific events. Used to
343
+ // annotate events as they are returned, and to settle exactly the mentions a
344
+ // read actually delivered — the UNIQUE constraint's index leads with
345
+ // mentioned_nid and can't answer either.
346
+ db.exec("CREATE INDEX IF NOT EXISTS mentions_src ON mentions (src_narrative, src_seq)");
347
+ // Absorb's memory of what it has already read, for sources that can be
348
+ // re-read: a local agent's session store grows while brane runs, and
349
+ // `brane absorb claude` is meant to be run again and again.
350
+ //
351
+ // absorb_messages — provider-issued message id → where it landed. The
352
+ // dedup that makes a re-run idempotent *per message*, not just per
353
+ // conversation: Claude's `--fork-session` copies a predecessor's
354
+ // records, ids included, into the successor's file, so a count-based
355
+ // append would import the prefix twice under two narratives. Same
356
+ // trick as usage_ledger's keyed INSERT OR IGNORE — the first narrative
357
+ // to hold a key keeps it, and a later reading of the same key elsewhere
358
+ // becomes a cross-narrative `re` instead of a copy.
359
+ //
360
+ // absorb_files — (size, mtime) of each session file at its last absorb,
361
+ // and the parser generation that read it. A file that has not changed
362
+ // since a parser as new as this one read it is skipped on the stat
363
+ // alone, which is what keeps a re-run over hundreds of unchanged
364
+ // sessions instant. It is an optimisation only: a changed file is
365
+ // re-read in full and absorb_messages catches every duplicate, so a
366
+ // stale or missing row costs time, never correctness.
367
+ //
368
+ // Both are keyed by **brane** as well as provider. `--brane` names a
369
+ // destination, and a destination is independent of every other one: the
370
+ // same store imported into two branes is two complete copies, each with
371
+ // its own idempotent re-runs. The first cut keyed on provider alone, so a
372
+ // second brane saw every file as already absorbed and read nothing
373
+ // (claude-xiy9e8wq:38). The `managed` check in classifySessions stays
374
+ // global on purpose — a live narrative owns its session whatever brane it
375
+ // is in.
376
+ //
377
+ // Neither is ever deleted. Both are derivable from the files and the log,
378
+ // and additive by construction. The provider-only tables from before the
379
+ // brane column are carried over by joining each row to the narrative it
380
+ // named, and kept under `_v1` names rather than dropped.
381
+ db.exec(`
382
+ CREATE TABLE IF NOT EXISTS absorb_messages (
383
+ brane_id TEXT NOT NULL,
384
+ provider TEXT NOT NULL,
385
+ key TEXT NOT NULL,
386
+ narrative_id TEXT NOT NULL,
387
+ seq INTEGER NOT NULL,
388
+ PRIMARY KEY (brane_id, provider, key)
389
+ )
390
+ `);
391
+ db.exec(`
392
+ CREATE TABLE IF NOT EXISTS absorb_files (
393
+ brane_id TEXT NOT NULL,
394
+ provider TEXT NOT NULL,
395
+ external_id TEXT NOT NULL,
396
+ path TEXT NOT NULL,
397
+ bytes INTEGER NOT NULL,
398
+ mtime INTEGER NOT NULL,
399
+ absorbed_at INTEGER NOT NULL,
400
+ parser_version INTEGER NOT NULL DEFAULT 0,
401
+ PRIMARY KEY (brane_id, provider, external_id)
402
+ )
403
+ `);
404
+ migrateAbsorbLedgersToBraneScope(db);
405
+ // Which parser read the file (`LocalSource.parserVersion`). A row from
406
+ // before the column existed reads as 0, older than any parser, so each
407
+ // such file is read once more by the current one.
408
+ const fileCols = new Set(db.pragma("table_info(absorb_files)").map((c) => c.name));
409
+ if (!fileCols.has("parser_version")) {
410
+ db.exec("ALTER TABLE absorb_files ADD COLUMN parser_version INTEGER NOT NULL DEFAULT 0");
411
+ }
412
+ // What a token was first presented from, so the Devices list can tell rows
413
+ // apart (`DbTokenStore.find`, src/db/tokens.ts). Written once and never
414
+ // overwritten: a row keeps naming the device that claimed it.
415
+ const tokenCols = new Set(db.pragma("table_info(tokens)").map((c) => c.name));
416
+ if (!tokenCols.has("user_agent")) {
417
+ db.exec("ALTER TABLE tokens ADD COLUMN user_agent TEXT");
418
+ }
419
+ if (!tokenCols.has("remote_addr")) {
420
+ db.exec("ALTER TABLE tokens ADD COLUMN remote_addr TEXT");
421
+ }
422
+ // The latest sighting beside the first, and when the token was last
423
+ // presented from an address other than its first: what shows a copy.
424
+ if (!tokenCols.has("last_user_agent")) {
425
+ db.exec("ALTER TABLE tokens ADD COLUMN last_user_agent TEXT");
426
+ }
427
+ if (!tokenCols.has("last_remote_addr")) {
428
+ db.exec("ALTER TABLE tokens ADD COLUMN last_remote_addr TEXT");
429
+ }
430
+ if (!tokenCols.has("elsewhere_at")) {
431
+ db.exec("ALTER TABLE tokens ADD COLUMN elsewhere_at INTEGER");
432
+ }
433
+ // Which token's request minted this one. Written at mint and impossible to
434
+ // reconstruct later, so it is recorded before anything uses it.
435
+ if (!tokenCols.has("minted_by")) {
436
+ db.exec("ALTER TABLE tokens ADD COLUMN minted_by TEXT");
437
+ }
438
+ // The narrative an `agent` token belongs to: the only one it may bind as.
439
+ if (!tokenCols.has("owner_nid")) {
440
+ db.exec("ALTER TABLE tokens ADD COLUMN owner_nid TEXT");
441
+ }
442
+ // The `Origin` a token was first presented from, and the latest: which
443
+ // copy of the app a device runs (a hosted one, or a daemon's own). The
444
+ // origin is reported, never gated — the token is the credential — so this
445
+ // is what lets the Devices list say "via main.brane.organelle.dev".
446
+ if (!tokenCols.has("app_origin")) {
447
+ db.exec("ALTER TABLE tokens ADD COLUMN app_origin TEXT");
448
+ }
449
+ if (!tokenCols.has("last_app_origin")) {
450
+ db.exec("ALTER TABLE tokens ADD COLUMN last_app_origin TEXT");
451
+ }
452
+ // Which token's request last launched a narrative's process (spawn, run or
453
+ // revive), so revoking a device can say which agents it started. Recorded
454
+ // from now on; there is nothing to backfill it from.
455
+ const launchCols = new Set(db.pragma("table_info(narratives)").map((c) => c.name));
456
+ if (!launchCols.has("launched_by")) {
457
+ db.exec("ALTER TABLE narratives ADD COLUMN launched_by TEXT");
458
+ }
459
+ }
460
+ /** Carry provider-only `absorb_messages` / `absorb_files` rows (the shape
461
+ * #456 shipped) into the brane-scoped tables. Detected by the old table
462
+ * lacking a `brane_id` column; runs once, since the old table is renamed
463
+ * to `<name>_v1` afterwards and never touched again. Idempotent on a
464
+ * database that has already been migrated or never had the old shape. */
465
+ function migrateAbsorbLedgersToBraneScope(db) {
466
+ const cols = (table) => new Set(db.pragma(`table_info(${table})`).map((c) => c.name));
467
+ // The CREATE TABLE IF NOT EXISTS above is a no-op when the old table
468
+ // exists, so an old-shape table is one that is present without brane_id.
469
+ if (!cols("absorb_messages").has("brane_id")) {
470
+ db.exec("ALTER TABLE absorb_messages RENAME TO absorb_messages_v1");
471
+ db.exec(`
472
+ CREATE TABLE absorb_messages (
473
+ brane_id TEXT NOT NULL,
474
+ provider TEXT NOT NULL,
475
+ key TEXT NOT NULL,
476
+ narrative_id TEXT NOT NULL,
477
+ seq INTEGER NOT NULL,
478
+ PRIMARY KEY (brane_id, provider, key)
479
+ )
480
+ `);
481
+ // A message's brane is its narrative's; a row whose narrative is gone
482
+ // has nothing to be scoped to and stays only in the _v1 table.
483
+ db.exec(`
484
+ INSERT OR IGNORE INTO absorb_messages (brane_id, provider, key, narrative_id, seq)
485
+ SELECT n.brane_id, m.provider, m.key, m.narrative_id, m.seq
486
+ FROM absorb_messages_v1 m JOIN narratives n ON n.id = m.narrative_id
487
+ `);
488
+ }
489
+ if (!cols("absorb_files").has("brane_id")) {
490
+ db.exec("ALTER TABLE absorb_files RENAME TO absorb_files_v1");
491
+ // Kept in step with the CREATE in applyMigrations; carried rows take
492
+ // parser_version 0, so each is read once more by the current parser.
493
+ db.exec(`
494
+ CREATE TABLE absorb_files (
495
+ brane_id TEXT NOT NULL,
496
+ provider TEXT NOT NULL,
497
+ external_id TEXT NOT NULL,
498
+ path TEXT NOT NULL,
499
+ bytes INTEGER NOT NULL,
500
+ mtime INTEGER NOT NULL,
501
+ absorbed_at INTEGER NOT NULL,
502
+ parser_version INTEGER NOT NULL DEFAULT 0,
503
+ PRIMARY KEY (brane_id, provider, external_id)
504
+ )
505
+ `);
506
+ // A file's brane is that of the absorbed narrative holding its session
507
+ // id. A file whose narrative is gone (or that parsed to nothing and so
508
+ // has no narrative) is simply re-read next time — the stat-skip is an
509
+ // optimisation, and losing it costs one parse.
510
+ db.exec(`
511
+ INSERT OR IGNORE INTO absorb_files (brane_id, provider, external_id, path, bytes, mtime, absorbed_at)
512
+ SELECT n.brane_id, f.provider, f.external_id, f.path, f.bytes, f.mtime, f.absorbed_at
513
+ FROM absorb_files_v1 f
514
+ JOIN narratives n ON n.agent_session_id = f.external_id
515
+ AND n.agent_type = f.provider AND n.role = 'absorbed'
516
+ `);
517
+ }
518
+ }
519
+ export function openDatabase() {
520
+ // Under `node --test` there is no operator: the suite builds its databases
521
+ // with `createTestDb()`, and code under test is handed one. A path that
522
+ // reaches this instead is about to open the operator's live `~/.brane` —
523
+ // and run the migration ladder on it — from a test run, including the one
524
+ // `brane doctor` starts on an installed machine. Refuse loudly so the path
525
+ // gets a `db` threaded to it rather than quietly touching real data.
526
+ if (process.env.NODE_TEST_CONTEXT) {
527
+ throw new Error("openDatabase() called under node --test: pass the test's db (createTestDb) instead of opening ~/.brane");
528
+ }
529
+ ensureBraneDir();
530
+ const db = new Database(DB_PATH);
531
+ for (const f of [DB_PATH, `${DB_PATH}-wal`, `${DB_PATH}-shm`]) {
532
+ try {
533
+ chmodSync(f, 0o600);
534
+ }
535
+ catch { }
536
+ }
537
+ db.pragma("journal_mode = WAL");
538
+ db.pragma("foreign_keys = ON");
539
+ db.exec(SCHEMA_SQL);
540
+ applyMigrations(db);
541
+ // Ensure brane_id exists
542
+ const existing = db.prepare("SELECT value FROM meta WHERE key = 'brane_id'").get();
543
+ if (!existing) {
544
+ const braneId = generateBraneId();
545
+ db.prepare("INSERT INTO meta (key, value) VALUES ('brane_id', ?)").run(braneId);
546
+ }
547
+ return db;
548
+ }
549
+ export function getBraneId(db) {
550
+ const row = db.prepare("SELECT value FROM meta WHERE key = 'brane_id'").get();
551
+ return row.value;
552
+ }
553
+ /** Where a running daemon listens, or null if none is. Cleans up stale
554
+ * entries. For callers that need the address but no credential — a hook
555
+ * script carries its narrative's bind secret, never the daemon token. */
556
+ export function getDaemonAddress(db) {
557
+ const portRow = db.prepare("SELECT value FROM meta WHERE key = 'daemon_port'").get();
558
+ const pidRow = db.prepare("SELECT value FROM meta WHERE key = 'daemon_pid'").get();
559
+ if (!portRow || !pidRow)
560
+ return null;
561
+ try {
562
+ process.kill(parseInt(pidRow.value, 10), 0);
563
+ }
564
+ catch {
565
+ // Process is dead — clean up stale entry
566
+ db.prepare("DELETE FROM meta WHERE key IN ('daemon_port', 'daemon_token', 'daemon_pid', 'daemon_version', 'daemon_socket')").run();
567
+ return null;
568
+ }
569
+ const versionRow = db.prepare("SELECT value FROM meta WHERE key = 'daemon_version'").get();
570
+ return { port: portRow.value, version: versionRow?.value };
571
+ }
572
+ /**
573
+ * Atomically claim the daemon role using an exclusive SQLite transaction.
574
+ * Returns { claimed: true } if we won, or { claimed: false, pid } if
575
+ * another daemon is already running.
576
+ */
577
+ export function claimDaemon(db) {
578
+ // BEGIN EXCLUSIVE takes a database-level lock, preventing any other
579
+ // connection from reading or writing until we commit/rollback.
580
+ db.exec("BEGIN EXCLUSIVE");
581
+ try {
582
+ const pidRow = db.prepare("SELECT value FROM meta WHERE key = 'daemon_pid'").get();
583
+ if (pidRow) {
584
+ const existingPid = parseInt(pidRow.value, 10);
585
+ try {
586
+ process.kill(existingPid, 0);
587
+ // Process is alive — another daemon owns the role
588
+ db.exec("ROLLBACK");
589
+ return { claimed: false, pid: existingPid };
590
+ }
591
+ catch {
592
+ // Process is dead — clean up stale entry and claim
593
+ db.prepare("DELETE FROM meta WHERE key IN ('daemon_token', 'daemon_pid', 'daemon_version', 'daemon_tls', 'daemon_socket')").run();
594
+ }
595
+ }
596
+ // Claim: write our PID and version. No token: the database holds only
597
+ // token hashes, and this machine's CLI presents none.
598
+ const version = getPackageVersion();
599
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('daemon_pid', ?)").run(String(process.pid));
600
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('daemon_version', ?)").run(version);
601
+ db.exec("COMMIT");
602
+ return { claimed: true };
603
+ }
604
+ catch (err) {
605
+ try {
606
+ db.exec("ROLLBACK");
607
+ }
608
+ catch { }
609
+ throw err;
610
+ }
611
+ }
612
+ export function getDbPath() {
613
+ return DB_PATH;
614
+ }
615
+ export function getBraneDir() {
616
+ return BRANE_DIR;
617
+ }
618
+ /** Get (and ensure exists) the payloads directory for a narrative */
619
+ export function getPayloadsDir(narrativeId) {
620
+ const dir = join(BRANE_DIR, "payloads", narrativeId);
621
+ mkdirSync(dir, { recursive: true });
622
+ return dir;
623
+ }
624
+ // Convert an absolute path to ~/relative if under home directory
625
+ export function toPortablePath(absolutePath) {
626
+ const home = homedir();
627
+ if (absolutePath === home)
628
+ return "~";
629
+ if (absolutePath.startsWith(home + "/")) {
630
+ return "~/" + absolutePath.slice(home.length + 1);
631
+ }
632
+ return absolutePath;
633
+ }
634
+ // Resolve a ~/relative or absolute path to an absolute path
635
+ export function fromPortablePath(portablePath) {
636
+ if (portablePath === "~")
637
+ return homedir();
638
+ if (portablePath.startsWith("~/")) {
639
+ return join(homedir(), portablePath.slice(2));
640
+ }
641
+ return portablePath;
642
+ }
643
+ //# sourceMappingURL=schema.js.map