@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,501 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import { join } from "node:path"
3
+ import type { Session, SessionHeader, Turn, Usage } from "../../session/types.ts"
4
+ import { appSupport, exists, gitInfo, mtime } from "../../fsutil.ts"
5
+ import { withSnapshot } from "../../sqlite.ts"
6
+ import { asText, clip, header, iso, lossCounter, noteModel, toolKindOf } from "../../session/util.ts"
7
+ import type { Reader, SessionRef, Writer } from "../types.ts"
8
+ import { bytes, composerRoot, messageOrder, toolFailed, toolResultText } from "./agentkv.ts"
9
+ import { cliChatIds, type Loss, transcriptIndex, transcriptTurns } from "./transcripts.ts"
10
+ import { folderFromUri, parse, Workspaces } from "./workspaces.ts"
11
+ import { cursorWriter } from "./writer.ts"
12
+
13
+ // Cursor IDE chats (catalogue entry 23 and its fact-check). One global SQLite store,
14
+ // <appSupport>/Cursor/User/globalStorage/state.vscdb, in WAL mode, read only through a snapshot. In its
15
+ // cursorDiskKV table each chat ("composer") is a composerData:<id> JSON document whose
16
+ // fullConversationHeadersOnly is the only record of message order, and each message is a
17
+ // bubbleId:<id>:<bubbleId> JSON document: type 1 user, type 2 assistant. An assistant bubble holds at most
18
+ // one tool call together with its result (toolFormerData), and its thinking as {text, signature}: only
19
+ // the text form is readable. Newer composers (_v 18) also point into agentKv blobs (AI-SDK messages plus
20
+ // protobuf, agentkv.ts). The bubbles carry the conversation, but from _v 18 many tool results in them are
21
+ // only metadata (a line count, content ids), so what the model was shown is taken from the agentKv tool
22
+ // messages, matched by toolCallId. Checked against composerData _v 10, 11 and 18 and bubble _v 3
23
+ // (Cursor 3.27.22). The writer (writer.ts) goes through Cursor's own Claude Code import.
24
+
25
+ const TOOL = "cursor-ide"
26
+
27
+ const userDir = (home: string) => join(appSupport(home), "Cursor", "User")
28
+ const globalDb = (home: string) => join(userDir(home), "globalStorage", "state.vscdb")
29
+ const workspaces = (home: string) => new Workspaces(join(userDir(home), "workspaceStorage"))
30
+
31
+ /** Every key that starts with `prefix` (which ends in ":"), as an index range: ";" sorts right after ":". */
32
+ const range = (prefix: string): [string, string] => [prefix, `${prefix.slice(0, -1)};`]
33
+
34
+ const hasTable = (db: Database, name: string) => !!db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)
35
+
36
+ const valueOf = (db: Database, key: string) => (db.query("SELECT value FROM cursorDiskKV WHERE key = ?").get(key) as { value: unknown } | null)?.value
37
+
38
+ /** One field of one JSON value, without parsing the rest in JavaScript. */
39
+ function field(db: Database, key: string, path: string): unknown {
40
+ const r = db.query("SELECT json_extract(j, ?) AS x FROM (SELECT CAST(value AS TEXT) AS j FROM cursorDiskKV WHERE key = ?) WHERE json_valid(j)").get(path, key) as { x: unknown } | null
41
+ return r?.x ?? undefined
42
+ }
43
+
44
+ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined)
45
+
46
+ // composerHeaders (3.x) indexes composers with their name, times and workspace; older stores lack it.
47
+ interface Head {
48
+ workspaceId?: string
49
+ name?: string
50
+ createdAt?: unknown
51
+ lastUpdatedAt?: unknown
52
+ uri?: { scheme?: string; fsPath?: string; path?: string }
53
+ isSubagent?: boolean
54
+ }
55
+
56
+ function heads(db: Database): Map<string, Head> {
57
+ const out = new Map<string, Head>()
58
+ if (!hasTable(db, "composerHeaders")) return out
59
+ for (const r of db.query("SELECT * FROM composerHeaders").all() as Array<Record<string, any>>) {
60
+ const v = (parse(r.value) ?? {}) as Record<string, any>
61
+ out.set(String(r.composerId), {
62
+ workspaceId: str(r.workspaceId) ?? str(v.workspaceIdentifier?.id),
63
+ name: str(v.name),
64
+ createdAt: r.createdAt ?? v.createdAt,
65
+ lastUpdatedAt: r.lastUpdatedAt ?? v.lastUpdatedAt,
66
+ uri: v.workspaceIdentifier?.uri,
67
+ isSubagent: !!r.isSubagent || !!v.isSubagent,
68
+ })
69
+ }
70
+ return out
71
+ }
72
+
73
+ /** The folder a workspaceIdentifier.uri names, when it is a local folder. */
74
+ function uriFolder(uri: Head["uri"] | undefined): string | undefined {
75
+ if (!uri || (uri.scheme && uri.scheme !== "file")) return undefined
76
+ return str(uri.fsPath) ?? str(uri.path)
77
+ }
78
+
79
+ /** A folder from a bubble's workspaceProjectDir (a path) or workspaceUris entry (a file URI). */
80
+ const bubbleFolder = (v: unknown) => (typeof v === "string" ? (v.startsWith("file:") ? folderFromUri(v) : v.startsWith("/") ? v : undefined) : undefined)
81
+
82
+ /** The first project folder any of a composer's bubbles records (bubbles carry it from Cursor 2.x on). */
83
+ function projectDir(db: Database, id: string): string | undefined {
84
+ const r = db
85
+ .query(
86
+ `SELECT d FROM (SELECT coalesce(json_extract(j, '$.workspaceProjectDir'), json_extract(j, '$.workspaceUris[0]')) AS d
87
+ FROM (SELECT CAST(value AS TEXT) AS j FROM cursorDiskKV WHERE key >= ? AND key < ?) WHERE json_valid(j)) WHERE d IS NOT NULL LIMIT 1`,
88
+ )
89
+ .get(...range(`bubbleId:${id}:`)) as { d: unknown } | null
90
+ return bubbleFolder(r?.d)
91
+ }
92
+
93
+ /** The Cursor version that last ran on this store; composers do not record the version that wrote them. */
94
+ function appVersion(db: Database): string | undefined {
95
+ if (!hasTable(db, "ItemTable")) return undefined
96
+ const r = db.query("SELECT CAST(value AS TEXT) AS v FROM ItemTable WHERE key IN ('releaseNotes/lastVersion', 'cursor.startupMetrics.lastVersion') LIMIT 1").get() as { v: unknown } | null
97
+ return str(r?.v)
98
+ }
99
+
100
+ const oneLine = (s: string) => {
101
+ const t = s.replace(/\s+/g, " ").trim()
102
+ return t.length > 80 ? `${t.slice(0, 79)}…` : t
103
+ }
104
+
105
+ // ---- list -------------------------------------------------------------------------------------------
106
+
107
+ interface Found {
108
+ id: string
109
+ v?: number
110
+ title?: string
111
+ updatedAt?: string
112
+ cwd?: string
113
+ hash?: string
114
+ parent?: string
115
+ transcript?: string
116
+ inDb: boolean
117
+ }
118
+
119
+ const COMPOSERS = `
120
+ SELECT substr(key, 14) AS id,
121
+ json_extract(j, '$._v') AS v, json_extract(j, '$.name') AS name,
122
+ json_extract(j, '$.createdAt') AS createdAt, json_extract(j, '$.lastUpdatedAt') AS lastUpdatedAt,
123
+ json_extract(j, '$.workspaceIdentifier.id') AS wsId, json_extract(j, '$.workspaceIdentifier.uri') AS wsUri,
124
+ coalesce(json_array_length(j, '$.fullConversationHeadersOnly'), 0) AS n,
125
+ json_extract(j, '$.fullConversationHeadersOnly[0].bubbleId') AS first,
126
+ json_extract(j, '$.fullConversationHeadersOnly[#-1].bubbleId') AS last,
127
+ json_extract(j, '$.subagentInfo.parentComposerId') AS parent,
128
+ coalesce(json_extract(j, '$.isBestOfNSubcomposer'), 0) AS bestOfN
129
+ FROM (SELECT key, CAST(value AS TEXT) AS j FROM cursorDiskKV WHERE key >= 'composerData:' AND key < 'composerData;')
130
+ WHERE json_valid(j)`
131
+
132
+ /** Composers that hold a conversation, from their headers and a few single-row lookups, never whole chats. */
133
+ function composers(db: Database, ws: Workspaces, transcripts: Map<string, { file: string }>): Found[] {
134
+ if (!hasTable(db, "cursorDiskKV")) return []
135
+ const bubbled = new Set<string>()
136
+ for (const r of db.query("SELECT key FROM cursorDiskKV WHERE key >= 'bubbleId:' AND key < 'bubbleId;'").all() as Array<{ key: string }>) bubbled.add(r.key.split(":")[1] ?? "")
137
+ const hs = heads(db)
138
+ const rows = db.query(COMPOSERS).all() as Array<Record<string, any>>
139
+ const known = new Set(rows.map((r) => String(r.id)))
140
+ const out: Found[] = []
141
+ const lastTime = (id: string, last: unknown) =>
142
+ (last ? field(db, `bubbleId:${id}:${last}`, "$.createdAt") : undefined) ??
143
+ (db.query("SELECT max(json_extract(j, '$.createdAt')) AS t FROM (SELECT CAST(value AS TEXT) AS j FROM cursorDiskKV WHERE key >= ? AND key < ?) WHERE json_valid(j)").get(...range(`bubbleId:${id}:`)) as { t: unknown } | null)?.t
144
+ for (const r of rows) {
145
+ const id = String(r.id)
146
+ if (!(r.n > 0 || bubbled.has(id) || transcripts.has(id))) continue // drafts and never-used chats
147
+ if (r.parent && known.has(r.parent) && !r.bestOfN) continue // a subagent: it is read as part of its parent
148
+ const h = hs.get(id)
149
+ const wsUri = parse(r.wsUri)
150
+ const hash = str(h?.workspaceId) ?? str(r.wsId)
151
+ const first = r.n > 0 && r.first ? (field(db, `bubbleId:${id}:${r.first}`, "$.type") === 1 ? str(field(db, `bubbleId:${id}:${r.first}`, "$.text")) : undefined) : undefined
152
+ out.push({
153
+ id,
154
+ v: typeof r.v === "number" ? r.v : undefined,
155
+ title: str(r.name) ?? h?.name ?? (first ? oneLine(first) : undefined),
156
+ updatedAt: iso(r.lastUpdatedAt) ?? iso(h?.lastUpdatedAt) ?? iso(lastTime(id, r.last)) ?? iso(r.createdAt),
157
+ cwd: uriFolder(wsUri) ?? uriFolder(h?.uri) ?? ws.folder(hash) ?? projectDir(db, id),
158
+ hash,
159
+ parent: r.parent && r.bestOfN ? String(r.parent) : undefined,
160
+ inDb: true,
161
+ })
162
+ }
163
+ // Index entries and bubbles whose composerData is gone still hold a conversation when they have bubbles.
164
+ for (const id of new Set([...hs.keys(), ...bubbled])) {
165
+ if (known.has(id) || !(bubbled.has(id) || transcripts.has(id))) continue
166
+ const h = hs.get(id)
167
+ if (h?.isSubagent) continue
168
+ out.push({ id, title: h?.name, updatedAt: iso(h?.lastUpdatedAt) ?? iso(lastTime(id, undefined)), cwd: uriFolder(h?.uri) ?? ws.folder(h?.workspaceId) ?? projectDir(db, id), hash: h?.workspaceId, inDb: true })
169
+ }
170
+ return out
171
+ }
172
+
173
+ async function list(home: string): Promise<SessionRef[]> {
174
+ const dbPath = globalDb(home)
175
+ const ws = workspaces(home)
176
+ const transcripts = transcriptIndex(home)
177
+ const found = exists(dbPath) ? withSnapshot(dbPath, (db) => composers(db, ws, transcripts)) : []
178
+ const inDb = new Set(found.map((f) => f.id))
179
+ // A transcript whose composer is gone from the database is still a session (text and calls only),
180
+ // unless it is a Cursor CLI chat's, which the CLI reader lists from its store in full.
181
+ const cli = cliChatIds(home)
182
+ for (const [id, t] of transcripts) if (!inDb.has(id) && !cli.has(id)) found.push({ id, updatedAt: mtime(t.file), inDb: false })
183
+ // Place what the store did not: the transcript folder's slug, then the workspaces that list the composer.
184
+ // A known workspace hash is final even without a folder (an empty window or a .code-workspace).
185
+ for (const f of found) {
186
+ const t = transcripts.get(f.id)
187
+ if (t) f.transcript = t.file
188
+ if (!f.cwd && t && !ws.known(f.hash)) f.cwd = ws.bySlug(t.slug)
189
+ }
190
+ const placed = ws.listing(found.filter((f) => !f.cwd && !ws.known(f.hash)).map((f) => f.id))
191
+ return found
192
+ .map((f): SessionRef => {
193
+ const cwd = f.cwd ?? placed.get(f.id)
194
+ return {
195
+ tool: TOOL,
196
+ sourceId: f.id,
197
+ path: f.inDb ? dbPath : f.transcript!,
198
+ ...(f.title ? { title: f.title } : {}),
199
+ ...(cwd ? { cwd } : {}),
200
+ ...(f.updatedAt ? { updatedAt: f.updatedAt } : {}),
201
+ extra: { ...(f.v !== undefined ? { composerVersion: f.v } : {}), ...(f.hash ? { workspace: f.hash } : {}), ...(f.parent ? { forkOf: f.parent } : {}), ...(f.transcript ? { transcript: f.transcript } : {}) },
202
+ }
203
+ })
204
+ .sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
205
+ }
206
+
207
+ // ---- read -------------------------------------------------------------------------------------------
208
+
209
+ /** A JSON-string field parsed, or the value itself when it is already an object. */
210
+ function json(v: unknown): unknown {
211
+ if (typeof v !== "string") return v ?? undefined
212
+ try {
213
+ return JSON.parse(v)
214
+ } catch {
215
+ return undefined
216
+ }
217
+ }
218
+
219
+ /** The arguments the model sent (rawArgs), else Cursor's normalised params, else whatever text there is. */
220
+ function toolInput(t: Record<string, any>): unknown {
221
+ const raw = json(t.rawArgs)
222
+ if (raw && typeof raw === "object" && Object.keys(raw).length) return raw
223
+ const params = json(t.params)
224
+ if (params !== undefined) return params
225
+ return raw ?? str(t.rawArgs) ?? {}
226
+ }
227
+
228
+ // The readable part of a tool result, by the shapes Cursor's tools return: terminal output, file
229
+ // contents, an edit's summary for the model, MCP results. Anything else stays JSON.
230
+ const RESULT_TEXT = ["output", "contents", "resultForModel", "result", "content", "text", "message"]
231
+
232
+ // Results from Cursor 3.x tools that say what happened but not what the model saw (read_file_v2 keeps a
233
+ // line count, edit_file_v2 two content ids); the output itself is in the agentKv tool message.
234
+ const METADATA_ONLY = new Set(["totalLinesInFile", "afterContentId", "beforeContentId"])
235
+ const metadataOnly = (v: unknown) => !!v && typeof v === "object" && Object.keys(v).length > 0 && Object.keys(v).every((k) => METADATA_ONLY.has(k))
236
+
237
+ /** The result as text, and whether it held readable output rather than only fields such as a line count. */
238
+ function resultText(raw: unknown): { text: string; readable: boolean } {
239
+ if (raw === undefined || raw === null) return { text: "", readable: false }
240
+ const v = typeof raw === "string" ? (json(raw) ?? raw) : raw
241
+ if (typeof v === "string") return { text: v, readable: !!v.trim() }
242
+ if (!v || typeof v !== "object") return { text: asText(v), readable: true }
243
+ const o = v as Record<string, any>
244
+ const key = RESULT_TEXT.find((k) => typeof o[k] === "string")
245
+ let text = key ? (o[key] as string) : typeof raw === "string" ? raw : asText(raw)
246
+ const code = typeof o.exitCode === "number" ? o.exitCode : typeof o.exitCodeV2 === "number" ? o.exitCodeV2 : undefined
247
+ if (code) text += `\n[exit code ${code}]`
248
+ return { text, readable: !!key && !!(o[key] as string).trim() }
249
+ }
250
+
251
+ /** What each tool call returned to the model, by toolCallId, from the composer's agentKv tool messages. */
252
+ type ModelResults = Map<string, { text: string; failed: boolean }>
253
+
254
+ function modelResults(db: Database, state: unknown): ModelResults {
255
+ const out: ModelResults = new Map()
256
+ const root = composerRoot(state)
257
+ if (!root) return out
258
+ const get = db.query("SELECT value FROM cursorDiskKV WHERE key = ?")
259
+ const blob = (id: string) => (get.get(`agentKv:blob:${id}`) as { value: unknown } | null)?.value
260
+ for (const id of messageOrder(root, (id) => bytes(blob(id))) ?? []) {
261
+ const m = parse(blob(id)) as Record<string, any> | undefined
262
+ if (m?.role !== "tool" || !Array.isArray(m.content)) continue
263
+ for (const p of m.content) if (p?.type === "tool-result" && str(p.toolCallId)) out.set(p.toolCallId, { text: toolResultText(p), failed: toolFailed(m, p) })
264
+ }
265
+ return out
266
+ }
267
+
268
+ function errorText(e: unknown): string {
269
+ const v = json(e) ?? e
270
+ if (v && typeof v === "object") return str((v as Record<string, any>).message) ?? str((v as Record<string, any>).error) ?? asText(v)
271
+ return asText(v)
272
+ }
273
+
274
+ interface Ctx {
275
+ dbPath: string
276
+ ws: Workspaces
277
+ transcripts: Map<string, { file: string; slug: string }>
278
+ depth: number
279
+ seen: Set<string>
280
+ }
281
+
282
+ interface Read {
283
+ session: Session
284
+ /** Nothing in the store placed it and no known workspace rules it out: worth scanning the workspaces. */
285
+ scan: boolean
286
+ }
287
+
288
+ /** What a bubble holds that a reader keeps: its text, readable thinking and tool call. */
289
+ function contentOf(b: Record<string, any>, bubbleId: string): string[] {
290
+ const out: string[] = []
291
+ if (typeof b.text === "string" && b.text.trim()) out.push(`text:${b.text}`)
292
+ const blocks: unknown[] = Array.isArray(b.allThinkingBlocks) ? [...b.allThinkingBlocks, b.thinking] : [b.thinking]
293
+ for (const t of blocks) {
294
+ const text = typeof t === "string" ? t : str((t as Record<string, any> | undefined)?.text)
295
+ if (text?.trim()) out.push(`thinking:${text}`)
296
+ }
297
+ const t = b.toolFormerData as Record<string, any> | undefined
298
+ if (t && (str(t.name) || str(t.toolCallId))) out.push(`call:${str(t.toolCallId) ?? bubbleId}`)
299
+ return out
300
+ }
301
+
302
+ /** Turns from one bubble, in the order they happened: thinking, the reply, the tool call, its result. */
303
+ function bubbleTurns(b: Record<string, any>, bubbleId: string, h: SessionHeader, lost: Loss, ids: Set<string>, shownToModel: ModelResults): Turn[] {
304
+ const out: Turn[] = []
305
+ const at = iso(b.createdAt)
306
+ const stamp = at ? { at } : {}
307
+ if (b.type === 1) {
308
+ if (Array.isArray(b.images) && b.images.length) lost.add("images", b.images.length)
309
+ if (typeof b.text === "string" && b.text.trim()) out.push({ kind: "user", ...stamp, text: b.text })
310
+ return out
311
+ }
312
+ if (b.type !== 2) {
313
+ lost.add(`unknown record types: bubble type ${String(b.type)}`)
314
+ return out
315
+ }
316
+ const blocks: unknown[] = Array.isArray(b.allThinkingBlocks) && b.allThinkingBlocks.length ? b.allThinkingBlocks : b.thinking !== undefined && b.thinking !== null ? [b.thinking] : []
317
+ for (const t of blocks) {
318
+ const text = typeof t === "string" ? t : str((t as Record<string, any>)?.text)
319
+ if (text?.trim()) out.push({ kind: "reasoning", ...stamp, text })
320
+ else if ((t as Record<string, any>)?.signature) lost.add("signed reasoning")
321
+ }
322
+ const model = str(b.modelInfo?.modelName)
323
+ noteModel(h, model)
324
+ if (typeof b.text === "string" && b.text.trim()) {
325
+ const tc = b.tokenCount ?? {}
326
+ const usage: Usage | undefined = tc.inputTokens > 0 || tc.outputTokens > 0 ? { input: tc.inputTokens || 0, output: tc.outputTokens || 0 } : undefined
327
+ out.push({ kind: "assistant", ...stamp, text: b.text, ...(model ? { model } : {}), ...(usage ? { usage } : {}) })
328
+ }
329
+ const t = b.toolFormerData as Record<string, any> | undefined
330
+ // Placeholder toolFormerData objects (only additionalData) are common and carry no call.
331
+ if (t && (str(t.name) || str(t.toolCallId))) {
332
+ let id = str(t.toolCallId) ?? `bubble-${bubbleId}`
333
+ for (let n = 2; ids.has(id); n++) id = `${str(t.toolCallId) ?? `bubble-${bubbleId}`}#${n}`
334
+ ids.add(id)
335
+ const name = str(t.name) ?? `tool-${String(t.tool ?? "unknown")}`
336
+ out.push({ kind: "tool_call", ...stamp, id, name, toolKind: toolKindOf(name), input: toolInput(t) })
337
+ const res = json(t.result) as Record<string, any> | undefined
338
+ const shown = str(t.toolCallId) ? shownToModel.get(t.toolCallId) : undefined
339
+ const failed = t.status === "error" || t.status === "cancelled" || !!t.error || res?.rejected === true || !!shown?.failed
340
+ const r = resultText(t.result)
341
+ // The bubble's own output when it has one, else what the model was shown (agentKv), else the metadata.
342
+ let text = r.readable || !shown?.text ? r.text : shown.text
343
+ if (!r.readable && !shown?.text && metadataOnly(res)) lost.add("tool outputs (only metadata kept, no agentKv message)")
344
+ if (t.error) text = [errorText(t.error), text].filter(Boolean).join("\n")
345
+ if (!text) text = t.status && t.status !== "completed" ? `[${t.status}]` : "[no result recorded]"
346
+ const c = clip(text)
347
+ out.push({ kind: "tool_result", ...stamp, callId: id, ok: !failed, output: c.text, ...(c.truncated ? { truncated: true } : {}) })
348
+ }
349
+ if (b.errorDetails) lost.add("error notices")
350
+ return out
351
+ }
352
+
353
+ /** One composer in full; undefined when the store knows nothing of it. */
354
+ function readComposer(db: Database, id: string, ctx: Ctx): Read | undefined {
355
+ ctx.seen.add(id)
356
+ const data = parse(valueOf(db, `composerData:${id}`)) as Record<string, any> | undefined
357
+ const head = heads(db).get(id)
358
+ const prefix = `bubbleId:${id}:`
359
+ const bubbles = new Map<string, Record<string, any>>()
360
+ let unreadable = 0
361
+ for (const r of db.query("SELECT key, value FROM cursorDiskKV WHERE key >= ? AND key < ?").all(...range(prefix)) as Array<{ key: string; value: unknown }>) {
362
+ const b = parse(r.value)
363
+ if (b && typeof b === "object") bubbles.set(r.key.slice(prefix.length), b)
364
+ else unreadable++
365
+ }
366
+ if (!data && !head && !bubbles.size) return undefined
367
+
368
+ const lost = lossCounter()
369
+ const app = appVersion(db)
370
+ const v = typeof data?._v === "number" ? `composer v${data._v}` : undefined
371
+ const h = header(TOOL, id, {
372
+ toolVersion: app && v ? `${app} (${v})` : (app ?? v),
373
+ sourcePath: ctx.dbPath,
374
+ title: str(data?.name) ?? head?.name,
375
+ startedAt: iso(data?.createdAt) ?? iso(head?.createdAt),
376
+ updatedAt: iso(data?.lastUpdatedAt) ?? iso(head?.lastUpdatedAt),
377
+ })
378
+ const parent = str(data?.subagentInfo?.parentComposerId)
379
+ if (parent) h.parent = { id: `${TOOL}:${parent}`, kind: data?.isBestOfNSubcomposer ? "fork" : "subagent" }
380
+
381
+ // Bubble order comes only from fullConversationHeadersOnly. Without it (an index that lost it), fall
382
+ // back to the bubbles' own timestamps.
383
+ const order: string[] = (Array.isArray(data?.fullConversationHeadersOnly) ? data!.fullConversationHeadersOnly : []).map((x: any) => str(x?.bubbleId)).filter((x: string | undefined): x is string => !!x)
384
+ let ordered: Array<[string, Record<string, any>]>
385
+ if (order.length) {
386
+ ordered = []
387
+ let missing = 0
388
+ for (const b of order) {
389
+ const x = bubbles.get(b)
390
+ if (x) ordered.push([b, x])
391
+ else missing++
392
+ }
393
+ if (missing) lost.add(data?.conversationState ? "missing bubbles (kept only in agentKv, not rebuilt)" : "missing bubbles", missing)
394
+ // Bubbles outside the order are mostly empty placeholders or copies of ordered ones (the same tool
395
+ // call id, the same text); only those holding something the order does not are lost.
396
+ const inOrder = new Set(order)
397
+ const kept = new Set(ordered.flatMap(([bid, b]) => contentOf(b, bid)))
398
+ const outside = [...bubbles].filter(([bid, b]) => !inOrder.has(bid) && contentOf(b, bid).some((c) => !kept.has(c))).length
399
+ if (outside) lost.add("bubbles outside the conversation order", outside)
400
+ } else {
401
+ ordered = [...bubbles.entries()].sort((a, b) => String(a[1].createdAt ?? "").localeCompare(String(b[1].createdAt ?? "")))
402
+ }
403
+ if (unreadable) lost.add("unreadable bubbles", unreadable)
404
+
405
+ // Subagents ran inside this chat, after `conversationLengthAtSpawn` bubbles. Best-of-N runs are forks,
406
+ // listed on their own, not children.
407
+ const spawned = (
408
+ db
409
+ .query(
410
+ `SELECT substr(key, 14) AS id, json_extract(j, '$.subagentInfo.conversationLengthAtSpawn') AS at
411
+ FROM (SELECT key, CAST(value AS TEXT) AS j FROM cursorDiskKV WHERE key >= 'composerData:' AND key < 'composerData;')
412
+ WHERE json_valid(j) AND json_extract(j, '$.subagentInfo.parentComposerId') = ? AND coalesce(json_extract(j, '$.isBestOfNSubcomposer'), 0) = 0`,
413
+ )
414
+ .all(id) as Array<{ id: string; at: unknown }>
415
+ ).filter((c) => !ctx.seen.has(c.id))
416
+ const children: Session[] = []
417
+ const turns: Turn[] = []
418
+ const ids = new Set<string>()
419
+ const shown = modelResults(db, data?.conversationState)
420
+ const spawn = (upTo: number) => {
421
+ for (const c of spawned.filter((c) => (typeof c.at === "number" ? c.at : Number.POSITIVE_INFINITY) <= upTo && !ctx.seen.has(c.id))) {
422
+ if (ctx.depth >= 3) {
423
+ lost.add("nested subagents beyond depth 3")
424
+ ctx.seen.add(c.id)
425
+ continue
426
+ }
427
+ const child = readComposer(db, c.id, { ...ctx, depth: ctx.depth + 1 })
428
+ if (!child) continue
429
+ children.push(child.session)
430
+ turns.push({ kind: "subagent", sessionId: child.session.header.id, ...(child.session.header.title ? { description: child.session.header.title } : {}) })
431
+ }
432
+ }
433
+ spawn(0)
434
+ ordered.forEach(([bid, b], i) => {
435
+ turns.push(...bubbleTurns(b, bid, h, lost, ids, shown))
436
+ spawn(i + 1)
437
+ })
438
+ spawn(Number.POSITIVE_INFINITY)
439
+
440
+ if (!ordered.length) {
441
+ const t = ctx.transcripts.get(id)
442
+ if (t) {
443
+ turns.push(...transcriptTurns(t.file, id, lost))
444
+ h.sourcePath = t.file
445
+ } else if (Array.isArray(data?.conversation) && data!.conversation.length) lost.add("inline conversation (older composer format, not read)", data!.conversation.length)
446
+ }
447
+ if (!h.models.length) {
448
+ const m = str(data?.modelConfig?.modelName)
449
+ if (m && m !== "default") noteModel(h, m) // "default" is Cursor's placeholder, not a model
450
+ }
451
+ h.updatedAt ??= [...turns].reverse().find((t) => t.at)?.at ?? h.startedAt
452
+ h.startedAt ??= turns.find((t) => t.at)?.at
453
+
454
+ // Where it ran, cheapest first; the session's own branch and commit win over the folder's current ones.
455
+ const hash = head?.workspaceId ?? str(data?.workspaceIdentifier?.id)
456
+ let cwd = uriFolder(data?.workspaceIdentifier?.uri) ?? uriFolder(head?.uri) ?? ctx.ws.folder(hash)
457
+ for (const [, b] of ordered) cwd ??= bubbleFolder(b.workspaceProjectDir) ?? (Array.isArray(b.workspaceUris) ? bubbleFolder(b.workspaceUris[0]) : undefined)
458
+ if (!cwd && ctx.transcripts.has(id) && !ctx.ws.known(hash)) cwd = ctx.ws.bySlug(ctx.transcripts.get(id)!.slug)
459
+ h.project = {
460
+ ...(cwd ? { cwd } : {}),
461
+ ...(str(data?.gitWorktree?.branchName) ?? str(data?.createdOnBranch) ? { gitBranch: str(data?.gitWorktree?.branchName) ?? str(data?.createdOnBranch) } : {}),
462
+ ...(str(data?.gitWorktree?.commitHash) ? { gitCommit: str(data?.gitWorktree?.commitHash) } : {}),
463
+ }
464
+ lost.flush(h)
465
+ const session: Session = children.length ? { header: h, turns, children } : { header: h, turns }
466
+ return { session, scan: !cwd && !ctx.ws.known(hash) }
467
+ }
468
+
469
+ /** The project folder's git state under what the session itself recorded; children inherit the folder. */
470
+ function settle(s: Session, cwd: string | undefined) {
471
+ const p = s.header.project
472
+ const dir = p.cwd ?? cwd
473
+ s.header.project = { ...(dir ? { cwd: dir } : {}), ...gitInfo(dir), ...(p.gitBranch ? { gitBranch: p.gitBranch } : {}), ...(p.gitCommit ? { gitCommit: p.gitCommit } : {}) }
474
+ for (const c of s.children ?? []) settle(c, dir)
475
+ }
476
+
477
+ async function read(ref: SessionRef, home: string): Promise<Session> {
478
+ const dbPath = globalDb(home)
479
+ const ws = workspaces(home)
480
+ const transcripts = transcriptIndex(home)
481
+ const ctx: Ctx = { dbPath, ws, transcripts, depth: 0, seen: new Set() }
482
+ let found = exists(dbPath) ? withSnapshot(dbPath, (db) => (hasTable(db, "cursorDiskKV") ? readComposer(db, ref.sourceId, ctx) : undefined)) : undefined
483
+ if (!found) {
484
+ const t = transcripts.get(ref.sourceId)
485
+ if (!t) throw new Error(`Cursor has no chat ${ref.sourceId} in ${dbPath} or ~/.cursor/projects`)
486
+ const lost = lossCounter()
487
+ const h = header(TOOL, ref.sourceId, { sourcePath: t.file, updatedAt: mtime(t.file) })
488
+ const turns = transcriptTurns(t.file, ref.sourceId, lost)
489
+ h.startedAt = turns.find((x) => x.at)?.at
490
+ lost.flush(h)
491
+ found = { session: { header: h, turns }, scan: true }
492
+ }
493
+ // The last resort for the folder: the workspace databases that list this composer (outside the snapshot).
494
+ let cwd = found.session.header.project.cwd ?? ref.cwd
495
+ if (!cwd && found.scan) cwd = ws.listing([ref.sourceId]).get(ref.sourceId)
496
+ settle(found.session, cwd)
497
+ return found.session
498
+ }
499
+
500
+ export const readers: Reader[] = [{ tool: TOOL, label: "Cursor", list, read }]
501
+ export const writers: Writer[] = [cursorWriter]
@@ -0,0 +1,122 @@
1
+ import { join } from "node:path"
2
+ import type { Turn } from "../../session/types.ts"
3
+ import { exists, isDir, ls, readJsonl } from "../../fsutil.ts"
4
+ import { iso, type lossCounter, toolKindOf } from "../../session/util.ts"
5
+
6
+ export type Loss = ReturnType<typeof lossCounter>
7
+
8
+ // Cursor's readable agent transcripts, <home>/.cursor/projects/<slug>/agent-transcripts/<id>/<id>.jsonl,
9
+ // written by both the IDE and the CLI. They keep text and tool calls but no tool results and no ids
10
+ // (catalogue entry 23), so the IDE reader uses them only for a composer whose bubbles are gone.
11
+ // Also here: the user-text cleanup both Cursor readers need, since Cursor wraps what the person typed in
12
+ // harness tags (<timestamp>, <user_query>) and injects context (<user_info>, <rules>...) as user text.
13
+
14
+ /** The project-folder slug Cursor uses under ~/.cursor/projects (cursor-agent 2026.10, utils/workspace-paths). */
15
+ export function slugOf(folder: string): string {
16
+ return folder.replace(/[^a-zA-Z0-9]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "")
17
+ }
18
+
19
+ /** Every primary transcript under `home`: composer id → { file, slug }. Directory listings only. */
20
+ export function transcriptIndex(home: string): Map<string, { file: string; slug: string }> {
21
+ const out = new Map<string, { file: string; slug: string }>()
22
+ const root = join(home, ".cursor", "projects")
23
+ for (const slug of ls(root)) {
24
+ const dir = join(root, slug, "agent-transcripts")
25
+ if (!isDir(dir)) continue
26
+ for (const name of ls(dir)) {
27
+ // Current layout: <id>/<id>.jsonl; legacy layout: <id>.jsonl directly under agent-transcripts.
28
+ const nested = join(dir, name, `${name}.jsonl`)
29
+ if (exists(nested)) out.set(name, { file: nested, slug })
30
+ else if (name.endsWith(".jsonl") && !out.has(name.slice(0, -6))) out.set(name.slice(0, -6), { file: join(dir, name), slug })
31
+ }
32
+ }
33
+ return out
34
+ }
35
+
36
+ /**
37
+ * Ids of the Cursor CLI's chats and ACP sessions that have a store. The CLI writes its transcripts beside
38
+ * the IDE's, under the same ids, so these are the CLI reader's sessions, not orphaned IDE chats.
39
+ */
40
+ export function cliChatIds(home: string): Set<string> {
41
+ const root = join(home, ".cursor")
42
+ const out = new Set<string>()
43
+ for (const bucket of ls(join(root, "chats"))) for (const id of ls(join(root, "chats", bucket))) if (exists(join(root, "chats", bucket, id, "store.db"))) out.add(id)
44
+ for (const id of ls(join(root, "acp-sessions"))) if (exists(join(root, "acp-sessions", id, "store.db"))) out.add(id)
45
+ return out
46
+ }
47
+
48
+ // Context blocks Cursor's harness puts into user messages (cursor-agent 2026.10, agent-transcript), which
49
+ // are not what the person typed. Cursor's own transcript writer strips the same list.
50
+ const CONTEXT_TAGS = [
51
+ "user_info", "project_layout", "rules", "always_applied_workspace_rules", "agent_requestable_workspace_rules", "user_rules",
52
+ "agent_skills", "available_skills", "cloud_instructions", "cloud_task_instructions", "open_and_recently_viewed_files",
53
+ "system_reminder", "system-reminder", "instructions_update", "mcp_instructions", "mcp_file_system", "mcp_file_system_servers",
54
+ "git_status", "agent_transcripts", "cursor_rules_context", "attached_files", "system_notification", "task_notification", "agent_notification",
55
+ ]
56
+ const block = (tag: string) => new RegExp(`<${tag}(?:\\s[^>]*)?>[\\s\\S]*?</${tag}>`, "gi")
57
+ const CONTEXT_RE = CONTEXT_TAGS.map(block)
58
+
59
+ /**
60
+ * A <timestamp> value, "Sunday, November 8, 2026, 10:00 AM (GMT+5:30)", as ISO. The zone is in
61
+ * parentheses, which JavaScript's date parser ignores (it would read the time as the reader's local
62
+ * time), so the offset is applied here.
63
+ */
64
+ export function stampTime(s: string): string | undefined {
65
+ const zone = s.match(/\((?:GMT|UTC)(?:\s*([+-])(\d{1,2})(?::?(\d{2}))?)?\)\s*$/)
66
+ const base = s.replace(/\s*\([^)]*\)\s*$/, "")
67
+ if (!zone) return iso(base) // no zone written: local time is the only reading left
68
+ const t = Date.parse(`${base} UTC`)
69
+ if (!Number.isFinite(t)) return undefined
70
+ const offset = (Number(zone[2] ?? 0) * 60 + Number(zone[3] ?? 0)) * (zone[1] === "-" ? -1 : 1)
71
+ return new Date(t - offset * 60_000).toISOString()
72
+ }
73
+
74
+ /**
75
+ * What the person typed, from a harness user message: the <user_query> blocks when there are any, else
76
+ * the text with injected context blocks removed. `at` comes from a <timestamp> block when present.
77
+ * `context` says the message was nothing but injected context.
78
+ */
79
+ export function userText(raw: string): { text: string; at?: string; context: boolean } {
80
+ const stamp = raw.match(/<timestamp>([\s\S]*?)<\/timestamp>/)?.[1]?.trim()
81
+ const at = stamp ? stampTime(stamp) : undefined
82
+ const queries = [...raw.matchAll(/<user_query>([\s\S]*?)<\/user_query>/g)].map((m) => m[1]!.trim()).filter(Boolean)
83
+ if (queries.length) return { text: queries.join("\n\n"), at, context: false }
84
+ let text = raw
85
+ if (text.includes("<")) for (const re of [...CONTEXT_RE, block("timestamp")]) text = text.replace(re, "")
86
+ text = text.replace(/\n{3,}/g, "\n\n").trim()
87
+ return { text, at, context: !text && raw.trim().length > 0 }
88
+ }
89
+
90
+ /**
91
+ * Turns from a transcript file. Tool calls get ids made from the session id and their position, since
92
+ * the file has none; their results were never written, which is counted in `lost`.
93
+ */
94
+ export function transcriptTurns(file: string, sessionId: string, lost: Loss): Turn[] {
95
+ const { rows, bad } = readJsonl(file)
96
+ const turns: Turn[] = []
97
+ let calls = 0
98
+ for (const r of rows) {
99
+ if (r.type === "turn_ended") continue // a turn boundary with a status, nothing said
100
+ const role = r.role
101
+ const content = r.message?.content
102
+ if ((role !== "user" && role !== "assistant") || (!Array.isArray(content) && typeof content !== "string")) {
103
+ lost.add(`unknown record types: ${String(r.type ?? role ?? "record")}`)
104
+ continue
105
+ }
106
+ const parts: Array<Record<string, any>> = typeof content === "string" ? [{ type: "text", text: content }] : content
107
+ for (const p of parts) {
108
+ if (p?.type === "text" && typeof p.text === "string") {
109
+ if (role === "user") {
110
+ const u = userText(p.text)
111
+ if (u.text) turns.push({ kind: "user", ...(u.at ? { at: u.at } : {}), text: u.text })
112
+ else if (u.context) lost.add("injected context messages")
113
+ } else if (p.text.trim()) turns.push({ kind: "assistant", text: p.text })
114
+ } else if (p?.type === "tool_use" && typeof p.name === "string") {
115
+ turns.push({ kind: "tool_call", id: `${sessionId}:t${++calls}`, name: p.name, toolKind: toolKindOf(p.name), input: p.input ?? {} })
116
+ } else lost.add(`unknown record types: ${String(p?.type ?? "part")}`)
117
+ }
118
+ }
119
+ if (calls) lost.add("tool results (agent-transcripts do not keep them)", calls)
120
+ if (bad) lost.add("unreadable transcript lines", bad)
121
+ return turns
122
+ }