@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,169 @@
1
+ import { join } from "node:path"
2
+ import { exists, isDir, versionOf } from "../../fsutil.ts"
3
+ import { resolveBin } from "../opencode/writer.ts"
4
+
5
+ // Crush (charmbracelet/crush): where its stores are and what its rows look like, from the v0.100.0 source
6
+ // (internal/db/migrations, internal/db/sql, internal/message/{message,content}.go, internal/config/load.go,
7
+ // internal/projects/projects.go) and the store the v0.100.0 binary creates.
8
+ //
9
+ // Each project has its own store, <project>/.crush/crush.db: tables sessions and messages, a message's
10
+ // content a JSON array of {type, data} parts (text, reasoning, tool_call, tool_result, finish, …). A tool
11
+ // call sits in its assistant message; each result is a separate message with role "tool". Times are Unix
12
+ // seconds and messages are read back ordered by created_at alone. The projects Crush has run in are listed in
13
+ // ~/.local/share/crush/projects.json, which is how a reader finds the stores.
14
+
15
+ export const TOOL = "crush"
16
+ export const LABEL = "Crush"
17
+ export const TESTED_VERSIONS = ["0.100.0"]
18
+
19
+ /** The Crush binary: `go install`'s copy under home first (~/go/bin), else the one on PATH (brew, npm). */
20
+ export const crushBin = (home: string) => resolveBin({ bin: "crush", installDir: "go" }, home)
21
+
22
+ /** The installed Crush version ("crush version v0.100.0" → "0.100.0"). */
23
+ export const crushVersion = (home: string) => versionOf(crushBin(home).path)?.match(/(\d+\.\d+\.\d+)/)?.[1]
24
+
25
+ export const projectsFile = (home: string) => join(home, ".local", "share", "crush", "projects.json")
26
+
27
+ /**
28
+ * The data folder Crush uses for a project folder, as config.setDefaults finds it: the closest `.crush` from
29
+ * the folder up to its repository root (never the one in the home folder), else `<folder>/.crush`. A
30
+ * data_directory set in the person's crush.json is not followed.
31
+ */
32
+ export function dataDirFor(cwd: string, home: string): string {
33
+ let root = cwd
34
+ for (let dir = cwd; dir && dir !== "/"; dir = dir.slice(0, dir.lastIndexOf("/")) || "/")
35
+ if (exists(join(dir, ".git"))) {
36
+ root = dir
37
+ break
38
+ }
39
+ for (let dir = cwd; ; dir = dir.slice(0, dir.lastIndexOf("/")) || "/") {
40
+ if (isDir(join(dir, ".crush"))) {
41
+ if (dir === home) break
42
+ return join(dir, ".crush")
43
+ }
44
+ if (dir === root || dir === "/") break
45
+ }
46
+ return join(cwd, ".crush")
47
+ }
48
+
49
+ /** The goose migrations a v0.100.0 store has applied (version 0 is goose's own first row). */
50
+ export const MIGRATIONS = [
51
+ 0, 20250424200609, 20250515105448, 20250624000000, 20250627000000, 20250810000000, 20250812000000, 20260127000000, 20260902000000, 20260912000000,
52
+ 20260921000000, 20260928000000, 20260928000001,
53
+ ]
54
+
55
+ /** The schema Crush v0.100.0 leaves after its migrations, for a project that has no store yet. */
56
+ export const SCHEMA = `
57
+ CREATE TABLE goose_db_version (
58
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
59
+ version_id INTEGER NOT NULL,
60
+ is_applied INTEGER NOT NULL,
61
+ tstamp TIMESTAMP DEFAULT (datetime('now'))
62
+ );
63
+ CREATE TABLE sessions (
64
+ id TEXT PRIMARY KEY,
65
+ parent_session_id TEXT,
66
+ title TEXT NOT NULL,
67
+ message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
68
+ prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0),
69
+ completion_tokens INTEGER NOT NULL DEFAULT 0 CHECK (completion_tokens>= 0),
70
+ cost REAL NOT NULL DEFAULT 0.0 CHECK (cost >= 0.0),
71
+ updated_at INTEGER NOT NULL,
72
+ created_at INTEGER NOT NULL
73
+ , summary_message_id TEXT, todos TEXT, channel TEXT);
74
+ CREATE TRIGGER update_sessions_updated_at
75
+ AFTER UPDATE ON sessions
76
+ BEGIN
77
+ UPDATE sessions SET updated_at = strftime('%s', 'now')
78
+ WHERE id = new.id;
79
+ END;
80
+ CREATE TABLE files (
81
+ id TEXT PRIMARY KEY,
82
+ session_id TEXT NOT NULL,
83
+ path TEXT NOT NULL,
84
+ content TEXT NOT NULL,
85
+ version INTEGER NOT NULL DEFAULT 0,
86
+ created_at INTEGER NOT NULL,
87
+ updated_at INTEGER NOT NULL,
88
+ FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
89
+ UNIQUE(path, session_id, version)
90
+ );
91
+ CREATE INDEX idx_files_session_id ON files (session_id);
92
+ CREATE INDEX idx_files_path ON files (path);
93
+ CREATE TRIGGER update_files_updated_at
94
+ AFTER UPDATE ON files
95
+ BEGIN
96
+ UPDATE files SET updated_at = strftime('%s', 'now')
97
+ WHERE id = new.id;
98
+ END;
99
+ CREATE TABLE messages (
100
+ id TEXT PRIMARY KEY,
101
+ session_id TEXT NOT NULL,
102
+ role TEXT NOT NULL,
103
+ parts TEXT NOT NULL default '[]',
104
+ model TEXT,
105
+ created_at INTEGER NOT NULL,
106
+ updated_at INTEGER NOT NULL,
107
+ finished_at INTEGER, provider TEXT, is_summary_message INTEGER DEFAULT 0 NOT NULL, prism_model_id TEXT, prism_model_name TEXT, prism_hypercredit_savings REAL, prism_dollar_savings REAL,
108
+ FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
109
+ );
110
+ CREATE INDEX idx_messages_session_id ON messages (session_id);
111
+ CREATE TRIGGER update_messages_updated_at
112
+ AFTER UPDATE ON messages
113
+ BEGIN
114
+ UPDATE messages SET updated_at = strftime('%s', 'now')
115
+ WHERE id = new.id;
116
+ END;
117
+ CREATE TRIGGER update_session_message_count_on_insert
118
+ AFTER INSERT ON messages
119
+ BEGIN
120
+ UPDATE sessions SET
121
+ message_count = message_count + 1
122
+ WHERE id = new.session_id;
123
+ END;
124
+ CREATE TRIGGER update_session_message_count_on_delete
125
+ AFTER DELETE ON messages
126
+ BEGIN
127
+ UPDATE sessions SET
128
+ message_count = message_count - 1
129
+ WHERE id = old.session_id;
130
+ END;
131
+ CREATE INDEX idx_sessions_created_at ON sessions (created_at);
132
+ CREATE INDEX idx_messages_created_at ON messages (created_at);
133
+ CREATE INDEX idx_files_created_at ON files (created_at);
134
+ CREATE TABLE read_files (
135
+ session_id TEXT NOT NULL CHECK (session_id != ''),
136
+ path TEXT NOT NULL CHECK (path != ''),
137
+ read_at INTEGER NOT NULL,
138
+ FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE,
139
+ PRIMARY KEY (path, session_id)
140
+ );
141
+ CREATE INDEX idx_read_files_session_id ON read_files (session_id);
142
+ CREATE INDEX idx_read_files_path ON read_files (path);
143
+ CREATE INDEX idx_messages_role_created_at ON messages (role, created_at);
144
+ CREATE TABLE mcp_disabled_servers (
145
+ name TEXT PRIMARY KEY
146
+ );
147
+ CREATE TABLE mcp_enabled_servers (
148
+ name TEXT PRIMARY KEY
149
+ );
150
+ `
151
+
152
+ /** One stored part, as marshalParts writes it. */
153
+ export interface Part {
154
+ type: "text" | "reasoning" | "tool_call" | "tool_result" | "finish" | "image_url" | "binary" | "shell_command" | "sub_agent_report"
155
+ data: Record<string, any>
156
+ }
157
+
158
+ /** A messages row before it is inserted. */
159
+ export interface MessageRow {
160
+ id: string
161
+ role: "user" | "assistant" | "tool"
162
+ parts: Part[]
163
+ /** Crush stores "" for user and tool messages, the model id for assistant ones. */
164
+ model: string
165
+ provider: string | null
166
+ created_at: number
167
+ finished_at: number | null
168
+ is_summary_message: 0 | 1
169
+ }
@@ -0,0 +1,333 @@
1
+ import { Database } from "bun:sqlite"
2
+ import { randomUUID } from "node:crypto"
3
+ import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"
4
+ import { basename, join, resolve } from "node:path"
5
+ import { imageDataUrl, parseDataUrl } from "../../session/attachments.ts"
6
+ import { toolLabel } from "../../brief.ts"
7
+ import { exists, isDir, isRunning, readJson } from "../../fsutil.ts"
8
+ import { withSnapshot } from "../../sqlite.ts"
9
+ import type { Session, Turn } from "../../session/types.ts"
10
+ import { countTurns, unpaired } from "../../session/util.ts"
11
+ import { continuedNote } from "../opencode/note.ts"
12
+ import { random62 } from "../opencode/ids.ts"
13
+ import { CRUSH, mapCall } from "../opencode/tools.ts"
14
+ import { continuedTitle, modelRef, sh } from "../opencode/writer.ts"
15
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
16
+ import { readCrush } from "./reader.ts"
17
+ import { LABEL, MIGRATIONS, SCHEMA, TESTED_VERSIONS, TOOL, crushBin, crushVersion, dataDirFor, projectsFile, type MessageRow, type Part } from "./records.ts"
18
+
19
+ // Continues a portable session in Crush as a new session of the project's own store, written as Crush's
20
+ // message service writes one (message.Service.Create and Update, agent.go): the note as a hidden user text,
21
+ // a user message per user turn, an assistant message per model response (reasoning, text and tool calls, then
22
+ // a finish part), one "tool" message per result, and a compaction as Crush's summary message, an assistant
23
+ // message flagged is_summary_message that sessions.summary_message_id points at; Crush sends the model only
24
+ // the messages from it on. The project is registered in projects.json, as Crush does when it starts.
25
+ //
26
+ // The store is SQLite that Crush holds open while it runs, so this writes only with Crush closed and backs
27
+ // the store up first. A project with no store yet gets the schema Crush v0.100.0 creates. Verified live with
28
+ // the v0.100.0 release binary (temp home, no network): `crush session list --json` and `session show --json`.
29
+
30
+ /** Crush's provider ids (catwalk) where they differ from the model family's. */
31
+ const providerOf = (model: string | undefined) => {
32
+ const p = modelRef(model).providerID
33
+ return p === "google" ? "gemini" : p === "unknown" ? null : p
34
+ }
35
+
36
+ export interface CrushPlan {
37
+ id: string
38
+ title: string
39
+ messages: MessageRow[]
40
+ summaryId: string | null
41
+ lost: string[]
42
+ /** What a read-back must find. */
43
+ expect: { users: number; assistants: number; calls: number }
44
+ }
45
+
46
+ /** The rows of a new Crush session continuing `session`. Pure: writes nothing. */
47
+ export function planCrush(session: Session, opts: { id: string; now: Date }): CrushPlan {
48
+ const drops = new Map<string, number>()
49
+ const drop = (what: string, n = 1) => drops.set(what, (drops.get(what) ?? 0) + n)
50
+ const mapped = { n: 0 }
51
+ const messages: MessageRow[] = []
52
+ const expect = { users: 0, assistants: 0, calls: 0 }
53
+ const nowSec = Math.floor(opts.now.getTime() / 1000)
54
+
55
+ // Crush orders a session's messages by created_at (whole seconds) alone, so every message gets its own second.
56
+ let clock = Math.floor((Date.parse(session.header.startedAt ?? "") || opts.now.getTime() - session.turns.length * 1000) / 1000) - 1
57
+ const tick = (at?: string) => {
58
+ const t = at ? Math.floor(Date.parse(at) / 1000) : NaN
59
+ clock = Number.isFinite(t) && t > clock ? t : clock + 1
60
+ return clock
61
+ }
62
+ const finish = (reason: string, time: number): Part => ({ type: "finish", data: { reason, time } })
63
+ const row = (role: MessageRow["role"], parts: Part[], at: number, extra: Partial<MessageRow> = {}): MessageRow => {
64
+ const m: MessageRow = { id: randomUUID(), role, parts, model: "", provider: null, created_at: at, finished_at: null, is_summary_message: 0, ...extra }
65
+ messages.push(m)
66
+ return m
67
+ }
68
+ // User and tool messages get the finish part Create gives every message that is not the assistant's.
69
+ const plain = (role: "user" | "tool", parts: Part[], at?: string) => row(role, [...parts, finish("stop", 0)], tick(at))
70
+
71
+ let model = session.header.models.at(-1)
72
+ let step: { m: MessageRow; text: string[]; thinking: string[]; calls: Part[]; results: boolean } | undefined
73
+ const pending: Part[] = []
74
+ const callIds = new Map<string, { id: string; name: string }>()
75
+ const answered = new Set<string>()
76
+ let summaryId: string | null = null
77
+
78
+ const openStep = (at?: string) => {
79
+ if (step && !step.results) return step
80
+ closeStep()
81
+ const m = row("assistant", [], tick(at), { model: model ?? "unknown", provider: providerOf(model) })
82
+ step = { m, text: [], thinking: [], calls: [], results: false }
83
+ return step
84
+ }
85
+ function closeStep() {
86
+ if (!step) return
87
+ const { m, text, thinking, calls } = step
88
+ const end = Math.max(m.created_at, clock)
89
+ m.parts = [
90
+ ...(thinking.length ? [{ type: "reasoning" as const, data: { thinking: thinking.join("\n\n"), signature: "", thought_signature: "", tool_id: "", responses_data: null, started_at: m.created_at, finished_at: m.created_at } }] : []),
91
+ ...(text.length ? [{ type: "text" as const, data: { text: text.join("\n\n") } }] : []),
92
+ ...calls,
93
+ finish(calls.length ? "tool_use" : "end_turn", end),
94
+ ]
95
+ m.finished_at = end
96
+ if (text.length) expect.assistants++
97
+ step = undefined
98
+ flushResults()
99
+ }
100
+ // Each result is its own message, after the response that asked for it.
101
+ const flushResults = () => {
102
+ for (const r of pending.splice(0)) plain("tool", [r])
103
+ }
104
+
105
+ plain("user", [{ type: "text", data: { text: continuedNote(toolLabel(session.header.tool), session.header.id, opts.now.toISOString().slice(0, 10)), hidden: true } }])
106
+
107
+ for (const t of session.turns as Turn[]) {
108
+ switch (t.kind) {
109
+ case "user": {
110
+ // Crush keeps an attachment as a "binary" part (Go BinaryContent: Path, MIMEType, Data as base64) after the text.
111
+ const bins: Part[] = []
112
+ let n = 0
113
+ for (const a of t.attachments ?? []) {
114
+ const d = a.kind === "image" ? parseDataUrl(imageDataUrl(a)) : undefined
115
+ if (d) bins.push({ type: "binary", data: { Path: a.name ?? (a.path ? basename(a.path) : "image"), MIMEType: d.mime, Data: d.base64 } })
116
+ else {
117
+ drop("attachments")
118
+ n++
119
+ }
120
+ }
121
+ const text = t.text.trim() ? t.text : n ? `(${n} attachment${n > 1 ? "s" : ""} not carried over)` : bins.length ? "(image)" : ""
122
+ if (!text) break
123
+ closeStep()
124
+ plain("user", [{ type: "text", data: { text } }, ...bins], t.at)
125
+ expect.users++
126
+ break
127
+ }
128
+ case "assistant": {
129
+ if (!t.text.trim()) break
130
+ if (t.model && t.model !== model) {
131
+ // A response belongs to one model: text from another starts a new one.
132
+ if (step) closeStep()
133
+ model = t.model
134
+ }
135
+ openStep(t.at).text.push(t.text)
136
+ break
137
+ }
138
+ case "reasoning":
139
+ openStep(t.at).thinking.push(t.text)
140
+ drop("reasoning: kept as text, without provider signatures")
141
+ break
142
+ case "tool_call": {
143
+ if (callIds.has(t.id)) {
144
+ drop("repeated tool call ids")
145
+ break
146
+ }
147
+ const st = openStep(t.at)
148
+ tick(t.at)
149
+ const call = mapCall(CRUSH, t.name, t.toolKind, t.input, mapped)
150
+ const id = `call_${random62(24)}`
151
+ callIds.set(t.id, { id, name: call.tool })
152
+ st.calls.push({ type: "tool_call", data: { id, name: call.tool, input: JSON.stringify(call.input), provider_executed: false, finished: true } })
153
+ expect.calls++
154
+ break
155
+ }
156
+ case "tool_result": {
157
+ const call = callIds.get(t.callId)
158
+ if (!call) {
159
+ drop("tool results without a call")
160
+ break
161
+ }
162
+ if (answered.has(t.callId)) {
163
+ drop("repeated tool results")
164
+ break
165
+ }
166
+ answered.add(t.callId)
167
+ pending.push({ type: "tool_result", data: { tool_call_id: call.id, name: call.name, content: t.output, data: "", mime_type: "", metadata: "", is_error: !t.ok } })
168
+ if (step) step.results = true
169
+ else flushResults()
170
+ break
171
+ }
172
+ case "compaction": {
173
+ closeStep()
174
+ const at = tick(t.at)
175
+ const m = row("assistant", [{ type: "text", data: { text: t.summary } }, finish("end_turn", at)], at, {
176
+ model: model ?? "unknown",
177
+ provider: providerOf(model),
178
+ finished_at: at,
179
+ is_summary_message: 1,
180
+ })
181
+ summaryId = m.id
182
+ break
183
+ }
184
+ case "file_edit":
185
+ if (!t.callId) drop("file edits not made by a tool call")
186
+ break
187
+ case "subagent":
188
+ drop("subagent sessions (their call and result are kept)")
189
+ break
190
+ }
191
+ }
192
+ // Calls that never got a result are closed as failed, as Crush closes an interrupted one.
193
+ for (const [src, call] of callIds)
194
+ if (!answered.has(src)) {
195
+ pending.push({ type: "tool_result", data: { tool_call_id: call.id, name: call.name, content: "No result was recorded for this call.", data: "", mime_type: "", metadata: "", is_error: true } })
196
+ drop("calls with no result, marked as failed")
197
+ }
198
+ closeStep()
199
+ flushResults()
200
+
201
+ // Nothing may sort after the next message Crush writes, which is stamped now.
202
+ const late = (messages.at(-1)?.created_at ?? 0) - nowSec
203
+ if (late > 0)
204
+ for (const m of messages) {
205
+ m.created_at -= late
206
+ if (m.finished_at !== null) m.finished_at -= late
207
+ for (const p of m.parts) if (p.type === "finish" && p.data.time) p.data.time -= late
208
+ }
209
+
210
+ const lost = [...drops].map(([what, n]) => `${what} (${n})`)
211
+ if (callIds.size) lost.push(`tool-call ids replaced (${callIds.size})`)
212
+ if (mapped.n) lost.push(`tool calls renamed to Crush's own tools (${mapped.n})`)
213
+ return { id: opts.id, title: continuedTitle(session), messages, summaryId, lost, expect }
214
+ }
215
+
216
+ /** The newest migration a store has applied, read from a copy. */
217
+ function storeVersion(db: string): number {
218
+ return withSnapshot(db, (d) => (d.query("SELECT max(version_id) AS v FROM goose_db_version WHERE is_applied = 1").get() as { v: number | null }).v ?? 0)
219
+ }
220
+
221
+ const NEEDED = {
222
+ sessions: ["id", "parent_session_id", "title", "message_count", "prompt_tokens", "completion_tokens", "cost", "updated_at", "created_at", "summary_message_id"],
223
+ messages: ["id", "session_id", "role", "parts", "model", "created_at", "updated_at", "finished_at", "provider", "is_summary_message"],
224
+ }
225
+
226
+ export const crushWriter: Writer = {
227
+ tool: TOOL,
228
+ label: LABEL,
229
+ way: "native",
230
+
231
+ async ready(opts: WriteOptions): Promise<Readiness> {
232
+ const v = crushVersion(opts.home)
233
+ if (!v) return { ok: false, why: "Crush is not installed (no crush on PATH or in ~/go/bin)" }
234
+ if (!TESTED_VERSIONS.includes(v)) return { ok: false, why: `Crush ${v} is not a version this writer was checked against (${TESTED_VERSIONS.join(", ")})` }
235
+ if (!isDir(opts.cwd)) return { ok: false, why: `the folder to continue in does not exist: ${opts.cwd}` }
236
+ if (isRunning("crush")) return { ok: false, why: "Crush is running; close it first, since it holds the session store open" }
237
+ const db = join(dataDirFor(resolve(opts.cwd), opts.home), "crush.db")
238
+ if (exists(db)) {
239
+ const applied = storeVersion(db)
240
+ if (applied > MIGRATIONS.at(-1)!) return { ok: false, why: `the project's Crush store was migrated by a newer Crush (migration ${applied})` }
241
+ }
242
+ return { ok: true }
243
+ },
244
+
245
+ async write(session: Session, opts: WriteOptions): Promise<WriteResult> {
246
+ const cwd = resolve(opts.cwd)
247
+ const dataDir = dataDirFor(cwd, opts.home)
248
+ const dbPath = join(dataDir, "crush.db")
249
+ const now = new Date()
250
+ const plan = planCrush(session, { id: randomUUID(), now })
251
+ const backupDir = join(opts.home, ".brain", "backups", "crush", now.toISOString().replace(/[:.]/g, "-"))
252
+ const written = [dbPath, projectsFile(opts.home), backupDir]
253
+ if (!opts.dryRun) {
254
+ backup(dbPath, projectsFile(opts.home), backupDir)
255
+ insert(dbPath, dataDir, plan, cwd)
256
+ register(opts.home, cwd, dataDir, now)
257
+ }
258
+ return {
259
+ tool: TOOL,
260
+ way: "native",
261
+ sessionId: plan.id,
262
+ written,
263
+ open: `cd ${sh(cwd)} && ${sh(crushBin(opts.home).command)} --session ${plan.id}`,
264
+ lost: plan.lost,
265
+ dryRun: !!opts.dryRun,
266
+ }
267
+ },
268
+ }
269
+
270
+ /** Copies the store (with its write-ahead log) and the projects list before anything is changed. */
271
+ function backup(dbPath: string, projects: string, dir: string) {
272
+ mkdirSync(dir, { recursive: true })
273
+ for (const f of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`, projects]) if (exists(f)) copyFileSync(f, join(dir, f.split("/").pop()!))
274
+ }
275
+
276
+ /** Writes the session in one transaction and reads it back before committing; a mismatch leaves the store as it was. */
277
+ function insert(dbPath: string, dataDir: string, plan: CrushPlan, cwd: string) {
278
+ const fresh = !exists(dbPath)
279
+ if (fresh) {
280
+ mkdirSync(dataDir, { recursive: true, mode: 0o700 })
281
+ // Crush keeps its data folder out of git the same way.
282
+ if (!exists(join(dataDir, ".gitignore"))) writeFileSync(join(dataDir, ".gitignore"), "*\n")
283
+ }
284
+ const db = new Database(dbPath)
285
+ try {
286
+ db.exec("PRAGMA busy_timeout = 5000")
287
+ db.exec("PRAGMA foreign_keys = ON")
288
+ if (fresh) {
289
+ db.exec("PRAGMA journal_mode = WAL")
290
+ db.exec(SCHEMA)
291
+ const add = db.prepare("INSERT INTO goose_db_version (version_id, is_applied) VALUES (?, 1)")
292
+ for (const v of MIGRATIONS) add.run(v)
293
+ }
294
+ for (const [table, cols] of Object.entries(NEEDED)) {
295
+ const have = new Set((db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).map((c) => c.name))
296
+ const missing = cols.filter((c) => !have.has(c))
297
+ if (missing.length) throw new Error(`the Crush store at ${dbPath} has no ${table}.${missing.join(", ")}; it is not a store this writer knows`)
298
+ }
299
+ const first = plan.messages[0]?.created_at ?? Math.floor(Date.now() / 1000)
300
+ db.transaction(() => {
301
+ db.prepare(
302
+ "INSERT INTO sessions (id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, summary_message_id, updated_at, created_at) VALUES (?, NULL, ?, 0, 0, 0, 0, ?, ?, ?)",
303
+ ).run(plan.id, plan.title, plan.summaryId, first, first)
304
+ const add = db.prepare(
305
+ "INSERT INTO messages (id, session_id, role, parts, model, provider, is_summary_message, created_at, updated_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
306
+ )
307
+ for (const m of plan.messages)
308
+ add.run(m.id, plan.id, m.role, JSON.stringify(m.parts), m.model, m.provider, m.is_summary_message, m.created_at, m.finished_at ?? m.created_at, m.finished_at)
309
+ // Read back as Crush's rows are read; throwing here rolls the transaction back.
310
+ const back = readCrush(db, plan.id, { path: dbPath, cwd })
311
+ const n = countTurns(back)
312
+ const pairs = unpaired(back)
313
+ if (n.user !== plan.expect.users || n.assistant !== plan.expect.assistants || n.tool_call !== plan.expect.calls || pairs.calls.length || pairs.results.length)
314
+ throw new Error(
315
+ `the Crush session did not read back as written (users ${n.user}/${plan.expect.users}, replies ${n.assistant}/${plan.expect.assistants}, tool calls ${n.tool_call}/${plan.expect.calls}, unpaired ${pairs.calls.length + pairs.results.length}); nothing was kept`,
316
+ )
317
+ })()
318
+ // Leave the store as Crush leaves it when it exits: everything in the main file.
319
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)")
320
+ } finally {
321
+ db.close()
322
+ }
323
+ }
324
+
325
+ /** Puts the project first in projects.json, as projects.Register does whenever Crush starts in a folder. */
326
+ function register(home: string, cwd: string, dataDir: string, now: Date) {
327
+ const file = projectsFile(home)
328
+ const list = readJson<{ projects?: Array<Record<string, unknown>> }>(file) ?? {}
329
+ const projects = (Array.isArray(list.projects) ? list.projects : []).filter((p) => p.path !== cwd)
330
+ projects.unshift({ path: cwd, data_dir: dataDir, last_accessed: now.toISOString() })
331
+ mkdirSync(join(file, ".."), { recursive: true, mode: 0o700 })
332
+ writeFileSync(file, JSON.stringify({ ...list, projects }, null, 2), { mode: 0o600 })
333
+ }