backpass 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +406 -0
  3. package/bin/backpass.js +4 -0
  4. package/package.json +62 -0
  5. package/src/acpx.js +576 -0
  6. package/src/agents.js +389 -0
  7. package/src/analyze.js +289 -0
  8. package/src/apply/lavish.js +128 -0
  9. package/src/apply/terminal.js +119 -0
  10. package/src/apply/writer.js +101 -0
  11. package/src/bootstrap.js +74 -0
  12. package/src/cli.js +261 -0
  13. package/src/commands/analyze.js +88 -0
  14. package/src/commands/apply.js +103 -0
  15. package/src/commands/bootstrap.js +172 -0
  16. package/src/commands/init.js +59 -0
  17. package/src/commands/propose.js +136 -0
  18. package/src/commands/run.js +95 -0
  19. package/src/commands/scan.js +90 -0
  20. package/src/commands/status.js +143 -0
  21. package/src/commands/usage.js +25 -0
  22. package/src/config.js +249 -0
  23. package/src/diff.js +305 -0
  24. package/src/discovery/adapters/claude.js +77 -0
  25. package/src/discovery/adapters/codex.js +162 -0
  26. package/src/discovery/adapters/cursor-cli.js +109 -0
  27. package/src/discovery/adapters/cursor-ide.js +130 -0
  28. package/src/discovery/adapters/grok.js +107 -0
  29. package/src/discovery/adapters/opencode.js +151 -0
  30. package/src/discovery/adapters/pi.js +87 -0
  31. package/src/discovery/adapters/shared.js +195 -0
  32. package/src/discovery/adapters/sqlite.js +50 -0
  33. package/src/discovery/association.js +100 -0
  34. package/src/discovery/index.js +226 -0
  35. package/src/discovery/self.js +62 -0
  36. package/src/distill.js +182 -0
  37. package/src/fold.js +214 -0
  38. package/src/gap-ledger.js +174 -0
  39. package/src/logger.js +74 -0
  40. package/src/memory.js +244 -0
  41. package/src/progress.js +29 -0
  42. package/src/prompts/analysis.md +48 -0
  43. package/src/prompts/annotate.md +48 -0
  44. package/src/prompts/synthesis.md +98 -0
  45. package/src/prompts.js +36 -0
  46. package/src/proposal.js +430 -0
  47. package/src/redact.js +36 -0
  48. package/src/repo.js +118 -0
  49. package/src/sample.js +99 -0
  50. package/src/skills.js +207 -0
  51. package/src/state.js +202 -0
  52. package/src/subprocess.js +47 -0
  53. package/src/synthesize.js +287 -0
  54. package/src/tokens.js +48 -0
  55. package/src/tui/index.js +336 -0
  56. package/src/tui/render.js +487 -0
  57. package/src/tui/term.js +130 -0
  58. package/src/tui/theme.js +111 -0
  59. package/src/workspace.js +162 -0
  60. package/templates/apply.html +928 -0
package/src/diff.js ADDED
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Line diff between a file as backpass read it and the copy the synthesis agent edited
3
+ * natively (design section 3, native-edit revision).
4
+ *
5
+ * The synthesis model never hands backpass text to locate in a file; it edits a staging
6
+ * copy with its own file tools and backpass measures what changed. Each measured hunk is
7
+ * then turned into a find/replace pair whose `find` is copied out of the original file by
8
+ * construction - so it always matches, and `applyEdit` can apply it later (at apply time,
9
+ * against whatever the file is by then) with no fuzzing.
10
+ *
11
+ * Two guarantees make the hunks independently reviewable:
12
+ * - each `find` is widened with context lines until it occurs exactly once in the file
13
+ * - hunks whose context windows touch are merged, so accepting one never moves
14
+ * another's anchor
15
+ */
16
+
17
+ /** Myers refinement is bounded; past this many edit steps the middle is one hunk. */
18
+ const MAX_EDIT_DISTANCE = 4000;
19
+
20
+ /**
21
+ * Shortest edit script between two line arrays (Myers, O(ND)), as a list of ops:
22
+ * `{ type: "equal" | "delete" | "insert", oldIndex, newIndex }`.
23
+ */
24
+ export function diffOps(oldLines, newLines) {
25
+ // Common prefix/suffix first: a real edit touches a tiny fraction of the file.
26
+ let prefix = 0;
27
+ const maxPrefix = Math.min(oldLines.length, newLines.length);
28
+ while (prefix < maxPrefix && oldLines[prefix] === newLines[prefix]) prefix += 1;
29
+ let suffix = 0;
30
+ while (
31
+ suffix < maxPrefix - prefix &&
32
+ oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]
33
+ ) {
34
+ suffix += 1;
35
+ }
36
+
37
+ const ops = [];
38
+ for (let i = 0; i < prefix; i += 1) ops.push({ type: "equal", oldIndex: i, newIndex: i });
39
+
40
+ const a = oldLines.slice(prefix, oldLines.length - suffix);
41
+ const b = newLines.slice(prefix, newLines.length - suffix);
42
+ for (const op of myers(a, b)) {
43
+ ops.push({ type: op.type, oldIndex: op.oldIndex + prefix, newIndex: op.newIndex + prefix });
44
+ }
45
+
46
+ for (let i = 0; i < suffix; i += 1) {
47
+ ops.push({
48
+ type: "equal",
49
+ oldIndex: oldLines.length - suffix + i,
50
+ newIndex: newLines.length - suffix + i,
51
+ });
52
+ }
53
+ return ops;
54
+ }
55
+
56
+ function myers(a, b) {
57
+ const n = a.length;
58
+ const m = b.length;
59
+ if (!n && !m) return [];
60
+ if (!n) return b.map((_, j) => ({ type: "insert", oldIndex: 0, newIndex: j }));
61
+ if (!m) return a.map((_, i) => ({ type: "delete", oldIndex: i, newIndex: 0 }));
62
+
63
+ const max = Math.min(n + m, MAX_EDIT_DISTANCE);
64
+ const offset = max;
65
+ const trace = [];
66
+ let v = new Int32Array(2 * max + 2);
67
+ v[offset + 1] = 0;
68
+ let found = false;
69
+
70
+ for (let d = 0; d <= max && !found; d += 1) {
71
+ const snapshot = new Int32Array(v);
72
+ trace.push(snapshot);
73
+ for (let k = -d; k <= d; k += 2) {
74
+ let x;
75
+ if (k === -d || (k !== d && v[offset + k - 1] < v[offset + k + 1])) x = v[offset + k + 1];
76
+ else x = v[offset + k - 1] + 1;
77
+ let y = x - k;
78
+ while (x < n && y < m && a[x] === b[y]) {
79
+ x += 1;
80
+ y += 1;
81
+ }
82
+ v[offset + k] = x;
83
+ if (x >= n && y >= m) {
84
+ found = true;
85
+ break;
86
+ }
87
+ }
88
+ }
89
+
90
+ if (!found) {
91
+ // Too different to refine within bounds: one hunk replacing the whole middle.
92
+ return [
93
+ ...a.map((_, i) => ({ type: "delete", oldIndex: i, newIndex: 0 })),
94
+ ...b.map((_, j) => ({ type: "insert", oldIndex: n, newIndex: j })),
95
+ ];
96
+ }
97
+
98
+ // Backtrack through the recorded frontiers.
99
+ const ops = [];
100
+ let x = n;
101
+ let y = m;
102
+ for (let d = trace.length - 1; d >= 0; d -= 1) {
103
+ const frontier = trace[d];
104
+ const k = x - y;
105
+ let prevK;
106
+ if (k === -d || (k !== d && frontier[offset + k - 1] < frontier[offset + k + 1])) prevK = k + 1;
107
+ else prevK = k - 1;
108
+ const prevX = frontier[offset + prevK];
109
+ const prevY = prevX - prevK;
110
+ while (x > prevX && y > prevY) {
111
+ x -= 1;
112
+ y -= 1;
113
+ ops.push({ type: "equal", oldIndex: x, newIndex: y });
114
+ }
115
+ if (d > 0) {
116
+ if (x === prevX) {
117
+ y -= 1;
118
+ ops.push({ type: "insert", oldIndex: x, newIndex: y });
119
+ } else {
120
+ x -= 1;
121
+ ops.push({ type: "delete", oldIndex: x, newIndex: y });
122
+ }
123
+ }
124
+ }
125
+ ops.reverse();
126
+ return ops;
127
+ }
128
+
129
+ /**
130
+ * Raw hunks: maximal runs of non-equal ops, as half-open line ranges into each side.
131
+ */
132
+ export function rawHunks(ops) {
133
+ const hunks = [];
134
+ let current = null;
135
+ let oldCursor = 0;
136
+ let newCursor = 0;
137
+ for (const op of ops) {
138
+ if (op.type === "equal") {
139
+ if (current) {
140
+ hunks.push(current);
141
+ current = null;
142
+ }
143
+ oldCursor = op.oldIndex + 1;
144
+ newCursor = op.newIndex + 1;
145
+ continue;
146
+ }
147
+ if (!current) current = { oldStart: oldCursor, oldEnd: oldCursor, newStart: newCursor, newEnd: newCursor };
148
+ if (op.type === "delete") {
149
+ current.oldEnd = op.oldIndex + 1;
150
+ oldCursor = op.oldIndex + 1;
151
+ } else {
152
+ current.newEnd = op.newIndex + 1;
153
+ newCursor = op.newIndex + 1;
154
+ }
155
+ }
156
+ if (current) hunks.push(current);
157
+ return hunks;
158
+ }
159
+
160
+ /**
161
+ * Occurrences may overlap (a run of identical lines): counting them non-overlapping
162
+ * would call a window unique while the first match sits somewhere else entirely.
163
+ */
164
+ function countOccurrences(haystack, needle) {
165
+ if (!needle) return 0;
166
+ let count = 0;
167
+ let at = haystack.indexOf(needle);
168
+ while (at !== -1) {
169
+ count += 1;
170
+ at = haystack.indexOf(needle, at + 1);
171
+ }
172
+ return count;
173
+ }
174
+
175
+ /**
176
+ * The text of lines [start, end) together with the separators that make them whole lines
177
+ * in `lines.join("\n")`: a newline after each line, except that the file's tail carries the
178
+ * newline *before* it instead. Both sides of a hunk share tail-ness (the suffix after the
179
+ * change is identical), so `find` and `replace` built this way splice cleanly.
180
+ */
181
+ function span(lines, start, end) {
182
+ if (end <= start) return "";
183
+ const body = lines.slice(start, end).join("\n");
184
+ if (end === lines.length) return `${start > 0 ? "\n" : ""}${body}`;
185
+ return `${body}\n`;
186
+ }
187
+
188
+ /**
189
+ * Widen a hunk's window symmetrically until its `find` text occurs exactly once in the
190
+ * original. Terminates because the whole file occurs once.
191
+ */
192
+ function uniqueWindow(oldLines, oldText, hunk) {
193
+ let before = 0;
194
+ let after = 0;
195
+ for (;;) {
196
+ const start = hunk.oldStart - before;
197
+ const end = hunk.oldEnd + after;
198
+ const find = span(oldLines, start, end);
199
+ if (find && countOccurrences(oldText, find) === 1) return { before, after };
200
+ const canBefore = start > 0;
201
+ const canAfter = end < oldLines.length;
202
+ if (!canBefore && !canAfter) return { before, after };
203
+ // Grow the side that still has room, alternating when both do.
204
+ if (canBefore && (!canAfter || before <= after)) before += 1;
205
+ else after += 1;
206
+ }
207
+ }
208
+
209
+ function mergeTouching(windows) {
210
+ const merged = [];
211
+ for (const w of windows) {
212
+ const last = merged[merged.length - 1];
213
+ if (last && w.oldStart - w.before <= last.oldEnd + last.after) {
214
+ last.oldEnd = Math.max(last.oldEnd, w.oldEnd);
215
+ last.newEnd = Math.max(last.newEnd, w.newEnd);
216
+ last.touched = true;
217
+ } else {
218
+ merged.push({ ...w });
219
+ }
220
+ }
221
+ return merged;
222
+ }
223
+
224
+ /** Display lines for one window: context, removed, and added lines in file order. */
225
+ function displayLines(oldSlice, newSlice) {
226
+ const lines = [];
227
+ for (const op of diffOps(oldSlice, newSlice)) {
228
+ if (op.type === "equal") lines.push({ type: "ctx", text: oldSlice[op.oldIndex] });
229
+ else if (op.type === "delete") lines.push({ type: "del", text: oldSlice[op.oldIndex] });
230
+ else lines.push({ type: "ins", text: newSlice[op.newIndex] });
231
+ }
232
+ return lines;
233
+ }
234
+
235
+ /**
236
+ * Measure `newText` against `oldText` as anchored hunks:
237
+ *
238
+ * {
239
+ * find, replace, // context-widened; `find` occurs exactly once in oldText
240
+ * oldStart, oldEnd, // 1-based inclusive line range of the changed lines in oldText
241
+ * // (oldEnd === oldStart - 1 for a pure insertion)
242
+ * removed, added, // changed line counts, context excluded
243
+ * lines, // [{ type: "ctx" | "del" | "ins", text }] for display
244
+ * }
245
+ *
246
+ * Hunks are independent: their context windows never overlap, so any subset applies in
247
+ * any order via `applyEdit`.
248
+ */
249
+ export function anchoredHunks(oldText, newText) {
250
+ if (oldText === newText) return [];
251
+ if (!oldText) {
252
+ return [
253
+ {
254
+ find: "",
255
+ replace: newText,
256
+ oldStart: 1,
257
+ oldEnd: 0,
258
+ removed: 0,
259
+ added: newText.split("\n").length,
260
+ lines: newText.split("\n").map((text) => ({ type: "ins", text })),
261
+ },
262
+ ];
263
+ }
264
+
265
+ const oldLines = oldText.split("\n");
266
+ const newLines = newText.split("\n");
267
+ let windows = rawHunks(diffOps(oldLines, newLines)).map((h) => ({ ...h, ...uniqueWindow(oldLines, oldText, h) }));
268
+
269
+ // Merging can widen a window past a neighbour; iterate to a fixed point.
270
+ for (;;) {
271
+ const merged = mergeTouching(windows);
272
+ const settled = merged.map((w) =>
273
+ w.touched ? { ...w, ...uniqueWindow(oldLines, oldText, w), touched: false } : w,
274
+ );
275
+ if (settled.length === windows.length) {
276
+ windows = settled;
277
+ break;
278
+ }
279
+ windows = settled;
280
+ }
281
+
282
+ return windows.map((w) => {
283
+ const start = w.oldStart - w.before;
284
+ const end = w.oldEnd + w.after;
285
+ const newStart = w.newStart - w.before;
286
+ const newEnd = w.newEnd + w.after;
287
+ return {
288
+ find: span(oldLines, start, end),
289
+ replace: span(newLines, newStart, newEnd),
290
+ oldStart: w.oldStart + 1,
291
+ oldEnd: w.oldEnd,
292
+ removed: w.oldEnd - w.oldStart,
293
+ added: w.newEnd - w.newStart,
294
+ lines: displayLines(oldLines.slice(start, end), newLines.slice(newStart, newEnd)),
295
+ };
296
+ });
297
+ }
298
+
299
+ /** Plain-text rendering of a hunk's lines, unified-diff style, for prompts and terminals. */
300
+ export function renderHunkLines(lines, { maxLines = 400 } = {}) {
301
+ const marks = { ctx: " ", del: "-", ins: "+" };
302
+ const shown = lines.slice(0, maxLines).map((l) => `${marks[l.type]} ${l.text}`);
303
+ if (lines.length > maxLines) shown.push(` ... ${lines.length - maxLines} more line(s)`);
304
+ return shown.join("\n");
305
+ }
@@ -0,0 +1,77 @@
1
+ import path from "node:path";
2
+
3
+ import {
4
+ attachToolResults,
5
+ contentToEvents,
6
+ home,
7
+ listDirs,
8
+ listFiles,
9
+ parseJsonLine,
10
+ readHeadLines,
11
+ readJsonl,
12
+ statOrNull,
13
+ } from "./shared.js";
14
+
15
+ /**
16
+ * Claude Code: ~/.claude/projects/<munged-cwd>/<session-uuid>.jsonl
17
+ *
18
+ * Every message line carries `cwd`, `gitBranch`, `sessionId` and `version`. The
19
+ * directory name is a lossy munge of the cwd (slashes and dots both become dashes),
20
+ * so it is only used to narrow the search - the per-line `cwd` is the authority.
21
+ * No git remote is recorded, so a deleted worktree can only reach tier 3.
22
+ */
23
+
24
+ const HEADER_LINES = 40;
25
+
26
+ export const name = "claude";
27
+
28
+ export function storeRoot() {
29
+ return home(".claude", "projects");
30
+ }
31
+
32
+ export function enumerate() {
33
+ const out = [];
34
+ for (const dir of listDirs(storeRoot())) {
35
+ for (const file of listFiles(dir, ".jsonl")) {
36
+ const stat = statOrNull(file);
37
+ if (!stat) continue;
38
+ out.push({ key: file, path: file, mtimeMs: stat.mtimeMs, bytes: stat.size });
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ export function classify(candidate) {
45
+ for (const line of readHeadLines(candidate.path, HEADER_LINES)) {
46
+ const entry = parseJsonLine(line);
47
+ if (!entry || !entry.cwd) continue;
48
+ return {
49
+ id: entry.sessionId || path.basename(candidate.path, ".jsonl"),
50
+ cwd: entry.cwd,
51
+ gitBranch: entry.gitBranch || null,
52
+ remotes: [],
53
+ startedAt: entry.timestamp ? Date.parse(entry.timestamp) : candidate.mtimeMs,
54
+ model: null,
55
+ };
56
+ }
57
+ return null;
58
+ }
59
+
60
+ export function read(ref) {
61
+ const entries = readJsonl(ref.path);
62
+ const events = [];
63
+ let model = null;
64
+
65
+ for (const entry of entries) {
66
+ if (entry.type === "user" && entry.message) {
67
+ if (entry.isSidechain) continue;
68
+ contentToEvents("user", entry.message.content, events);
69
+ } else if (entry.type === "assistant" && entry.message) {
70
+ if (entry.isSidechain) continue;
71
+ model = model || entry.message.model || null;
72
+ contentToEvents("assistant", entry.message.content, events);
73
+ }
74
+ }
75
+
76
+ return { events: attachToolResults(events), model };
77
+ }
@@ -0,0 +1,162 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import {
5
+ attachToolResults,
6
+ contentToEvents,
7
+ home,
8
+ parseJsonLine,
9
+ readHeadLines,
10
+ readJsonl,
11
+ statOrNull,
12
+ } from "./shared.js";
13
+
14
+ /**
15
+ * Codex: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl
16
+ *
17
+ * Line 1 is a `session_meta` record carrying `cwd` and, for sessions started inside a
18
+ * repo, `git.repository_url` - which is why codex stays deterministic even after the
19
+ * worktree is deleted (tier 2). The store is date-sharded and large (10k+ rollouts on
20
+ * a working machine), so discovery reads only line 1 and leans on the scan cache.
21
+ */
22
+
23
+ export const name = "codex";
24
+
25
+ export function storeRoot() {
26
+ return home(".codex", "sessions");
27
+ }
28
+
29
+ /**
30
+ * Walk the YYYY/MM/DD shards, skipping whole day directories outside the time window.
31
+ * @param {{ cutoffMs?: number }} [options]
32
+ */
33
+ export function enumerate({ cutoffMs } = {}) {
34
+ const root = storeRoot();
35
+ const out = [];
36
+ if (!fs.existsSync(root)) return out;
37
+
38
+ for (const year of shardDirs(root)) {
39
+ for (const month of shardDirs(year)) {
40
+ for (const day of shardDirs(month)) {
41
+ if (cutoffMs && dayIsBefore(root, day, cutoffMs)) continue;
42
+ let files;
43
+ try {
44
+ files = fs.readdirSync(day, { withFileTypes: true });
45
+ } catch {
46
+ continue;
47
+ }
48
+ for (const entry of files) {
49
+ if (!entry.isFile() || !entry.name.startsWith("rollout-") || !entry.name.endsWith(".jsonl")) continue;
50
+ const file = path.join(day, entry.name);
51
+ const stat = statOrNull(file);
52
+ if (!stat) continue;
53
+ out.push({ key: file, path: file, mtimeMs: stat.mtimeMs, bytes: stat.size });
54
+ }
55
+ }
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function shardDirs(dir) {
62
+ try {
63
+ return fs
64
+ .readdirSync(dir, { withFileTypes: true })
65
+ .filter((e) => e.isDirectory() && /^\d+$/.test(e.name))
66
+ .map((e) => path.join(dir, e.name));
67
+ } catch {
68
+ return [];
69
+ }
70
+ }
71
+
72
+ /** The shard path itself dates the sessions, so whole days can be skipped without stat-ing. */
73
+ function dayIsBefore(root, dayDir, cutoffMs) {
74
+ const parts = path.relative(root, dayDir).split(path.sep);
75
+ if (parts.length !== 3) return false;
76
+ const [y, m, d] = parts.map(Number);
77
+ if (!y || !m || !d) return false;
78
+ // End of that day in UTC+14 (the earliest a local timestamp can roll over).
79
+ const endOfDay = Date.UTC(y, m - 1, d + 1) + 14 * 3600_000;
80
+ return endOfDay < cutoffMs;
81
+ }
82
+
83
+ export function classify(candidate) {
84
+ const [first] = readHeadLines(candidate.path, 1);
85
+ const entry = first && parseJsonLine(first);
86
+ if (!entry || entry.type !== "session_meta") return null;
87
+ const payload = entry.payload || {};
88
+ const git = payload.git || {};
89
+ return {
90
+ id: payload.session_id || payload.id || path.basename(candidate.path, ".jsonl"),
91
+ cwd: payload.cwd || null,
92
+ gitBranch: git.branch || null,
93
+ remotes: git.repository_url ? [git.repository_url] : [],
94
+ startedAt: payload.timestamp ? Date.parse(payload.timestamp) : candidate.mtimeMs,
95
+ model: payload.model || null,
96
+ };
97
+ }
98
+
99
+ export function read(ref) {
100
+ const entries = readJsonl(ref.path);
101
+ const events = [];
102
+ let model = null;
103
+
104
+ for (const entry of entries) {
105
+ if (entry.type === "turn_context" && entry.payload?.model) {
106
+ model = model || entry.payload.model;
107
+ continue;
108
+ }
109
+ if (entry.type !== "response_item") continue;
110
+ const payload = entry.payload || {};
111
+
112
+ switch (payload.type) {
113
+ case "message": {
114
+ // `developer` messages are harness scaffolding, never user intent.
115
+ if (payload.role !== "user" && payload.role !== "assistant") break;
116
+ contentToEvents(payload.role, payload.content, events);
117
+ break;
118
+ }
119
+ case "function_call":
120
+ case "custom_tool_call":
121
+ events.push({
122
+ kind: "tool",
123
+ name: payload.name,
124
+ input: parseMaybeJson(payload.arguments ?? payload.input),
125
+ pendingId: payload.call_id,
126
+ });
127
+ break;
128
+ case "function_call_output":
129
+ case "custom_tool_call_output":
130
+ events.push({
131
+ kind: "tool-result",
132
+ id: payload.call_id,
133
+ result: flattenOutput(payload.output),
134
+ });
135
+ break;
136
+ default:
137
+ break;
138
+ }
139
+ }
140
+
141
+ return { events: attachToolResults(events), model };
142
+ }
143
+
144
+ function parseMaybeJson(value) {
145
+ if (typeof value !== "string") return value;
146
+ try {
147
+ return JSON.parse(value);
148
+ } catch {
149
+ return value;
150
+ }
151
+ }
152
+
153
+ function flattenOutput(output) {
154
+ if (typeof output === "string") return output;
155
+ if (Array.isArray(output)) {
156
+ return output
157
+ .map((b) => (typeof b === "string" ? b : (b?.text ?? "")))
158
+ .filter(Boolean)
159
+ .join("\n");
160
+ }
161
+ return output;
162
+ }
@@ -0,0 +1,109 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ import { home, listDirs, readJsonFile, statOrNull } from "./shared.js";
6
+ import { openReadOnly, safeJsonParse } from "./sqlite.js";
7
+
8
+ /**
9
+ * Cursor CLI: ~/.cursor/chats/<md5(cwd)>/<session-uuid>/{meta.json, store.db}
10
+ *
11
+ * The chat directory name is md5 of the cwd (verified by reproducing the digest), so a
12
+ * live worktree can be looked up directly instead of scanning. meta.json carries the
13
+ * exact `cwd` and timestamps.
14
+ *
15
+ * store.db is `blobs(id TEXT PRIMARY KEY, data BLOB)`: a content-addressed graph whose
16
+ * index blobs are an undocumented binary format. Plain-JSON `{role, content}` message
17
+ * blobs are readable and are what backpass uses; anything that does not parse is
18
+ * skipped. Because the store is content-addressed there is no recorded message order,
19
+ * so blobs are read in insertion (rowid) order - the closest available proxy.
20
+ */
21
+
22
+ export const name = "cursor";
23
+ export const sqliteBacked = true;
24
+
25
+ export function storeRoot() {
26
+ return home(".cursor", "chats");
27
+ }
28
+
29
+ export function cwdHash(cwd) {
30
+ return crypto.createHash("md5").update(cwd, "utf8").digest("hex");
31
+ }
32
+
33
+ export function enumerate() {
34
+ const out = [];
35
+ for (const hashDir of listDirs(storeRoot())) {
36
+ for (const sessionDir of listDirs(hashDir)) {
37
+ const meta = path.join(sessionDir, "meta.json");
38
+ const stat = statOrNull(meta);
39
+ if (!stat) continue;
40
+ out.push({ key: sessionDir, path: sessionDir, mtimeMs: stat.mtimeMs, bytes: stat.size });
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ export function classify(candidate) {
47
+ const meta = readJsonFile(path.join(candidate.path, "meta.json"));
48
+ if (!meta?.cwd) return null;
49
+ return {
50
+ id: path.basename(candidate.path),
51
+ cwd: meta.cwd,
52
+ gitBranch: null,
53
+ remotes: [],
54
+ title: meta.title || null,
55
+ startedAt: meta.createdAtMs || candidate.mtimeMs,
56
+ model: null,
57
+ };
58
+ }
59
+
60
+ export async function read(ref) {
61
+ const db = await openReadOnly(path.join(ref.path, "store.db"));
62
+ if (!db) return { events: [], model: null };
63
+
64
+ try {
65
+ const rows = db.prepare("SELECT data FROM blobs ORDER BY rowid").all();
66
+ const events = [];
67
+
68
+ for (const row of rows) {
69
+ const text = toUtf8(row.data);
70
+ if (!text || text[0] !== "{") continue;
71
+ const value = safeJsonParse(text);
72
+ if (!value || typeof value !== "object") continue;
73
+ const role = value.role;
74
+ if (role !== "user" && role !== "assistant") continue;
75
+ pushContent(role, value.content, events);
76
+ }
77
+
78
+ return { events, model: null };
79
+ } finally {
80
+ db.close();
81
+ }
82
+ }
83
+
84
+ function toUtf8(data) {
85
+ if (typeof data === "string") return data.trim();
86
+ if (data instanceof Uint8Array || Buffer.isBuffer(data)) return Buffer.from(data).toString("utf8").trim();
87
+ return null;
88
+ }
89
+
90
+ function pushContent(role, content, events) {
91
+ if (typeof content === "string") {
92
+ if (content.trim()) events.push({ kind: "message", role, text: content });
93
+ return;
94
+ }
95
+ if (!Array.isArray(content)) return;
96
+ const texts = content
97
+ .map((b) => (typeof b === "string" ? b : b?.text))
98
+ .filter((t) => typeof t === "string" && t.trim());
99
+ if (texts.length) events.push({ kind: "message", role, text: texts.join("\n") });
100
+ }
101
+
102
+ /**
103
+ * Fast path for live worktrees: the md5 lookup finds the chat directory without
104
+ * scanning every session on the machine.
105
+ */
106
+ export function directoriesForCwd(cwd) {
107
+ const dir = path.join(storeRoot(), cwdHash(cwd));
108
+ return fs.existsSync(dir) ? listDirs(dir) : [];
109
+ }