@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,270 @@
1
+ // The brain's own memory store: Chitta (permission-aware graph + vector memory) at `<home>/.brain/memory/context.db`.
2
+ //
3
+ // Chitta is optional and not a dependency: the SDK is loaded lazily from the first place it is found (`$BRAIN_CHITTA`,
4
+ // next to brain's own install, `~/node_modules/@100xprompt/chitta`, a global bun/npm install, then $BRAIN_CHITTA_DIRS) and
5
+ // `status()` says why when it is not.
6
+ // The person's personal Chitta store (CONTEXT_DB, ~/.local/share/100xprompt) is never opened: the path is always explicit.
7
+ //
8
+ // Project scoping, chosen: ONE CHITTA PRINCIPAL PER PROJECT (`chitta.agent("brain-<hash of project id>")`). Every record is
9
+ // private to its principal, so the engine's own ACL keeps projects apart in search, graph expansion and facts; a memory
10
+ // from project A cannot come back for project B even by a bug in this file. (The alternative, filtering after recall, would
11
+ // leak across projects the day a filter was forgotten, and would let one project's records crowd out another's top-k.)
12
+ // A small side table `brain_items` in the same SQLite file keeps the full text and the kind/source/project of every item:
13
+ // Chitta chunks long records into passages, and recall must hand back whole items, so Chitta only ranks and the table answers.
14
+ import { existsSync, mkdirSync, statSync } from "node:fs"
15
+ import { homedir } from "node:os"
16
+ import { dirname, join } from "node:path"
17
+ import { pathToFileURL } from "node:url"
18
+ import { createHash } from "node:crypto"
19
+ import { openRemoteMemory } from "../org/client.ts"
20
+ import { readOrgConfig } from "../org/config.ts"
21
+ import { redact } from "./redact.ts"
22
+ import { approxTokens, type Memory, type MemoryItem, type MemoryKind, type MemoryStatus, type ProjectKey, type Recalled } from "./types.ts"
23
+
24
+ export interface MemoryCounts {
25
+ total: number
26
+ byKind: Partial<Record<MemoryKind, number>>
27
+ }
28
+
29
+ /** `Memory` plus what the CLI and the MCP server's status need. */
30
+ export interface BrainMemory extends Memory {
31
+ /** Item counts for one project, or the whole store. */
32
+ counts(project?: ProjectKey): Promise<MemoryCounts>
33
+ /** Projects with memories: id, label, items. */
34
+ projects(): Promise<Array<{ id: string; label: string; items: number }>>
35
+ /** Closes the database (long-running servers may keep it open; tests and one-shot commands close it). */
36
+ close(): void
37
+ }
38
+
39
+ export const memoryDbPath = (home: string): string => join(home, ".brain", "memory", "context.db")
40
+
41
+ const sha = (s: string, n = 12): string => createHash("sha1").update(s).digest("hex").slice(0, n)
42
+ const principalOf = (project: ProjectKey): string => `brain-${sha(project.id)}`
43
+ const recordId = (project: ProjectKey, item: MemoryItem): string => `${sha(project.id)}~${sha(item.source, 8)}~${item.id}`
44
+
45
+ // ---- locating the SDK ---------------------------------------------------------------------------------------------
46
+
47
+ /** Where Chitta's SDK entry might be, in order; an explicit `$BRAIN_CHITTA` is the only candidate when it is set. */
48
+ export function chittaCandidates(env = process.env, home = homedir()): string[] {
49
+ const entry = (dir: string): string[] => [join(dir, "dist", "sdk.js"), join(dir, "src", "sdk.ts")]
50
+ const explicit = env.BRAIN_CHITTA
51
+ if (explicit) return /\.(?:js|ts|mjs)$/.test(explicit) ? [explicit] : entry(explicit)
52
+ const dirs = [
53
+ // Beside this brain: installed in the same node_modules (a global bun or npm install, or a project's).
54
+ join(import.meta.dir, "..", "..", "..", "chitta"),
55
+ join(home, "node_modules", "@100xprompt", "chitta"),
56
+ ...(env.BUN_INSTALL ? [join(env.BUN_INSTALL, "install", "global", "node_modules", "@100xprompt", "chitta")] : []),
57
+ join(home, ".bun", "install", "global", "node_modules", "@100xprompt", "chitta"),
58
+ "/opt/homebrew/lib/node_modules/@100xprompt/chitta",
59
+ "/usr/local/lib/node_modules/@100xprompt/chitta",
60
+ join(home, ".npm-global", "lib", "node_modules", "@100xprompt", "chitta"),
61
+ // More places, searched last (a Chitta checkout, for example): BRAIN_CHITTA_DIRS, separated like PATH.
62
+ ...(env.BRAIN_CHITTA_DIRS ?? "").split(process.platform === "win32" ? ";" : ":").filter(Boolean),
63
+ ]
64
+ return dirs.flatMap(entry)
65
+ }
66
+
67
+ interface ChittaLike {
68
+ ctx: { store: { db: SqlDb; close(): void }; deleteRecord(actingUserId: string, recordId: string): void }
69
+ agent(id: string): { userId: string; remember(text: string, opts: Record<string, unknown>): Promise<{ id: string }>; recall(query: string, opts?: { limit?: number }): Promise<Array<{ text: string; score: number; recordId?: string }>> }
70
+ close(): void
71
+ }
72
+ interface SqlDb {
73
+ exec(sql: string): void
74
+ query(sql: string): { all(...p: unknown[]): any[]; get(...p: unknown[]): any; run(...p: unknown[]): unknown }
75
+ }
76
+
77
+ async function loadChittaClass(): Promise<{ Chitta: new (o: Record<string, unknown>) => ChittaLike; from: string } | { why: string }> {
78
+ const tried: string[] = []
79
+ for (const file of chittaCandidates()) {
80
+ if (!existsSync(file)) continue
81
+ try {
82
+ const mod = await import(pathToFileURL(file).href)
83
+ if (typeof mod.Chitta === "function") return { Chitta: mod.Chitta, from: file }
84
+ tried.push(`${file} (no Chitta export)`)
85
+ } catch (e) {
86
+ tried.push(`${file} (${(e as Error).message.split("\n")[0]})`)
87
+ }
88
+ }
89
+ return { why: tried.length ? `Chitta could not be loaded: ${tried.join("; ")}` : "Chitta is not installed (set BRAIN_CHITTA to its folder, or install @100xprompt/chitta)" }
90
+ }
91
+
92
+ // ---- busy handling ------------------------------------------------------------------------------------------------
93
+
94
+ const BUSY = /SQLITE_BUSY|database is locked|database table is locked/i
95
+ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
96
+
97
+ /** Runs `fn`, retrying while another process holds the database (several tools each run their own `brain mcp`). */
98
+ async function whenIdle<T>(fn: () => T | Promise<T>, tries = 12): Promise<T> {
99
+ for (let i = 0; ; i++) {
100
+ try {
101
+ return await fn()
102
+ } catch (e) {
103
+ if (i >= tries || !BUSY.test(String((e as Error)?.message ?? e))) throw e
104
+ await sleep(Math.min(40 * 2 ** Math.min(i, 5), 600) + Math.random() * 40)
105
+ }
106
+ }
107
+ }
108
+
109
+ // ---- the store ----------------------------------------------------------------------------------------------------
110
+
111
+ /** The memory the brain uses: the organisation's server when this machine is logged in to one (<home>/.brain/org.json, unless
112
+ * BRAIN_ORG=off), else the local store below, exactly as before. */
113
+ export function openMemory(home: string): BrainMemory {
114
+ if (process.env.BRAIN_ORG !== "off") {
115
+ const cfg = readOrgConfig(home)
116
+ if (cfg) return openRemoteMemory(cfg)
117
+ }
118
+ return openLocalMemory(home)
119
+ }
120
+
121
+ /** The local store only: `<home>/.brain/memory/context.db`. */
122
+ export function openLocalMemory(home: string): BrainMemory {
123
+ const path = memoryDbPath(home)
124
+ const hash = process.env.BRAIN_MEMORY_EMBEDDINGS === "hash" || process.env.NODE_ENV === "test"
125
+ let handle: Promise<{ chitta: ChittaLike; db: SqlDb } | { why: string }> | undefined
126
+
127
+ const open = (): Promise<{ chitta: ChittaLike; db: SqlDb } | { why: string }> =>
128
+ (handle ??= (async () => {
129
+ const loaded = await loadChittaClass()
130
+ if ("why" in loaded) return loaded
131
+ try {
132
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 })
133
+ const chitta = await whenIdle(() => new loaded.Chitta({ path, embeddings: hash ? "hash" : "auto", rerank: false }))
134
+ const db = chitta.ctx.store.db
135
+ try {
136
+ db.exec("PRAGMA busy_timeout = 5000")
137
+ } catch {}
138
+ await whenIdle(() =>
139
+ db.exec(
140
+ `CREATE TABLE IF NOT EXISTS brain_items (rid TEXT PRIMARY KEY, project TEXT NOT NULL, label TEXT NOT NULL, source TEXT NOT NULL, kind TEXT NOT NULL, name TEXT NOT NULL, text TEXT NOT NULL, at TEXT);
141
+ CREATE INDEX IF NOT EXISTS brain_items_project ON brain_items(project, source);`,
142
+ ),
143
+ )
144
+ return { chitta, db }
145
+ } catch (e) {
146
+ return { why: `memory store could not be opened at ${path}: ${(e as Error).message.split("\n")[0]}` }
147
+ }
148
+ })())
149
+
150
+ const ready = async () => {
151
+ const h = await open()
152
+ return "why" in h ? undefined : h
153
+ }
154
+
155
+ return {
156
+ async status(): Promise<MemoryStatus> {
157
+ const h = await open()
158
+ if ("why" in h) return { available: false, why: h.why }
159
+ const items = (h.db.query("SELECT count(*) c FROM brain_items").get() as { c: number }).c
160
+ return { available: true, store: path, items }
161
+ },
162
+
163
+ async ingest(project, items) {
164
+ const h = await ready()
165
+ if (!h) throw new Error("memory is not available")
166
+ const agent = h.chitta.agent(principalOf(project))
167
+ let stored = 0
168
+ for (const item of items) {
169
+ // Everything is redacted on the way in, even when the caller already did: this is the last gate before disk.
170
+ const text = redact(item.text)
171
+ const name = redact(item.name)
172
+ const rid = recordId(project, item)
173
+ await whenIdle(() =>
174
+ agent.remember(text, {
175
+ id: rid,
176
+ name: `${item.kind}: ${name}`,
177
+ entities: item.entities?.map((e) => ({ name: redact(e.name), ...(e.type ? { type: e.type } : {}) })),
178
+ relations: item.relations?.map((r) => ({ from: redact(r.from), to: redact(r.to), type: r.type })),
179
+ procedures: item.procedures?.map((p) => ({ ...(p.trigger ? { trigger: redact(p.trigger) } : {}), action: redact(p.action) })),
180
+ occurredAt: item.occurredAt,
181
+ }),
182
+ )
183
+ await whenIdle(() =>
184
+ h.db
185
+ .query("INSERT OR REPLACE INTO brain_items (rid, project, label, source, kind, name, text, at) VALUES (?,?,?,?,?,?,?,?)")
186
+ .run(rid, project.id, project.label, item.source, item.kind, name, text, item.occurredAt ?? null),
187
+ )
188
+ stored++
189
+ }
190
+ return { stored }
191
+ },
192
+
193
+ async recall(project, query, opts = {}) {
194
+ const h = await ready()
195
+ if (!h || !query.trim()) return []
196
+ const limit = opts.limit ?? 8
197
+ const maxTokens = opts.maxTokens ?? 1500
198
+ const agent = h.chitta.agent(principalOf(project))
199
+ const hits = await whenIdle(() => agent.recall(query, { limit: Math.max(limit * 4, 20) }))
200
+ const seen = new Set<string>()
201
+ const out: Recalled[] = []
202
+ let tokens = 0
203
+ for (const hit of hits) {
204
+ if (!hit.recordId || seen.has(hit.recordId) || !(hit.score > 0)) continue
205
+ seen.add(hit.recordId)
206
+ const row = h.db.query("SELECT kind, name, source, text FROM brain_items WHERE rid = ? AND project = ?").get(hit.recordId, project.id) as
207
+ | { kind: MemoryKind; name: string; source: string; text: string }
208
+ | undefined
209
+ if (!row) continue // not ours, or deleted: never returned
210
+ const cost = approxTokens(row.text)
211
+ if (out.length && tokens + cost > maxTokens) continue
212
+ out.push({ text: redact(row.text), kind: row.kind, name: row.name, score: hit.score, source: row.source })
213
+ tokens += cost
214
+ if (out.length >= limit) break
215
+ }
216
+ return out
217
+ },
218
+
219
+ async forget(project, opts = {}) {
220
+ const h = await ready()
221
+ if (!h) return 0
222
+ const agent = h.chitta.agent(principalOf(project))
223
+ const rows = (opts.source
224
+ ? h.db.query("SELECT rid FROM brain_items WHERE project = ? AND source = ?").all(project.id, opts.source)
225
+ : h.db.query("SELECT rid FROM brain_items WHERE project = ?").all(project.id)) as Array<{ rid: string }>
226
+ for (const { rid } of rows) {
227
+ await whenIdle(() => h.chitta.ctx.deleteRecord(agent.userId, rid))
228
+ await whenIdle(() => h.db.query("DELETE FROM brain_items WHERE rid = ?").run(rid))
229
+ }
230
+ return rows.length
231
+ },
232
+
233
+ async counts(project) {
234
+ const h = await ready()
235
+ if (!h) return { total: 0, byKind: {} }
236
+ const rows = (project
237
+ ? h.db.query("SELECT kind, count(*) c FROM brain_items WHERE project = ? GROUP BY kind").all(project.id)
238
+ : h.db.query("SELECT kind, count(*) c FROM brain_items GROUP BY kind").all()) as Array<{ kind: MemoryKind; c: number }>
239
+ const byKind: MemoryCounts["byKind"] = {}
240
+ let total = 0
241
+ for (const r of rows) {
242
+ byKind[r.kind] = r.c
243
+ total += r.c
244
+ }
245
+ return { total, byKind }
246
+ },
247
+
248
+ async projects() {
249
+ const h = await ready()
250
+ if (!h) return []
251
+ return (h.db.query("SELECT project id, max(label) label, count(*) items FROM brain_items GROUP BY project ORDER BY items DESC").all() as Array<{ id: string; label: string; items: number }>)
252
+ },
253
+
254
+ close() {
255
+ if (!handle) return
256
+ void handle.then((h) => {
257
+ if (!("why" in h)) try { h.chitta.close() } catch {}
258
+ })
259
+ },
260
+ }
261
+ }
262
+
263
+ /** The size of the store on disk in bytes (0 when it does not exist), for status output. */
264
+ export function storeBytes(home: string): number {
265
+ try {
266
+ return statSync(memoryDbPath(home)).size
267
+ } catch {
268
+ return 0
269
+ }
270
+ }
@@ -0,0 +1,166 @@
1
+ // Keeps a project's memory up to date by itself: the sessions of this project that memory has not seen (or that grew since)
2
+ // are distilled and ingested. It runs in the background when a session starts in any tool (the session-start hook starts it),
3
+ // and by hand as `brain memory sync`. Idempotent: item ids are stable and a ledger records what was done, so running it
4
+ // twice does nothing the second time. Bounded: a few sessions and a few seconds per run; the rest waits for the next run.
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
6
+ import { join } from "node:path"
7
+ import { gitInfo } from "../fsutil.ts"
8
+ import type { Session } from "../session/types.ts"
9
+ import { distill } from "./distill.ts"
10
+ import { projectKey } from "./project.ts"
11
+ import { openMemory } from "./store.ts"
12
+ import type { Memory } from "./types.ts"
13
+
14
+ export interface SessionSource {
15
+ /** Portable id (`<tool>:<id>`). */
16
+ id: string
17
+ cwd?: string
18
+ updatedAt?: string
19
+ read(): Promise<Session>
20
+ }
21
+
22
+ export interface SyncOptions {
23
+ home: string
24
+ /** Only sessions of the project this folder belongs to (default: every project). */
25
+ cwd?: string
26
+ /** At most this many sessions per run. Default 8. */
27
+ limit?: number
28
+ /** Stop starting new sessions after this long. Default 8,000 ms. */
29
+ maxMs?: number
30
+ }
31
+
32
+ export interface SyncResult {
33
+ ingested: number
34
+ items: number
35
+ /** Sessions already in the ledger at their current time. */
36
+ upToDate: number
37
+ /** Sessions left for a later run (over the limit or the time budget). */
38
+ deferred: number
39
+ unavailable?: string
40
+ /** Another sync holds the lock: nothing was done. */
41
+ busy?: boolean
42
+ errors: string[]
43
+ ms: number
44
+ }
45
+
46
+ type Ledger = Record<string, { u: string; n: number }>
47
+
48
+ const dirOf = (home: string) => join(home, ".brain", "memory")
49
+ const ledgerFile = (home: string) => join(dirOf(home), "ledger.json")
50
+ const lockDir = (home: string) => join(dirOf(home), "sync.lock")
51
+ /** A lock older than this is left over from a run that died. */
52
+ const STALE_LOCK_MS = 2 * 60_000
53
+
54
+ function readLedger(home: string): Ledger {
55
+ try {
56
+ const j = JSON.parse(readFileSync(ledgerFile(home), "utf8"))
57
+ return j && typeof j === "object" && !Array.isArray(j) ? j : {}
58
+ } catch {
59
+ return {}
60
+ }
61
+ }
62
+
63
+ function writeLedger(home: string, l: Ledger) {
64
+ mkdirSync(dirOf(home), { recursive: true })
65
+ const tmp = `${ledgerFile(home)}.tmp-${process.pid}`
66
+ writeFileSync(tmp, JSON.stringify(l))
67
+ renameSync(tmp, ledgerFile(home))
68
+ }
69
+
70
+ /** Takes the lock (an atomic mkdir), clearing one left by a dead run. False when another run holds it. */
71
+ function lock(home: string): boolean {
72
+ mkdirSync(dirOf(home), { recursive: true })
73
+ const dir = lockDir(home)
74
+ for (let attempt = 0; attempt < 2; attempt++) {
75
+ try {
76
+ mkdirSync(dir)
77
+ return true
78
+ } catch {
79
+ try {
80
+ if (Date.now() - statSync(dir).mtimeMs < STALE_LOCK_MS) return false
81
+ rmSync(dir, { recursive: true, force: true })
82
+ } catch {
83
+ return false
84
+ }
85
+ }
86
+ }
87
+ return false
88
+ }
89
+
90
+ const unlock = (home: string) => rmSync(lockDir(home), { recursive: true, force: true })
91
+
92
+ /** Whether a session that ran in `cwd` belongs to the project of `here` (the same folder, one inside it, or one above it in one repository). */
93
+ function inProject(cwd: string | undefined, here: string, home: string): boolean {
94
+ if (!cwd) return false
95
+ if (cwd === here || cwd.startsWith(`${here}/`)) return true
96
+ if (!here.startsWith(`${cwd}/`) || cwd === home || home.startsWith(`${cwd}/`)) return false
97
+ const a = gitInfo(cwd).gitRemote
98
+ return !!a && a === gitInfo(here).gitRemote
99
+ }
100
+
101
+ async function defaultSessions(home: string): Promise<SessionSource[]> {
102
+ const { READERS } = await import("../adapters/index.ts")
103
+ const lists = await Promise.all(READERS.map((r) => r.list(home).then((refs) => refs.map((ref) => ({ r, ref }))).catch(() => [])))
104
+ return lists
105
+ .flat()
106
+ .map(({ r, ref }) => ({ id: `${ref.tool}:${ref.sourceId}`, ...(ref.cwd ? { cwd: ref.cwd } : {}), ...(ref.updatedAt ? { updatedAt: ref.updatedAt } : {}), read: () => r.read(ref, home) }))
107
+ }
108
+
109
+ export async function syncMemory(o: SyncOptions, deps: { memory?: Memory; sessions?: () => Promise<SessionSource[]>; now?: () => number } = {}): Promise<SyncResult> {
110
+ const now = deps.now ?? Date.now
111
+ const t0 = now()
112
+ const result: SyncResult = { ingested: 0, items: 0, upToDate: 0, deferred: 0, errors: [], ms: 0 }
113
+ const done = (): SyncResult => ({ ...result, ms: now() - t0 })
114
+
115
+ const memory = deps.memory ?? openMemory(o.home)
116
+ const status = await memory.status().catch((e: Error) => ({ available: false as const, why: e.message }))
117
+ if (!status.available) return { ...done(), unavailable: status.why ?? "unavailable" }
118
+ if (!lock(o.home)) return { ...done(), busy: true }
119
+ try {
120
+ const here = o.cwd
121
+ const all = (await (deps.sessions ?? (() => defaultSessions(o.home)))()).filter((s) => !here || inProject(s.cwd, here, o.home))
122
+ all.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
123
+ const ledger = readLedger(o.home)
124
+ const todo = all.filter((s) => (ledger[s.id]?.u ?? "") !== (s.updatedAt ?? "none"))
125
+ result.upToDate = all.length - todo.length
126
+ const limit = o.limit ?? 8
127
+ const budget = o.maxMs ?? 8_000
128
+ for (const [i, s] of todo.entries()) {
129
+ if (result.ingested >= limit || now() - t0 > budget) {
130
+ result.deferred = todo.length - i
131
+ break
132
+ }
133
+ try {
134
+ const session = await s.read()
135
+ const items = distill(session)
136
+ if (items.length) {
137
+ const key = projectKey({ ...session.header.project, ...(session.header.project.cwd ? gitInfo(session.header.project.cwd) : {}) })
138
+ await memory.ingest(key, items)
139
+ }
140
+ ledger[s.id] = { u: s.updatedAt ?? "none", n: items.length }
141
+ result.ingested++
142
+ result.items += items.length
143
+ } catch (e) {
144
+ // One unreadable session must not stop the rest; it is retried next time (it is not in the ledger).
145
+ result.errors.push(`${s.id}: ${(e as Error).message}`)
146
+ }
147
+ }
148
+ if (result.ingested) writeLedger(o.home, ledger)
149
+ } finally {
150
+ unlock(o.home)
151
+ }
152
+ return done()
153
+ }
154
+
155
+ /** Starts a sync in a separate process and returns at once: a hook must never wait for it. */
156
+ export function syncInBackground(home: string, cwd: string, cli: string) {
157
+ if (process.env.BRAIN_NO_MEMORY === "1") return
158
+ try {
159
+ // Only where memory has been set up on this machine: a person who never used it does not get a store created by a hook.
160
+ if (!existsSync(join(home, ".brain", "memory"))) return
161
+ const child = Bun.spawn([process.execPath, cli, "memory", "sync", "--here", "--quiet"], { cwd, stdin: "ignore", stdout: "ignore", stderr: "ignore", env: { ...process.env, BRAIN_HOME: home } })
162
+ child.unref()
163
+ } catch {
164
+ // Best effort.
165
+ }
166
+ }
@@ -0,0 +1,193 @@
1
+ // tidy: lossless-in-meaning cleaning of a tool result that STAYS in the conversation (the original text of anything replaced
2
+ // by a digest is kept byte for byte in the blob store; this step never sees it). Pure, deterministic, no model, never grows a text.
3
+ //
4
+ // What it does (and what it deliberately does not):
5
+ // - strips ANSI escape sequences (CSI and OSC);
6
+ // - resolves carriage-return progress lines the way a terminal shows them (the last state of a `\r`-overwritten line);
7
+ // - turns CRLF into LF, trims trailing spaces/tabs per line, collapses 3+ blank lines to one blank line;
8
+ // - collapses 3+ identical consecutive lines into the line plus `[previous line repeated N more times]`, only when that is shorter;
9
+ // - minifies a text that is ONE JSON document, or JSON lines that each parse, by removing only the whitespace outside strings:
10
+ // every lexeme (1.0, 1e2, big integers, escapes, key order, duplicate keys) stays byte for byte, and the result is checked to
11
+ // parse to the same value, else the text is left alone.
12
+ // NOT done: stripping a `cat -n` style line-number prefix. The numbers carry meaning (a model cites them, an edit tool is told them)
13
+ // and the strip cannot be made reversible, so it is skipped by design.
14
+ import { STUB_PREFIX } from "./types.ts"
15
+ import type { Session, Turn } from "../session/types.ts"
16
+
17
+ export interface Tidied {
18
+ text: string
19
+ /** Characters saved (never negative). */
20
+ saved: number
21
+ }
22
+
23
+ /** Longer texts are left alone: bounded time matters more than the last few percent. */
24
+ const MAX_CHARS = 10_000_000
25
+ const MAX_JSON_VERIFY = 5_000_000
26
+
27
+ // CSI: ESC [ params intermediates final. OSC: ESC ] ... BEL or ESC \. Neither class can contain ESC, so scans are linear.
28
+ const ANSI = /\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g
29
+
30
+ function trimEnd(line: string): string {
31
+ let e = line.length
32
+ while (e > 0) {
33
+ const c = line.charCodeAt(e - 1)
34
+ if (c === 32 || c === 9) e--
35
+ else break
36
+ }
37
+ return e === line.length ? line : line.slice(0, e)
38
+ }
39
+
40
+ /** The state of a line a terminal would show after all its `\r`-separated writes (later writes overwrite from column 0). */
41
+ function overlay(line: string): string {
42
+ let cur: string[] = []
43
+ for (const seg of line.split("\r")) {
44
+ if (!seg) continue
45
+ const s = Array.from(seg)
46
+ cur = s.length >= cur.length ? s : [...s, ...cur.slice(s.length)]
47
+ }
48
+ return cur.join("")
49
+ }
50
+
51
+ const isWs = (c: number) => c === 32 || c === 9 || c === 10 || c === 13
52
+
53
+ /** Removes the whitespace outside strings from text that is already known to be valid JSON. Linear, no recursion. */
54
+ function stripJsonWhitespace(s: string): string {
55
+ const parts: string[] = []
56
+ let start = 0
57
+ let inStr = false
58
+ for (let i = 0; i < s.length; i++) {
59
+ const c = s.charCodeAt(i)
60
+ if (inStr) {
61
+ if (c === 92) i++
62
+ else if (c === 34) inStr = false
63
+ } else if (c === 34) inStr = true
64
+ else if (isWs(c)) {
65
+ if (i > start) parts.push(s.slice(start, i))
66
+ start = i + 1
67
+ }
68
+ }
69
+ if (start < s.length) parts.push(s.slice(start))
70
+ return parts.join("")
71
+ }
72
+
73
+ /**
74
+ * For text that is one JSON container document: its minified form (or the trimmed text itself when nothing can be removed or the
75
+ * minified form cannot be proven to parse to the same value). undefined when the text is not one JSON container document.
76
+ */
77
+ function minifyDoc(s: string): string | undefined {
78
+ const t = s.trim()
79
+ if (t.length < 2) return undefined
80
+ const a = t[0]
81
+ const z = t[t.length - 1]
82
+ if (!((a === "{" && z === "}") || (a === "[" && z === "]"))) return undefined
83
+ let v: unknown
84
+ try {
85
+ v = JSON.parse(t)
86
+ } catch {
87
+ return undefined
88
+ }
89
+ const min = stripJsonWhitespace(t)
90
+ if (min.length >= t.length) return t
91
+ try {
92
+ if (t.length <= MAX_JSON_VERIFY && JSON.stringify(JSON.parse(min)) !== JSON.stringify(v)) return t
93
+ } catch {
94
+ return t
95
+ }
96
+ return min
97
+ }
98
+
99
+ /** Minified JSON text (possibly unchanged) when the text is one JSON document or JSON lines that all parse; else undefined. */
100
+ function minifyJson(s: string): string | undefined {
101
+ const whole = minifyDoc(s)
102
+ if (whole !== undefined) return whole
103
+ const out: string[] = []
104
+ let docs = 0
105
+ for (const line of s.split("\n")) {
106
+ if (!line.trim()) {
107
+ out.push("")
108
+ continue
109
+ }
110
+ const m = minifyDoc(line)
111
+ if (m === undefined) return undefined
112
+ out.push(m)
113
+ docs++
114
+ }
115
+ return docs >= 2 ? out.join("\n") : undefined
116
+ }
117
+
118
+ function markerFor(n: number): string {
119
+ return `[previous line repeated ${n} more times]`
120
+ }
121
+
122
+ function collapseLines(lines: string[]): string[] {
123
+ // 1. blank runs of 3+ become one blank line
124
+ const a: string[] = []
125
+ for (let i = 0; i < lines.length; ) {
126
+ if (lines[i] === "") {
127
+ let j = i
128
+ while (j < lines.length && lines[j] === "") j++
129
+ const n = j - i
130
+ if (n >= 3) a.push("")
131
+ else for (let k = 0; k < n; k++) a.push("")
132
+ i = j
133
+ } else a.push(lines[i]!), i++
134
+ }
135
+ // 2. runs of 3+ identical lines become the line plus a marker, only when shorter
136
+ const out: string[] = []
137
+ for (let i = 0; i < a.length; ) {
138
+ let j = i + 1
139
+ while (j < a.length && a[j] === a[i]) j++
140
+ const n = j - i
141
+ if (n >= 3 && a[i] !== "") {
142
+ const marker = markerFor(n - 1)
143
+ if (marker.length < (n - 1) * (a[i]!.length + 1)) {
144
+ out.push(a[i]!, marker)
145
+ i = j
146
+ continue
147
+ }
148
+ }
149
+ for (let k = i; k < j; k++) out.push(a[k]!)
150
+ i = j
151
+ }
152
+ return out
153
+ }
154
+
155
+ function pass(text: string): string {
156
+ let s = text
157
+ if (s.includes("\x1b")) s = s.replace(ANSI, "")
158
+ if (s.includes("\r")) {
159
+ s = s.replace(/\r\n/g, "\n")
160
+ if (s.includes("\r")) s = s.split("\n").map((l) => (l.includes("\r") ? overlay(l) : l)).join("\n")
161
+ }
162
+ const lines = s.split("\n").map(trimEnd)
163
+ s = lines.join("\n")
164
+ const json = minifyJson(s)
165
+ if (json !== undefined) return json
166
+ return collapseLines(lines).join("\n")
167
+ }
168
+
169
+ /** Tidy one tool result's text. Never longer than the input; unchanged when the text is a brain stub, holds one, or looks binary. */
170
+ export function tidyOutput(text: string): Tidied {
171
+ if (typeof text !== "string" || text.length < 4 || text.length > MAX_CHARS) return { text, saved: 0 }
172
+ if (text.includes(STUB_PREFIX) || text.includes("\0")) return { text, saved: 0 }
173
+ let cur = text
174
+ for (let i = 0; i < 6; i++) {
175
+ const next = pass(cur)
176
+ if (next === cur || next.length > cur.length) break
177
+ cur = next
178
+ }
179
+ return cur.length < text.length ? { text: cur, saved: text.length - cur.length } : { text, saved: 0 }
180
+ }
181
+
182
+ /** Tidies every tool result of a session (the input is not modified; untouched turns are shared). `tidied` is characters saved. */
183
+ export function tidySession(session: Session): { session: Session; tidied: number } {
184
+ let tidied = 0
185
+ const turns = session.turns.map((t): Turn => {
186
+ if (t.kind !== "tool_result") return t
187
+ const r = tidyOutput(t.output)
188
+ if (!r.saved) return t
189
+ tidied += r.saved
190
+ return { ...t, output: r.text }
191
+ })
192
+ return tidied ? { session: { ...session, header: { ...session.header }, turns }, tidied } : { session, tidied: 0 }
193
+ }