@100xprompt/brain 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +172 -2
  3. package/package.json +48 -4
  4. package/src/adapters/aider/index.ts +8 -0
  5. package/src/adapters/aider/reader.ts +129 -0
  6. package/src/adapters/aider/records.ts +220 -0
  7. package/src/adapters/aider/writer.ts +269 -0
  8. package/src/adapters/amazon-q-cli/index.ts +8 -0
  9. package/src/adapters/amazon-q-cli/reader.ts +84 -0
  10. package/src/adapters/amazon-q-cli/records.ts +191 -0
  11. package/src/adapters/amazon-q-cli/writer.ts +370 -0
  12. package/src/adapters/amazon-q-ide/index.ts +9 -0
  13. package/src/adapters/amazon-q-ide/reader.ts +131 -0
  14. package/src/adapters/amazon-q-ide/records.ts +141 -0
  15. package/src/adapters/amazon-q-ide/writer.ts +325 -0
  16. package/src/adapters/antigravity/index.ts +9 -0
  17. package/src/adapters/antigravity/proto.ts +194 -0
  18. package/src/adapters/antigravity/reader.ts +424 -0
  19. package/src/adapters/antigravity/records.ts +236 -0
  20. package/src/adapters/antigravity/writer.ts +463 -0
  21. package/src/adapters/auggie/index.ts +36 -0
  22. package/src/adapters/claude-code/index.ts +8 -0
  23. package/src/adapters/claude-code/reader.ts +639 -0
  24. package/src/adapters/claude-code/records.ts +88 -0
  25. package/src/adapters/claude-code/writer.ts +329 -0
  26. package/src/adapters/cline/index.ts +12 -0
  27. package/src/adapters/cline/reader.ts +432 -0
  28. package/src/adapters/cline/records.ts +151 -0
  29. package/src/adapters/cline/vscdb.ts +56 -0
  30. package/src/adapters/cline/writer.ts +656 -0
  31. package/src/adapters/cline/xml.ts +177 -0
  32. package/src/adapters/cline4/index.ts +9 -0
  33. package/src/adapters/cline4/reader.ts +179 -0
  34. package/src/adapters/cline4/records.ts +92 -0
  35. package/src/adapters/cline4/writer.ts +361 -0
  36. package/src/adapters/codebuff/index.ts +8 -0
  37. package/src/adapters/codebuff/project.ts +207 -0
  38. package/src/adapters/codebuff/reader.ts +215 -0
  39. package/src/adapters/codebuff/records.ts +173 -0
  40. package/src/adapters/codebuff/writer.ts +383 -0
  41. package/src/adapters/codex/index.ts +8 -0
  42. package/src/adapters/codex/native.ts +239 -0
  43. package/src/adapters/codex/reader.ts +747 -0
  44. package/src/adapters/codex/records.ts +114 -0
  45. package/src/adapters/codex/writer.ts +479 -0
  46. package/src/adapters/continue/index.ts +9 -0
  47. package/src/adapters/continue/reader.ts +68 -0
  48. package/src/adapters/continue/records.ts +418 -0
  49. package/src/adapters/continue/writer.ts +162 -0
  50. package/src/adapters/copilot-cli/index.ts +9 -0
  51. package/src/adapters/copilot-cli/reader.ts +186 -0
  52. package/src/adapters/copilot-cli/records.ts +241 -0
  53. package/src/adapters/copilot-cli/writer.ts +353 -0
  54. package/src/adapters/crush/index.ts +8 -0
  55. package/src/adapters/crush/reader.ts +180 -0
  56. package/src/adapters/crush/records.ts +169 -0
  57. package/src/adapters/crush/writer.ts +333 -0
  58. package/src/adapters/cursor-cli/index.ts +405 -0
  59. package/src/adapters/cursor-ide/agentkv.ts +95 -0
  60. package/src/adapters/cursor-ide/index.ts +501 -0
  61. package/src/adapters/cursor-ide/transcripts.ts +122 -0
  62. package/src/adapters/cursor-ide/workspaces.ts +119 -0
  63. package/src/adapters/cursor-ide/writer.ts +170 -0
  64. package/src/adapters/devin/index.ts +8 -0
  65. package/src/adapters/devin/reader.ts +305 -0
  66. package/src/adapters/devin/records.ts +277 -0
  67. package/src/adapters/devin/writer.ts +523 -0
  68. package/src/adapters/factory-droid/index.ts +12 -0
  69. package/src/adapters/factory-droid/reader.ts +184 -0
  70. package/src/adapters/factory-droid/records.ts +79 -0
  71. package/src/adapters/factory-droid/writer.ts +350 -0
  72. package/src/adapters/gemini-cli/exchanges.ts +228 -0
  73. package/src/adapters/gemini-cli/index.ts +396 -0
  74. package/src/adapters/gemini-cli/records.ts +521 -0
  75. package/src/adapters/gemini-cli/writer.ts +354 -0
  76. package/src/adapters/goose/index.ts +8 -0
  77. package/src/adapters/goose/reader.ts +138 -0
  78. package/src/adapters/goose/records.ts +199 -0
  79. package/src/adapters/goose/writer.ts +340 -0
  80. package/src/adapters/grok-cli/image.ts +44 -0
  81. package/src/adapters/grok-cli/index.ts +8 -0
  82. package/src/adapters/grok-cli/reader.ts +302 -0
  83. package/src/adapters/grok-cli/records.ts +172 -0
  84. package/src/adapters/grok-cli/writer.ts +329 -0
  85. package/src/adapters/hermes/index.ts +8 -0
  86. package/src/adapters/hermes/reader.ts +249 -0
  87. package/src/adapters/hermes/records.ts +285 -0
  88. package/src/adapters/hermes/writer.ts +452 -0
  89. package/src/adapters/iflow-cli/index.ts +72 -0
  90. package/src/adapters/iflow-cli/records.ts +215 -0
  91. package/src/adapters/iflow-cli/writer.ts +204 -0
  92. package/src/adapters/index.ts +112 -0
  93. package/src/adapters/kilo-cli/index.ts +21 -0
  94. package/src/adapters/kilo-code/index.ts +11 -0
  95. package/src/adapters/kilo-code/writer.ts +92 -0
  96. package/src/adapters/kimi/index.ts +9 -0
  97. package/src/adapters/kimi/reader.ts +92 -0
  98. package/src/adapters/kimi/records.ts +221 -0
  99. package/src/adapters/kimi/writer.ts +367 -0
  100. package/src/adapters/kiro-cli/index.ts +8 -0
  101. package/src/adapters/kiro-cli/reader.ts +87 -0
  102. package/src/adapters/kiro-cli/records.ts +260 -0
  103. package/src/adapters/kiro-cli/writer.ts +305 -0
  104. package/src/adapters/kiro-ide/index.ts +8 -0
  105. package/src/adapters/kiro-ide/writer.ts +52 -0
  106. package/src/adapters/letta/index.ts +8 -0
  107. package/src/adapters/letta/reader.ts +177 -0
  108. package/src/adapters/letta/records.ts +119 -0
  109. package/src/adapters/letta/writer.ts +413 -0
  110. package/src/adapters/mastracode/index.ts +8 -0
  111. package/src/adapters/mastracode/reader.ts +323 -0
  112. package/src/adapters/mastracode/records.ts +336 -0
  113. package/src/adapters/mastracode/writer.ts +385 -0
  114. package/src/adapters/mistral-vibe/index.ts +9 -0
  115. package/src/adapters/mistral-vibe/reader.ts +62 -0
  116. package/src/adapters/mistral-vibe/records.ts +104 -0
  117. package/src/adapters/mistral-vibe/writer.ts +359 -0
  118. package/src/adapters/muse/index.ts +10 -0
  119. package/src/adapters/muse/reader.ts +332 -0
  120. package/src/adapters/muse/records.ts +104 -0
  121. package/src/adapters/muse/writer.ts +387 -0
  122. package/src/adapters/omp/index.ts +13 -0
  123. package/src/adapters/omp/reader.ts +314 -0
  124. package/src/adapters/omp/records.ts +153 -0
  125. package/src/adapters/omp/writer.ts +216 -0
  126. package/src/adapters/opencode/ids.ts +52 -0
  127. package/src/adapters/opencode/index.ts +31 -0
  128. package/src/adapters/opencode/note.ts +8 -0
  129. package/src/adapters/opencode/reader.ts +452 -0
  130. package/src/adapters/opencode/tools.ts +174 -0
  131. package/src/adapters/opencode/v2.ts +367 -0
  132. package/src/adapters/opencode/writer.ts +379 -0
  133. package/src/adapters/openhands/index.ts +8 -0
  134. package/src/adapters/openhands/reader.ts +222 -0
  135. package/src/adapters/openhands/records.ts +136 -0
  136. package/src/adapters/openhands/writer.ts +404 -0
  137. package/src/adapters/pearai/index.ts +10 -0
  138. package/src/adapters/pi/index.ts +28 -0
  139. package/src/adapters/pi/reader.ts +261 -0
  140. package/src/adapters/pi/records.ts +99 -0
  141. package/src/adapters/pi/writer.ts +362 -0
  142. package/src/adapters/qoder-cli/index.ts +8 -0
  143. package/src/adapters/qoder-cli/reader.ts +83 -0
  144. package/src/adapters/qoder-cli/records.ts +45 -0
  145. package/src/adapters/qoder-cli/writer.ts +72 -0
  146. package/src/adapters/qwen-code/index.ts +84 -0
  147. package/src/adapters/qwen-code/records.ts +194 -0
  148. package/src/adapters/qwen-code/writer.ts +224 -0
  149. package/src/adapters/tabnine-cli/index.ts +8 -0
  150. package/src/adapters/tabnine-cli/reader.ts +106 -0
  151. package/src/adapters/tabnine-cli/records.ts +33 -0
  152. package/src/adapters/tabnine-cli/writer.ts +156 -0
  153. package/src/adapters/types.ts +73 -0
  154. package/src/adapters/void/index.ts +9 -0
  155. package/src/adapters/void/reader.ts +48 -0
  156. package/src/adapters/void/records.ts +285 -0
  157. package/src/adapters/void/writer.ts +93 -0
  158. package/src/adapters/vscode-copilot/index.ts +9 -0
  159. package/src/adapters/vscode-copilot/reader.ts +212 -0
  160. package/src/adapters/vscode-copilot/records.ts +388 -0
  161. package/src/adapters/vscode-copilot/shared.ts +68 -0
  162. package/src/adapters/vscode-copilot/writer.ts +546 -0
  163. package/src/adapters/zed/index.ts +8 -0
  164. package/src/adapters/zed/reader.ts +198 -0
  165. package/src/adapters/zed/records.ts +87 -0
  166. package/src/adapters/zed/writer.ts +341 -0
  167. package/src/brief-writers.ts +71 -0
  168. package/src/brief.ts +238 -0
  169. package/src/cli.ts +326 -0
  170. package/src/fsutil.ts +133 -0
  171. package/src/gateway/connect.ts +845 -0
  172. package/src/gateway/signin.ts +323 -0
  173. package/src/handoff.ts +104 -0
  174. package/src/hooks.ts +455 -0
  175. package/src/memory/audit.ts +514 -0
  176. package/src/memory/blobs.ts +94 -0
  177. package/src/memory/cli.ts +117 -0
  178. package/src/memory/distill.ts +381 -0
  179. package/src/memory/externalize.ts +435 -0
  180. package/src/memory/lookups.ts +70 -0
  181. package/src/memory/mcp-install.ts +401 -0
  182. package/src/memory/mcp-server.ts +296 -0
  183. package/src/memory/prepare.ts +179 -0
  184. package/src/memory/preview.ts +154 -0
  185. package/src/memory/project.ts +35 -0
  186. package/src/memory/redact.ts +72 -0
  187. package/src/memory/relevance.ts +187 -0
  188. package/src/memory/repomap.ts +516 -0
  189. package/src/memory/store.ts +270 -0
  190. package/src/memory/sync.ts +166 -0
  191. package/src/memory/tidy.ts +193 -0
  192. package/src/memory/types.ts +118 -0
  193. package/src/org/admin-ops.ts +96 -0
  194. package/src/org/auth.ts +112 -0
  195. package/src/org/backup.ts +115 -0
  196. package/src/org/cli.ts +438 -0
  197. package/src/org/client.ts +404 -0
  198. package/src/org/config.ts +44 -0
  199. package/src/org/crypto.ts +537 -0
  200. package/src/org/db.ts +435 -0
  201. package/src/org/graph.ts +318 -0
  202. package/src/org/oidc.ts +435 -0
  203. package/src/org/policy.ts +398 -0
  204. package/src/org/server.ts +920 -0
  205. package/src/org/sso.ts +316 -0
  206. package/src/org/tls.ts +66 -0
  207. package/src/org/types.ts +193 -0
  208. package/src/self.ts +68 -0
  209. package/src/session/attachments.ts +53 -0
  210. package/src/session/peekcache.ts +50 -0
  211. package/src/session/types.ts +104 -0
  212. package/src/session/util.ts +130 -0
  213. package/src/sqlite.ts +48 -0
@@ -0,0 +1,405 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import { createHash } from "node:crypto"
3
+ import { dirname, join, resolve } from "node:path"
4
+ import type { Session, SessionHeader, Turn } from "../../session/types.ts"
5
+ import { appSupport, exists, gitInfo, ls, mtime, readJson } from "../../fsutil.ts"
6
+ import { withSnapshot } from "../../sqlite.ts"
7
+ import { clip, header, iso, lossCounter, noteModel, toolKindOf } from "../../session/util.ts"
8
+ import type { Reader, SessionRef, Writer } from "../types.ts"
9
+ import { bytes, messageOrder, toolFailed, toolResultText } from "../cursor-ide/agentkv.ts"
10
+ import { type Loss, userText } from "../cursor-ide/transcripts.ts"
11
+ import { Workspaces } from "../cursor-ide/workspaces.ts"
12
+
13
+ // Cursor CLI chats (catalogue entry 24 and its fact-check). Each chat is its own SQLite store,
14
+ // <home>/.cursor/chats/<md5 of the resolved cwd>/<agentId>/store.db, and ACP sessions keep the same store
15
+ // at <home>/.cursor/acp-sessions/<sessionId>/store.db. A store has two tables (cursor-agent 2025.09 and
16
+ // 2026.10 bundles agree): meta(key, value), whose key "0" holds the chat's metadata as JSON, hex-encoded
17
+ // by both bundles (plain JSON is accepted too); and blobs(id, data), content-addressed by SHA-256. JSON
18
+ // blobs are AI-SDK messages (role user/assistant/tool/system; parts text, reasoning, tool-call,
19
+ // tool-result); the rest are protobuf conversation state. Message order comes from the protobuf root
20
+ // that meta's latestRootBlobId names (agentkv.ts), not from the order blobs were stored in, which
21
+ // differs (blobs are written with INSERT OR REPLACE); only when the root cannot be read is the stored
22
+ // order used. 2026.x adds a meta.json sidecar per chat that `agent ls`
23
+ // reads ({schemaVersion 1, title, createdAtMs, updatedAtMs, hasConversation, isSubagent, cwd}); an ACP
24
+ // session's meta.json is {schemaVersion 1, cwd, title}. Subagents are chats of their own in the same
25
+ // folder, with subagentInfo {parentAgentId, toolCallId} in their metadata. No CLI chat exists on the Mac
26
+ // this was written on, so the shapes come from the two bundles and the IDE's agentKv blobs, which the
27
+ // same harness writes. There is no writer: in phase 1 Cursor is reached by the continuation brief.
28
+
29
+ const TOOL = "cursor-cli"
30
+
31
+ /** Where cursor-agent keeps its state (CURSOR_CONFIG_DIR and XDG_CONFIG_HOME overrides are not followed). */
32
+ const cursorDir = (home: string) => join(home, ".cursor")
33
+ const chatsDir = (home: string) => join(cursorDir(home), "chats")
34
+ const acpDir = (home: string) => join(cursorDir(home), "acp-sessions")
35
+
36
+ /** The chats folder for a project: md5 of the resolved path, as cursor-agent names it. */
37
+ export const bucketOf = (cwd: string) => createHash("md5").update(resolve(cwd)).digest("hex")
38
+
39
+ /** The default chat name, which says nothing. */
40
+ const DEFAULT_NAME = "New Agent"
41
+
42
+ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined)
43
+
44
+ /** Milliseconds from a time cursor-agent stored in seconds, milliseconds, micro- or nanoseconds. */
45
+ function ms(v: unknown): number | undefined {
46
+ const t = typeof v === "number" ? v : typeof v === "string" && /^\d+$/.test(v) ? Number(v) : Number.NaN
47
+ if (!Number.isFinite(t) || t <= 0) return undefined
48
+ return t < 1e11 ? t * 1000 : t >= 1e17 ? t / 1e6 : t >= 1e14 ? t / 1e3 : t
49
+ }
50
+
51
+ /** When the store was last written: its write-ahead log is newer than the file while uncheckpointed. */
52
+ const storeTime = (store: string) => [mtime(store), mtime(`${store}-wal`)].filter((x): x is string => !!x).sort().pop()
53
+
54
+ const hasTable = (db: Database, name: string) => !!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)
55
+
56
+ const asString = (v: unknown) => (typeof v === "string" ? v : v instanceof Uint8Array ? new TextDecoder().decode(v) : undefined)
57
+
58
+ /** The metadata JSON from meta key "0": hex of the JSON text in both bundles, plain JSON in some builds. */
59
+ export function decodeMeta(value: unknown): Record<string, any> | undefined {
60
+ const t = asString(value)?.trim()
61
+ if (!t) return undefined
62
+ const text = /^[0-9a-f]+$/i.test(t) && t.length % 2 === 0 ? Buffer.from(t, "hex").toString("utf8") : t
63
+ try {
64
+ const v = JSON.parse(text)
65
+ return v && typeof v === "object" && !Array.isArray(v) ? v : undefined
66
+ } catch {
67
+ return undefined
68
+ }
69
+ }
70
+
71
+ interface Store {
72
+ meta: Record<string, any>
73
+ /** PRAGMA user_version: the store schema (1 in both bundles). */
74
+ version?: number
75
+ }
76
+
77
+ function readMeta(db: Database): Store {
78
+ const version = (db.query("PRAGMA user_version").get() as { user_version?: number } | null)?.user_version
79
+ if (!hasTable(db, "meta")) return { meta: {}, version }
80
+ const rows = db.query("SELECT key, value FROM meta").all() as Array<{ key: string; value: unknown }>
81
+ const zero = rows.find((r) => r.key === "0")
82
+ const meta = zero ? decodeMeta(zero.value) : undefined
83
+ // Without key "0", the fields as rows of their own (agentId, name, createdAt, lastUsedModel...).
84
+ return { meta: meta ?? Object.fromEntries(rows.filter((r) => r.key !== "0").map((r) => [r.key, asString(r.value)])), version }
85
+ }
86
+
87
+ // ---- list -------------------------------------------------------------------------------------------
88
+
89
+ interface Found {
90
+ id: string
91
+ store: string
92
+ kind: "chat" | "acp"
93
+ title?: string
94
+ cwd?: string
95
+ updatedAt?: string
96
+ }
97
+
98
+ /** What `agent ls` shows for one chat folder, from its meta.json, else from the store's metadata row. */
99
+ function chatEntry(id: string, dir: string): Found | undefined {
100
+ const store = join(dir, "store.db")
101
+ const side = readJson<Record<string, any>>(join(dir, "meta.json"))
102
+ if (side && typeof side.createdAtMs === "number") {
103
+ if (side.isSubagent === true || side.hasConversation !== true) return undefined
104
+ return { id, store, kind: "chat", title: str(side.title), cwd: str(side.cwd), updatedAt: iso(ms(side.updatedAtMs)) ?? storeTime(store) }
105
+ }
106
+ // Older builds keep no sidecar; cursor-agent then opens the store, and so does this.
107
+ let entry: Found | undefined
108
+ try {
109
+ entry = withSnapshot(store, (db) => {
110
+ const { meta } = readMeta(db)
111
+ if (meta.subagentInfo) return undefined // read with its parent
112
+ const blobs = hasTable(db, "blobs") ? ((db.query("SELECT count(*) AS n FROM blobs").get() as { n: number }).n ?? 0) : 0
113
+ if (!str(meta.latestRootBlobId) && !blobs) return undefined // never used
114
+ const name = str(meta.name)
115
+ return { id, store, kind: "chat" as const, title: name !== DEFAULT_NAME ? name : undefined, cwd: str(meta.workspacePath), updatedAt: storeTime(store) }
116
+ })
117
+ } catch {
118
+ return undefined // not a store cursor-agent could open either
119
+ }
120
+ return entry
121
+ }
122
+
123
+ async function list(home: string): Promise<SessionRef[]> {
124
+ const found: Found[] = []
125
+ for (const bucket of ls(chatsDir(home))) {
126
+ for (const id of ls(join(chatsDir(home), bucket))) {
127
+ const dir = join(chatsDir(home), bucket, id)
128
+ if (!exists(join(dir, "store.db"))) continue
129
+ const e = chatEntry(id, dir)
130
+ if (e) found.push(e)
131
+ }
132
+ }
133
+ for (const id of ls(acpDir(home))) {
134
+ const store = join(acpDir(home), id, "store.db")
135
+ if (!exists(store)) continue
136
+ const side = readJson<Record<string, any>>(join(acpDir(home), id, "meta.json"))
137
+ found.push({ id, store, kind: "acp", title: str(side?.title), cwd: str(side?.cwd), updatedAt: storeTime(store) })
138
+ }
139
+ // A chat's folder is named by md5 of its cwd: match it against folders known from elsewhere.
140
+ if (found.some((f) => !f.cwd)) {
141
+ const byBucket = folderIndex(home, found.map((f) => f.cwd))
142
+ for (const f of found) if (!f.cwd && f.kind === "chat") f.cwd = byBucket.get(dirname(dirname(f.store)).split("/").pop()!)
143
+ }
144
+ return found
145
+ .map((f): SessionRef => ({
146
+ tool: TOOL,
147
+ sourceId: f.id,
148
+ path: f.store,
149
+ ...(f.title ? { title: f.title } : {}),
150
+ ...(f.cwd ? { cwd: f.cwd } : {}),
151
+ ...(f.updatedAt ? { updatedAt: f.updatedAt } : {}),
152
+ extra: { kind: f.kind },
153
+ }))
154
+ .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
155
+ }
156
+
157
+ /** md5 bucket → folder, for every folder known: sessions that record theirs and Cursor's workspaces. */
158
+ function folderIndex(home: string, known: Array<string | undefined>): Map<string, string> {
159
+ const out = new Map<string, string>()
160
+ const folders = [...known.filter((x): x is string => !!x), ...new Workspaces(join(appSupport(home), "Cursor", "User", "workspaceStorage")).folders()]
161
+ for (const f of folders) out.set(bucketOf(f), f)
162
+ return out
163
+ }
164
+
165
+ // ---- read -------------------------------------------------------------------------------------------
166
+
167
+ /** The text of a message's text parts (or its string content). */
168
+ const textOf = (m: Record<string, any>) =>
169
+ typeof m.content === "string" ? m.content : Array.isArray(m.content) ? m.content.filter((p: any) => p?.type === "text" && typeof p.text === "string").map((p: any) => p.text).join("\n\n") : ""
170
+
171
+ interface Conv {
172
+ h: SessionHeader
173
+ lost: Loss
174
+ turns: Turn[]
175
+ /** Original tool-call id → the id used in the session (made unique if a store repeats one). */
176
+ calls: Map<string, string>
177
+ used: Set<string>
178
+ fallbackModel?: string
179
+ }
180
+
181
+ function userTurns(m: Record<string, any>, c: Conv) {
182
+ const parts: Array<Record<string, any>> = typeof m.content === "string" ? [{ type: "text", text: m.content }] : Array.isArray(m.content) ? m.content : []
183
+ const texts: string[] = []
184
+ let at: string | undefined
185
+ let context = false
186
+ for (const p of parts) {
187
+ if (p?.type === "text" && typeof p.text === "string") {
188
+ const u = userText(p.text)
189
+ at ??= u.at
190
+ if (u.text) texts.push(u.text)
191
+ else context ||= u.context
192
+ } else if (p?.type === "image") c.lost.add("images")
193
+ else if (p?.type === "file") c.lost.add("attached files")
194
+ else c.lost.add(`unknown record types: user part ${String(p?.type)}`)
195
+ }
196
+ if (texts.length) c.turns.push({ kind: "user", ...(at ? { at } : {}), text: texts.join("\n\n") })
197
+ else if (context) c.lost.add("injected context messages")
198
+ }
199
+
200
+ function assistantTurns(m: Record<string, any>, c: Conv) {
201
+ const parts: Array<Record<string, any>> = typeof m.content === "string" ? [{ type: "text", text: m.content }] : Array.isArray(m.content) ? m.content : []
202
+ const model = str(m.providerOptions?.cursor?.modelName) ?? parts.map((p) => str(p?.providerOptions?.cursor?.modelName)).find(Boolean) ?? c.fallbackModel
203
+ noteModel(c.h, model)
204
+ let text = ""
205
+ const flush = () => {
206
+ if (text.trim()) c.turns.push({ kind: "assistant", text, ...(model ? { model } : {}) })
207
+ text = ""
208
+ }
209
+ for (const p of parts) {
210
+ switch (p?.type) {
211
+ case "text":
212
+ text += typeof p.text === "string" ? p.text : ""
213
+ break
214
+ case "reasoning":
215
+ flush()
216
+ if (typeof p.text === "string" && p.text.trim()) c.turns.push({ kind: "reasoning", text: p.text })
217
+ else if (p.signature || p.providerOptions?.anthropic?.signature) c.lost.add("signed reasoning")
218
+ break
219
+ case "redacted-reasoning":
220
+ c.lost.add("redacted reasoning")
221
+ break
222
+ case "tool-call": {
223
+ flush()
224
+ const orig = str(p.toolCallId) ?? `call-${c.used.size + 1}`
225
+ let id = orig
226
+ for (let n = 2; c.used.has(id); n++) id = `${orig}#${n}`
227
+ c.used.add(id)
228
+ c.calls.set(orig, id)
229
+ const name = str(p.toolName) ?? "unknown"
230
+ c.turns.push({ kind: "tool_call", id, name, toolKind: toolKindOf(name), input: p.args ?? p.input ?? {} })
231
+ break
232
+ }
233
+ default:
234
+ c.lost.add(`unknown record types: assistant part ${String(p?.type)}`)
235
+ }
236
+ }
237
+ flush()
238
+ }
239
+
240
+ function toolTurns(m: Record<string, any>, c: Conv) {
241
+ for (const p of (Array.isArray(m.content) ? m.content : []) as Array<Record<string, any>>) {
242
+ if (p?.type !== "tool-result") {
243
+ c.lost.add(`unknown record types: tool part ${String(p?.type)}`)
244
+ continue
245
+ }
246
+ const images = Array.isArray(p.experimental_content) ? p.experimental_content.filter((x: any) => x?.type === "image").length : 0
247
+ if (images) c.lost.add("images", images)
248
+ const orig = str(p.toolCallId) ?? ""
249
+ const out = clip(toolResultText(p))
250
+ c.turns.push({ kind: "tool_result", callId: c.calls.get(orig) ?? orig, ok: !toolFailed(m, p), output: out.text, ...(out.truncated ? { truncated: true } : {}) })
251
+ }
252
+ }
253
+
254
+ /**
255
+ * The JSON messages of a store in conversation order: as the protobuf root lists them, else, when the
256
+ * root cannot be read, in the order they were stored. Protobuf blobs are skipped.
257
+ */
258
+ function messages(db: Database, meta: Record<string, any>, lost: Loss): Array<Record<string, any>> {
259
+ if (!hasTable(db, "blobs")) return []
260
+ let rows: Array<{ id: unknown; data: unknown }>
261
+ try {
262
+ rows = db.query("SELECT id, data FROM blobs ORDER BY rowid").all() as Array<{ id: unknown; data: unknown }>
263
+ } catch {
264
+ rows = db.query("SELECT id, data FROM blobs").all() as Array<{ id: unknown; data: unknown }>
265
+ }
266
+ const json = new Map<string, Record<string, any>>()
267
+ const decoder = new TextDecoder("utf-8", { fatal: true })
268
+ for (const r of rows) {
269
+ let text: string | undefined
270
+ try {
271
+ text = typeof r.data === "string" ? r.data : r.data instanceof Uint8Array ? decoder.decode(r.data) : undefined
272
+ } catch {
273
+ continue // binary: protobuf graph state
274
+ }
275
+ if (!text || text[0] !== "{") continue
276
+ let v: unknown
277
+ try {
278
+ v = JSON.parse(text)
279
+ } catch {
280
+ continue
281
+ }
282
+ if (v && typeof v === "object" && typeof (v as Record<string, unknown>).role === "string") json.set(String(r.id), v as Record<string, any>)
283
+ else lost.add("unknown record types: JSON blob without a role")
284
+ }
285
+ const one = db.query("SELECT data FROM blobs WHERE id = ?")
286
+ const blob = (id: string) => bytes((one.get(id) as { data: unknown } | null)?.data)
287
+ const rootId = str(meta.latestRootBlobId)?.toLowerCase()
288
+ const root = rootId ? blob(rootId) : undefined
289
+ const order = root ? messageOrder(root, blob) : undefined
290
+ if (!order?.some((id) => json.has(id))) {
291
+ if (json.size && rootId) lost.add("conversation order (root not readable; stored order used)")
292
+ return [...json.values()]
293
+ }
294
+ const missing = order.filter((id) => !json.has(id)).length
295
+ if (missing) lost.add("messages the root lists but the store lacks", missing)
296
+ // Messages no root lists (left by a rewind or an earlier state of the chat): Cursor's own transcript
297
+ // writer shows none of them either.
298
+ const listed = new Set(order)
299
+ const outside = [...json].filter(([id, m]) => !listed.has(id) && m.role !== "system").length
300
+ if (outside) lost.add("messages outside the conversation (not in its root)", outside)
301
+ return order.filter((id) => json.has(id)).map((id) => json.get(id)!)
302
+ }
303
+
304
+ interface Opened {
305
+ store: Store
306
+ messages: Array<Record<string, any>>
307
+ lost: Loss
308
+ }
309
+
310
+ function open(storePath: string): Opened {
311
+ return withSnapshot(storePath, (db) => {
312
+ const lost = lossCounter()
313
+ const store = readMeta(db)
314
+ return { store, messages: messages(db, store.meta, lost), lost }
315
+ })
316
+ }
317
+
318
+ /** Subagent chats in `bucket` spawned by `agentId`, with the tool call that spawned each. */
319
+ function subagentsOf(bucket: string, agentId: string, self: string): Array<{ id: string; store: string; callId?: string }> {
320
+ const out: Array<{ id: string; store: string; callId?: string }> = []
321
+ for (const id of ls(bucket)) {
322
+ const store = join(bucket, id, "store.db")
323
+ if (id === self || !exists(store)) continue
324
+ const side = readJson<Record<string, any>>(join(bucket, id, "meta.json"))
325
+ if (side && side.isSubagent !== true && typeof side.createdAtMs === "number") continue // a sidecar says it is a main chat
326
+ try {
327
+ const info = withSnapshot(store, (db) => readMeta(db).meta.subagentInfo) as Record<string, any> | undefined
328
+ if (str(info?.parentAgentId) === agentId) out.push({ id, store, callId: str(info?.toolCallId) })
329
+ } catch {
330
+ /* unreadable sibling: not ours to report */
331
+ }
332
+ }
333
+ return out
334
+ }
335
+
336
+ function readStore(ref: { sourceId: string; path: string; kind: string; cwd?: string; updatedAt?: string }, home: string, depth: number): Session {
337
+ const { store, messages: msgs, lost } = open(ref.path)
338
+ const dir = dirname(ref.path)
339
+ const side = readJson<Record<string, any>>(join(dir, "meta.json"))
340
+ const meta = store.meta
341
+ const name = str(meta.name)
342
+ const h = header(TOOL, ref.sourceId, {
343
+ toolVersion: [store.version !== undefined ? `store v${store.version}` : undefined, typeof side?.schemaVersion === "number" ? `meta.json v${side.schemaVersion}` : undefined].filter(Boolean).join(", ") || undefined,
344
+ sourcePath: ref.path,
345
+ title: str(side?.title) ?? (name !== DEFAULT_NAME ? name : undefined),
346
+ startedAt: iso(ms(meta.createdAt) ?? ms(side?.createdAtMs)),
347
+ updatedAt: ref.updatedAt ?? iso(ms(side?.updatedAtMs)) ?? storeTime(ref.path),
348
+ })
349
+ const parent = str(meta.subagentInfo?.parentAgentId)
350
+ if (parent) h.parent = { id: `${TOOL}:${parent}`, kind: "subagent" }
351
+ const c: Conv = { h, lost, turns: [], calls: new Map(), used: new Set(), fallbackModel: str(meta.lastUsedModel) }
352
+ for (const m of msgs) {
353
+ // A compaction's summary comes back as an ordinary message flagged isSummary; it is not something
354
+ // the person or the model said (Cursor's transcript writer leaves it out).
355
+ if (m.providerOptions?.cursor?.isSummary === true) {
356
+ const summary = textOf(m)
357
+ if (summary.trim()) c.turns.push({ kind: "compaction", summary })
358
+ } else if (m.role === "user") userTurns(m, c)
359
+ else if (m.role === "assistant") assistantTurns(m, c)
360
+ else if (m.role === "tool") toolTurns(m, c)
361
+ else if (m.role !== "system") lost.add(`unknown record types: role ${String(m.role)}`) // the system prompt is Cursor's own; targets write theirs
362
+ }
363
+ if (!h.models.length) noteModel(h, str(meta.lastUsedModel))
364
+ if (!msgs.length && str(meta.latestRootBlobId)) lost.add("conversation kept only as protobuf state (not decoded)")
365
+
366
+ const cwd = ref.cwd ?? str(side?.cwd) ?? str(meta.workspacePath) ?? (ref.kind === "chat" ? folderIndex(home, []).get(dirname(dir).split("/").pop()!) : undefined)
367
+ h.project = { ...(cwd ? { cwd } : {}), ...gitInfo(cwd) }
368
+
369
+ // Subagents live beside the chat (or, for an ACP session, in its project's chats folder) and are placed
370
+ // after the tool call that started them.
371
+ const children: Session[] = []
372
+ const bucket = ref.kind === "chat" ? dirname(dir) : cwd ? join(chatsDir(home), bucketOf(cwd)) : undefined
373
+ const agentId = str(meta.agentId) ?? ref.sourceId
374
+ if (bucket && depth < 3) {
375
+ for (const sub of subagentsOf(bucket, agentId, ref.sourceId)) {
376
+ let child: Session
377
+ try {
378
+ child = readStore({ sourceId: sub.id, path: sub.store, kind: "chat", cwd }, home, depth + 1)
379
+ } catch {
380
+ lost.add("unreadable subagent chats")
381
+ continue
382
+ }
383
+ children.push(child)
384
+ const turn: Turn = { kind: "subagent", sessionId: child.header.id, ...(sub.callId ? { callId: c.calls.get(sub.callId) ?? sub.callId } : {}), ...(child.header.title ? { description: child.header.title } : {}) }
385
+ const at = sub.callId ? c.turns.findIndex((t) => t.kind === "tool_call" && t.id === (c.calls.get(sub.callId!) ?? sub.callId)) : -1
386
+ if (at < 0) c.turns.push(turn)
387
+ else {
388
+ // After the call's result when there is one, so the call and result stay together.
389
+ const res = c.turns.findIndex((t, i) => i > at && t.kind === "tool_result" && t.callId === (c.turns[at] as Extract<Turn, { kind: "tool_call" }>).id)
390
+ c.turns.splice((res >= 0 ? res : at) + 1, 0, turn)
391
+ }
392
+ }
393
+ }
394
+ lost.flush(h)
395
+ return children.length ? { header: h, turns: c.turns, children } : { header: h, turns: c.turns }
396
+ }
397
+
398
+ async function read(ref: SessionRef, home: string): Promise<Session> {
399
+ if (!exists(ref.path)) throw new Error(`no Cursor CLI store at ${ref.path}`)
400
+ const kind = ref.extra?.kind === "acp" || ref.path.startsWith(acpDir(home)) ? "acp" : "chat"
401
+ return readStore({ sourceId: ref.sourceId, path: ref.path, kind, ...(ref.cwd ? { cwd: ref.cwd } : {}), ...(ref.updatedAt ? { updatedAt: ref.updatedAt } : {}) }, home, 0)
402
+ }
403
+
404
+ export const readers: Reader[] = [{ tool: TOOL, label: "Cursor CLI", list, read }]
405
+ export const writers: Writer[] = []
@@ -0,0 +1,95 @@
1
+ import { asText } from "../../session/util.ts"
2
+
3
+ // Cursor's agent harness, in the IDE and the CLI alike, keeps the conversation the model sees as
4
+ // content-addressed blobs: AI-SDK messages as JSON, plus a protobuf root, ConversationStateStructure,
5
+ // that lists them in order. The order matters: blobs are written with INSERT OR REPLACE, so the order
6
+ // they were stored in is not the conversation's (on the Mac this was written on, a tool result was
7
+ // stored before the call that asked for it in 2 of 4 conversations).
8
+ //
9
+ // Only the fields needed for the order are read, by field number, from the protobuf wire format
10
+ // (cursor-agent 2025.09 and 2026.10 bundles agree): 1 root_prompt_messages_json, the blob ids of the
11
+ // current prompt's messages; 13 summary_archives (2026.10) and 11 summary_archive (older), blob ids of
12
+ // ConversationSummaryArchive messages, whose field 1, summarized_messages, lists the messages a
13
+ // compaction replaced. Cursor's own transcript writer reads the same: every archive's messages, then the
14
+ // root's. Blob ids are 32 raw bytes, written as lower-case hex wherever a store keys blobs by text.
15
+
16
+ /** The length-delimited fields of one protobuf message, by field number; undefined when the bytes are not protobuf. */
17
+ export function protoFields(buf: Uint8Array): Map<number, Uint8Array[]> | undefined {
18
+ const out = new Map<number, Uint8Array[]>()
19
+ let i = 0
20
+ const varint = (): number | undefined => {
21
+ let v = 0
22
+ for (let shift = 1, n = 0; n < 10; n++, shift *= 128) {
23
+ if (i >= buf.length) return undefined
24
+ const b = buf[i++]!
25
+ v += (b & 0x7f) * shift
26
+ if (!(b & 0x80)) return v
27
+ }
28
+ return undefined
29
+ }
30
+ while (i < buf.length) {
31
+ const tag = varint()
32
+ if (tag === undefined || tag < 8) return undefined
33
+ const wire = tag % 8
34
+ if (wire === 0) {
35
+ if (varint() === undefined) return undefined
36
+ } else if (wire === 1) i += 8
37
+ else if (wire === 5) i += 4
38
+ else if (wire === 2) {
39
+ const n = varint()
40
+ if (n === undefined || i + n > buf.length) return undefined
41
+ const no = Math.floor(tag / 8)
42
+ out.set(no, [...(out.get(no) ?? []), buf.subarray(i, i + n)])
43
+ i += n
44
+ } else return undefined // groups (3, 4) are not used by these messages
45
+ if (i > buf.length) return undefined
46
+ }
47
+ return out
48
+ }
49
+
50
+ const hex = (b: Uint8Array) => Buffer.from(b).toString("hex")
51
+ const ids = (f: Uint8Array[] | undefined) => (f ?? []).filter((b) => b.length === 32).map(hex)
52
+
53
+ /**
54
+ * The blob ids of a conversation's messages, oldest first: each compaction's archived messages, then the
55
+ * current prompt's. Undefined when `root` is not a ConversationStateStructure that lists any message.
56
+ */
57
+ export function messageOrder(root: Uint8Array, getBlob: (id: string) => Uint8Array | undefined): string[] | undefined {
58
+ const top = protoFields(root)
59
+ const prompt = ids(top?.get(1))
60
+ if (!top || !prompt.length) return undefined
61
+ const archives = [...new Set([...ids(top.get(13)), ...ids(top.get(11))])]
62
+ const order: string[] = []
63
+ for (const a of archives) {
64
+ const blob = getBlob(a)
65
+ order.push(...ids(blob ? protoFields(blob)?.get(1) : undefined))
66
+ }
67
+ order.push(...prompt)
68
+ return [...new Set(order)] // a message kept across a compaction is listed in both; it happened once
69
+ }
70
+
71
+ /** The root of an IDE composer: composerData.conversationState is "~" and the root's bytes in base64. */
72
+ export function composerRoot(state: unknown): Uint8Array | undefined {
73
+ if (typeof state !== "string" || !state.startsWith("~") || state.length < 2) return undefined
74
+ const b = Buffer.from(state.slice(1), "base64")
75
+ return b.length ? new Uint8Array(b) : undefined
76
+ }
77
+
78
+ /** A blob's bytes from a SQLite value, which is a BLOB; text values cannot hold protobuf intact. */
79
+ export const bytes = (v: unknown) => (v instanceof Uint8Array ? v : undefined)
80
+
81
+ /** What a tool-result part returned to the model: its result, else the text parts of experimental_content. */
82
+ export function toolResultText(p: Record<string, any>): string {
83
+ if (typeof p.result === "string") return p.result
84
+ if (p.result !== undefined && p.result !== null) return asText(p.result)
85
+ if (Array.isArray(p.experimental_content)) return p.experimental_content.map((c: any) => (c?.type === "text" ? String(c.text ?? "") : `[${String(c?.type ?? "part")}]`)).join("\n")
86
+ return ""
87
+ }
88
+
89
+ /**
90
+ * Whether a tool message reports failure. The harness flags it on the message
91
+ * (providerOptions.cursor.highLevelToolCallResult.isError), not on the part; every tool message seen holds
92
+ * one result, so the message's flag is trusted only then.
93
+ */
94
+ export const toolFailed = (m: Record<string, any>, p: Record<string, any>) =>
95
+ p.isError === true || (Array.isArray(m.content) && m.content.length === 1 && m.providerOptions?.cursor?.highLevelToolCallResult?.isError === true)