@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,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
|
+
}
|