@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,119 @@
1
+ import { statSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+ import { exists, ls, readJson } from "../../fsutil.ts"
5
+ import { withSnapshot } from "../../sqlite.ts"
6
+ import { slugOf } from "./transcripts.ts"
7
+
8
+ // Which folder a Cursor composer ran in. A composer records it only sometimes (workspaceIdentifier.uri on
9
+ // newer ones), so the reader falls back, cheapest first: the workspace hash → workspaceStorage/<hash>/
10
+ // workspace.json; the transcript folder's slug; and last the per-workspace databases whose ItemTable
11
+ // lists the composer (757 of them on the Mac this was written on: about a second, so done once per list
12
+ // and only for composers nothing else placed).
13
+
14
+ /** A folder path from a VS Code-style URI; undefined for remote or non-file URIs. */
15
+ export function folderFromUri(uri: unknown): string | undefined {
16
+ if (typeof uri !== "string" || !uri.startsWith("file:")) return undefined
17
+ try {
18
+ return fileURLToPath(uri)
19
+ } catch {
20
+ return undefined
21
+ }
22
+ }
23
+
24
+ export class Workspaces {
25
+ private byHash = new Map<string, string | undefined>()
26
+ private all?: Map<string, string>
27
+ private scanned = new Map<string, string>()
28
+ private scannedDbs = new Set<string>()
29
+
30
+ constructor(private readonly storage: string) {}
31
+
32
+ /** The folder of one workspace hash, from its workspace.json (a .code-workspace or empty window has none). */
33
+ folder(hash: string | undefined): string | undefined {
34
+ if (!hash) return undefined
35
+ if (!this.byHash.has(hash)) this.byHash.set(hash, folderFromUri(readJson<{ folder?: string }>(join(this.storage, hash, "workspace.json"))?.folder))
36
+ return this.byHash.get(hash)
37
+ }
38
+
39
+ /**
40
+ * Whether the composer's own record names its workspace. That answer is final even when it has no
41
+ * folder (an empty window, a .code-workspace, a workspace whose storage is gone): scanning the other
42
+ * workspaces cannot place a composer that belongs to none of them.
43
+ */
44
+ known(hash: string | undefined): boolean {
45
+ return !!hash
46
+ }
47
+
48
+ /** Every folder Cursor has opened as a workspace, from each workspace.json (read once). */
49
+ folders(): string[] {
50
+ if (!this.all) {
51
+ this.all = new Map()
52
+ for (const hash of ls(this.storage)) {
53
+ const f = this.folder(hash)
54
+ if (f) this.all.set(slugOf(f), f)
55
+ }
56
+ }
57
+ return [...this.all.values()]
58
+ }
59
+
60
+ /** The folder whose transcript slug is `slug`. */
61
+ bySlug(slug: string): string | undefined {
62
+ this.folders()
63
+ return this.all!.get(slug)
64
+ }
65
+
66
+ /**
67
+ * Folders for composers listed in a workspace's ItemTable (composer.composerData.allComposers), newest
68
+ * workspace first, stopping once every wanted composer is placed. Index entries may point at composers
69
+ * that no longer exist (the catalogue counted 957 entries for 64 composers); only wanted ids are kept.
70
+ */
71
+ listing(wanted: Iterable<string>): Map<string, string> {
72
+ const want = new Set(wanted)
73
+ const out = new Map<string, string>()
74
+ if (!want.size) return out
75
+ for (const id of want) {
76
+ const f = this.scanned.get(id)
77
+ if (f) {
78
+ out.set(id, f)
79
+ want.delete(id)
80
+ }
81
+ }
82
+ const dbs = ls(this.storage)
83
+ .map((hash) => ({ hash, db: join(this.storage, hash, "state.vscdb") }))
84
+ .filter((w) => !this.scannedDbs.has(w.hash) && exists(w.db) && this.folder(w.hash))
85
+ .map((w) => ({ ...w, t: statSync(w.db).mtimeMs }))
86
+ .sort((a, b) => b.t - a.t)
87
+ for (const w of dbs) {
88
+ if (!want.size) break
89
+ this.scannedDbs.add(w.hash)
90
+ let ids: string[] = []
91
+ try {
92
+ ids = withSnapshot(w.db, (db) => {
93
+ const row = db.query("SELECT value FROM ItemTable WHERE key = 'composer.composerData'").get() as { value: unknown } | null
94
+ const v = row ? parse(row.value) : undefined
95
+ return Array.isArray(v?.allComposers) ? v.allComposers.map((c: { composerId?: unknown }) => String(c?.composerId ?? "")) : []
96
+ })
97
+ } catch {
98
+ continue // a damaged or foreign database: skip it, it only helps place composers
99
+ }
100
+ const folder = this.folder(w.hash)!
101
+ for (const id of ids) {
102
+ this.scanned.set(id, folder)
103
+ if (want.delete(id)) out.set(id, folder)
104
+ }
105
+ }
106
+ return out
107
+ }
108
+ }
109
+
110
+ /** JSON from a SQLite value, which Cursor stores as TEXT or as a BLOB of UTF-8. */
111
+ export function parse(v: unknown): any {
112
+ try {
113
+ if (typeof v === "string") return JSON.parse(v)
114
+ if (v instanceof Uint8Array) return JSON.parse(new TextDecoder().decode(v))
115
+ } catch {
116
+ /* not JSON */
117
+ }
118
+ return undefined
119
+ }
@@ -0,0 +1,170 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import { toolLabel } from "../../brief.ts"
5
+ import { appSupport, exists, gitInfo, isDir } from "../../fsutil.ts"
6
+ import type { Session, Turn } from "../../session/types.ts"
7
+ import { countTurns, firstUserText, unpaired } from "../../session/util.ts"
8
+ import { withSnapshot } from "../../sqlite.ts"
9
+ import { claudeCodeReader } from "../claude-code/reader.ts"
10
+ import { TESTED as CLAUDE_TESTED, claudeVersion, projectSlug, projectsDir } from "../claude-code/records.ts"
11
+ import { planTranscript } from "../claude-code/writer.ts"
12
+ import type { Readiness, WriteOptions, WriteResult, Writer } from "../types.ts"
13
+
14
+ // Continues a portable session in Cursor through Cursor's own Claude Code import. Cursor 3.27's Agents
15
+ // window lists the Claude Code transcripts of each open workspace (setting "Import Claude Code
16
+ // Conversations"), and when the person sends a follow-up to one, Cursor builds a real Cursor chat from it
17
+ // itself: its conversation state, blobs, bubbles and sidebar entry, with the whole history as the model's
18
+ // context. So this writer only writes a Claude Code transcript where that importer looks, shaped to its
19
+ // rules (read from Cursor 3.27.22's workbench.glass.main.js):
20
+ //
21
+ // - folder: ~/.claude/projects/ + the workspace path with only "/" replaced by "-" (Claude Code replaces
22
+ // every non-alphanumeric, so for a path with ".", "_" or spaces the two differ; the file goes where
23
+ // Cursor looks, and Claude Code then does not list it);
24
+ // - the first `cwd` in the file must equal the workspace path exactly, and the head of the file must be
25
+ // valid JSON lines;
26
+ // - the title is the `ai-title` record, else the first prompt;
27
+ // - records are read in file order, ignoring parentUuid, compaction boundaries and isCompactSummary, so a
28
+ // compacted session is written from its last summary on (as a plain first message) rather than as a
29
+ // boundary Cursor would not honour; the turns before it would otherwise all go to the model.
30
+ //
31
+ // Nothing of Cursor's is written: no database, so Cursor may stay open (it polls ~/.claude/projects).
32
+
33
+ export const TOOL = "cursor"
34
+ const LABEL = "Cursor"
35
+
36
+ /**
37
+ * Cursor versions whose importer this was checked against: 3.27.22 (rules above, read from its workbench bundle and run),
38
+ * and 3.29.9, whose importer (workbench.glass.main.js: parse-full / from-jsonl / identity) was re-read on 2026-11-08 and
39
+ * follows the same rules: first `cwd` of the file, `user`/`assistant` records in file order, `tool_result` blocks paired to
40
+ * their `tool_use` by id, `ai-title` for the title, no use of parentUuid. Read, not run: Cursor was not driven.
41
+ */
42
+ export const TESTED_CURSOR = /^3\.(27|29)\./
43
+
44
+ const SETTING = "cursor/importClaudeCodeAgents"
45
+
46
+ const shellQuote = (s: string) => (/^[\w./@%+=:,-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`)
47
+
48
+ /** The folder Cursor's importer reads for a workspace path. */
49
+ export const cursorSlug = (cwd: string) => cwd.replace(/\//g, "-")
50
+
51
+ const appPaths = (home: string) => [join(home, "Applications", "Cursor.app"), "/Applications/Cursor.app"]
52
+
53
+ /** Cursor's own version (product.json "version", e.g. 3.27.22; Info.plist carries the same). */
54
+ export function cursorVersion(home: string): string | undefined {
55
+ for (const app of appPaths(home)) {
56
+ try {
57
+ const v = JSON.parse(readFileSync(join(app, "Contents", "Resources", "app", "product.json"), "utf8")).version
58
+ if (typeof v === "string") return v
59
+ } catch {}
60
+ }
61
+ return undefined
62
+ }
63
+
64
+ /** Whether "Import Claude Code Conversations" is on: an application-scope key in Cursor's global store. Undefined when unknown. */
65
+ export function importEnabled(home: string): boolean | undefined {
66
+ const db = join(appSupport(home), "Cursor", "User", "globalStorage", "state.vscdb")
67
+ if (!exists(db)) return undefined
68
+ try {
69
+ return withSnapshot(db, (d) => {
70
+ const row = d.query("SELECT value FROM ItemTable WHERE key = ?").get(SETTING) as { value: unknown } | null
71
+ if (!row) return false
72
+ const v = typeof row.value === "string" ? row.value : Buffer.from(row.value as Uint8Array).toString("utf8")
73
+ return v.trim() === "true"
74
+ })
75
+ } catch {
76
+ return undefined
77
+ }
78
+ }
79
+
80
+ /** A title of at most 60 characters, cut at a word. */
81
+ function shortTitle(text: string): string {
82
+ const t = text.replace(/\s+/g, " ").trim()
83
+ if (t.length <= 60) return t
84
+ const cut = t.slice(0, 59)
85
+ return `${cut.slice(0, cut.lastIndexOf(" ") > 30 ? cut.lastIndexOf(" ") : 59)}…`
86
+ }
87
+
88
+ /** The session from its last compaction on, the summary as a plain first message; how many turns that leaves out. */
89
+ export function fromLastCompaction(session: Session): { session: Session; dropped: number } {
90
+ const i = session.turns.map((t) => t.kind).lastIndexOf("compaction")
91
+ if (i < 0) return { session, dropped: 0 }
92
+ const c = session.turns[i] as Extract<Turn, { kind: "compaction" }>
93
+ const summary: Turn = {
94
+ kind: "user",
95
+ ...(c.at ? { at: c.at } : {}),
96
+ text: `[Summary of the earlier conversation in ${toolLabel(session.header.tool)}, which replaced the turns before it]\n\n${c.summary}`,
97
+ }
98
+ return { session: { ...session, turns: [summary, ...session.turns.slice(i + 1)] }, dropped: i }
99
+ }
100
+
101
+ /** The writer; `version`, `enabled` and `claude` are for tests, which must not depend on the installed apps. */
102
+ export function cursorImportWriter(opts: { version?: string; enabled?: boolean; claude?: string } = {}): Writer {
103
+ const installed = (home: string) => opts.version ?? cursorVersion(home)
104
+ return {
105
+ tool: TOOL,
106
+ label: LABEL,
107
+ way: "import",
108
+
109
+ async ready(w: WriteOptions): Promise<Readiness> {
110
+ const v = installed(w.home)
111
+ if (!v) return { ok: false, why: "Cursor is not installed (no Cursor.app)" }
112
+ if (!TESTED_CURSOR.test(v)) return { ok: false, why: `Cursor ${v} is not a version whose Claude Code import was checked (3.27.x, 3.29.x)` }
113
+ if (!isDir(w.cwd)) return { ok: false, why: `${w.cwd} is not a folder; Cursor imports sessions for the folder it has open` }
114
+ return { ok: true }
115
+ },
116
+
117
+ async write(session: Session, w: WriteOptions): Promise<WriteResult> {
118
+ // The path exactly as Cursor has the folder open: not resolved through symlinks, unlike Claude Code's own.
119
+ const cwd = w.cwd.replace(/\/+$/, "") || "/"
120
+ const label = toolLabel(session.header.tool)
121
+ const { session: tail, dropped } = fromLastCompaction(session)
122
+ const git = gitInfo(cwd)
123
+ let sessionId = randomUUID()
124
+ const dir = join(projectsDir(w.home), cursorSlug(cwd))
125
+ while (exists(join(dir, `${sessionId}.jsonl`))) sessionId = randomUUID()
126
+ const path = join(dir, `${sessionId}.jsonl`)
127
+ const version = opts.claude ?? claudeVersion() ?? CLAUDE_TESTED.max
128
+ const plan = planTranscript(tail, { sessionId, cwd, version, gitBranch: git.gitBranch ?? (git.gitCommit ? "HEAD" : ""), now: new Date() })
129
+ const title = `${shortTitle(session.header.title ?? firstUserText(session) ?? "Session")} (from ${label})`
130
+ // Near the top: Cursor finds the title while reading the head of the file.
131
+ plan.lines.splice(1, 0, { type: "ai-title", aiTitle: title, sessionId })
132
+
133
+ const lost = [...plan.lost]
134
+ if (dropped) lost.unshift(`turns before the last compaction (${dropped}), carried by its summary`)
135
+ const images = session.turns.reduce((n, t) => n + (t.kind === "user" ? (t.attachments ?? []).filter((a) => a.kind === "image").length : 0), 0)
136
+ if (images) lost.push(`images (${images}; Cursor's import keeps text only)`)
137
+
138
+ if (!w.dryRun) {
139
+ mkdirSync(dir, { recursive: true })
140
+ writeFileSync(path, `${plan.lines.map((l) => JSON.stringify(l)).join("\n")}\n`, { flag: "wx" })
141
+ const back = await claudeCodeReader.read({ tool: "claude-code", sourceId: sessionId, path }, w.home)
142
+ const pairs = unpaired(back)
143
+ const got = countTurns(back).tool_call
144
+ if (got !== plan.expect.calls || pairs.calls.length || pairs.results.length) {
145
+ rmSync(path, { force: true })
146
+ throw new Error(`the transcript for Cursor did not read back intact (${got} of ${plan.expect.calls} tool calls); nothing was kept`)
147
+ }
148
+ }
149
+
150
+ const enabled = opts.enabled ?? importEnabled(w.home)
151
+ const steps = [
152
+ enabled === true ? "" : `${enabled === false ? "Once" : "If not done yet"}: in Cursor's Agents window, Settings → Third-Party Imports → turn on "Import Claude Code Conversations".`,
153
+ `In the Agents window, open "${title}" (a Claude Code session) and send your next message: Cursor turns it into a Cursor chat with the whole history.`,
154
+ cursorSlug(cwd) === projectSlug(cwd) ? "Claude Code's /resume lists it too." : "",
155
+ ].filter(Boolean)
156
+ return {
157
+ tool: TOOL,
158
+ way: "import",
159
+ sessionId,
160
+ written: [path],
161
+ open: `open -a Cursor ${shellQuote(cwd)}`,
162
+ note: steps.join(" "),
163
+ lost,
164
+ dryRun: !!w.dryRun,
165
+ }
166
+ },
167
+ }
168
+ }
169
+
170
+ export const cursorWriter = cursorImportWriter()
@@ -0,0 +1,8 @@
1
+ import type { Reader, Writer } from "../types.ts"
2
+ import { devinReader } from "./reader.ts"
3
+ import { devinWriter } from "./writer.ts"
4
+
5
+ // Devin CLI (Cognition): reads every visible session of the shared sessions.db (the active branch of each), and
6
+ // continues any session as a new native session in that store, after a backup. Ported from session-migrate (MIT).
7
+ export const readers: Reader[] = [devinReader]
8
+ export const writers: Writer[] = [devinWriter]
@@ -0,0 +1,305 @@
1
+ import type { Database } from "bun:sqlite"
2
+ import { realpathSync } from "node:fs"
3
+ import { exists, gitInfo } from "../../fsutil.ts"
4
+ import { withSnapshot } from "../../sqlite.ts"
5
+ import { keepImage, parseDataUrl } from "../../session/attachments.ts"
6
+ import type { Attachment, Session, Turn, Usage } from "../../session/types.ts"
7
+ import { clip, header, iso, lossCounter, noteModel } from "../../session/util.ts"
8
+ import { CONTINUED_NOTE } from "../opencode/note.ts"
9
+ import type { Reader, SessionRef } from "../types.ts"
10
+ import {
11
+ COMPACTION_EXT,
12
+ LABEL,
13
+ MIGRATIONS,
14
+ NEEDED_TO_READ,
15
+ PINNED_VERSION,
16
+ SM_SUMMARY_PREFIX,
17
+ SUMMARY_PREFIX,
18
+ TOOL,
19
+ appliedMigrations,
20
+ devinToolKind,
21
+ missingColumns,
22
+ sniffImage,
23
+ storeCandidates,
24
+ } from "./records.ts"
25
+
26
+ // Reads Devin CLI sessions from the shared sessions.db (records.ts), always from a copy, as Devin keeps it in
27
+ // WAL mode. A session is the active branch of its message forest: the walk from sessions.main_chain_id up the
28
+ // parent links. Nodes off that branch (retries, edited-away turns) are counted, not read. Devin's system and
29
+ // context messages are counted as not carried, except the note brain's writer opens a session with.
30
+
31
+ /** The stores under home, each once (two candidate paths can be the same file). */
32
+ function stores(home: string): string[] {
33
+ const seen = new Set<string>()
34
+ const out: string[] = []
35
+ for (const p of storeCandidates(home)) {
36
+ if (!exists(p)) continue
37
+ let real = p
38
+ try {
39
+ real = realpathSync(p)
40
+ } catch {}
41
+ if (seen.has(real)) continue
42
+ seen.add(real)
43
+ out.push(p)
44
+ }
45
+ return out
46
+ }
47
+
48
+ export const devinReader: Reader = {
49
+ tool: TOOL,
50
+ label: LABEL,
51
+ async list(home) {
52
+ const refs: SessionRef[] = []
53
+ for (const db of stores(home)) {
54
+ try {
55
+ const rows = withSnapshot(db, (d) => d.query("SELECT id, title, working_directory, last_activity_at FROM sessions WHERE hidden = 0").all()) as Array<Record<string, any>>
56
+ for (const r of rows) {
57
+ const updatedAt = iso(r.last_activity_at)
58
+ refs.push({
59
+ tool: TOOL,
60
+ sourceId: String(r.id),
61
+ path: db,
62
+ ...(r.title ? { title: String(r.title) } : {}),
63
+ ...(r.working_directory ? { cwd: String(r.working_directory) } : {}),
64
+ ...(updatedAt ? { updatedAt } : {}),
65
+ })
66
+ }
67
+ } catch {
68
+ // A store that cannot be copied or is not Devin's lists nothing; read() says why for a session in it.
69
+ }
70
+ }
71
+ return refs.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "") || a.sourceId.localeCompare(b.sourceId))
72
+ },
73
+ async read(ref) {
74
+ if (!exists(ref.path)) throw new Error(`Devin store not found: ${ref.path}`)
75
+ return withSnapshot(ref.path, (d) => readDevin(d, ref.sourceId, ref.path))
76
+ },
77
+ }
78
+
79
+ const json = (s: unknown): any => {
80
+ try {
81
+ return JSON.parse(String(s))
82
+ } catch {
83
+ return undefined
84
+ }
85
+ }
86
+ const str = (v: unknown) => (typeof v === "string" && v ? v : undefined)
87
+ const obj = (v: unknown): Record<string, any> => (v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, any>) : {})
88
+ const num = (v: unknown) => (typeof v === "number" && Number.isFinite(v) ? v : undefined)
89
+
90
+ /** A message's text: Devin stores a string; a list of parts keeps its text parts. */
91
+ function textOf(content: unknown): string {
92
+ if (typeof content === "string") return content
93
+ if (!Array.isArray(content)) return ""
94
+ return content
95
+ .filter((p) => p?.type === "text" && typeof p.text === "string")
96
+ .map((p) => p.text as string)
97
+ .join("\n")
98
+ }
99
+
100
+ interface ChainNode {
101
+ node_id: number
102
+ parent_node_id: number | null
103
+ chat_message: string
104
+ created_at: number
105
+ }
106
+
107
+ /** The active branch, root first. Throws when its links are broken: what the conversation is would be a guess. */
108
+ function activeChain(d: Database, id: string, tip: unknown): ChainNode[] {
109
+ if (typeof tip !== "number" || tip <= 0) return []
110
+ const rows = d
111
+ .query(
112
+ `WITH RECURSIVE chain(node_id, parent_node_id, chat_message, created_at, depth) AS (
113
+ SELECT node_id, parent_node_id, chat_message, created_at, 0 FROM message_nodes WHERE session_id = ?1 AND node_id = ?2
114
+ UNION ALL
115
+ SELECT m.node_id, m.parent_node_id, m.chat_message, m.created_at, c.depth + 1
116
+ FROM chain c JOIN message_nodes m ON m.session_id = ?1 AND m.node_id = c.parent_node_id
117
+ WHERE c.depth < 100000
118
+ )
119
+ SELECT node_id, parent_node_id, chat_message, created_at FROM chain ORDER BY depth DESC`,
120
+ )
121
+ .all(id, tip) as ChainNode[]
122
+ if (!rows.length) throw new Error(`Devin session ${id}: its active branch ends at message ${tip}, which is not in the store`)
123
+ const seen = new Set<number>()
124
+ for (const r of rows) {
125
+ if (seen.has(r.node_id)) throw new Error(`Devin session ${id}: its message links loop`)
126
+ seen.add(r.node_id)
127
+ }
128
+ if (rows[0]!.parent_node_id !== null) throw new Error(`Devin session ${id}: message ${rows[0]!.node_id} names a parent (${rows[0]!.parent_node_id}) that is not in the store`)
129
+ return rows
130
+ }
131
+
132
+ /** One session from an open Devin store (a snapshot, or the writer's own transaction). */
133
+ export function readDevin(d: Database, id: string, path: string): Session {
134
+ const missing = missingColumns(d, NEEDED_TO_READ)
135
+ if (missing.length) throw new Error(`${path} is not a Devin store this reader knows (no ${missing.join(", ")})`)
136
+ const row = d.query("SELECT * FROM sessions WHERE id = ?").get(id) as Record<string, any> | null
137
+ if (!row) throw new Error(`Devin session ${id} not found in ${path}`)
138
+ if (row.hidden) throw new Error(`Devin session ${id} is hidden`)
139
+ const cwd = str(row.working_directory)
140
+ const startedAt = iso(row.created_at)
141
+ const updatedAt = iso(row.last_activity_at)
142
+ // Devin's rows do not say which CLI wrote them; a store with exactly the pinned migrations is that version's.
143
+ const pinned = JSON.stringify(appliedMigrations(d)) === JSON.stringify(MIGRATIONS)
144
+ const h = header(TOOL, id, {
145
+ sourcePath: path,
146
+ ...(pinned ? { toolVersion: PINNED_VERSION } : {}),
147
+ ...(str(row.title) ? { title: String(row.title) } : {}),
148
+ project: { ...(cwd ? { cwd } : {}), ...gitInfo(cwd) },
149
+ ...(startedAt ? { startedAt } : {}),
150
+ ...(updatedAt ? { updatedAt } : {}),
151
+ })
152
+ const lost = lossCounter()
153
+ const turns: Turn[] = []
154
+ const chain = activeChain(d, id, row.main_chain_id)
155
+ const total = (d.query("SELECT count(*) AS n FROM message_nodes WHERE session_id = ?").get(id) as { n: number }).n
156
+ if (total > chain.length) lost.add("messages on abandoned branches (retries, edits)", total - chain.length)
157
+
158
+ // The ACP view of each call Devin keeps beside the messages: its kind, its final status, the diffs it showed.
159
+ const states = new Map<string, { call: Record<string, any>; update: Record<string, any> }>()
160
+ try {
161
+ for (const s of d.query("SELECT tool_call_id, tool_call_json, tool_call_update_json FROM tool_call_state WHERE session_id = ?").all(id) as Array<Record<string, any>>)
162
+ states.set(String(s.tool_call_id), { call: obj(json(s.tool_call_json)), update: obj(json(s.tool_call_update_json)) })
163
+ } catch {
164
+ // A store without tool_call_state still has every call and result in its messages.
165
+ }
166
+ const shown = new Map<string, Record<string, any>>()
167
+
168
+ const fallbackModel = str(row.model)
169
+ const byNative = new Map<string, string>()
170
+ const callIds = new Set<string>()
171
+ const answered = new Set<string>()
172
+
173
+ for (const node of chain) {
174
+ const m = obj(json(node.chat_message))
175
+ if (!m.role) {
176
+ lost.add("unreadable messages")
177
+ continue
178
+ }
179
+ const meta = obj(m.metadata)
180
+ const ext = obj(meta.extensions)
181
+ const at = iso(meta.created_at) ?? iso(node.created_at)
182
+ const stamp = at ? { at } : {}
183
+ const text = textOf(m.content)
184
+
185
+ if (m.role === "system") {
186
+ if (ext[COMPACTION_EXT] === true) {
187
+ turns.push({ kind: "compaction", ...stamp, summary: text.startsWith(SUMMARY_PREFIX) ? text.slice(SUMMARY_PREFIX.length) : text })
188
+ continue
189
+ }
190
+ const continued = text.match(CONTINUED_NOTE)?.[1]
191
+ if (continued) h.parent ??= { id: continued, kind: "continued" }
192
+ else lost.add("Devin system prompt and context (not carried)")
193
+ continue
194
+ }
195
+
196
+ if (m.role === "user") {
197
+ // session-migrate's writer stores a compaction as a flagged user message.
198
+ if (ext[COMPACTION_EXT] === true || (meta.session_migrate_compaction === true && text.startsWith(SM_SUMMARY_PREFIX))) {
199
+ const prefix = text.startsWith(SUMMARY_PREFIX) ? SUMMARY_PREFIX : text.startsWith(SM_SUMMARY_PREFIX) ? SM_SUMMARY_PREFIX : ""
200
+ turns.push({ kind: "compaction", ...stamp, summary: text.slice(prefix.length) })
201
+ continue
202
+ }
203
+ if (meta.is_user_input === false) {
204
+ lost.add("messages Devin injected as the user")
205
+ continue
206
+ }
207
+ const attachments: Attachment[] = []
208
+ const images = Array.isArray(m.images) ? (m.images as Array<Record<string, any>>) : []
209
+ for (const img of images) {
210
+ const data = str(img.base64_data)
211
+ const bytes = data ? Buffer.from(data, "base64") : undefined
212
+ const mime = str(img.mime_type) ?? str(img.media_type) ?? (bytes ? sniffImage(bytes) : undefined)
213
+ const kept = data ? keepImage(data, mime) : undefined
214
+ if (kept) attachments.push(kept)
215
+ else lost.add("images (too large or of an unknown type)")
216
+ if (str(img.caption)) lost.add("image captions Devin wrote")
217
+ }
218
+ if (Array.isArray(m.content))
219
+ for (const p of m.content as Array<Record<string, any>>) {
220
+ if (p?.type === "text") continue
221
+ const url = p?.type === "image_url" ? parseDataUrl(p.image_url?.url ?? p.image_url) : undefined
222
+ const kept = url ? keepImage(url.base64, url.mime) : undefined
223
+ if (kept) attachments.push(kept)
224
+ else lost.add(p?.type === "image_url" || p?.type === "image" ? "images (too large or of an unknown type)" : "message parts of other types")
225
+ }
226
+ // What the person attached through ACP besides images (documents, audio, video): Devin keeps the bytes
227
+ // in the message's metadata and only a marker ("[Binary resource: …]", "[Audio content]") in its text.
228
+ const blocks = Array.isArray(ext["chisel/acp-content-blocks"]) ? (ext["chisel/acp-content-blocks"] as Array<Record<string, any>>) : []
229
+ const others = blocks.filter((b) => !["text", "image", "resource_link"].includes(String(b?.type))).length
230
+ if (others) lost.add("attached documents, audio and video (only Devin's marker text is kept)", others)
231
+ if (text.trim() || attachments.length) turns.push({ kind: "user", ...stamp, text, ...(attachments.length ? { attachments } : {}) })
232
+ continue
233
+ }
234
+
235
+ if (m.role === "assistant") {
236
+ const model = str(meta.generation_model) ?? fallbackModel
237
+ const calls = Array.isArray(m.tool_calls) ? (m.tool_calls as Array<Record<string, any>>) : []
238
+ if (text.trim() || calls.length) noteModel(h, model)
239
+ const th = m.thinking
240
+ if (th && typeof th === "object") {
241
+ const signed = !!str(th.signature)
242
+ if (str(th.thinking)?.trim()) {
243
+ turns.push({ kind: "reasoning", ...stamp, text: th.thinking })
244
+ if (signed) lost.add("reasoning signatures")
245
+ } else if (signed || th.redacted || th.data) lost.add("signed or redacted reasoning")
246
+ }
247
+ if (text.trim()) {
248
+ // Devin's metrics hold the counts; num_tokens alone is the reply's output (243 of 243 in the capture).
249
+ const metrics = obj(meta.metrics)
250
+ const output = num(metrics.output_tokens) ?? num(meta.num_tokens)
251
+ const usage: Usage = {
252
+ ...(num(metrics.input_tokens) !== undefined ? { input: metrics.input_tokens } : {}),
253
+ ...(output !== undefined ? { output } : {}),
254
+ ...(num(metrics.cache_read_tokens) !== undefined ? { cacheRead: metrics.cache_read_tokens } : {}),
255
+ ...(num(metrics.cache_creation_tokens) !== undefined ? { cacheWrite: metrics.cache_creation_tokens } : {}),
256
+ }
257
+ turns.push({ kind: "assistant", ...stamp, text, ...(model ? { model } : {}), ...(Object.keys(usage).length ? { usage } : {}) })
258
+ }
259
+ const content = obj(ext["chisel/tool_call_content"])
260
+ calls.forEach((c, i) => {
261
+ const nativeId = String(c.id ?? "")
262
+ const name = str(c.name) ?? "unknown"
263
+ let callId = nativeId || `call-${node.node_id}-${i}`
264
+ for (let n = 2; callIds.has(callId); n++) callId = `${nativeId || "call"}#${n}`
265
+ callIds.add(callId)
266
+ byNative.set(nativeId, callId)
267
+ if (content[nativeId]) shown.set(nativeId, obj(content[nativeId]))
268
+ const acp = str(states.get(nativeId)?.call.kind) ?? str(obj(content[nativeId]).kind)
269
+ const input = typeof c.arguments === "string" ? (json(c.arguments) ?? c.arguments) : (c.arguments ?? {})
270
+ turns.push({ kind: "tool_call", ...stamp, id: callId, name, toolKind: devinToolKind(name, acp), input })
271
+ })
272
+ continue
273
+ }
274
+
275
+ if (m.role === "tool") {
276
+ const nativeId = String(m.tool_call_id ?? "")
277
+ const callId = byNative.get(nativeId)
278
+ if (!callId) {
279
+ lost.add("tool results without a call")
280
+ continue
281
+ }
282
+ if (answered.has(callId)) {
283
+ lost.add("repeated tool results")
284
+ continue
285
+ }
286
+ answered.add(callId)
287
+ const state = states.get(nativeId)
288
+ // Devin marks a failed call in the result's metadata and in the call's final ACP status; session-migrate's
289
+ // writer adds is_error to the message.
290
+ const failed = obj(ext["chisel/tool_result_meta"]).success === false || !!ext["chisel/tool_failure"] || m.is_error === true || state?.update.status === "failed"
291
+ const out = clip(text)
292
+ turns.push({ kind: "tool_result", ...stamp, callId, ok: !failed, output: out.text, ...(out.truncated ? { truncated: true } : {}) })
293
+ if (Array.isArray(m.images) && m.images.length) lost.add("images in tool results", m.images.length)
294
+ // Edits the call showed as ACP diffs, in its final update, its first event or the reply that made it.
295
+ const diffs = [state?.update.content, state?.call.content, shown.get(nativeId)?.content].flatMap((c) => (Array.isArray(c) ? (c as Array<Record<string, any>>) : []))
296
+ const paths = new Set(diffs.filter((x) => x?.type === "diff" && str(x.path)).map((x) => String(x.path)))
297
+ for (const p of paths) turns.push({ kind: "file_edit", ...stamp, path: p, callId })
298
+ continue
299
+ }
300
+
301
+ lost.add(`unknown message roles: ${String(m.role)}`)
302
+ }
303
+ lost.flush(h)
304
+ return { header: h, turns }
305
+ }