@100xprompt/brain 0.0.0-stage → 0.1.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 (260) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +235 -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 +351 -0
  170. package/src/dashboard/cli.ts +148 -0
  171. package/src/dashboard/conversions.ts +170 -0
  172. package/src/dashboard/report.ts +261 -0
  173. package/src/dashboard/send.ts +213 -0
  174. package/src/dashboard/state.ts +95 -0
  175. package/src/fsutil.ts +133 -0
  176. package/src/gateway/common.ts +232 -0
  177. package/src/gateway/connect.ts +847 -0
  178. package/src/gateway/connectors/aider.ts +356 -0
  179. package/src/gateway/connectors/amazon-q-cli.ts +6 -0
  180. package/src/gateway/connectors/amazon-q-ide.ts +6 -0
  181. package/src/gateway/connectors/antigravity.ts +6 -0
  182. package/src/gateway/connectors/auggie.ts +6 -0
  183. package/src/gateway/connectors/cline-sdk.ts +85 -0
  184. package/src/gateway/connectors/cline.ts +34 -0
  185. package/src/gateway/connectors/codebuff.ts +6 -0
  186. package/src/gateway/connectors/continue.ts +579 -0
  187. package/src/gateway/connectors/copilot-cli.ts +138 -0
  188. package/src/gateway/connectors/crush.ts +313 -0
  189. package/src/gateway/connectors/cursor-cli.ts +6 -0
  190. package/src/gateway/connectors/cursor.ts +6 -0
  191. package/src/gateway/connectors/devin.ts +6 -0
  192. package/src/gateway/connectors/factory-droid.ts +308 -0
  193. package/src/gateway/connectors/goose.ts +79 -0
  194. package/src/gateway/connectors/grok-cli.ts +82 -0
  195. package/src/gateway/connectors/hermes.ts +230 -0
  196. package/src/gateway/connectors/iflow-cli.ts +50 -0
  197. package/src/gateway/connectors/index.ts +86 -0
  198. package/src/gateway/connectors/kilo-cli.ts +434 -0
  199. package/src/gateway/connectors/kilo-code.ts +32 -0
  200. package/src/gateway/connectors/kimi-code.ts +241 -0
  201. package/src/gateway/connectors/kiro-cli.ts +6 -0
  202. package/src/gateway/connectors/kiro-ide.ts +6 -0
  203. package/src/gateway/connectors/letta-code.ts +170 -0
  204. package/src/gateway/connectors/mastracode.ts +91 -0
  205. package/src/gateway/connectors/mistral-vibe.ts +495 -0
  206. package/src/gateway/connectors/muse.ts +27 -0
  207. package/src/gateway/connectors/omp.ts +279 -0
  208. package/src/gateway/connectors/openhands.ts +296 -0
  209. package/src/gateway/connectors/pearai.ts +52 -0
  210. package/src/gateway/connectors/pi.ts +357 -0
  211. package/src/gateway/connectors/qoder-cli.ts +6 -0
  212. package/src/gateway/connectors/qwen-code.ts +411 -0
  213. package/src/gateway/connectors/roo.ts +43 -0
  214. package/src/gateway/connectors/tabnine-cli.ts +6 -0
  215. package/src/gateway/connectors/void.ts +6 -0
  216. package/src/gateway/connectors/vscode-copilot.ts +286 -0
  217. package/src/gateway/connectors/windsurf.ts +6 -0
  218. package/src/gateway/connectors/zed.ts +29 -0
  219. package/src/gateway/signin.ts +323 -0
  220. package/src/handoff.ts +104 -0
  221. package/src/hooks.ts +455 -0
  222. package/src/memory/audit.ts +514 -0
  223. package/src/memory/blobs.ts +94 -0
  224. package/src/memory/cli.ts +117 -0
  225. package/src/memory/distill.ts +381 -0
  226. package/src/memory/externalize.ts +435 -0
  227. package/src/memory/lookups.ts +70 -0
  228. package/src/memory/mcp-install.ts +401 -0
  229. package/src/memory/mcp-server.ts +296 -0
  230. package/src/memory/prepare.ts +179 -0
  231. package/src/memory/preview.ts +154 -0
  232. package/src/memory/project.ts +35 -0
  233. package/src/memory/redact.ts +72 -0
  234. package/src/memory/relevance.ts +187 -0
  235. package/src/memory/repomap.ts +516 -0
  236. package/src/memory/store.ts +270 -0
  237. package/src/memory/sync.ts +166 -0
  238. package/src/memory/tidy.ts +193 -0
  239. package/src/memory/types.ts +118 -0
  240. package/src/org/admin-ops.ts +96 -0
  241. package/src/org/auth.ts +112 -0
  242. package/src/org/backup.ts +115 -0
  243. package/src/org/cli.ts +438 -0
  244. package/src/org/client.ts +404 -0
  245. package/src/org/config.ts +44 -0
  246. package/src/org/crypto.ts +537 -0
  247. package/src/org/db.ts +435 -0
  248. package/src/org/graph.ts +318 -0
  249. package/src/org/oidc.ts +435 -0
  250. package/src/org/policy.ts +398 -0
  251. package/src/org/server.ts +920 -0
  252. package/src/org/sso.ts +316 -0
  253. package/src/org/tls.ts +66 -0
  254. package/src/org/types.ts +193 -0
  255. package/src/self.ts +68 -0
  256. package/src/session/attachments.ts +53 -0
  257. package/src/session/peekcache.ts +50 -0
  258. package/src/session/types.ts +104 -0
  259. package/src/session/util.ts +130 -0
  260. package/src/sqlite.ts +48 -0
@@ -0,0 +1,118 @@
1
+ // brain memory: spend fewer tokens when a session moves tools, restarts or grows.
2
+ //
3
+ // Measured on nine real sessions (562K tokens): tool results 49%, tool calls 31%, file edits 7%, assistant prose 7%,
4
+ // the person's messages 3%, compaction summaries 3%. So what you and the model said is about a tenth of a long session;
5
+ // the rest is tool input and output. Two mechanisms, both zero-model:
6
+ //
7
+ // 1. externalize: a big tool call/result is replaced by a one-line digest and a pointer; the full text goes to a blob
8
+ // store on disk and comes back on demand (`brain_expand`). Standalone: needs no Chitta.
9
+ // 2. memory: what the session established (decisions, constraints, preferences, files, failed approaches, commands) is
10
+ // distilled into small items and stored in the brain's OWN Chitta store, per project; a new session in any tool is
11
+ // started with a budgeted handful of them and can ask for more (`brain_recall`). Chitta is optional: without it
12
+ // memory is unavailable and everything else still works.
13
+ //
14
+ // Hard rules: the brain never writes to the person's personal Chitta store (CONTEXT_DB / ~/.local/share/100xprompt);
15
+ // its store is `<home>/.brain/memory/context.db`. Nothing here calls a model. Secrets are filtered before anything is stored.
16
+
17
+ import type { Project, Session } from "../session/types.ts"
18
+
19
+ /** Rough token count (chars / 4), the same estimate the measurements use. */
20
+ export const approxTokens = (text: string): number => Math.ceil(text.length / 4)
21
+
22
+ // ---- externalize --------------------------------------------------------------------------------------------------
23
+
24
+ export interface BlobRef {
25
+ /** Content hash (short, stable): the same text always gets the same id. */
26
+ id: string
27
+ chars: number
28
+ }
29
+
30
+ /** Full texts that were replaced by digests in a session, on disk under `<home>/.brain/blobs/`. */
31
+ export interface BlobStore {
32
+ dir: string
33
+ /** Stores the text (idempotent: same text, same id) and returns its reference. */
34
+ put(text: string, meta?: { tool?: string; project?: string }): BlobRef
35
+ /** The full text, or undefined when it was never stored or has been removed. */
36
+ get(id: string): string | undefined
37
+ /** The tool kind recorded when the blob was stored (read, shell, search, ...), when known. */
38
+ kindOf?(id: string): string | undefined
39
+ /** Removes blobs not used since `olderThanMs`; returns how many were removed. */
40
+ prune(olderThanMs: number): number
41
+ }
42
+
43
+ export interface ExternalizeOptions {
44
+ /** Tool calls/results shorter than this stay as they are. Default 1500 characters. */
45
+ minChars?: number
46
+ /** The newest this-many tool turns are never replaced (the model is most likely to need them). Default 8. */
47
+ keepRecent?: number
48
+ }
49
+
50
+ export interface ExternalizeResult {
51
+ /** A copy of the session with big tool calls/results replaced by digest stubs. The input is not modified. */
52
+ session: Session
53
+ /** How many turns were replaced. */
54
+ replaced: number
55
+ /** Characters before and after, over the whole session. */
56
+ before: number
57
+ after: number
58
+ }
59
+
60
+ /** The stub text written in place of a big tool result: starts with this so a reader can tell it is one. */
61
+ export const STUB_PREFIX = "[brain: output stored"
62
+
63
+ // ---- memory -------------------------------------------------------------------------------------------------------
64
+
65
+ export type MemoryKind = "decision" | "constraint" | "preference" | "failed" | "file" | "command" | "summary" | "state"
66
+
67
+ /** A project: its remote when it has one (so a clone anywhere shares memory), else its folder. Stable across tools. */
68
+ export interface ProjectKey {
69
+ id: string
70
+ label: string
71
+ }
72
+
73
+ export interface MemoryItem {
74
+ /** Stable: re-distilling the same session gives the same ids, so re-ingesting updates rather than duplicates. */
75
+ id: string
76
+ kind: MemoryKind
77
+ /** A short title (one line). */
78
+ name: string
79
+ /** The memory itself, self-contained, at most a few hundred characters. */
80
+ text: string
81
+ /** Which session it came from (portable id), for provenance. */
82
+ source: string
83
+ occurredAt?: string
84
+ entities?: Array<{ name: string; type?: string }>
85
+ relations?: Array<{ from: string; to: string; type: string }>
86
+ procedures?: Array<{ trigger?: string; action: string }>
87
+ }
88
+
89
+ export interface Recalled {
90
+ text: string
91
+ kind?: MemoryKind
92
+ name?: string
93
+ score: number
94
+ source?: string
95
+ /** Set by the organisation memory (src/org): who may see the item and whose it is. */
96
+ scope?: "private" | "project" | "team" | "org"
97
+ owner?: string
98
+ }
99
+
100
+ export interface MemoryStatus {
101
+ available: boolean
102
+ /** Why it is unavailable, when it is ("Chitta is not installed", ...). */
103
+ why?: string
104
+ store?: string
105
+ items?: number
106
+ }
107
+
108
+ export interface Memory {
109
+ status(): Promise<MemoryStatus>
110
+ /** `opts.scope`/`opts.team` are used by the organisation memory (src/org); the local memory ignores them (everything local is private to the person). */
111
+ ingest(project: ProjectKey, items: MemoryItem[], opts?: { scope?: "private" | "project" | "team" | "org"; team?: string }): Promise<{ stored: number }>
112
+ /** The best matches for a question within a project, at most `limit` items and `maxTokens` tokens in total. */
113
+ recall(project: ProjectKey, query: string, opts?: { limit?: number; maxTokens?: number }): Promise<Recalled[]>
114
+ /** Removes a project's memories (all of them, or those from one session). Returns how many. */
115
+ forget(project: ProjectKey, opts?: { source?: string }): Promise<number>
116
+ }
117
+
118
+ export type { Project, Session }
@@ -0,0 +1,96 @@
1
+ // brain org admin: the offline operations on the database file: `init --encrypt`, `encrypt`, `rekey`, `backup`, `restore`. They run on the
2
+ // server's machine, need no login, and never print a key: they print where a key file is, and that is all. cli.ts routes to here.
3
+ import { existsSync } from "node:fs"
4
+ import { resolve } from "node:path"
5
+ import { backupDatabase, restoreDatabase } from "./backup.ts"
6
+ import { OrgKeyError, convertDatabase, dbState, generateKey, readKeyFile, resolveKey, wipeFile, writeKeyFile } from "./crypto.ts"
7
+
8
+ type Out = (line: string) => void
9
+ interface Args {
10
+ flags: Record<string, string>
11
+ bools: Set<string>
12
+ }
13
+
14
+ /** The key for the encrypted database `init --encrypt` / `encrypt` is about to create: an existing key file is used as it is, a missing one
15
+ * is created with 256 random bits (mode 0600, never overwriting), and without `--key-file` the key comes from BRAIN_ORG_KEY, or a new
16
+ * file `<db>.key` is made. Says where the key is, never what it is. */
17
+ export function keyForNewDatabase(db: string, keyFileFlag: string | undefined, out: Out): string {
18
+ if (keyFileFlag) {
19
+ const f = resolve(keyFileFlag)
20
+ if (existsSync(f)) {
21
+ const r = readKeyFile(f)
22
+ if (r.warning) out(`warning: ${r.warning}`)
23
+ out(`using the existing key file ${f}`)
24
+ return r.key
25
+ }
26
+ const key = generateKey()
27
+ writeKeyFile(f, key)
28
+ out(`a new random 256-bit key was written to ${f} (mode 0600). Keep a copy somewhere safe and separate: without this key the database cannot be read, by anyone, ever.`)
29
+ return key
30
+ }
31
+ const env = resolveKey()
32
+ if (env.key) {
33
+ out("using the key from BRAIN_ORG_KEY")
34
+ return env.key
35
+ }
36
+ return keyForNewDatabase(db, `${db}.key`, out)
37
+ }
38
+
39
+ /** The current key of an existing database: `--key-file`, else BRAIN_ORG_KEY; none for a plain database. */
40
+ function currentKey(a: Args, out: Out): string | undefined {
41
+ const r = resolveKey({ keyFile: a.flags["key-file"] ? resolve(a.flags["key-file"]) : undefined })
42
+ if (r.warning) out(`warning: ${r.warning}`)
43
+ return r.key
44
+ }
45
+
46
+ export async function adminOps(verb: "encrypt" | "rekey" | "backup" | "restore", a: Args, out: Out): Promise<number> {
47
+ try {
48
+ const f = a.flags
49
+ if (verb === "encrypt") {
50
+ if (!f.db) return (out("usage: brain org admin encrypt --db <path> [--key-file <path>] [--remove-backup]"), 2)
51
+ const db = resolve(f.db)
52
+ if (dbState(db) === "missing") return (out(`no database at ${db}`), 1)
53
+ if (dbState(db) === "encrypted") return (out(`${db} is already encrypted. To change its key use: brain org admin rekey --db ${db} --key-file <current key file> --new-key-file <new key file>`), 1)
54
+ const key = keyForNewDatabase(db, f["key-file"], out)
55
+ const r = convertDatabase({ path: db, newKey: key })
56
+ out(`${db} is now encrypted (${Object.keys(r.tables).length} tables copied, row counts and integrity verified).`)
57
+ if (a.bools.has("remove-backup")) {
58
+ wipeFile(r.backup)
59
+ out(`the plain-text copy ${r.backup} was overwritten and removed (best effort: not a guarantee on SSDs or copy-on-write file systems).`)
60
+ } else {
61
+ out(`the previous, UNENCRYPTED file is kept as ${r.backup}: once you have started the server and checked it works, remove it (brain org admin encrypt --remove-backup does that at conversion time), and remember copies of it elsewhere (snapshots, earlier backups) are still plain text.`)
62
+ }
63
+ out("start the server with the key: BRAIN_ORG_KEY=... or --key-file <path>")
64
+ return 0
65
+ }
66
+ if (verb === "rekey") {
67
+ if (!f.db || !f["new-key-file"]) return (out("usage: brain org admin rekey --db <path> --key-file <current key file> --new-key-file <new key file> (the current key may also be in BRAIN_ORG_KEY; the new file is created if missing)"), 2)
68
+ const db = resolve(f.db)
69
+ if (dbState(db) === "missing") return (out(`no database at ${db}`), 1)
70
+ if (dbState(db) !== "encrypted") return (out(`${db} is not encrypted: use \`brain org admin encrypt --db ${db}\``), 1)
71
+ const old = currentKey(a, out)
72
+ if (!old) return (out("give the current key with --key-file or BRAIN_ORG_KEY"), 2)
73
+ if (f["key-file"] && resolve(f["key-file"]) === resolve(f["new-key-file"])) return (out("--new-key-file must be a different file from --key-file"), 2)
74
+ const key = keyForNewDatabase(db, f["new-key-file"], out)
75
+ if (key === old) return (out("the new key is the same as the current one: nothing to do"), 1)
76
+ const r = convertDatabase({ path: db, oldKey: old, newKey: key })
77
+ out(`${db} is now encrypted with the new key (${Object.keys(r.tables).length} tables copied, row counts and integrity verified).`)
78
+ out(`the previous file (still encrypted with the OLD key) is kept as ${r.backup}. Backups made earlier also need the old key. Remove them once you no longer need them, and put the new key where the server reads it before starting it.`)
79
+ return 0
80
+ }
81
+ if (verb === "backup") {
82
+ if (!f.db || !f.out) return (out("usage: brain org admin backup --db <path> --out <file> [--key-file <path>] (an encrypted database is backed up encrypted with the same key)"), 2)
83
+ const r = backupDatabase({ db: f.db, out: f.out, key: currentKey(a, out) })
84
+ out(`backup written to ${r.out} (${r.bytes} bytes, ${r.items} items, ${r.encrypted ? "encrypted with the database's key" : "NOT encrypted: protect it like the database"}), mode 0600, verified.`)
85
+ return 0
86
+ }
87
+ // restore
88
+ if (!f.from || !f.db) return (out("usage: brain org admin restore --from <backup file> --db <path> [--key-file <path>] [--force] (stop the server first)"), 2)
89
+ const r = restoreDatabase({ from: f.from, db: f.db, key: currentKey(a, out), force: a.bools.has("force") })
90
+ out(`restored ${r.items} items into ${r.db}${r.movedAside ? `; the previous database was moved to ${r.movedAside}` : ""}. Start the server with the key the backup was made with.`)
91
+ out("everything after the backup is gone from this database: tokens created or revoked since, and items added or forgotten since.")
92
+ return 0
93
+ } catch (e) {
94
+ return (out(`error: ${e instanceof OrgKeyError ? e.message : String((e as Error)?.message ?? e).split("\n")[0]}`), 1)
95
+ }
96
+ }
@@ -0,0 +1,112 @@
1
+ // brain org: authentication primitives for the organisation server. Pure functions and two small limiters; no I/O except
2
+ // randomness. Nothing here ever logs, returns or stores a token: a token is generated here, handed to the caller ONCE, and
3
+ // from then on only its SHA-256 is kept.
4
+ //
5
+ // Token format: brn_<16 hex id>_<43 base64url chars = 256 random bits>
6
+ // * the id is a public handle (listing, revoking, rate-limit key, audit); knowing it gives no access;
7
+ // * the secret is 256 bits from the OS CSPRNG, so the stored SHA-256 (no salt, no stretching needed for a high-entropy
8
+ // secret) cannot be brute forced;
9
+ // * the server looks the row up by id, then compares SHA-256(candidate) with the stored hash in CONSTANT TIME. For an
10
+ // unknown id it still performs a comparison against a dummy hash so "no such id" and "wrong secret" cost the same.
11
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
12
+ import type { OrgDb, UserRow } from "./db.ts"
13
+
14
+ export const TOKEN_PREFIX = "brn_"
15
+ const TOKEN_RE = /^brn_([0-9a-f]{16})_([A-Za-z0-9_-]{43})$/
16
+
17
+ export const sha256Hex = (s: string): string => createHash("sha256").update(s).digest("hex")
18
+
19
+ export interface NewToken {
20
+ id: string
21
+ /** The secret string to give to the user. Shown once; never stored. */
22
+ token: string
23
+ /** What is stored. */
24
+ hash: string
25
+ }
26
+
27
+ export function generateToken(): NewToken {
28
+ const id = randomBytes(8).toString("hex")
29
+ const secret = randomBytes(32).toString("base64url")
30
+ const token = `${TOKEN_PREFIX}${id}_${secret}`
31
+ return { id, token, hash: sha256Hex(token) }
32
+ }
33
+
34
+ /** The id of a well-formed token, or undefined. */
35
+ export function parseToken(token: string): { id: string } | undefined {
36
+ const m = TOKEN_RE.exec(token)
37
+ return m ? { id: m[1]! } : undefined
38
+ }
39
+
40
+ /** Constant-time equality of two hex SHA-256 strings (both are always 64 chars; anything else is simply unequal). */
41
+ export function hashesEqual(a: string, b: string): boolean {
42
+ if (a.length !== 64 || b.length !== 64) return false
43
+ return timingSafeEqual(Buffer.from(a, "utf8"), Buffer.from(b, "utf8"))
44
+ }
45
+
46
+ const DUMMY_HASH = sha256Hex("brain-org-dummy")
47
+
48
+ export type AuthFailure = "missing" | "malformed" | "unknown" | "bad_secret" | "revoked" | "expired" | "disabled"
49
+ export type AuthResult = { ok: true; user: UserRow; tokenId: string } | { ok: false; reason: AuthFailure; actor?: string }
50
+
51
+ /** `Authorization: Bearer <token>` -> the active user it belongs to, or why not. The caller answers every failure with the same 401. */
52
+ export function authenticate(db: OrgDb, header: string | null, nowMs: number): AuthResult {
53
+ if (!header) return { ok: false, reason: "missing" }
54
+ const m = /^Bearer ([^\s]+)$/i.exec(header.trim())
55
+ if (!m) return { ok: false, reason: "malformed" }
56
+ const token = m[1]!
57
+ const parsed = parseToken(token)
58
+ if (!parsed) return { ok: false, reason: "malformed" }
59
+ const row = db.token(parsed.id)
60
+ const candidate = sha256Hex(token)
61
+ if (!row) {
62
+ hashesEqual(candidate, DUMMY_HASH) // keep the cost of the unknown-id path equal to the wrong-secret path
63
+ return { ok: false, reason: "unknown" }
64
+ }
65
+ if (!hashesEqual(candidate, row.hash)) return { ok: false, reason: "bad_secret" }
66
+ if (row.revoked_at != null) return { ok: false, reason: "revoked", actor: row.user_id }
67
+ if (row.expires_at != null && row.expires_at <= nowMs) return { ok: false, reason: "expired", actor: row.user_id }
68
+ const user = db.user(row.user_id)
69
+ if (!user || user.disabled) return { ok: false, reason: "disabled", actor: row.user_id }
70
+ // last-used is bookkeeping for admins; throttled so a busy client does not write on every request
71
+ if (row.last_used_at == null || nowMs - row.last_used_at > 60_000) db.touchToken(row.id, nowMs)
72
+ return { ok: true, user, tokenId: row.id }
73
+ }
74
+
75
+ /** Fixed-window counter per key (a token id, an IP address). Memory is bounded: expired windows are dropped when the map grows. */
76
+ export class WindowLimiter {
77
+ private windows = new Map<string, { start: number; n: number }>()
78
+ constructor(
79
+ private readonly limit: number,
80
+ private readonly windowMs = 60_000,
81
+ private readonly maxKeys = 10_000,
82
+ ) {}
83
+
84
+ /** Counts one event; returns false when the key is over its limit for the current window. */
85
+ hit(key: string, nowMs: number): boolean {
86
+ const w = this.windows.get(key)
87
+ if (!w || nowMs - w.start >= this.windowMs) {
88
+ if (this.windows.size >= this.maxKeys) this.sweep(nowMs)
89
+ if (this.windows.size >= this.maxKeys) this.windows.clear() // under attack with many keys: forget rather than grow
90
+ this.windows.set(key, { start: nowMs, n: 1 })
91
+ return true
92
+ }
93
+ w.n++
94
+ return w.n <= this.limit
95
+ }
96
+
97
+ /** Seconds until the key's window ends. */
98
+ retryAfter(key: string, nowMs: number): number {
99
+ const w = this.windows.get(key)
100
+ return w ? Math.max(1, Math.ceil((w.start + this.windowMs - nowMs) / 1000)) : 1
101
+ }
102
+
103
+ /** True when the key is already over its limit (does not count). */
104
+ blocked(key: string, nowMs: number): boolean {
105
+ const w = this.windows.get(key)
106
+ return !!w && nowMs - w.start < this.windowMs && w.n > this.limit
107
+ }
108
+
109
+ private sweep(nowMs: number) {
110
+ for (const [k, w] of this.windows) if (nowMs - w.start >= this.windowMs) this.windows.delete(k)
111
+ }
112
+ }
@@ -0,0 +1,115 @@
1
+ // brain org: backup and restore of the organisation database (one file: Chitta's tables and the `org_*` tables together).
2
+ //
3
+ // BACKUP is `VACUUM INTO`: SQLite writes a consistent snapshot of the database as of one moment, through the same connection layer
4
+ // that reads it, so it is safe while the server is running (readers do not block the writer in WAL mode; the copy sees the
5
+ // state at the moment it started) and it includes whatever is still only in the -wal. From an encrypted database the copy is
6
+ // written encrypted with the SAME key (checked in test/org-crypto.test.ts by reading the copy's bytes); from a plain one it is plain.
7
+ // It refuses to overwrite anything (the output file is created exclusively, mode 0600) and verifies the copy before reporting success.
8
+ //
9
+ // RESTORE (`brain org admin restore`) puts a backup in place as the database: stop the server first (it refuses while a live server's
10
+ // lock file exists), it refuses to replace an existing database unless `--force` (the old file is then moved aside, never deleted),
11
+ // it removes a stale -wal/-shm of the old file (a leftover log applied to a different file would corrupt it), and it checks the backup
12
+ // opens with the key before touching anything. Plain `cp` of the backup file back to the database path, with the server stopped and the
13
+ // old -wal/-shm removed, is equivalent; `restore` just does the checks. The key that opens a backup is the key the database had when
14
+ // the backup was made: after `admin rekey`, older backups still need the OLD key, so keep it until they are gone.
15
+ // A restore brings back everything as of the backup: tokens created or revoked, and items added or forgotten, after it are lost.
16
+ import { chmodSync, closeSync, copyFileSync, existsSync, openSync, renameSync, rmSync, statSync } from "node:fs"
17
+ import { dirname, resolve } from "node:path"
18
+ import { OrgKeyError, dbState, liveServer, openSqlite, rebuildVectorIndexAt, validateKey } from "./crypto.ts"
19
+
20
+ export interface BackupResult {
21
+ out: string
22
+ bytes: number
23
+ encrypted: boolean
24
+ items: number
25
+ }
26
+
27
+ /** Checks that `path` opens (with `key` when encrypted), passes SQLite's integrity check and holds an organisation. */
28
+ function verify(path: string, key: string | undefined): { items: number; org: string } {
29
+ const db = openSqlite(path, key)
30
+ try {
31
+ const ok = (db.query("PRAGMA integrity_check").get() as { integrity_check?: string } | null)?.integrity_check
32
+ if (ok !== "ok") throw new OrgKeyError(`${path} failed SQLite's integrity check`)
33
+ const org = db.query("SELECT value FROM org_meta WHERE key = 'org_id'").get() as { value: string } | null
34
+ if (!org) throw new OrgKeyError(`${path} holds no organisation`)
35
+ const items = (db.query("SELECT count(*) AS c FROM org_items").get() as { c: number }).c
36
+ return { items, org: org.value }
37
+ } finally {
38
+ db.close()
39
+ }
40
+ }
41
+
42
+ const sqlStr = (s: string): string => `'${s.replaceAll("'", "''")}'`
43
+
44
+ export function backupDatabase(o: { db: string; out: string; key?: string }): BackupResult {
45
+ const db = resolve(o.db)
46
+ const out = resolve(o.out)
47
+ const state = dbState(db)
48
+ if (state === "missing" || state === "empty") throw new OrgKeyError(`no database at ${db}`)
49
+ if (state === "encrypted" && !o.key) throw new OrgKeyError(`${db} is encrypted: give the key with BRAIN_ORG_KEY or --key-file <path> (the backup stays encrypted with it)`)
50
+ if (state === "plain" && o.key) throw new OrgKeyError(`${db} is not encrypted but a key was given`)
51
+ if (o.key) validateKey(o.key)
52
+ if (existsSync(out)) throw new OrgKeyError(`${out} already exists; a backup never overwrites a file: pick another name`)
53
+ if (!existsSync(dirname(out))) throw new OrgKeyError(`the folder ${dirname(out)} does not exist`)
54
+ const src = openSqlite(db, o.key)
55
+ // VACUUM INTO refuses a target that exists (so it can never overwrite) and creates it itself: a restrictive umask makes it private from the first byte
56
+ const oldMask = process.umask(0o077)
57
+ try {
58
+ try {
59
+ src.exec("PRAGMA busy_timeout = 5000")
60
+ } catch {}
61
+ src.exec(`VACUUM INTO ${sqlStr(out)}`)
62
+ } catch (e) {
63
+ if (!/already exists/i.test(String((e as Error)?.message))) rmSync(out, { force: true }) // never delete a file that was not ours
64
+ throw e instanceof OrgKeyError ? e : new OrgKeyError(`the backup failed: ${String((e as Error)?.message ?? e).split("\n")[0]}`)
65
+ } finally {
66
+ process.umask(oldMask)
67
+ src.close()
68
+ }
69
+ try {
70
+ chmodSync(out, 0o600)
71
+ if (o.key) rebuildVectorIndexAt(out, o.key)
72
+ const v = verify(out, o.key)
73
+ return { out, bytes: statSync(out).size, encrypted: state === "encrypted", items: v.items }
74
+ } catch (e) {
75
+ rmSync(out, { force: true })
76
+ throw e
77
+ }
78
+ }
79
+
80
+ export interface RestoreResult {
81
+ db: string
82
+ items: number
83
+ /** The database that was moved aside (only with --force and an existing database). */
84
+ movedAside?: string
85
+ }
86
+
87
+ export function restoreDatabase(o: { from: string; db: string; key?: string; force?: boolean; now?: () => number }): RestoreResult {
88
+ const from = resolve(o.from)
89
+ const db = resolve(o.db)
90
+ if (!existsSync(from)) throw new OrgKeyError(`no backup at ${from}`)
91
+ if (from === db) throw new OrgKeyError("the backup and the database are the same file")
92
+ const state = dbState(from)
93
+ if (state === "empty" || state === "missing") throw new OrgKeyError(`${from} is empty`)
94
+ if (state === "encrypted" && !o.key) throw new OrgKeyError(`${from} is encrypted: give the key it was made with (BRAIN_ORG_KEY or --key-file)`)
95
+ if (state === "plain" && o.key) throw new OrgKeyError(`${from} is not encrypted but a key was given`)
96
+ const live = liveServer(db)
97
+ if (live !== undefined) throw new OrgKeyError(`a server (pid ${live > 0 ? live : "unknown"}) is running on ${db}: stop it first, then restore`)
98
+ if (existsSync(db) && !o.force) throw new OrgKeyError(`${db} already exists; restoring would replace it. Move it away, or pass --force to have it moved aside for you`)
99
+ const v = verify(from, o.key) // before anything is touched
100
+ if (!existsSync(dirname(db))) throw new OrgKeyError(`the folder ${dirname(db)} does not exist`)
101
+ const tmp = `${db}.restoring-${process.pid}.tmp`
102
+ rmSync(tmp, { force: true })
103
+ closeSync(openSync(tmp, "wx", 0o600))
104
+ copyFileSync(from, tmp)
105
+ chmodSync(tmp, 0o600)
106
+ let movedAside: string | undefined
107
+ if (existsSync(db)) {
108
+ movedAside = `${db}.replaced-${(o.now ?? Date.now)()}`
109
+ renameSync(db, movedAside)
110
+ for (const s of ["-wal", "-shm"]) if (existsSync(`${db}${s}`)) renameSync(`${db}${s}`, `${movedAside}${s}`) // the log goes with its file
111
+ }
112
+ for (const s of ["-wal", "-shm"]) rmSync(`${db}${s}`, { force: true }) // a stale log must never be applied to the restored file
113
+ renameSync(tmp, db)
114
+ return { db, items: v.items, ...(movedAside ? { movedAside } : {}) }
115
+ }