@100xprompt/brain 0.0.0-stage → 0.1.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 (213) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +172 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +326 -0
  170. package/src/fsutil.ts +133 -0
  171. package/src/gateway/connect.ts +845 -0
  172. package/src/gateway/signin.ts +323 -0
  173. package/src/handoff.ts +104 -0
  174. package/src/hooks.ts +455 -0
  175. package/src/memory/audit.ts +514 -0
  176. package/src/memory/blobs.ts +94 -0
  177. package/src/memory/cli.ts +117 -0
  178. package/src/memory/distill.ts +381 -0
  179. package/src/memory/externalize.ts +435 -0
  180. package/src/memory/lookups.ts +70 -0
  181. package/src/memory/mcp-install.ts +401 -0
  182. package/src/memory/mcp-server.ts +296 -0
  183. package/src/memory/prepare.ts +179 -0
  184. package/src/memory/preview.ts +154 -0
  185. package/src/memory/project.ts +35 -0
  186. package/src/memory/redact.ts +72 -0
  187. package/src/memory/relevance.ts +187 -0
  188. package/src/memory/repomap.ts +516 -0
  189. package/src/memory/store.ts +270 -0
  190. package/src/memory/sync.ts +166 -0
  191. package/src/memory/tidy.ts +193 -0
  192. package/src/memory/types.ts +118 -0
  193. package/src/org/admin-ops.ts +96 -0
  194. package/src/org/auth.ts +112 -0
  195. package/src/org/backup.ts +115 -0
  196. package/src/org/cli.ts +438 -0
  197. package/src/org/client.ts +404 -0
  198. package/src/org/config.ts +44 -0
  199. package/src/org/crypto.ts +537 -0
  200. package/src/org/db.ts +435 -0
  201. package/src/org/graph.ts +318 -0
  202. package/src/org/oidc.ts +435 -0
  203. package/src/org/policy.ts +398 -0
  204. package/src/org/server.ts +920 -0
  205. package/src/org/sso.ts +316 -0
  206. package/src/org/tls.ts +66 -0
  207. package/src/org/types.ts +193 -0
  208. package/src/self.ts +68 -0
  209. package/src/session/attachments.ts +53 -0
  210. package/src/session/peekcache.ts +50 -0
  211. package/src/session/types.ts +104 -0
  212. package/src/session/util.ts +130 -0
  213. package/src/sqlite.ts +48 -0
@@ -0,0 +1,537 @@
1
+ // brain org: encryption at rest.
2
+ //
3
+ // WHAT IT IS. The whole organisation database (Chitta's tables AND the `org_*` tables: one file) is encrypted by libSQL's built-in
4
+ // whole-file AES-256 (the same driver Chitta itself switches to when `CONTEXT_DB_KEY` is set). Pages, the write-ahead log (-wal)
5
+ // and the shared-memory index (-shm) are all written through the cipher: there is no application-level encryption of single
6
+ // columns here and nothing is left readable on purpose. The proof is in test/org-crypto.test.ts: it reads the raw bytes of the
7
+ // three files and looks for item text, user ids and audit text.
8
+ //
9
+ // WHAT IT IS NOT. It protects the file at rest (a copied disk, a stolen backup, a snapshot, another account on the machine that
10
+ // can read the file but not the key). It does not protect against someone who can read the server process's memory or the key
11
+ // itself (`BRAIN_ORG_KEY`, the key file): while the server runs the key and decrypted pages are in its memory. The key is never
12
+ // stored in the database and never printed or logged by this code.
13
+ //
14
+ // HOW THE KEY GETS IN. `BRAIN_ORG_KEY` (an environment variable) or `--key-file <path>` (a file holding the key: the usual way with
15
+ // Docker/Kubernetes secrets and a Secret Manager mount). Never a command-line argument (it would show in `ps`). A key is
16
+ // 32-512 characters from `A-Za-z0-9+/=_.~-` (the generated one is 64 hex characters = 256 random bits).
17
+ //
18
+ // HOW CHITTA IS KEPT IN STEP. Chitta decides at construction time whether to open its store with libSQL by reading the
19
+ // `CONTEXT_DB_KEY` environment variable. `withKeyEnv` sets it only for the duration of that synchronous constructor call and then
20
+ // puts the previous value back, so the key is not left in the process environment (child processes never inherit it).
21
+ //
22
+ // ONE CONNECTION, TWO DRIVERS. `OrgDb` runs its queries on the connection Chitta opened. libSQL's handle is almost, but not quite,
23
+ // bun:sqlite's: no `transaction()`, named parameters bound without their `$`, and rows come back with an extra `_metadata`
24
+ // property. `adapt` closes exactly those gaps, so the same SQL (including the visibility predicate) runs unchanged on both.
25
+ //
26
+ // LIMITS (the honest list): libSQL must be installed where Chitta (or brain) can resolve it (`bun add libsql` in Chitta's folder;
27
+ // it is a dev dependency of Chitta, not shipped by default). While encrypted, Chitta cannot load the sqlite-vec extension: it
28
+ // uses libSQL's native vector index when it works and a brute-force scan otherwise (correct, slower on very large stores), and
29
+ // decryption costs CPU on every page read. Metadata that is not in the file is not hidden: the file's size, its modification
30
+ // times and the names of the files are visible to anyone who can list the directory. Converting a database (`encrypt`, `rekey`)
31
+ // is a logical copy; the previous file is kept next to it as `*.bak-<time>` (plain text for `encrypt`!) until you remove it.
32
+ import { Database } from "bun:sqlite"
33
+ import { randomBytes } from "node:crypto"
34
+ import { chmodSync, closeSync, existsSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs"
35
+ import { createRequire } from "node:module"
36
+ import { dirname } from "node:path"
37
+ import { chittaCandidates } from "../memory/store.ts"
38
+
39
+ export const SQLITE_MAGIC = "SQLite format 3\u0000"
40
+
41
+ /** A problem with the encryption key or with opening an encrypted file: the message is safe to show (it never holds the key). */
42
+ export class OrgKeyError extends Error {
43
+ constructor(message: string) {
44
+ super(message)
45
+ this.name = "OrgKeyError"
46
+ }
47
+ }
48
+
49
+ export type DbState = "missing" | "empty" | "plain" | "encrypted"
50
+
51
+ /** What the file's first bytes say: a plain SQLite database starts with "SQLite format 3\0"; an encrypted one starts with
52
+ * cipher text (indistinguishable from random). An empty file is a database about to be created. */
53
+ export function dbState(path: string): DbState {
54
+ if (!existsSync(path)) return "missing"
55
+ const fd = openSync(path, "r")
56
+ try {
57
+ const buf = Buffer.alloc(16)
58
+ const n = readSync(fd, buf, 0, 16, 0)
59
+ if (n === 0) return "empty"
60
+ return n === 16 && buf.toString("latin1") === SQLITE_MAGIC ? "plain" : "encrypted"
61
+ } finally {
62
+ closeSync(fd)
63
+ }
64
+ }
65
+
66
+ // ---- keys -------------------------------------------------------------------------------------------------------------------
67
+
68
+ const KEY_RE = /^[A-Za-z0-9+/=_.~-]{32,512}$/
69
+
70
+ /** Trims and checks a key; the error never contains the key. */
71
+ export function validateKey(raw: string, where = "the key"): string {
72
+ const k = raw.trim()
73
+ if (!KEY_RE.test(k)) throw new OrgKeyError(`${where} is not usable: it must be 32 to 512 characters from A-Z a-z 0-9 + / = _ . ~ - (a generated key is 64 hex characters: use \`brain org admin init --encrypt\`)`)
74
+ return k
75
+ }
76
+
77
+ /** 256 random bits, as 64 hex characters. */
78
+ export const generateKey = (): string => randomBytes(32).toString("hex")
79
+
80
+ /** Writes a new key file (mode 0600, refuses to overwrite). */
81
+ export function writeKeyFile(path: string, key: string): void {
82
+ writeFileSync(path, `${key}\n`, { mode: 0o600, flag: "wx" })
83
+ try {
84
+ chmodSync(path, 0o600)
85
+ } catch {}
86
+ }
87
+
88
+ /** Reads a key file. A file others can read is reported through `warning` (secret mounts are often 0444: a warning, not a refusal). */
89
+ export function readKeyFile(path: string): { key: string; warning?: string } {
90
+ let text: string
91
+ try {
92
+ text = readFileSync(path, "utf8")
93
+ } catch {
94
+ throw new OrgKeyError(`cannot read the key file ${path}`)
95
+ }
96
+ const key = validateKey(text, `the key in ${path}`)
97
+ let warning: string | undefined
98
+ try {
99
+ if (statSync(path).mode & 0o044) warning = `the key file ${path} is readable by other users: restrict it (chmod 600)`
100
+ } catch {}
101
+ return { key, ...(warning ? { warning } : {}) }
102
+ }
103
+
104
+ export interface KeySource {
105
+ /** The key itself (for programs: tests, an embedding application). */
106
+ key?: string
107
+ keyFile?: string
108
+ env?: Record<string, string | undefined>
109
+ }
110
+ /** The key from, in order: `key`, `keyFile`, `BRAIN_ORG_KEY`. A key file that cannot be read is an error, never a silent fallback. */
111
+ export function resolveKey(s: KeySource = {}): { key?: string; from?: "option" | "file" | "env"; warning?: string } {
112
+ if (s.key !== undefined) return { key: validateKey(s.key, "the key"), from: "option" }
113
+ if (s.keyFile) {
114
+ const r = readKeyFile(s.keyFile)
115
+ return { key: r.key, from: "file", ...(r.warning ? { warning: r.warning } : {}) }
116
+ }
117
+ const e = (s.env ?? process.env).BRAIN_ORG_KEY
118
+ if (e !== undefined && e.trim() !== "") return { key: validateKey(e, "BRAIN_ORG_KEY"), from: "env" }
119
+ return {}
120
+ }
121
+
122
+ // ---- libSQL -----------------------------------------------------------------------------------------------------------------
123
+
124
+ interface RawStmt {
125
+ all(...p: unknown[]): unknown[]
126
+ get(...p: unknown[]): unknown
127
+ run(...p: unknown[]): unknown
128
+ }
129
+ export interface RawHandle {
130
+ prepare(sql: string): RawStmt
131
+ exec(sql: string): void
132
+ close(): void
133
+ }
134
+ type RawCtor = new (path: string, opts?: { encryptionKey?: string }) => RawHandle
135
+
136
+ const NO_LIBSQL = (tried: string[]): OrgKeyError =>
137
+ new OrgKeyError(
138
+ `encryption needs the \`libsql\` package and none was found${tried.length ? ` (looked next to ${tried.join(", ")})` : ""}. Install it where Chitta lives: \`cd <chitta folder> && bun add libsql\` (BRAIN_CHITTA points to that folder), or in brain: \`bun add libsql\`.`,
139
+ )
140
+
141
+ let libsqlCache: RawCtor | undefined
142
+ const requireFrom = (file: string): ((id: string) => unknown) => createRequire(file)
143
+
144
+ /** The libSQL constructor, found next to brain or next to any Chitta that brain would load. */
145
+ export function loadLibsql(): RawCtor {
146
+ if (libsqlCache) return libsqlCache
147
+ const tried: string[] = []
148
+ const places = [import.meta.url, ...chittaCandidates().filter(existsSync)]
149
+ for (const p of places) {
150
+ try {
151
+ const mod = requireFrom(p)("libsql") as { default?: RawCtor } & RawCtor
152
+ libsqlCache = (mod.default ?? mod) as RawCtor
153
+ return libsqlCache
154
+ } catch {
155
+ tried.push(p === import.meta.url ? "brain" : dirname(p))
156
+ }
157
+ }
158
+ throw NO_LIBSQL(tried)
159
+ }
160
+
161
+ /** The first Chitta entry file (in brain's usual search order) whose folder can resolve `libsql`: the one to load for an encrypted
162
+ * database (Chitta's own switch to libSQL resolves the package from its own folder). Undefined when none can. */
163
+ export function chittaWithLibsql(): string | undefined {
164
+ for (const file of chittaCandidates()) {
165
+ if (!existsSync(file)) continue
166
+ try {
167
+ requireFrom(file)("libsql")
168
+ return file
169
+ } catch {}
170
+ }
171
+ return undefined
172
+ }
173
+
174
+ const isPlainObject = (x: unknown): x is Record<string, unknown> => typeof x === "object" && x !== null && Object.getPrototypeOf(x) === Object.prototype
175
+
176
+ function bindArgs(p: unknown[]): unknown[] {
177
+ const conv = (v: unknown) => (typeof v === "boolean" ? (v ? 1 : 0) : v)
178
+ // bun:sqlite binds `{ $name: v }` to `$name`; libSQL wants the key without the sigil (and silently binds NULL otherwise).
179
+ if (p.length === 1 && isPlainObject(p[0])) return [Object.fromEntries(Object.entries(p[0]).map(([k, v]) => [/^[$@:]/.test(k) ? k.slice(1) : k, conv(v)]))]
180
+ return p.map(conv)
181
+ }
182
+ function clean<T>(row: T): T {
183
+ if (row && typeof row === "object" && "_metadata" in (row as object)) delete (row as Record<string, unknown>)._metadata
184
+ return row
185
+ }
186
+
187
+ /** Makes a libSQL-shaped handle behave like the bun:sqlite `Database` the organisation code was written against. */
188
+ export function adapt(raw: { query?: (sql: string) => RawStmt; prepare?: (sql: string) => RawStmt; exec(sql: string): void; close(): void }): Database {
189
+ const prep = (sql: string): RawStmt => (raw.query ? raw.query(sql) : raw.prepare!(sql))
190
+ const cache = new Map<string, RawStmt>()
191
+ const stmt = (sql: string): RawStmt => {
192
+ let s = cache.get(sql)
193
+ if (!s) {
194
+ if (cache.size > 300) cache.clear()
195
+ s = prep(sql)
196
+ cache.set(sql, s)
197
+ }
198
+ return s
199
+ }
200
+ let depth = 0
201
+ let seq = 0
202
+ const handle = {
203
+ query(sql: string) {
204
+ const s = stmt(sql)
205
+ return {
206
+ all: (...p: unknown[]) => (s.all(...bindArgs(p)) as unknown[]).map(clean),
207
+ get: (...p: unknown[]) => clean(s.get(...bindArgs(p)) ?? null),
208
+ run: (...p: unknown[]) => s.run(...bindArgs(p)),
209
+ }
210
+ },
211
+ exec: (sql: string) => raw.exec(sql),
212
+ close: () => raw.close(),
213
+ /** `db.transaction(fn)` returns a function that runs `fn` in a transaction (a savepoint when already inside one). */
214
+ transaction<A extends unknown[], R>(fn: (...a: A) => R): (...a: A) => R {
215
+ return (...a: A): R => {
216
+ let sp: string | undefined = depth > 0 ? `brain_sp_${++seq}` : undefined
217
+ if (!sp) {
218
+ try {
219
+ raw.exec("BEGIN IMMEDIATE")
220
+ } catch (e) {
221
+ if (!/within a transaction/i.test(String((e as Error)?.message))) throw e
222
+ sp = `brain_sp_${++seq}` // Chitta holds a transaction open on this connection: join it
223
+ raw.exec(`SAVEPOINT ${sp}`)
224
+ }
225
+ } else raw.exec(`SAVEPOINT ${sp}`)
226
+ depth++
227
+ try {
228
+ const r = fn(...a)
229
+ raw.exec(sp ? `RELEASE ${sp}` : "COMMIT")
230
+ return r
231
+ } catch (e) {
232
+ try {
233
+ raw.exec(sp ? `ROLLBACK TO ${sp}; RELEASE ${sp}` : "ROLLBACK")
234
+ } catch {}
235
+ throw e
236
+ } finally {
237
+ depth--
238
+ }
239
+ }
240
+ },
241
+ }
242
+ return handle as unknown as Database
243
+ }
244
+
245
+ /** The handle as is when it already has `transaction` (bun:sqlite), otherwise adapted. */
246
+ export const ensureTransactions = (db: Database): Database => (typeof (db as { transaction?: unknown }).transaction === "function" ? db : adapt(db as never))
247
+
248
+ const WRONG_KEY = /not a database|file is encrypted|decrypt|malformed|SQLITE_NOTADB|SQLITE_CORRUPT/i
249
+
250
+ /** Opens the database file with its own connection: plain with bun:sqlite, encrypted with libSQL. Refuses a key on a plain file and
251
+ * no key on an encrypted one, each with a message that says what to do. Wrong key: "does not open". */
252
+ export function openSqlite(path: string, key?: string): Database {
253
+ const state = dbState(path)
254
+ if (!key) {
255
+ if (state === "encrypted") throw new OrgKeyError(`${path} is encrypted: give the key with BRAIN_ORG_KEY or --key-file <path>`)
256
+ return new Database(path, { create: true })
257
+ }
258
+ if (state === "plain") throw new OrgKeyError(`${path} is not encrypted but a key was given: convert it with \`brain org admin encrypt --db ${path}\`, or serve it without a key`)
259
+ const L = loadLibsql()
260
+ let raw: RawHandle | undefined
261
+ try {
262
+ raw = new L(path, { encryptionKey: key })
263
+ raw.prepare("SELECT count(*) AS c FROM sqlite_master").get()
264
+ } catch (e) {
265
+ try {
266
+ raw?.close()
267
+ } catch {}
268
+ if (WRONG_KEY.test(String((e as Error)?.message ?? e))) throw new OrgKeyError(`the key does not open ${path} (wrong key, or the file is damaged)`)
269
+ throw new OrgKeyError(`cannot open ${path} with the key: ${String((e as Error)?.message ?? e).split("\n")[0]}`)
270
+ }
271
+ return adapt(raw as never)
272
+ }
273
+
274
+ /** Runs `fn` (synchronously) with CONTEXT_DB_KEY set to `key` (or removed when none), then restores the previous value. Chitta reads
275
+ * this variable once, when it opens its store; the key must not stay in the environment afterwards. */
276
+ export function withKeyEnv<T>(key: string | undefined, fn: () => T): T {
277
+ const prev = process.env.CONTEXT_DB_KEY
278
+ if (key) process.env.CONTEXT_DB_KEY = key
279
+ else delete process.env.CONTEXT_DB_KEY
280
+ try {
281
+ return fn()
282
+ } finally {
283
+ if (prev === undefined) delete process.env.CONTEXT_DB_KEY
284
+ else process.env.CONTEXT_DB_KEY = prev
285
+ }
286
+ }
287
+
288
+ // ---- one server per file ----------------------------------------------------------------------------------------------------
289
+
290
+ const lockPath = (db: string): string => `${db}.lock`
291
+
292
+ /** Marks the database as served by this process. Not a mutex (a second server is not refused: that is not this file's job); it lets
293
+ * `encrypt`, `rekey` and `restore` refuse to replace a file a live server has open. Returns a function that releases it. */
294
+ export function claimServerLock(db: string): () => void {
295
+ const token = `${process.pid}:${randomBytes(8).toString("hex")}`
296
+ try {
297
+ writeFileSync(lockPath(db), `${token}\n`, { mode: 0o600 })
298
+ } catch {
299
+ return () => {}
300
+ }
301
+ return () => {
302
+ try {
303
+ if (readFileSync(lockPath(db), "utf8").trim() === token) unlinkSync(lockPath(db))
304
+ } catch {}
305
+ }
306
+ }
307
+
308
+ /** The pid of a live process serving this database, if there is one (a stale lock of a dead process is ignored). */
309
+ export function liveServer(db: string): number | undefined {
310
+ try {
311
+ const pid = Number(readFileSync(lockPath(db), "utf8").split(":")[0])
312
+ if (!Number.isInteger(pid) || pid <= 0) return undefined
313
+ process.kill(pid, 0)
314
+ return pid
315
+ } catch (e) {
316
+ return (e as NodeJS.ErrnoException)?.code === "EPERM" ? -1 : undefined
317
+ }
318
+ }
319
+
320
+ // ---- converting a database: encrypt, rekey ----------------------------------------------------------------------------------
321
+
322
+ const qIdent = (s: string): string => `"${s.replaceAll('"', '""')}"`
323
+
324
+ export interface ConvertResult {
325
+ /** The previous file, kept next to the database. For `encrypt` it holds PLAIN TEXT until you remove it. */
326
+ backup: string
327
+ tables: Record<string, number>
328
+ }
329
+
330
+ /** Copies every ordinary table (and its indexes, views, triggers) of the database at `path` into a new file encrypted with `newKey`,
331
+ * verifies the row counts and an integrity check on the new file, and swaps it into place (the old file becomes `<path>.bak-<time>`).
332
+ * `oldKey` is the current key, or undefined for a plain database. Works for Chitta's tables and for every `org_*` table, including
333
+ * ones added later: tables are discovered, not listed. Virtual tables (full-text, vector) and their shadow tables are not copied:
334
+ * Chitta rebuilds the full-text index when it next opens the store, and this function rebuilds the vector index it needs. */
335
+ export function convertDatabase(o: { path: string; oldKey?: string; newKey: string; now?: () => number }): ConvertResult {
336
+ const { path } = o
337
+ const state = dbState(path)
338
+ if (state === "missing" || state === "empty") throw new OrgKeyError(`no database at ${path}`)
339
+ if (state === "plain" && o.oldKey) throw new OrgKeyError(`${path} is not encrypted: do not pass a current key`)
340
+ if (state === "encrypted" && !o.oldKey) throw new OrgKeyError(`${path} is encrypted: give the current key (BRAIN_ORG_KEY or --key-file)`)
341
+ const live = liveServer(path)
342
+ if (live !== undefined) throw new OrgKeyError(`a server (pid ${live > 0 ? live : "unknown"}) is running on ${path}: stop it first, then convert`)
343
+ const newKey = validateKey(o.newKey, "the new key")
344
+ const L = loadLibsql()
345
+ const stamp = (o.now ?? Date.now)()
346
+ const tmp = `${path}.converting-${process.pid}.tmp`
347
+ const backup = `${path}.bak-${stamp}`
348
+ for (const f of [tmp, `${tmp}-wal`, `${tmp}-shm`]) rmSync(f, { force: true })
349
+ closeSync(openSync(tmp, "wx", 0o600))
350
+
351
+ const counts: Record<string, number> = {}
352
+ let src: RawHandle | undefined
353
+ try {
354
+ try {
355
+ src = o.oldKey ? new L(path, { encryptionKey: o.oldKey }) : new L(path)
356
+ src.prepare("SELECT count(*) FROM sqlite_master").get()
357
+ } catch (e) {
358
+ try {
359
+ src?.close()
360
+ } catch {}
361
+ src = undefined
362
+ throw new OrgKeyError(WRONG_KEY.test(String((e as Error)?.message)) ? `the current key does not open ${path} (wrong key, or the file is damaged)` : `cannot open ${path}: ${String((e as Error)?.message).split("\n")[0]}`)
363
+ }
364
+ const S = src
365
+ const rows = (sql: string, ...p: unknown[]): Array<Record<string, unknown>> => (S.prepare(sql).all(...p) as Array<Record<string, unknown>>).map((r) => clean(r))
366
+ try {
367
+ S.exec("PRAGMA busy_timeout = 3000")
368
+ S.exec("PRAGMA wal_checkpoint(TRUNCATE)")
369
+ } catch {}
370
+ S.prepare("ATTACH DATABASE ? AS dst KEY ?").run(tmp, newKey)
371
+ const master = rows("SELECT type, name, tbl_name, sql FROM main.sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'")
372
+ const virtual = master.filter((m) => m.type === "table" && /^\s*CREATE\s+VIRTUAL/i.test(String(m.sql))).map((m) => String(m.name))
373
+ const skipTable = (name: string): boolean => virtual.some((v) => name === v || name.startsWith(`${v}_`)) || name.startsWith("libsql_") || /_idx_shadow$/.test(name) || name === "vec_native"
374
+ const tables = master.filter((m) => m.type === "table" && !virtual.includes(String(m.name)) && !skipTable(String(m.name)))
375
+ const copied = new Set(tables.map((t) => String(t.name)))
376
+ const qualify = (sql: string, kind: "TABLE" | "INDEX" | "VIEW" | "TRIGGER"): string =>
377
+ sql.replace(new RegExp(`^\\s*CREATE\\s+(UNIQUE\\s+)?${kind}\\s+(IF\\s+NOT\\s+EXISTS\\s+)?`, "i"), (_m, u) => `CREATE ${u ?? ""}${kind} dst.`)
378
+ S.exec("BEGIN")
379
+ try {
380
+ for (const t of tables) S.exec(qualify(String(t.sql), "TABLE"))
381
+ for (const t of tables) {
382
+ const name = qIdent(String(t.name))
383
+ S.exec(`INSERT INTO dst.${name} SELECT * FROM main.${name}`)
384
+ counts[String(t.name)] = Number((S.prepare(`SELECT count(*) AS c FROM main.${name}`).get() as { c: number }).c)
385
+ }
386
+ for (const m of master) {
387
+ if (m.type === "index" && copied.has(String(m.tbl_name)) && !/libsql_vector_idx/i.test(String(m.sql))) S.exec(qualify(String(m.sql), "INDEX"))
388
+ else if ((m.type === "view" || m.type === "trigger") && !virtual.includes(String(m.tbl_name))) S.exec(qualify(String(m.sql), m.type === "view" ? "VIEW" : "TRIGGER"))
389
+ }
390
+ try {
391
+ S.exec("DELETE FROM dst.sqlite_sequence")
392
+ S.exec("INSERT INTO dst.sqlite_sequence SELECT * FROM main.sqlite_sequence")
393
+ } catch {} // no AUTOINCREMENT tables
394
+ S.exec("COMMIT")
395
+ } catch (e) {
396
+ try {
397
+ S.exec("ROLLBACK")
398
+ } catch {}
399
+ throw e
400
+ }
401
+ S.exec("DETACH DATABASE dst")
402
+ S.close()
403
+ src = undefined
404
+
405
+ // Second step, on the new file alone: verify, and rebuild the vector index Chitta keeps for an encrypted store.
406
+ const D = new L(tmp, { encryptionKey: newKey })
407
+ try {
408
+ if (String((D.prepare("PRAGMA integrity_check").get() as { integrity_check?: string })?.integrity_check ?? "") !== "ok") throw new OrgKeyError("the converted database failed its integrity check; nothing was changed")
409
+ for (const [name, n] of Object.entries(counts)) {
410
+ const c = Number((D.prepare(`SELECT count(*) AS c FROM ${qIdent(name)}`).get() as { c: number }).c)
411
+ if (c !== n) throw new OrgKeyError(`the converted database holds ${c} rows in ${name} instead of ${n}; nothing was changed`)
412
+ }
413
+ rebuildNativeVectors(D)
414
+ for (const pragma of ["PRAGMA journal_mode = WAL", "PRAGMA wal_checkpoint(TRUNCATE)"]) {
415
+ try {
416
+ D.exec(pragma) // best effort: closing the connection checkpoints anyway; a log left behind travels with the file below
417
+ } catch {}
418
+ }
419
+ } finally {
420
+ D.close()
421
+ }
422
+ } catch (e) {
423
+ try {
424
+ src?.close()
425
+ } catch {}
426
+ for (const f of [tmp, `${tmp}-wal`, `${tmp}-shm`]) rmSync(f, { force: true })
427
+ throw e
428
+ }
429
+ // swap: the old file stays as the backup; a stale -wal/-shm of the old file must not be read against the new one
430
+ renameSync(path, backup)
431
+ try {
432
+ if (statSync(`${path}-wal`).size > 0) renameSync(`${path}-wal`, `${backup}-wal`) // the old file's log stays with the old file
433
+ } catch {}
434
+ rmSync(`${path}-wal`, { force: true })
435
+ rmSync(`${path}-shm`, { force: true })
436
+ rmSync(`${tmp}-shm`, { force: true }) // the shared-memory index is rebuilt on open; a log (if one is left) must stay with its file
437
+ try {
438
+ renameSync(`${tmp}-wal`, `${path}-wal`)
439
+ } catch {}
440
+ renameSync(tmp, path)
441
+ try {
442
+ chmodSync(path, 0o600)
443
+ chmodSync(backup, 0o600)
444
+ } catch {}
445
+ return { backup, tables: counts }
446
+ }
447
+
448
+ /** Chitta's native vector index for an encrypted store (table `vec_native` + a DiskANN index over it) must hold EVERY chunk, or a
449
+ * search that uses it would silently miss the others. Rebuilt here from the chunks' stored embeddings, with Chitta's own DDL. */
450
+ /**
451
+ * libSQL keeps one row per vector index in `libsql_vector_meta_shadow` (a WITHOUT ROWID table keyed by the index name). `VACUUM INTO`
452
+ * re-creates each vector index in the copy, which writes that row, and then copies the source's rows of the table as well: the copy
453
+ * holds the name twice and fails SQLite's integrity check ("row not in PRIMARY KEY order"), so every backup of a store with
454
+ * vectors was refused. Found on the first real deployment. Keep one row per index name, rewritten in key order.
455
+ */
456
+ export function dedupeVectorMeta(D: RawHandle): void {
457
+ if (!D.prepare("SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = 'libsql_vector_meta_shadow'").get()) return
458
+ const d = D.prepare("SELECT count(*) - count(DISTINCT name) AS d FROM libsql_vector_meta_shadow").get() as { d: number }
459
+ if (!Number(d.d)) return
460
+ D.exec("BEGIN")
461
+ try {
462
+ D.exec("CREATE TEMP TABLE brain_vector_meta AS SELECT name, metadata FROM libsql_vector_meta_shadow GROUP BY name")
463
+ D.exec("DELETE FROM libsql_vector_meta_shadow")
464
+ D.exec("INSERT INTO libsql_vector_meta_shadow (name, metadata) SELECT name, metadata FROM brain_vector_meta ORDER BY name")
465
+ D.exec("DROP TABLE brain_vector_meta")
466
+ D.exec("COMMIT")
467
+ } catch (e) {
468
+ try {
469
+ D.exec("ROLLBACK")
470
+ } catch {}
471
+ throw e
472
+ }
473
+ }
474
+
475
+ function rebuildNativeVectors(D: RawHandle): void {
476
+ try {
477
+ const first = D.prepare("SELECT embedding FROM chunks WHERE embedding IS NOT NULL LIMIT 1").get() as { embedding?: ArrayBuffer | Uint8Array | string } | undefined
478
+ if (!first?.embedding) return
479
+ const bytes = typeof first.embedding === "string" ? null : first.embedding.byteLength
480
+ if (bytes === null) return // legacy JSON embeddings: Chitta rewrites them itself; brute force serves until then
481
+ const dim = bytes / 4
482
+ D.exec("DROP INDEX IF EXISTS vec_native_idx")
483
+ D.exec("DROP TABLE IF EXISTS vec_native")
484
+ D.exec(`CREATE TABLE vec_native (rowid INTEGER PRIMARY KEY, embedding F32_BLOB(${dim}))`)
485
+ const ins = D.prepare("INSERT INTO vec_native (rowid, embedding) VALUES (?, vector32(?))")
486
+ const all = D.prepare("SELECT rowid AS r, embedding AS e FROM chunks WHERE embedding IS NOT NULL").all() as Array<{ r: number; e: ArrayBuffer | Uint8Array }>
487
+ D.exec("BEGIN")
488
+ for (const c of all) {
489
+ const u8 = c.e instanceof Uint8Array ? c.e : new Uint8Array(c.e)
490
+ if (u8.byteLength !== dim * 4) continue
491
+ const f = new Float32Array(u8.slice().buffer)
492
+ ins.run(Math.trunc(c.r), JSON.stringify(Array.from(f)))
493
+ }
494
+ D.exec("COMMIT")
495
+ D.exec("CREATE INDEX vec_native_idx ON vec_native (libsql_vector_idx(embedding, 'metric=cosine'))")
496
+ } catch {
497
+ try {
498
+ D.exec("ROLLBACK")
499
+ } catch {}
500
+ try {
501
+ D.exec("DROP INDEX IF EXISTS vec_native_idx")
502
+ D.exec("DROP TABLE IF EXISTS vec_native")
503
+ } catch {} // brute force serves (Chitta falls back by itself)
504
+ }
505
+ }
506
+
507
+ /** `VACUUM INTO` copies libSQL's vector index tables in a state its own integrity check rejects ("row not in PRIMARY KEY order for
508
+ * libsql_vector_meta_shadow"). The copy is rebuilt here, from the chunks, so a backup restores to an index that is consistent. */
509
+ export function rebuildVectorIndexAt(path: string, key: string): void {
510
+ const L = loadLibsql()
511
+ const D = new L(path, { encryptionKey: key })
512
+ try {
513
+ dedupeVectorMeta(D)
514
+ if (D.prepare("SELECT 1 AS x FROM sqlite_master WHERE name = 'vec_native'").get()) rebuildNativeVectors(D)
515
+ } finally {
516
+ D.close()
517
+ }
518
+ }
519
+
520
+ /** Best-effort removal of a file that held plain text: overwritten with zeros, then unlinked. On SSDs, copy-on-write and journaling
521
+ * file systems this does NOT guarantee the old bytes are gone: delete the disk or snapshot if that matters. */
522
+ export function wipeFile(path: string): void {
523
+ try {
524
+ const size = statSync(path).size
525
+ const fd = openSync(path, "r+")
526
+ try {
527
+ const zero = Buffer.alloc(1 << 20)
528
+ for (let off = 0; off < size; off += zero.length) {
529
+ const n = Math.min(zero.length, size - off)
530
+ writeSync(fd, zero, 0, n, off)
531
+ }
532
+ } finally {
533
+ closeSync(fd)
534
+ }
535
+ } catch {}
536
+ rmSync(path, { force: true })
537
+ }