@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.
- package/LICENSE +48 -0
- package/README.md +235 -2
- package/package.json +48 -4
- package/src/adapters/aider/index.ts +8 -0
- package/src/adapters/aider/reader.ts +129 -0
- package/src/adapters/aider/records.ts +220 -0
- package/src/adapters/aider/writer.ts +269 -0
- package/src/adapters/amazon-q-cli/index.ts +8 -0
- package/src/adapters/amazon-q-cli/reader.ts +84 -0
- package/src/adapters/amazon-q-cli/records.ts +191 -0
- package/src/adapters/amazon-q-cli/writer.ts +370 -0
- package/src/adapters/amazon-q-ide/index.ts +9 -0
- package/src/adapters/amazon-q-ide/reader.ts +131 -0
- package/src/adapters/amazon-q-ide/records.ts +141 -0
- package/src/adapters/amazon-q-ide/writer.ts +325 -0
- package/src/adapters/antigravity/index.ts +9 -0
- package/src/adapters/antigravity/proto.ts +194 -0
- package/src/adapters/antigravity/reader.ts +424 -0
- package/src/adapters/antigravity/records.ts +236 -0
- package/src/adapters/antigravity/writer.ts +463 -0
- package/src/adapters/auggie/index.ts +36 -0
- package/src/adapters/claude-code/index.ts +8 -0
- package/src/adapters/claude-code/reader.ts +639 -0
- package/src/adapters/claude-code/records.ts +88 -0
- package/src/adapters/claude-code/writer.ts +329 -0
- package/src/adapters/cline/index.ts +12 -0
- package/src/adapters/cline/reader.ts +432 -0
- package/src/adapters/cline/records.ts +151 -0
- package/src/adapters/cline/vscdb.ts +56 -0
- package/src/adapters/cline/writer.ts +656 -0
- package/src/adapters/cline/xml.ts +177 -0
- package/src/adapters/cline4/index.ts +9 -0
- package/src/adapters/cline4/reader.ts +179 -0
- package/src/adapters/cline4/records.ts +92 -0
- package/src/adapters/cline4/writer.ts +361 -0
- package/src/adapters/codebuff/index.ts +8 -0
- package/src/adapters/codebuff/project.ts +207 -0
- package/src/adapters/codebuff/reader.ts +215 -0
- package/src/adapters/codebuff/records.ts +173 -0
- package/src/adapters/codebuff/writer.ts +383 -0
- package/src/adapters/codex/index.ts +8 -0
- package/src/adapters/codex/native.ts +239 -0
- package/src/adapters/codex/reader.ts +747 -0
- package/src/adapters/codex/records.ts +114 -0
- package/src/adapters/codex/writer.ts +479 -0
- package/src/adapters/continue/index.ts +9 -0
- package/src/adapters/continue/reader.ts +68 -0
- package/src/adapters/continue/records.ts +418 -0
- package/src/adapters/continue/writer.ts +162 -0
- package/src/adapters/copilot-cli/index.ts +9 -0
- package/src/adapters/copilot-cli/reader.ts +186 -0
- package/src/adapters/copilot-cli/records.ts +241 -0
- package/src/adapters/copilot-cli/writer.ts +353 -0
- package/src/adapters/crush/index.ts +8 -0
- package/src/adapters/crush/reader.ts +180 -0
- package/src/adapters/crush/records.ts +169 -0
- package/src/adapters/crush/writer.ts +333 -0
- package/src/adapters/cursor-cli/index.ts +405 -0
- package/src/adapters/cursor-ide/agentkv.ts +95 -0
- package/src/adapters/cursor-ide/index.ts +501 -0
- package/src/adapters/cursor-ide/transcripts.ts +122 -0
- package/src/adapters/cursor-ide/workspaces.ts +119 -0
- package/src/adapters/cursor-ide/writer.ts +170 -0
- package/src/adapters/devin/index.ts +8 -0
- package/src/adapters/devin/reader.ts +305 -0
- package/src/adapters/devin/records.ts +277 -0
- package/src/adapters/devin/writer.ts +523 -0
- package/src/adapters/factory-droid/index.ts +12 -0
- package/src/adapters/factory-droid/reader.ts +184 -0
- package/src/adapters/factory-droid/records.ts +79 -0
- package/src/adapters/factory-droid/writer.ts +350 -0
- package/src/adapters/gemini-cli/exchanges.ts +228 -0
- package/src/adapters/gemini-cli/index.ts +396 -0
- package/src/adapters/gemini-cli/records.ts +521 -0
- package/src/adapters/gemini-cli/writer.ts +354 -0
- package/src/adapters/goose/index.ts +8 -0
- package/src/adapters/goose/reader.ts +138 -0
- package/src/adapters/goose/records.ts +199 -0
- package/src/adapters/goose/writer.ts +340 -0
- package/src/adapters/grok-cli/image.ts +44 -0
- package/src/adapters/grok-cli/index.ts +8 -0
- package/src/adapters/grok-cli/reader.ts +302 -0
- package/src/adapters/grok-cli/records.ts +172 -0
- package/src/adapters/grok-cli/writer.ts +329 -0
- package/src/adapters/hermes/index.ts +8 -0
- package/src/adapters/hermes/reader.ts +249 -0
- package/src/adapters/hermes/records.ts +285 -0
- package/src/adapters/hermes/writer.ts +452 -0
- package/src/adapters/iflow-cli/index.ts +72 -0
- package/src/adapters/iflow-cli/records.ts +215 -0
- package/src/adapters/iflow-cli/writer.ts +204 -0
- package/src/adapters/index.ts +112 -0
- package/src/adapters/kilo-cli/index.ts +21 -0
- package/src/adapters/kilo-code/index.ts +11 -0
- package/src/adapters/kilo-code/writer.ts +92 -0
- package/src/adapters/kimi/index.ts +9 -0
- package/src/adapters/kimi/reader.ts +92 -0
- package/src/adapters/kimi/records.ts +221 -0
- package/src/adapters/kimi/writer.ts +367 -0
- package/src/adapters/kiro-cli/index.ts +8 -0
- package/src/adapters/kiro-cli/reader.ts +87 -0
- package/src/adapters/kiro-cli/records.ts +260 -0
- package/src/adapters/kiro-cli/writer.ts +305 -0
- package/src/adapters/kiro-ide/index.ts +8 -0
- package/src/adapters/kiro-ide/writer.ts +52 -0
- package/src/adapters/letta/index.ts +8 -0
- package/src/adapters/letta/reader.ts +177 -0
- package/src/adapters/letta/records.ts +119 -0
- package/src/adapters/letta/writer.ts +413 -0
- package/src/adapters/mastracode/index.ts +8 -0
- package/src/adapters/mastracode/reader.ts +323 -0
- package/src/adapters/mastracode/records.ts +336 -0
- package/src/adapters/mastracode/writer.ts +385 -0
- package/src/adapters/mistral-vibe/index.ts +9 -0
- package/src/adapters/mistral-vibe/reader.ts +62 -0
- package/src/adapters/mistral-vibe/records.ts +104 -0
- package/src/adapters/mistral-vibe/writer.ts +359 -0
- package/src/adapters/muse/index.ts +10 -0
- package/src/adapters/muse/reader.ts +332 -0
- package/src/adapters/muse/records.ts +104 -0
- package/src/adapters/muse/writer.ts +387 -0
- package/src/adapters/omp/index.ts +13 -0
- package/src/adapters/omp/reader.ts +314 -0
- package/src/adapters/omp/records.ts +153 -0
- package/src/adapters/omp/writer.ts +216 -0
- package/src/adapters/opencode/ids.ts +52 -0
- package/src/adapters/opencode/index.ts +31 -0
- package/src/adapters/opencode/note.ts +8 -0
- package/src/adapters/opencode/reader.ts +452 -0
- package/src/adapters/opencode/tools.ts +174 -0
- package/src/adapters/opencode/v2.ts +367 -0
- package/src/adapters/opencode/writer.ts +379 -0
- package/src/adapters/openhands/index.ts +8 -0
- package/src/adapters/openhands/reader.ts +222 -0
- package/src/adapters/openhands/records.ts +136 -0
- package/src/adapters/openhands/writer.ts +404 -0
- package/src/adapters/pearai/index.ts +10 -0
- package/src/adapters/pi/index.ts +28 -0
- package/src/adapters/pi/reader.ts +261 -0
- package/src/adapters/pi/records.ts +99 -0
- package/src/adapters/pi/writer.ts +362 -0
- package/src/adapters/qoder-cli/index.ts +8 -0
- package/src/adapters/qoder-cli/reader.ts +83 -0
- package/src/adapters/qoder-cli/records.ts +45 -0
- package/src/adapters/qoder-cli/writer.ts +72 -0
- package/src/adapters/qwen-code/index.ts +84 -0
- package/src/adapters/qwen-code/records.ts +194 -0
- package/src/adapters/qwen-code/writer.ts +224 -0
- package/src/adapters/tabnine-cli/index.ts +8 -0
- package/src/adapters/tabnine-cli/reader.ts +106 -0
- package/src/adapters/tabnine-cli/records.ts +33 -0
- package/src/adapters/tabnine-cli/writer.ts +156 -0
- package/src/adapters/types.ts +73 -0
- package/src/adapters/void/index.ts +9 -0
- package/src/adapters/void/reader.ts +48 -0
- package/src/adapters/void/records.ts +285 -0
- package/src/adapters/void/writer.ts +93 -0
- package/src/adapters/vscode-copilot/index.ts +9 -0
- package/src/adapters/vscode-copilot/reader.ts +212 -0
- package/src/adapters/vscode-copilot/records.ts +388 -0
- package/src/adapters/vscode-copilot/shared.ts +68 -0
- package/src/adapters/vscode-copilot/writer.ts +546 -0
- package/src/adapters/zed/index.ts +8 -0
- package/src/adapters/zed/reader.ts +198 -0
- package/src/adapters/zed/records.ts +87 -0
- package/src/adapters/zed/writer.ts +341 -0
- package/src/brief-writers.ts +71 -0
- package/src/brief.ts +238 -0
- package/src/cli.ts +351 -0
- package/src/dashboard/cli.ts +148 -0
- package/src/dashboard/conversions.ts +170 -0
- package/src/dashboard/report.ts +261 -0
- package/src/dashboard/send.ts +213 -0
- package/src/dashboard/state.ts +95 -0
- package/src/fsutil.ts +133 -0
- package/src/gateway/common.ts +232 -0
- package/src/gateway/connect.ts +847 -0
- package/src/gateway/connectors/aider.ts +356 -0
- package/src/gateway/connectors/amazon-q-cli.ts +6 -0
- package/src/gateway/connectors/amazon-q-ide.ts +6 -0
- package/src/gateway/connectors/antigravity.ts +6 -0
- package/src/gateway/connectors/auggie.ts +6 -0
- package/src/gateway/connectors/cline-sdk.ts +85 -0
- package/src/gateway/connectors/cline.ts +34 -0
- package/src/gateway/connectors/codebuff.ts +6 -0
- package/src/gateway/connectors/continue.ts +579 -0
- package/src/gateway/connectors/copilot-cli.ts +138 -0
- package/src/gateway/connectors/crush.ts +313 -0
- package/src/gateway/connectors/cursor-cli.ts +6 -0
- package/src/gateway/connectors/cursor.ts +6 -0
- package/src/gateway/connectors/devin.ts +6 -0
- package/src/gateway/connectors/factory-droid.ts +308 -0
- package/src/gateway/connectors/goose.ts +79 -0
- package/src/gateway/connectors/grok-cli.ts +82 -0
- package/src/gateway/connectors/hermes.ts +230 -0
- package/src/gateway/connectors/iflow-cli.ts +50 -0
- package/src/gateway/connectors/index.ts +86 -0
- package/src/gateway/connectors/kilo-cli.ts +434 -0
- package/src/gateway/connectors/kilo-code.ts +32 -0
- package/src/gateway/connectors/kimi-code.ts +241 -0
- package/src/gateway/connectors/kiro-cli.ts +6 -0
- package/src/gateway/connectors/kiro-ide.ts +6 -0
- package/src/gateway/connectors/letta-code.ts +170 -0
- package/src/gateway/connectors/mastracode.ts +91 -0
- package/src/gateway/connectors/mistral-vibe.ts +495 -0
- package/src/gateway/connectors/muse.ts +27 -0
- package/src/gateway/connectors/omp.ts +279 -0
- package/src/gateway/connectors/openhands.ts +296 -0
- package/src/gateway/connectors/pearai.ts +52 -0
- package/src/gateway/connectors/pi.ts +357 -0
- package/src/gateway/connectors/qoder-cli.ts +6 -0
- package/src/gateway/connectors/qwen-code.ts +411 -0
- package/src/gateway/connectors/roo.ts +43 -0
- package/src/gateway/connectors/tabnine-cli.ts +6 -0
- package/src/gateway/connectors/void.ts +6 -0
- package/src/gateway/connectors/vscode-copilot.ts +286 -0
- package/src/gateway/connectors/windsurf.ts +6 -0
- package/src/gateway/connectors/zed.ts +29 -0
- package/src/gateway/signin.ts +323 -0
- package/src/handoff.ts +104 -0
- package/src/hooks.ts +455 -0
- package/src/memory/audit.ts +514 -0
- package/src/memory/blobs.ts +94 -0
- package/src/memory/cli.ts +117 -0
- package/src/memory/distill.ts +381 -0
- package/src/memory/externalize.ts +435 -0
- package/src/memory/lookups.ts +70 -0
- package/src/memory/mcp-install.ts +401 -0
- package/src/memory/mcp-server.ts +296 -0
- package/src/memory/prepare.ts +179 -0
- package/src/memory/preview.ts +154 -0
- package/src/memory/project.ts +35 -0
- package/src/memory/redact.ts +72 -0
- package/src/memory/relevance.ts +187 -0
- package/src/memory/repomap.ts +516 -0
- package/src/memory/store.ts +270 -0
- package/src/memory/sync.ts +166 -0
- package/src/memory/tidy.ts +193 -0
- package/src/memory/types.ts +118 -0
- package/src/org/admin-ops.ts +96 -0
- package/src/org/auth.ts +112 -0
- package/src/org/backup.ts +115 -0
- package/src/org/cli.ts +438 -0
- package/src/org/client.ts +404 -0
- package/src/org/config.ts +44 -0
- package/src/org/crypto.ts +537 -0
- package/src/org/db.ts +435 -0
- package/src/org/graph.ts +318 -0
- package/src/org/oidc.ts +435 -0
- package/src/org/policy.ts +398 -0
- package/src/org/server.ts +920 -0
- package/src/org/sso.ts +316 -0
- package/src/org/tls.ts +66 -0
- package/src/org/types.ts +193 -0
- package/src/self.ts +68 -0
- package/src/session/attachments.ts +53 -0
- package/src/session/peekcache.ts +50 -0
- package/src/session/types.ts +104 -0
- package/src/session/util.ts +130 -0
- 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
|
+
}
|