trajectories 0.0.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +201 -0
  3. package/README.md +274 -2
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +8542 -0
  6. package/dist/convert.d.ts +31 -0
  7. package/dist/convert.js +431 -0
  8. package/dist/index.d.ts +7 -0
  9. package/dist/index.js +8402 -0
  10. package/dist/log-baseline.d.ts +26 -0
  11. package/dist/log-baseline.js +104 -0
  12. package/dist/log-content.d.ts +16 -0
  13. package/dist/log-content.js +80 -0
  14. package/dist/log-io.d.ts +10 -0
  15. package/dist/log-io.js +121 -0
  16. package/dist/log-read.d.ts +24 -0
  17. package/dist/log-read.js +117 -0
  18. package/dist/log-reconcile.d.ts +5 -0
  19. package/dist/log-reconcile.js +38 -0
  20. package/dist/log-records.d.ts +35 -0
  21. package/dist/log-records.js +85 -0
  22. package/dist/log-recovery.d.ts +18 -0
  23. package/dist/log-recovery.js +123 -0
  24. package/dist/log-stage.d.ts +14 -0
  25. package/dist/log-stage.js +138 -0
  26. package/dist/log-state.d.ts +89 -0
  27. package/dist/log-state.js +92 -0
  28. package/dist/log-switch.d.ts +3 -0
  29. package/dist/log-switch.js +98 -0
  30. package/dist/log-types.d.ts +129 -0
  31. package/dist/log-types.js +8 -0
  32. package/dist/log.d.ts +8 -0
  33. package/dist/log.js +62 -0
  34. package/dist/migrate.d.ts +29 -0
  35. package/dist/migrate.js +173 -0
  36. package/dist/normalization-runtime.d.ts +1 -0
  37. package/dist/normalization-runtime.js +3150 -0
  38. package/dist/portable.d.ts +9 -0
  39. package/dist/portable.js +26 -0
  40. package/dist/readers/canonical.d.ts +13 -0
  41. package/dist/readers/canonical.js +54 -0
  42. package/dist/readers/cline.d.ts +10 -0
  43. package/dist/readers/cline.js +107 -0
  44. package/dist/readers/cursor.d.ts +20 -0
  45. package/dist/readers/cursor.js +374 -0
  46. package/dist/readers/deepagents.d.ts +41 -0
  47. package/dist/readers/deepagents.js +163 -0
  48. package/dist/readers/fx.d.ts +11 -0
  49. package/dist/readers/fx.js +573 -0
  50. package/dist/readers/github-copilot.d.ts +8 -0
  51. package/dist/readers/github-copilot.js +216 -0
  52. package/dist/readers/grok-build.d.ts +12 -0
  53. package/dist/readers/grok-build.js +391 -0
  54. package/dist/readers/guards.d.ts +4 -0
  55. package/dist/readers/guards.js +121 -0
  56. package/dist/readers/pi-compaction.d.ts +7 -0
  57. package/dist/readers/pi-compaction.js +112 -0
  58. package/dist/readers/pi-context.d.ts +13 -0
  59. package/dist/readers/pi-context.js +64 -0
  60. package/dist/store.d.ts +25 -0
  61. package/dist/store.js +218 -0
  62. package/dist/types.d.ts +91 -0
  63. package/dist/types.js +1 -0
  64. package/dist/writers/claude-code.d.ts +5 -0
  65. package/dist/writers/claude-code.js +51 -0
  66. package/dist/writers/cline.d.ts +3 -0
  67. package/dist/writers/cline.js +77 -0
  68. package/dist/writers/codex.d.ts +3 -0
  69. package/dist/writers/codex.js +32 -0
  70. package/dist/writers/cursor.d.ts +3 -0
  71. package/dist/writers/cursor.js +197 -0
  72. package/dist/writers/deepagents.d.ts +3 -0
  73. package/dist/writers/deepagents.js +116 -0
  74. package/dist/writers/entropy.d.ts +5 -0
  75. package/dist/writers/entropy.js +33 -0
  76. package/dist/writers/fx.d.ts +3 -0
  77. package/dist/writers/fx.js +135 -0
  78. package/dist/writers/github-copilot.d.ts +3 -0
  79. package/dist/writers/github-copilot.js +71 -0
  80. package/dist/writers/grok-build.d.ts +3 -0
  81. package/dist/writers/grok-build.js +43 -0
  82. package/dist/writers/helpers.d.ts +17 -0
  83. package/dist/writers/helpers.js +75 -0
  84. package/dist/writers/omp.d.ts +3 -0
  85. package/dist/writers/omp.js +7 -0
  86. package/dist/writers/opencode.d.ts +3 -0
  87. package/dist/writers/opencode.js +65 -0
  88. package/dist/writers/pi.d.ts +3 -0
  89. package/dist/writers/pi.js +45 -0
  90. package/docs/cline-native-format.md +43 -0
  91. package/docs/copilot-native-format.md +78 -0
  92. package/docs/cursor-native-format.md +103 -0
  93. package/docs/deepagents-native-format.md +98 -0
  94. package/docs/evidence-api.md +170 -0
  95. package/docs/fx-native-format.md +87 -0
  96. package/docs/harness-parity-bootstrap.md +78 -0
  97. package/docs/harness-support-parity.md +107 -0
  98. package/docs/inspirations.md +103 -0
  99. package/docs/ledger-migration.md +141 -0
  100. package/docs/pi-compaction-context.md +75 -0
  101. package/docs/pi-provider-context.md +76 -0
  102. package/docs/research/2026-10-06-huihua-and-oar.md +176 -0
  103. package/docs/research/ledger/README.md +96 -0
  104. package/docs/research/ledger/confer/2026-09-13-round-10/README.md +61 -0
  105. package/docs/research/ledger/confer/2026-09-13-round-2/cass.md +72 -0
  106. package/docs/research/ledger/confer/2026-09-13-round-3/README.md +70 -0
  107. package/docs/research/ledger/confer/2026-09-13-round-5/README.md +61 -0
  108. package/docs/research/ledger/confer/2026-09-13-round-6/README.md +68 -0
  109. package/docs/research/ledger/confer/2026-09-13-round-7/README.md +64 -0
  110. package/docs/research/ledger/confer/2026-09-13-round-9/README.md +59 -0
  111. package/docs/research/ledger/confer/2026-09-14-wake/wake.md +146 -0
  112. package/docs/research/ledger/docs/inspirations.md +320 -0
  113. package/docs/research/ledger/docs/pr2-pr6-integration.md +53 -0
  114. package/docs/research/ledger/docs/research/2026-09-07-confer-inspirations.md +163 -0
  115. package/docs/research/ledger/docs/research/2026-09-07-letta-continuous-service.md +104 -0
  116. package/docs/research/ledger/docs/research/2026-09-08-claude-history-retrieval.md +117 -0
  117. package/docs/research/ledger/docs/research/2026-09-09-agentsview-native-discovery.md +87 -0
  118. package/docs/research/ledger/docs/research/2026-09-09-daemon-observation.md +231 -0
  119. package/docs/research/ledger/docs/research/2026-09-09-discovery-pagination.md +114 -0
  120. package/docs/research/ledger/docs/research/2026-09-09-harness-transcript-contract.md +145 -0
  121. package/docs/research/ledger/docs/research/2026-09-09-native-history-discovery.md +180 -0
  122. package/docs/research/ledger/docs/research/2026-09-09-native-history-implementation.md +137 -0
  123. package/docs/research/ledger/docs/research/2026-09-09-other-inspirations.md +279 -0
  124. package/docs/research/ledger/docs/research/2026-09-09-trajectory-and-letta-code.md +128 -0
  125. package/docs/research/ledger/docs/research/2026-09-13-diagnostics-coverage.md +116 -0
  126. package/docs/research/ledger/docs/research/2026-09-13-fts-rebuild.md +83 -0
  127. package/docs/research/ledger/docs/research/2026-09-13-read-path-split.md +74 -0
  128. package/docs/research/ledger/docs/research/2026-09-13-runtime-rollout.md +102 -0
  129. package/docs/research/ledger/docs/research/2026-09-13-scan-outcomes.md +80 -0
  130. package/docs/research/ledger/docs/research/2026-09-13-search-quality.md +105 -0
  131. package/docs/research/ledger/docs/research/2026-09-13-source-coverage.md +64 -0
  132. package/docs/research/ledger/docs/research/2026-09-13-sync-wall-budget.md +82 -0
  133. package/docs/research/ledger/docs/research/2026-09-13-typed-read-gate.md +81 -0
  134. package/docs/research/ledger/docs/research/2026-09-13-writer-lease.md +84 -0
  135. package/docs/research/ledger/docs/research/2026-09-16-c1-copy-fallback.md +227 -0
  136. package/docs/research/ledger/docs/research/2026-09-16-c10-kimi.md +414 -0
  137. package/docs/research/ledger/docs/research/2026-09-16-c2-derive-counter.md +216 -0
  138. package/docs/research/ledger/docs/research/2026-09-16-c3-echo-filter.md +305 -0
  139. package/docs/research/ledger/docs/research/2026-09-16-c4-title-index.md +349 -0
  140. package/docs/research/ledger/docs/research/2026-09-16-c5-trigram.md +268 -0
  141. package/docs/research/ledger/docs/research/2026-09-16-c6-recency-prior.md +438 -0
  142. package/docs/research/ledger/docs/research/2026-09-16-c7-tombstones.md +467 -0
  143. package/docs/research/ledger/docs/research/2026-09-16-c8-remote-truth.md +390 -0
  144. package/docs/research/ledger/docs/research/2026-09-16-c9-grok.md +380 -0
  145. package/docs/research/ledger/docs/research/2026-09-16-wake-port-program.md +38 -0
  146. package/docs/research/ledger/docs/research/2026-09-17-cursor-blobstore.md +157 -0
  147. package/docs/research/ledger/manifest.json +540 -0
  148. package/legacy/NOTICE.md +50 -0
  149. package/legacy/bin/ledger-core.ts +7 -0
  150. package/legacy/bin/ledger.ts +7 -0
  151. package/legacy/bin/traj-bridge.ts +7 -0
  152. package/legacy/scripts/import-claude-history.ts +130 -0
  153. package/legacy/scripts/native-history-smoke.ts +52 -0
  154. package/legacy/scripts/observe-daemon.ts +570 -0
  155. package/legacy/scripts/retitle-sessions.md +19 -0
  156. package/legacy/scripts/verify-runtime-rollout.ts +151 -0
  157. package/legacy/src/canonical.ts +108 -0
  158. package/legacy/src/cli.ts +1293 -0
  159. package/legacy/src/db.ts +2254 -0
  160. package/legacy/src/diagnostics.ts +87 -0
  161. package/legacy/src/discovery.ts +56 -0
  162. package/legacy/src/embeddings.ts +382 -0
  163. package/legacy/src/filter.ts +602 -0
  164. package/legacy/src/gemini_embeddings.ts +103 -0
  165. package/legacy/src/identity.ts +9 -0
  166. package/legacy/src/index.ts +10 -0
  167. package/legacy/src/ingest.ts +791 -0
  168. package/legacy/src/managed.ts +178 -0
  169. package/legacy/src/native/common.ts +68 -0
  170. package/legacy/src/native/cursor.ts +647 -0
  171. package/legacy/src/native/grok.ts +540 -0
  172. package/legacy/src/native/kimi.ts +706 -0
  173. package/legacy/src/native/opencode.ts +470 -0
  174. package/legacy/src/native/sqlite-ro.ts +281 -0
  175. package/legacy/src/native.ts +97 -0
  176. package/legacy/src/normalizer.ts +457 -0
  177. package/legacy/src/parentage.ts +562 -0
  178. package/legacy/src/pressure.ts +162 -0
  179. package/legacy/src/private-storage.ts +74 -0
  180. package/legacy/src/reader.ts +1255 -0
  181. package/legacy/src/scanner.ts +591 -0
  182. package/legacy/src/schema-gate.ts +263 -0
  183. package/legacy/src/schema.ts +829 -0
  184. package/legacy/src/semantic-top-k.ts +71 -0
  185. package/legacy/src/session-hash.ts +49 -0
  186. package/legacy/src/store-status.ts +218 -0
  187. package/legacy/src/sync-preparation.ts +45 -0
  188. package/legacy/src/sync-truth.ts +843 -0
  189. package/legacy/src/sync.ts +1219 -0
  190. package/legacy/src/types.ts +436 -0
  191. package/legacy/src/vocab.txt +30522 -0
  192. package/legacy/src/watch.ts +673 -0
  193. package/legacy/src/writer-lease.ts +310 -0
  194. package/ops/README.md +120 -0
  195. package/ops/install-cli.sh +63 -0
  196. package/ops/install-release.sh +49 -0
  197. package/ops/linux/install-systemd-user.sh +103 -0
  198. package/ops/macos/install-launchd.sh +72 -0
  199. package/ops/sync-settings.sh +37 -0
  200. package/package.json +19 -5
  201. package/skills/trajectories/SKILL.md +138 -0
  202. package/skills/trajectories/scripts/trajectories_recent.sh +26 -0
  203. package/skills/trajectories/scripts/trajectories_search.sh +26 -0
  204. package/index.js +0 -1
@@ -0,0 +1,647 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import { closeSync, fstatSync, lstatSync, openSync, readdirSync, readSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { fileURLToPath } from "node:url";
5
+ import { basename, dirname, extname, join, resolve } from "node:path";
6
+ import type { Diagnostic } from "@letta-ai/trajectory";
7
+ import type { DiscoveredSession } from "../types.js";
8
+ import {
9
+ MAX_NATIVE_BYTES, MAX_NATIVE_RECORDS, NativeInputError, nativeByteLimit, object, readBoundedText, reportNativeError,
10
+ safeNativeId, type NativeDiscoveryOptions, type NativeReadOptions, type NativeTranscript,
11
+ } from "./common.js";
12
+ import { withReadOnlyDb } from "./sqlite-ro.js";
13
+
14
+ const CAPTURE_READ_BYTES = 64 * 1024;
15
+ const CAPTURE_PREFIX_ROWS = 256;
16
+ const MAX_DEPTH = 12;
17
+ // Format facts: docs/research/ledger/docs/research/2026-09-09-agentsview-native-discovery.md.
18
+ const COMPOSER_PREFIX = "composerData:";
19
+ // Legacy tabs/bubbles: https://forum.cursor.com/t/how-do-i-export-chat-with-ai/144/34
20
+ const LEGACY_CHAT_KEY = "workbench.panel.aichat.view.aichat.chatdata";
21
+ const EXCLUDED_DIRECTORIES = new Set([
22
+ "node_modules", "plugins", "extensions", ".git", ".hg", ".svn", ".cache",
23
+ "cache", "cacheddata", "cachedextensions", "cachedprofilesdata", "logs", "telemetry",
24
+ ]);
25
+
26
+ export function cursorRoots(): string[] {
27
+ const home = homedir();
28
+ const user = process.platform === "darwin" ? join(home, "Library/Application Support/Cursor/User")
29
+ : process.platform === "win32" ? join(process.env.APPDATA || join(home, "AppData/Roaming"), "Cursor/User")
30
+ : join(process.env.XDG_CONFIG_HOME || join(home, ".config"), "Cursor/User");
31
+ return [join(home, ".cursor"), join(user, "globalStorage"), join(user, "workspaceStorage")];
32
+ }
33
+
34
+ function supportedBlock(value: unknown): boolean {
35
+ if (!object(value)) return false;
36
+ switch (value.type) {
37
+ case "text": return typeof value.text === "string" && value.text.trim().length > 0;
38
+ case "thinking": return typeof value.thinking === "string" && value.thinking.trim().length > 0;
39
+ case "tool_use": return typeof value.name === "string" && value.name.length > 0 && "input" in value;
40
+ case "tool_result": return "content" in value && (typeof value.content === "string" || Array.isArray(value.content) || object(value.content));
41
+ default: return false;
42
+ }
43
+ }
44
+
45
+ function captureRow(value: unknown): boolean {
46
+ if (!object(value) || (value.role !== "user" && value.role !== "assistant") || !object(value.message)) return false;
47
+ const content = value.message.content;
48
+ return typeof content === "string" ? content.trim().length > 0 : Array.isArray(content) && content.some(supportedBlock);
49
+ }
50
+
51
+ function validCapture(path: string, options: NativeDiscoveryOptions): boolean {
52
+ const fd = openSync(path, "r");
53
+ try {
54
+ const size = fstatSync(fd).size;
55
+ let offset = 0;
56
+ let rows = 0;
57
+ let parts: Buffer[] = [];
58
+ const completeLine = (): boolean => {
59
+ const line = Buffer.concat(parts).toString("utf8").replace(/^\uFEFF/, "");
60
+ parts = [];
61
+ rows++;
62
+ try { return captureRow(JSON.parse(line)); } catch { return false; }
63
+ };
64
+ while (offset < size && offset < MAX_NATIVE_BYTES && rows < CAPTURE_PREFIX_ROWS) {
65
+ const chunk = Buffer.alloc(Math.min(CAPTURE_READ_BYTES, size - offset, MAX_NATIVE_BYTES - offset));
66
+ const count = readSync(fd, chunk, 0, chunk.length, offset);
67
+ if (!count) break;
68
+ offset += count;
69
+ let start = 0;
70
+ while (start < count && rows < CAPTURE_PREFIX_ROWS) {
71
+ const newline = chunk.indexOf(10, start);
72
+ const end = newline < 0 || newline >= count ? count : newline;
73
+ parts.push(chunk.subarray(start, end));
74
+ if (end < count && completeLine()) return true;
75
+ start = end + 1;
76
+ }
77
+ }
78
+ if (offset === size && parts.length && rows < CAPTURE_PREFIX_ROWS && completeLine()) return true;
79
+ const bounded = offset < size || rows >= CAPTURE_PREFIX_ROWS;
80
+ options.onDiagnostic?.({
81
+ code: bounded ? "incomplete_evidence" : "invalid_metadata", sourcePath: path,
82
+ message: bounded ? "Cursor capture could not be validated within the prefix limit." : "No supported Cursor capture messages found.",
83
+ });
84
+ return false;
85
+ } finally { closeSync(fd); }
86
+ }
87
+
88
+ export function discoverCursorSessions(roots: string[], options: NativeDiscoveryOptions): DiscoveredSession[] {
89
+ if (!Number.isSafeInteger(options.limit) || options.limit < 1) throw new RangeError("Cursor discovery limit must be positive.");
90
+ const seen = new Set<string>();
91
+ const items: DiscoveredSession[] = [];
92
+ function walk(path: string, depth: number): void {
93
+ if (seen.has(path)) return;
94
+ seen.add(path);
95
+ try {
96
+ const stat = lstatSync(path);
97
+ if (stat.isSymbolicLink()) return;
98
+ if (stat.isDirectory()) {
99
+ if (EXCLUDED_DIRECTORIES.has(basename(path).toLowerCase())) return;
100
+ if (depth > MAX_DEPTH) {
101
+ options.onDiagnostic?.({ code: "incomplete_evidence", sourcePath: path, message: "Cursor discovery reached its directory depth limit." });
102
+ return;
103
+ }
104
+ const entries = readdirSync(path, { withFileTypes: true });
105
+ const storage = basename(path);
106
+ if (storage === "globalStorage" || basename(dirname(path)) === "workspaceStorage") {
107
+ if (entries.some((entry) => entry.name === "state.vscdb" && entry.isFile())) walk(join(path, "state.vscdb"), depth + 1);
108
+ return;
109
+ }
110
+ for (const entry of entries) {
111
+ if (storage === "workspaceStorage" && !entry.isDirectory()) continue;
112
+ if (entry.isDirectory() || entry.isFile()) walk(join(path, entry.name), depth + 1);
113
+ }
114
+ } else if (stat.isFile() && extname(path).toLowerCase() === ".jsonl" && validCapture(path, options)) {
115
+ const id = basename(path, extname(path));
116
+ if (safeNativeId(id)) items.push({ id, source: "cursor", path, sizeBytes: stat.size, updatedAt: stat.mtime.toISOString() });
117
+ } else if (stat.isFile() && basename(path) === "state.vscdb") {
118
+ items.push(...discoverDatabase(path, options));
119
+ } else if (stat.isFile() && basename(path) === "store.db") {
120
+ items.push(...discoverBlobStore(path, options));
121
+ } else if (stat.isFile() && extname(path) === ".txt" && (depth === 0 || path.split(/[\\/]/).includes("agent-transcripts"))) {
122
+ options.onDiagnostic?.({ code: "incomplete_evidence", sourcePath: path, message: "Cursor text transcripts are unsupported; JSONL or supported SQLite history is required." });
123
+ }
124
+ } catch (error) {
125
+ if (object(error) && error.code === "ENOENT" && depth === 0) return;
126
+ reportNativeError(options, path);
127
+ }
128
+ }
129
+ for (const root of roots) walk(resolve(root), 0);
130
+ const ordered = items.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? "") ||
131
+ Number(b.path.endsWith(join("globalStorage", "state.vscdb"))) - Number(a.path.endsWith(join("globalStorage", "state.vscdb"))) ||
132
+ a.path.localeCompare(b.path) || a.id.localeCompare(b.id));
133
+ const nativeIds = new Set<string>();
134
+ const deduplicated = ordered.filter((item) => {
135
+ if (!item.native) return true;
136
+ if (nativeIds.has(item.id)) return false;
137
+ nativeIds.add(item.id);
138
+ return true;
139
+ });
140
+ return options.select ? options.select(deduplicated) : deduplicated.slice(0, options.limit);
141
+ }
142
+
143
+ function hasTable(db: Database, table: "cursorDiskKV" | "ItemTable"): boolean {
144
+ if (!db.query("SELECT 1 FROM sqlite_schema WHERE type = 'table' AND name = ?").get(table)) return false;
145
+ // Validate before projection without relying on runtime-specific SQLite error codes.
146
+ const columns = new Set((db.query("SELECT lower(name) AS name FROM pragma_table_xinfo(?)").all(table) as { name: string }[])
147
+ .map((column) => column.name));
148
+ if (!columns.has("key") || !columns.has("value")) throw new NativeInputError("Unsupported Cursor SQLite schema: missing key or value column.");
149
+ return true;
150
+ }
151
+
152
+ function withCursorDb<T>(path: string, read: (db: Database) => T): T {
153
+ try { return withReadOnlyDb(path, read); }
154
+ catch (error) {
155
+ if (object(error) && ["SQLITE_ERROR", "SQLITE_CORRUPT", "SQLITE_NOTADB"].includes(String(error.code))) {
156
+ throw new NativeInputError("Unsupported or corrupt Cursor SQLite schema.");
157
+ }
158
+ throw error;
159
+ }
160
+ }
161
+
162
+ function nativeTime(value: unknown): string | undefined {
163
+ if ((typeof value !== "number" || !Number.isFinite(value) || value <= 0) && (typeof value !== "string" || !value.trim())) return;
164
+ const time = new Date(value as string | number);
165
+ return Number.isFinite(time.getTime()) ? time.toISOString() : undefined;
166
+ }
167
+
168
+ function discoverDatabase(path: string, options: NativeDiscoveryOptions): DiscoveredSession[] {
169
+ return withCursorDb(path, (db) => {
170
+ const hasComposers = hasTable(db, "cursorDiskKV");
171
+ const hasLegacy = hasTable(db, "ItemTable");
172
+ if (!hasComposers && !hasLegacy) {
173
+ options.onDiagnostic?.({ code: "invalid_metadata", sourcePath: path, message: "Unsupported Cursor database schema." });
174
+ return [];
175
+ }
176
+ // Project only keys and listing metadata; message bodies belong to the per-session reader.
177
+ const rows = hasComposers ? db.query(`SELECT key,
178
+ CASE WHEN json_valid(CAST(value AS TEXT)) THEN json_extract(CAST(value AS TEXT), '$.name') END AS title,
179
+ CASE WHEN json_valid(CAST(value AS TEXT)) THEN json_extract(CAST(value AS TEXT), '$.lastUpdatedAt') END AS updated,
180
+ CASE WHEN json_valid(CAST(value AS TEXT)) THEN json_type(CAST(value AS TEXT), '$.fullConversationHeadersOnly') END AS headerType,
181
+ CASE WHEN json_valid(CAST(value AS TEXT)) THEN json_array_length(CAST(value AS TEXT), '$.fullConversationHeadersOnly') END AS headerCount,
182
+ CASE WHEN json_valid(CAST(value AS TEXT)) THEN EXISTS(SELECT 1 FROM json_each(CAST(value AS TEXT), '$.conversationMap')) END AS hasInline
183
+ FROM cursorDiskKV WHERE key >= 'composerData:' AND key < 'composerData;'
184
+ ORDER BY updated DESC, key LIMIT ?`).all(MAX_NATIVE_RECORDS + 1) as { key: string; title: unknown; updated: unknown; headerType: unknown; headerCount: number | null; hasInline: number }[] : [];
185
+ if (rows.length > MAX_NATIVE_RECORDS) options.onDiagnostic?.({ code: "incomplete_evidence", sourcePath: path, message: "Cursor discovery reached its session record limit." });
186
+ const items: DiscoveredSession[] = [];
187
+ for (const row of rows.slice(0, MAX_NATIVE_RECORDS)) {
188
+ const id = row.key.slice(COMPOSER_PREFIX.length);
189
+ if (!safeNativeId(id)) {
190
+ options.onDiagnostic?.({ code: "invalid_metadata", sourcePath: path, message: "Cursor composer has an invalid native session ID." });
191
+ continue;
192
+ }
193
+ if (row.headerType === "array" && row.headerCount === 0 && !row.hasInline) {
194
+ options.onDiagnostic?.({ code: "incomplete_evidence", sourcePath: path, message: "Cursor session is an empty draft with zero conversation headers." });
195
+ continue;
196
+ }
197
+ if (row.headerType !== "array" || row.headerCount === 0) {
198
+ options.onDiagnostic?.({ code: "invalid_metadata", sourcePath: path, message: "Unsupported Cursor conversation layout; ordered headers are missing." });
199
+ }
200
+ items.push({ id, source: "cursor", path, native: { kind: "cursor-sqlite" },
201
+ updatedAt: nativeTime(row.updated),
202
+ ...(typeof row.title === "string" ? { title: row.title } : {}),
203
+ });
204
+ }
205
+ if (hasComposers && db.query("SELECT 1 FROM cursorDiskKV WHERE key >= 'agentKv:' AND key < 'agentKv;' LIMIT 1").get()) {
206
+ options.onDiagnostic?.({ code: "incomplete_evidence", sourcePath: path, message: "Cursor agentKv storage is opaque and was not decoded." });
207
+ }
208
+ if (hasLegacy) {
209
+ const shape = db.query(`SELECT CASE WHEN json_valid(CAST(value AS TEXT)) THEN json_type(CAST(value AS TEXT), '$.tabs') END AS type
210
+ FROM ItemTable WHERE key = ?`).get(LEGACY_CHAT_KEY) as { type: string | null } | null;
211
+ if (!shape) return items;
212
+ if (shape.type !== "array") {
213
+ options.onDiagnostic?.({ code: "invalid_metadata", sourcePath: path, message: "Unsupported or malformed Cursor legacy chatdata layout." });
214
+ return items;
215
+ }
216
+ const tabs = db.query(`SELECT json_extract(tab.value, '$.id') AS id,
217
+ COALESCE(json_extract(tab.value, '$.chatTitle'), json_extract(tab.value, '$.title')) AS title,
218
+ COALESCE(json_extract(tab.value, '$.lastSendTime'), json_extract(tab.value, '$.updatedAt'), json_extract(tab.value, '$.timestamp')) AS updated
219
+ FROM ItemTable AS store, json_each(CAST(store.value AS TEXT), '$.tabs') AS tab
220
+ WHERE store.key = ? AND tab.type = 'object' ORDER BY updated DESC, tab.key LIMIT ?`
221
+ ).all(LEGACY_CHAT_KEY, MAX_NATIVE_RECORDS + 1) as { id: unknown; title: unknown; updated: unknown }[];
222
+ if (tabs.length > MAX_NATIVE_RECORDS) options.onDiagnostic?.({ code: "incomplete_evidence", sourcePath: path, message: "Cursor legacy discovery reached its session record limit." });
223
+ for (const tab of tabs.slice(0, MAX_NATIVE_RECORDS)) {
224
+ if (!safeNativeId(tab.id)) {
225
+ options.onDiagnostic?.({ code: "invalid_metadata", sourcePath: path, message: "Cursor legacy chat has no valid native session ID." });
226
+ continue;
227
+ }
228
+ items.push({ id: tab.id, source: "cursor", path, native: { kind: "cursor-sqlite" },
229
+ updatedAt: nativeTime(tab.updated), ...(typeof tab.title === "string" ? { title: tab.title } : {}),
230
+ });
231
+ }
232
+ }
233
+ return items;
234
+ });
235
+ }
236
+
237
+ interface ReadBudget { bytes: number; rows: number; limit: number }
238
+ type ContentBlock = Record<string, unknown>;
239
+ interface CursorRow { id?: string; role: "user" | "assistant"; message: { content: ContentBlock[]; model?: string } }
240
+
241
+ // Field allowlists keep cloud-sync keys, request contexts, and unrelated state out of the reader.
242
+ const COMPOSER_FIELDS = ["composerId", "name", "fullConversationHeadersOnly", "conversationMap", "createdAt", "lastUpdatedAt"];
243
+ const BUBBLE_FIELDS = ["bubbleId", "type", "text", "createdAt", "thinking", "isPlaceholder", "toolFormerData"];
244
+
245
+ function readJsonRow(db: Database, key: string, budget: ReadBudget): Record<string, unknown> | undefined {
246
+ const meta = db.query("SELECT length(CAST(value AS BLOB)) AS bytes FROM cursorDiskKV WHERE key = ?").get(key) as { bytes: number | null } | null;
247
+ if (!meta) return;
248
+ if (meta.bytes === null) throw new NativeInputError("Incomplete Cursor row: missing JSON value.");
249
+ budget.bytes += meta.bytes;
250
+ budget.rows++;
251
+ if (budget.bytes > budget.limit) throw new NativeInputError("Cursor session exceeds the native byte limit.");
252
+ if (budget.rows > MAX_NATIVE_RECORDS) throw new NativeInputError("Cursor session exceeds the native record limit.");
253
+ const shape = db.query(`SELECT CASE WHEN json_valid(CAST(value AS TEXT)) THEN json_type(CAST(value AS TEXT)) END AS type
254
+ FROM cursorDiskKV WHERE key = ?`).get(key) as { type: string | null };
255
+ if (shape.type !== "object") throw new NativeInputError("Invalid Cursor JSON row: expected an object.");
256
+ const json = "CAST(value AS TEXT)";
257
+ const fields = key.startsWith(COMPOSER_PREFIX) ? COMPOSER_FIELDS : BUBBLE_FIELDS;
258
+ const projection = fields.map((field) => field === "name"
259
+ ? `'name', CASE WHEN json_type(${json}, '$.name') = 'text' THEN json_extract(${json}, '$.name') END`
260
+ : field === "thinking"
261
+ ? `'thinking', CASE WHEN json_type(${json}, '$.thinking') = 'object' THEN json_object('text', json_extract(${json}, '$.thinking.text')) END`
262
+ : field === "isPlaceholder"
263
+ ? `'isPlaceholder', CASE json_type(${json}, '$.isPlaceholder') WHEN 'true' THEN json('true') WHEN 'false' THEN json('false') END`
264
+ : `'${field}', json_extract(${json}, '$.${field}')`).join(", ");
265
+ const metadata = key.startsWith(COMPOSER_PREFIX)
266
+ ? `, 'cwd', json_extract(${json}, '$.workspaceIdentifier.uri.fsPath'),
267
+ 'repoPath', json_extract(${json}, '$.trackedGitRepos[0].repoPath'),
268
+ 'model', json_extract(${json}, '$.modelConfig.modelName')` : "";
269
+ let value: unknown;
270
+ const row = db.query(`SELECT json_object(${projection}${metadata}) AS value FROM cursorDiskKV WHERE key = ?`).get(key) as { value: string };
271
+ try { value = JSON.parse(row.value); }
272
+ catch { throw new NativeInputError("Invalid Cursor JSON row; transcript may be incomplete."); }
273
+ if (!object(value)) throw new NativeInputError("Unsupported Cursor JSON row: expected an object.");
274
+ return value;
275
+ }
276
+
277
+ function bubbleRow(bubble: Record<string, unknown>, id?: string, headerType?: unknown): CursorRow {
278
+ if (bubble.bubbleId != null && bubble.bubbleId !== id) throw new NativeInputError("Conflicting Cursor bubble IDs.");
279
+ const type = bubble.type == null || bubble.type === 0 ? headerType : bubble.type;
280
+ const role = type === 1 || type === "user" ? "user" : type === 2 || type === "ai" || type === "assistant" ? "assistant"
281
+ : type == null && (bubble.role === "user" || bubble.role === "assistant") ? bubble.role : undefined;
282
+ if (!role) throw new NativeInputError("Unsupported Cursor bubble type; transcript may be incomplete.");
283
+ const content: ContentBlock[] = [];
284
+ if (typeof bubble.text === "string" && bubble.text.length) content.push({ type: "text", text: bubble.text });
285
+ else if (typeof bubble.content === "string" && bubble.content.length) content.push({ type: "text", text: bubble.content });
286
+ if (bubble.thinking != null) {
287
+ if (!object(bubble.thinking) || typeof bubble.thinking.text !== "string") throw new NativeInputError("Unsupported Cursor thinking content.");
288
+ if (bubble.thinking.text.length) content.push({ type: "thinking", thinking: bubble.thinking.text });
289
+ }
290
+ if (bubble.toolFormerData !== undefined && bubble.toolFormerData !== null) {
291
+ if (!object(bubble.toolFormerData)) throw new NativeInputError("Unsupported Cursor toolFormerData.");
292
+ const tool = bubble.toolFormerData;
293
+ let input = tool.rawArgs;
294
+ if (typeof input === "string") { try { input = JSON.parse(input); } catch { /* Preserve unfinished/raw tool arguments as a string. */ } }
295
+ const callId = typeof tool.toolCallId === "string" && tool.toolCallId ? tool.toolCallId : undefined;
296
+ if (input !== undefined || typeof tool.name === "string") {
297
+ content.push({ type: "tool_use", ...(callId ? { id: callId } : {}),
298
+ ...(typeof tool.name === "string" ? { name: tool.name } : {}), ...(input !== undefined ? { input } : {}),
299
+ });
300
+ }
301
+ if (tool.result !== undefined) content.push({ type: "tool_result", ...(callId ? { tool_use_id: callId } : {}), content: tool.result });
302
+ }
303
+ // Retain the empty row so a completed placeholder cannot shift later source sequences.
304
+ if (!content.length && !(role === "assistant" && bubble.isPlaceholder === true)) {
305
+ throw new NativeInputError("Incomplete Cursor conversational bubble: missing message content.");
306
+ }
307
+ return { ...(id ? { id } : {}), role, message: { content } };
308
+ }
309
+
310
+ function readLegacySession(db: Database, id: string, budget: ReadBudget): NativeTranscript {
311
+ if (!hasTable(db, "ItemTable")) throw new NativeInputError("Missing Cursor composer session.");
312
+ const meta = db.query(`SELECT CASE WHEN json_valid(CAST(value AS TEXT)) THEN json_type(CAST(value AS TEXT), '$.tabs') END AS type
313
+ FROM ItemTable WHERE key = ?`).get(LEGACY_CHAT_KEY) as { type: string | null } | null;
314
+ if (!meta) throw new NativeInputError("Missing Cursor session.");
315
+ if (meta.type !== "array") throw new NativeInputError("Unsupported Cursor legacy chat JSON layout.");
316
+ const selection = `FROM ItemTable AS store, json_each(CAST(store.value AS TEXT), '$.tabs') AS tab
317
+ WHERE store.key = ? AND tab.type = 'object' AND json_extract(tab.value, '$.id') = ?`;
318
+ const matches = db.query(`SELECT length(CAST(tab.value AS BLOB)) AS bytes ${selection} LIMIT 2`).all(LEGACY_CHAT_KEY, id) as { bytes: number }[];
319
+ if (matches.length !== 1) throw new NativeInputError("Missing or conflicting Cursor legacy chat ID.");
320
+ budget.bytes += matches[0].bytes;
321
+ if (budget.bytes > budget.limit) throw new NativeInputError("Cursor session exceeds the native byte limit.");
322
+ const fields = ["chatTitle", "title", "bubbles", "messages", "createdAt", "updatedAt", "lastSendTime", "timestamp"];
323
+ const projection = fields.map((field) => field === "chatTitle" || field === "title"
324
+ ? `'${field}', CASE WHEN json_type(tab.value, '$.${field}') = 'text' THEN json_extract(tab.value, '$.${field}') END`
325
+ : `'${field}', json_extract(tab.value, '$.${field}')`).join(", ");
326
+ const selected = db.query(`SELECT json_object(${projection}) AS value ${selection}`).get(LEGACY_CHAT_KEY, id) as { value: string };
327
+ const tab = JSON.parse(selected.value) as Record<string, unknown>;
328
+ const bubbles = tab.bubbles ?? tab.messages;
329
+ if (!Array.isArray(bubbles)) throw new NativeInputError("Unsupported Cursor legacy message layout.");
330
+ if (bubbles.length >= MAX_NATIVE_RECORDS) throw new NativeInputError("Cursor session exceeds the native record limit.");
331
+ const lines: string[] = [];
332
+ const ids = new Set<string>();
333
+ let bytes = 0;
334
+ for (const bubble of bubbles) {
335
+ if (!object(bubble)) throw new NativeInputError("Invalid Cursor legacy bubble.");
336
+ const messageId = bubble.bubbleId ?? bubble.id;
337
+ if (messageId != null && !safeNativeId(messageId)) throw new NativeInputError("Invalid Cursor native message ID.");
338
+ if (typeof messageId === "string") {
339
+ if (ids.has(messageId)) throw new NativeInputError("Duplicate Cursor native message ID.");
340
+ ids.add(messageId);
341
+ }
342
+ const line = JSON.stringify(bubbleRow(bubble, messageId as string | undefined));
343
+ bytes += Buffer.byteLength(line) + 1;
344
+ if (bytes > budget.limit) throw new NativeInputError("Cursor transcript exceeds the native byte limit.");
345
+ lines.push(line);
346
+ }
347
+ const startedAt = nativeTime(tab.createdAt);
348
+ const endedAt = nativeTime(tab.lastSendTime ?? tab.updatedAt ?? tab.timestamp);
349
+ const title = typeof tab.chatTitle === "string" ? tab.chatTitle : typeof tab.title === "string" ? tab.title : undefined;
350
+ return { transcript: lines.length ? lines.join("\n") + "\n" : "",
351
+ ...(title !== undefined ? { title } : {}), ...(startedAt ? { startedAt } : {}), ...(endedAt ? { endedAt } : {}),
352
+ };
353
+ }
354
+
355
+ // Cursor agent chats live in ~/.cursor/chats/<workspace>/<agentId>/store.db as a
356
+ // content-addressed blob store: one JSON `meta` row names the agent and its root
357
+ // blob, the root is protobuf whose repeated field 1 lists the ordered message
358
+ // blob IDs and whose field 9 is the workspace URI, and each message blob is JSON.
359
+ // Block names differ from the capture format the trajectory adapter decodes
360
+ // (tool-call/tool-result/reasoning here, tool_use/tool_result/thinking there), so
361
+ // this reader translates them. Format facts: docs/research/2026-09-17-cursor-blobstore.md.
362
+ const BLOB_MESSAGE_FIELD = 1;
363
+ const BLOB_WORKSPACE_FIELD = 9;
364
+ const BLOB_ID_BYTES = 32;
365
+ const BLOB_META_ROWS = 16;
366
+
367
+ interface BlobStoreRoot { ids: string[]; cwd?: string }
368
+ /** `role` stays open: an unmapped role is handed on so the adapter records the drop. */
369
+ interface BlobStoreRow { id: string; role: string; message: { content: ContentBlock[]; model?: string } }
370
+
371
+ function blobBuffer(value: unknown): Buffer {
372
+ if (value instanceof Uint8Array) return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
373
+ if (typeof value === "string") return Buffer.from(value, "utf8");
374
+ throw new NativeInputError("Unsupported Cursor blob-store value: expected text or a blob.");
375
+ }
376
+
377
+ function blobVarint(buffer: Buffer, offset: number): [number, number] {
378
+ let value = 0;
379
+ let shift = 0;
380
+ for (let index = offset; index < buffer.length; index++) {
381
+ const byte = buffer[index] as number;
382
+ value += (byte & 0x7f) * 2 ** shift;
383
+ if ((byte & 0x80) === 0) return [value, index + 1];
384
+ shift += 7;
385
+ if (shift > 56) break;
386
+ }
387
+ throw new NativeInputError("Malformed Cursor blob-store root: truncated or oversized varint.");
388
+ }
389
+
390
+ /** Skip unknown fields by wire type so a field Cursor adds later cannot break the walk. */
391
+ function parseBlobStoreRoot(buffer: Buffer): BlobStoreRoot {
392
+ const ids: string[] = [];
393
+ let cwd: string | undefined;
394
+ let offset = 0;
395
+ while (offset < buffer.length) {
396
+ const [tag, afterTag] = blobVarint(buffer, offset);
397
+ const field = tag >>> 3;
398
+ const wire = tag & 7;
399
+ offset = afterTag;
400
+ if (wire === 2) {
401
+ const [length, afterLength] = blobVarint(buffer, offset);
402
+ const end = afterLength + length;
403
+ if (end > buffer.length) throw new NativeInputError("Malformed Cursor blob-store root: field length exceeds the blob.");
404
+ if (field === BLOB_MESSAGE_FIELD) {
405
+ if (length !== BLOB_ID_BYTES) throw new NativeInputError("Unsupported Cursor blob-store root: message ID is not 32 bytes.");
406
+ if (ids.length >= MAX_NATIVE_RECORDS) throw new NativeInputError("Cursor session exceeds the native record limit.");
407
+ ids.push(buffer.subarray(afterLength, end).toString("hex"));
408
+ } else if (field === BLOB_WORKSPACE_FIELD && cwd === undefined) {
409
+ const uri = buffer.subarray(afterLength, end).toString("utf8");
410
+ // A workspace URI is metadata; a malformed one must not fail the transcript.
411
+ if (uri.startsWith("file://")) { try { cwd = fileURLToPath(uri); } catch { /* ignored */ } }
412
+ }
413
+ offset = end;
414
+ } else if (wire === 0) offset = blobVarint(buffer, offset)[1];
415
+ else if (wire === 5) offset += 4;
416
+ else if (wire === 1) offset += 8;
417
+ else throw new NativeInputError("Unsupported Cursor blob-store root wire type.");
418
+ }
419
+ if (offset !== buffer.length) throw new NativeInputError("Malformed Cursor blob-store root: trailing bytes.");
420
+ return { ids, ...(cwd ? { cwd } : {}) };
421
+ }
422
+
423
+ function hasBlobStore(db: Database): boolean {
424
+ const tables = new Set((db.query("SELECT name FROM sqlite_schema WHERE type = 'table'").all() as { name: string }[])
425
+ .map((table) => table.name));
426
+ if (!tables.has("blobs") || !tables.has("meta")) return false;
427
+ const columnsOf = (table: string) => new Set((db.query("SELECT lower(name) AS name FROM pragma_table_xinfo(?)").all(table) as { name: string }[])
428
+ .map((column) => column.name));
429
+ const blobs = columnsOf("blobs");
430
+ const meta = columnsOf("meta");
431
+ if (!blobs.has("id") || !blobs.has("data") || !meta.has("key") || !meta.has("value")) {
432
+ throw new NativeInputError("Unsupported Cursor blob-store schema: missing id/data or key/value column.");
433
+ }
434
+ return true;
435
+ }
436
+
437
+ /**
438
+ * Cursor stores the meta row as hex-encoded ASCII rather than raw bytes, unlike
439
+ * the message blobs. Decode that when it is present and accept plain JSON too, so
440
+ * the reader keeps working if Cursor drops the extra encoding.
441
+ */
442
+ function blobStoreJson(value: unknown): unknown {
443
+ const text = blobBuffer(value).toString("utf8").trim();
444
+ const decoded = /^(?:[0-9a-fA-F]{2})+$/.test(text) ? Buffer.from(text, "hex").toString("utf8") : text;
445
+ try { return JSON.parse(decoded); } catch { return undefined; }
446
+ }
447
+
448
+ /** The first meta row naming both an agent and a root blob; other rows are ignored. */
449
+ function blobStoreMeta(db: Database): Record<string, unknown> | undefined {
450
+ const rows = db.query("SELECT CAST(value AS BLOB) AS value FROM meta LIMIT ?").all(BLOB_META_ROWS) as { value: unknown }[];
451
+ for (const row of rows) {
452
+ const value = blobStoreJson(row.value);
453
+ if (object(value) && safeNativeId(value.agentId) && safeNativeId(value.latestRootBlobId)) return value;
454
+ }
455
+ return undefined;
456
+ }
457
+
458
+ function blobData(db: Database, id: string): Buffer | undefined {
459
+ const row = db.query("SELECT CAST(data AS BLOB) AS data FROM blobs WHERE id = ?").get(id) as { data: unknown } | null;
460
+ return row ? blobBuffer(row.data) : undefined;
461
+ }
462
+
463
+ function blobModel(block: Record<string, unknown>): string | undefined {
464
+ if (!object(block.providerOptions) || !object(block.providerOptions.cursor)) return;
465
+ const name = block.providerOptions.cursor.modelName;
466
+ return typeof name === "string" && name ? name : undefined;
467
+ }
468
+
469
+ /** Prefer the flat result; fall back to the block list Cursor writes alongside it. */
470
+ function blobResult(block: Record<string, unknown>): unknown {
471
+ if (block.result !== undefined && block.result !== null) return block.result;
472
+ return Array.isArray(block.experimental_content) ? block.experimental_content : "";
473
+ }
474
+
475
+ /**
476
+ * Rename Cursor's blocks to the ones the trajectory adapter decodes. A
477
+ * `redacted-reasoning` block holds only provider-encrypted bytes, so it is passed
478
+ * through unchanged rather than given invented plaintext: the adapter then records
479
+ * the drop instead of this reader hiding it.
480
+ *
481
+ * One unusable block drops itself, never the session. A tool call without the ID
482
+ * that pairs it to its result cannot be represented, but refusing the whole store
483
+ * over it would lose every other message in the file — the same reasoning that
484
+ * makes a non-message blob a reported skip rather than a fatal read.
485
+ */
486
+ function blobStoreContent(blocks: unknown[], diagnostics: Diagnostic[]): { content: ContentBlock[]; model?: string } {
487
+ const content: ContentBlock[] = [];
488
+ let model: string | undefined;
489
+ for (const block of blocks) {
490
+ if (!object(block)) {
491
+ diagnostics.push({ code: "noise_record_dropped", message: "Skipped a Cursor content block that is not an object." });
492
+ continue;
493
+ }
494
+ model ??= blobModel(block);
495
+ if (block.type === "text") {
496
+ if (typeof block.text === "string" && block.text.length) content.push({ type: "text", text: block.text });
497
+ } else if (block.type === "reasoning") {
498
+ if (typeof block.text === "string" && block.text.length) content.push({ type: "thinking", thinking: block.text });
499
+ } else if (block.type === "tool-call") {
500
+ // A call ID pairs a call with its result; it is never a path, so it is not
501
+ // held to safeNativeId — Cursor writes IDs containing separators.
502
+ if (typeof block.toolCallId !== "string" || !block.toolCallId || typeof block.toolName !== "string" || !block.toolName) {
503
+ diagnostics.push({ code: "noise_record_dropped", message: "Skipped a Cursor tool call with no native call ID or tool name." });
504
+ continue;
505
+ }
506
+ content.push({ type: "tool_use", id: block.toolCallId, name: block.toolName, input: block.args ?? {} });
507
+ } else if (block.type === "tool-result") {
508
+ if (typeof block.toolCallId !== "string" || !block.toolCallId) {
509
+ diagnostics.push({ code: "noise_record_dropped", message: "Skipped a Cursor tool result with no native call ID." });
510
+ continue;
511
+ }
512
+ content.push({ type: "tool_result", tool_use_id: block.toolCallId, content: blobResult(block) });
513
+ } else {
514
+ content.push(block);
515
+ }
516
+ }
517
+ return { content, ...(model ? { model } : {}) };
518
+ }
519
+
520
+ function discoverBlobStore(path: string, options: NativeDiscoveryOptions): DiscoveredSession[] {
521
+ return withCursorDb(path, (db) => {
522
+ if (!hasBlobStore(db)) {
523
+ options.onDiagnostic?.({ code: "invalid_metadata", sourcePath: path, message: "Unsupported Cursor blob-store database schema." });
524
+ return [];
525
+ }
526
+ const meta = blobStoreMeta(db);
527
+ if (!meta) {
528
+ options.onDiagnostic?.({ code: "invalid_metadata", sourcePath: path, message: "Cursor blob store has no agent metadata row." });
529
+ return [];
530
+ }
531
+ const id = meta.agentId as string;
532
+ if (!blobData(db, meta.latestRootBlobId as string)) {
533
+ options.onDiagnostic?.({ code: "incomplete_evidence", sourcePath: path, message: "Cursor blob store is missing its root blob." });
534
+ return [];
535
+ }
536
+ const title = typeof meta.name === "string" && meta.name ? meta.name : undefined;
537
+ return [{ id, source: "cursor", path, native: { kind: "cursor-blobstore" },
538
+ updatedAt: lstatSync(path).mtime.toISOString(),
539
+ ...(title !== undefined ? { title } : {}) }];
540
+ });
541
+ }
542
+
543
+ /**
544
+ * Cursor writes tool results as their own `tool` role. The canonical shape keeps
545
+ * a tool result on the user turn that carries it, so the role is mapped here; the
546
+ * blob hash is the record ID, which the store proves unique within a root.
547
+ */
548
+ function readBlobStore(db: Database, id: string, limit: number): NativeTranscript {
549
+ if (!hasBlobStore(db)) throw new NativeInputError("Missing Cursor blob-store tables.");
550
+ const meta = blobStoreMeta(db);
551
+ if (!meta) throw new NativeInputError("Missing Cursor blob-store agent metadata.");
552
+ if (meta.agentId !== id) throw new NativeInputError("Conflicting Cursor native session ID.");
553
+ const rootData = blobData(db, meta.latestRootBlobId as string);
554
+ if (!rootData) throw new NativeInputError("Missing Cursor blob-store root blob.");
555
+ const root = parseBlobStoreRoot(rootData);
556
+ const lines: string[] = [];
557
+ const diagnostics: Diagnostic[] = [];
558
+ let bytes = 0;
559
+ for (const blobId of root.ids) {
560
+ const data = blobData(db, blobId);
561
+ // A root can reference a blob the store no longer holds, and the same store
562
+ // holds non-message blobs (out-of-line file bodies, and protobuf records in
563
+ // the ACP layout). Neither is corruption, so both are dropped through the
564
+ // reader's own diagnostics rather than failing the whole session.
565
+ if (!data) {
566
+ diagnostics.push({ code: "noise_record_dropped", message: "Cursor root references a blob the store no longer holds." });
567
+ continue;
568
+ }
569
+ bytes += data.length;
570
+ if (bytes > limit) throw new NativeInputError("Cursor session exceeds the native byte limit.");
571
+ let value: unknown;
572
+ try { value = JSON.parse(data.toString("utf8")); } catch {
573
+ diagnostics.push({ code: "noise_record_dropped", message: "Skipped a Cursor blob that is not JSON." });
574
+ continue;
575
+ }
576
+ if (!object(value) || typeof value.role !== "string" || !value.role) {
577
+ diagnostics.push({ code: "noise_record_dropped", message: "Skipped a Cursor blob with no role." });
578
+ continue;
579
+ }
580
+ const raw = value.content;
581
+ const projected = Array.isArray(raw) ? blobStoreContent(raw, diagnostics)
582
+ : { content: typeof raw === "string" && raw.length ? [{ type: "text", text: raw } as ContentBlock] : [] };
583
+ if (!projected.content.length) {
584
+ diagnostics.push({ code: "noise_record_dropped", message: "Skipped a Cursor message whose content projected to nothing." });
585
+ continue;
586
+ }
587
+ const row: BlobStoreRow = { id: blobId, role: value.role === "tool" ? "user" : value.role,
588
+ message: { content: projected.content, ...(projected.model ? { model: projected.model } : {}) } };
589
+ const line = JSON.stringify(row);
590
+ bytes += Buffer.byteLength(line) + 1;
591
+ if (bytes > limit) throw new NativeInputError("Cursor transcript exceeds the native byte limit.");
592
+ lines.push(line);
593
+ }
594
+ const title = typeof meta.name === "string" && meta.name ? meta.name : undefined;
595
+ const startedAt = nativeTime(meta.createdAt);
596
+ return { transcript: lines.length ? lines.join("\n") + "\n" : "",
597
+ ...(title !== undefined ? { title } : {}), ...(root.cwd ? { cwd: root.cwd } : {}),
598
+ ...(startedAt ? { startedAt } : {}), ...(diagnostics.length ? { diagnostics } : {}) };
599
+ }
600
+
601
+ export function readCursorSession(item: DiscoveredSession, options: NativeReadOptions = {}): NativeTranscript {
602
+ const limit = nativeByteLimit(options);
603
+ if (!item.native) return { transcript: readBoundedText(item.path, limit) };
604
+ if (!safeNativeId(item.id)) throw new NativeInputError("Unsupported Cursor native session.");
605
+ if (item.native.kind === "cursor-blobstore") return withCursorDb(item.path, (db) => readBlobStore(db, item.id, limit));
606
+ if (item.native.kind !== "cursor-sqlite") throw new NativeInputError("Unsupported Cursor native session.");
607
+ return withCursorDb(item.path, (db) => {
608
+ const budget: ReadBudget = { bytes: 0, rows: 0, limit };
609
+ const composer = hasTable(db, "cursorDiskKV") ? readJsonRow(db, `${COMPOSER_PREFIX}${item.id}`, budget) : undefined;
610
+ if (!composer) return readLegacySession(db, item.id, budget);
611
+ if (composer.composerId != null && composer.composerId !== item.id) throw new NativeInputError("Conflicting Cursor native session ID.");
612
+ if (!Array.isArray(composer.fullConversationHeadersOnly)) throw new NativeInputError("Unsupported Cursor conversation layout.");
613
+ if (composer.fullConversationHeadersOnly.length === 0 && object(composer.conversationMap) && Object.keys(composer.conversationMap).length) {
614
+ throw new NativeInputError("Incomplete Cursor conversation: inline messages have no ordered headers.");
615
+ }
616
+ if (composer.fullConversationHeadersOnly.length >= MAX_NATIVE_RECORDS) throw new NativeInputError("Cursor session exceeds the native record limit.");
617
+ const lines: string[] = [];
618
+ const timestamps: string[] = [];
619
+ const ids = new Set<string>();
620
+ let outputBytes = 0;
621
+ for (const header of composer.fullConversationHeadersOnly) {
622
+ if (!object(header) || !safeNativeId(header.bubbleId)) throw new NativeInputError("Incomplete Cursor header: missing native bubble ID.");
623
+ if (ids.has(header.bubbleId)) throw new NativeInputError("Duplicate Cursor native bubble ID.");
624
+ ids.add(header.bubbleId);
625
+ const inline = object(composer.conversationMap) && Object.hasOwn(composer.conversationMap, header.bubbleId)
626
+ ? composer.conversationMap[header.bubbleId] : undefined;
627
+ const bubble = inline !== undefined ? inline : readJsonRow(db, `bubbleId:${item.id}:${header.bubbleId}`, budget);
628
+ if (!bubble) throw new NativeInputError("Missing referenced Cursor bubble; transcript is incomplete.");
629
+ if (!object(bubble)) throw new NativeInputError("Invalid Cursor inline conversation bubble.");
630
+ const line = JSON.stringify(bubbleRow(bubble, header.bubbleId, header.type));
631
+ outputBytes += Buffer.byteLength(line) + 1;
632
+ if (outputBytes > limit) throw new NativeInputError("Cursor transcript exceeds the native byte limit.");
633
+ lines.push(line);
634
+ const time = nativeTime(bubble.createdAt);
635
+ if (time) timestamps.push(time);
636
+ }
637
+ const cwd = typeof composer.cwd === "string" ? composer.cwd : typeof composer.repoPath === "string" ? composer.repoPath : undefined;
638
+ const model = typeof composer.model === "string" ? composer.model : undefined;
639
+ const title = typeof composer.name === "string" ? composer.name : undefined;
640
+ const startedAt = nativeTime(composer.createdAt) ?? timestamps[0];
641
+ const endedAt = [nativeTime(composer.lastUpdatedAt), ...timestamps].filter((time): time is string => !!time).sort().at(-1);
642
+ return { transcript: lines.length ? lines.join("\n") + "\n" : "",
643
+ ...(title !== undefined ? { title } : {}),
644
+ ...(cwd ? { cwd } : {}), ...(model ? { model } : {}), ...(startedAt ? { startedAt } : {}), ...(endedAt ? { endedAt } : {}),
645
+ };
646
+ });
647
+ }