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
@@ -0,0 +1,130 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { readJsonFile } from "./shared.js";
6
+ import { openReadOnly, safeJsonParse } from "./sqlite.js";
7
+
8
+ /**
9
+ * Cursor IDE - DEFERRED TO v1.1 (captain decision 3).
10
+ *
11
+ * The store is ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb:
12
+ * cursorDiskKV `composerData:<id>` and `bubbleId:<composerId>:<bubbleId>` JSON
13
+ * composerHeaders (composerId, workspaceId, ...) - the only repo link there is
14
+ *
15
+ * The repo association runs composerHeaders.workspaceId -> workspaceStorage/<id>/
16
+ * workspace.json -> folder URI. On the machine this was designed against those
17
+ * workspaceId rows were empty, which makes association version-dependent and
18
+ * genuinely best-effort - hence the deferral.
19
+ *
20
+ * v1 ships this adapter behind --include-cursor-ide only. It never runs by default,
21
+ * every transcript it yields is labelled tier 3, and a failure here is a warning.
22
+ *
23
+ * TODO(v1.1): promote to a first-class adapter once a reliable composer -> workspace
24
+ * link exists across Cursor versions (and add Linux/Windows globalStorage paths).
25
+ */
26
+
27
+ export const name = "cursor-ide";
28
+ export const sqliteBacked = true;
29
+ export const experimental = true;
30
+
31
+ export function storeRoot() {
32
+ if (process.platform === "darwin") {
33
+ return path.join(os.homedir(), "Library", "Application Support", "Cursor", "User");
34
+ }
35
+ if (process.platform === "win32") {
36
+ return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "Cursor", "User");
37
+ }
38
+ return path.join(os.homedir(), ".config", "Cursor", "User");
39
+ }
40
+
41
+ function globalDbPath() {
42
+ return path.join(storeRoot(), "globalStorage", "state.vscdb");
43
+ }
44
+
45
+ /** workspaceId -> folder path, read from workspaceStorage/<id>/workspace.json. */
46
+ function workspaceFolders() {
47
+ const dir = path.join(storeRoot(), "workspaceStorage");
48
+ const map = new Map();
49
+ let entries;
50
+ try {
51
+ entries = fs.readdirSync(dir, { withFileTypes: true });
52
+ } catch {
53
+ return map;
54
+ }
55
+ for (const entry of entries) {
56
+ if (!entry.isDirectory()) continue;
57
+ const meta = readJsonFile(path.join(dir, entry.name, "workspace.json"));
58
+ const folder = meta?.folder;
59
+ if (typeof folder !== "string") continue;
60
+ map.set(entry.name, decodeURIComponent(folder.replace(/^file:\/\//, "")));
61
+ }
62
+ return map;
63
+ }
64
+
65
+ export async function discover({ cutoffMs }) {
66
+ const db = await openReadOnly(globalDbPath());
67
+ if (!db) return [];
68
+
69
+ try {
70
+ const folders = workspaceFolders();
71
+ let headers = [];
72
+ try {
73
+ headers = db.prepare("SELECT composerId, workspaceId FROM composerHeaders").all();
74
+ } catch {
75
+ // Older/newer Cursor builds may not have this table at all.
76
+ return [];
77
+ }
78
+
79
+ const out = [];
80
+ for (const header of headers) {
81
+ const cwd = header.workspaceId ? folders.get(header.workspaceId) : null;
82
+ if (!cwd) continue; // No usable repo link - the deferral in one line.
83
+ const row = db.prepare("SELECT value FROM cursorDiskKV WHERE key = ?").get(`composerData:${header.composerId}`);
84
+ const data = row ? safeJsonParse(row.value) : null;
85
+ const updatedAt = data?.lastUpdatedAt || data?.createdAt || 0;
86
+ if (cutoffMs && updatedAt && updatedAt < cutoffMs) continue;
87
+
88
+ out.push({
89
+ key: `cursor-ide:${header.composerId}`,
90
+ id: header.composerId,
91
+ path: globalDbPath(),
92
+ cwd,
93
+ remotes: [],
94
+ startedAt: data?.createdAt || null,
95
+ mtimeMs: updatedAt,
96
+ bytes: 0,
97
+ model: null,
98
+ experimental: true,
99
+ extra: { composerId: header.composerId },
100
+ });
101
+ }
102
+ return out;
103
+ } finally {
104
+ db.close();
105
+ }
106
+ }
107
+
108
+ export async function read(ref) {
109
+ const db = await openReadOnly(globalDbPath());
110
+ if (!db) return { events: [], model: null };
111
+
112
+ try {
113
+ const composerId = ref.extra?.composerId || ref.id;
114
+ const rows = db
115
+ .prepare("SELECT key, value FROM cursorDiskKV WHERE key LIKE ? ORDER BY key")
116
+ .all(`bubbleId:${composerId}:%`);
117
+
118
+ const events = [];
119
+ for (const row of rows) {
120
+ const bubble = safeJsonParse(row.value);
121
+ if (!bubble) continue;
122
+ const role = bubble.type === 1 ? "user" : "assistant";
123
+ const text = bubble.text || bubble.richText || "";
124
+ if (typeof text === "string" && text.trim()) events.push({ kind: "message", role, text });
125
+ }
126
+ return { events, model: null };
127
+ } finally {
128
+ db.close();
129
+ }
130
+ }
@@ -0,0 +1,107 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { attachToolResults, contentToEvents, home, listDirs, readJsonFile, readJsonl, statOrNull } from "./shared.js";
5
+
6
+ /**
7
+ * grok: ~/.grok/sessions/<url-encoded-cwd>/<session-uuid>/
8
+ * chat_history.jsonl - the conversation
9
+ * summary.json - {info:{cwd}, git_root_dir, git_remotes[], head_branch, ...}
10
+ *
11
+ * `git_remotes` is recorded at session start, so grok reaches tier 2 for dead worktrees.
12
+ * The unit of discovery is the session directory rather than a single file.
13
+ */
14
+
15
+ export const name = "grok";
16
+
17
+ export function storeRoot() {
18
+ return home(".grok", "sessions");
19
+ }
20
+
21
+ export function enumerate() {
22
+ const out = [];
23
+ for (const cwdDir of listDirs(storeRoot())) {
24
+ for (const sessionDir of listDirs(cwdDir)) {
25
+ const chat = path.join(sessionDir, "chat_history.jsonl");
26
+ const stat = statOrNull(chat);
27
+ if (!stat) continue;
28
+ out.push({ key: sessionDir, path: sessionDir, chatPath: chat, mtimeMs: stat.mtimeMs, bytes: stat.size });
29
+ }
30
+ }
31
+ return out;
32
+ }
33
+
34
+ export function classify(candidate) {
35
+ const summary = readJsonFile(path.join(candidate.path, "summary.json"));
36
+ const cwd = summary?.info?.cwd ?? decodeDirName(path.basename(path.dirname(candidate.path)));
37
+ if (!cwd) return null;
38
+ return {
39
+ id: summary?.info?.id || path.basename(candidate.path),
40
+ cwd,
41
+ gitRoot: summary?.git_root_dir || null,
42
+ gitBranch: summary?.head_branch || null,
43
+ remotes: Array.isArray(summary?.git_remotes) ? summary.git_remotes : [],
44
+ startedAt: summary?.created_at ? Date.parse(summary.created_at) : candidate.mtimeMs,
45
+ model: summary?.current_model_id || null,
46
+ extra: { chatPath: candidate.chatPath || path.join(candidate.path, "chat_history.jsonl") },
47
+ };
48
+ }
49
+
50
+ function decodeDirName(encoded) {
51
+ try {
52
+ return decodeURIComponent(encoded);
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ export function read(ref) {
59
+ const chatPath = ref.extra?.chatPath || path.join(ref.path, "chat_history.jsonl");
60
+ if (!fs.existsSync(chatPath)) return { events: [], model: null };
61
+
62
+ const entries = readJsonl(chatPath);
63
+ const events = [];
64
+ let model = null;
65
+
66
+ for (const entry of entries) {
67
+ switch (entry.type) {
68
+ case "user":
69
+ contentToEvents("user", entry.content, events);
70
+ break;
71
+ case "assistant":
72
+ model = model || entry.model_id || null;
73
+ contentToEvents("assistant", entry.content, events);
74
+ // grok hangs tool calls off the assistant record rather than emitting blocks.
75
+ for (const call of entry.tool_calls || []) {
76
+ events.push({
77
+ kind: "tool",
78
+ name: call.name,
79
+ input: parseMaybeJson(call.arguments ?? call.input),
80
+ pendingId: call.id,
81
+ });
82
+ }
83
+ break;
84
+ case "tool_result":
85
+ events.push({ kind: "tool-result", id: entry.tool_call_id ?? entry.id, result: entry.content });
86
+ break;
87
+ default:
88
+ break;
89
+ }
90
+ }
91
+
92
+ return { events: attachToolResults(events), model };
93
+ }
94
+
95
+ /** Exported for the raw-transcript escape hatch: the analysis agent opens this file. */
96
+ export function rawPath(ref) {
97
+ return ref.extra?.chatPath || path.join(ref.path, "chat_history.jsonl");
98
+ }
99
+
100
+ function parseMaybeJson(value) {
101
+ if (typeof value !== "string") return value;
102
+ try {
103
+ return JSON.parse(value);
104
+ } catch {
105
+ return value;
106
+ }
107
+ }
@@ -0,0 +1,151 @@
1
+ import path from "node:path";
2
+
3
+ import { home, listDirs, readJsonFile, statOrNull } from "./shared.js";
4
+ import { openReadOnly, safeJsonParse } from "./sqlite.js";
5
+
6
+ /**
7
+ * opencode: ~/.local/share/opencode/opencode.db (sqlite)
8
+ *
9
+ * project(id, worktree) - one row per project root
10
+ * session(id, project_id, directory, title, time_created)
11
+ * message(id, session_id, data) - data is JSON: {role, model, time, ...}
12
+ * part(id, message_id, session_id, data) - data is JSON: {type: text|tool|reasoning|...}
13
+ *
14
+ * This is the best-behaved store of the six: association is a SQL predicate on
15
+ * `session.directory`, deleted worktrees included, and both listing and reading are
16
+ * indexed. Older opencode versions used file storage under `storage/`; that layout is
17
+ * handled as a fallback so long-lived machines still yield transcripts.
18
+ */
19
+
20
+ export const name = "opencode";
21
+ export const sqliteBacked = true;
22
+
23
+ export function storeRoot() {
24
+ return home(".local", "share", "opencode");
25
+ }
26
+
27
+ export function dbPath() {
28
+ return path.join(storeRoot(), "opencode.db");
29
+ }
30
+
31
+ /**
32
+ * Discovery is direct: one query returns every session with its directory, and the
33
+ * caller applies the shared association tiers.
34
+ */
35
+ export async function discover({ cutoffMs }) {
36
+ const db = await openReadOnly(dbPath());
37
+ if (!db) return legacyDiscover({ cutoffMs });
38
+
39
+ try {
40
+ const rows = db
41
+ .prepare(
42
+ `SELECT s.id AS id, s.directory AS directory, s.title AS title,
43
+ s.time_created AS time_created, s.time_updated AS time_updated,
44
+ p.worktree AS worktree
45
+ FROM session s
46
+ LEFT JOIN project p ON p.id = s.project_id
47
+ WHERE (? IS NULL OR s.time_updated >= ?)`,
48
+ )
49
+ .all(cutoffMs ?? null, cutoffMs ?? 0);
50
+
51
+ return rows.map((row) => ({
52
+ key: `opencode:${row.id}`,
53
+ id: row.id,
54
+ path: dbPath(),
55
+ cwd: row.directory,
56
+ gitRoot: row.worktree || null,
57
+ gitBranch: null,
58
+ remotes: [],
59
+ title: row.title || null,
60
+ startedAt: Number(row.time_created) || null,
61
+ mtimeMs: Number(row.time_updated) || Number(row.time_created) || 0,
62
+ bytes: 0,
63
+ model: null,
64
+ extra: { sessionId: row.id },
65
+ }));
66
+ } finally {
67
+ db.close();
68
+ }
69
+ }
70
+
71
+ export async function read(ref) {
72
+ const db = await openReadOnly(dbPath());
73
+ if (!db) return legacyRead();
74
+
75
+ try {
76
+ const sessionId = ref.extra?.sessionId || ref.id;
77
+ const messages = db
78
+ .prepare("SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created, id")
79
+ .all(sessionId);
80
+ const parts = db
81
+ .prepare("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created, id")
82
+ .all(sessionId);
83
+
84
+ const partsByMessage = new Map();
85
+ for (const part of parts) {
86
+ if (!partsByMessage.has(part.message_id)) partsByMessage.set(part.message_id, []);
87
+ partsByMessage.get(part.message_id).push(safeJsonParse(part.data));
88
+ }
89
+
90
+ const events = [];
91
+ let model = null;
92
+
93
+ for (const message of messages) {
94
+ const data = safeJsonParse(message.data) || {};
95
+ const role = data.role === "user" ? "user" : "assistant";
96
+ model = model || data.modelID || data.model?.modelID || null;
97
+
98
+ const texts = [];
99
+ for (const part of partsByMessage.get(message.id) || []) {
100
+ if (!part) continue;
101
+ if (part.type === "text" && part.text) {
102
+ texts.push(part.text);
103
+ } else if (part.type === "tool") {
104
+ events.push({
105
+ kind: "tool",
106
+ name: part.tool || part.name,
107
+ input: part.state?.input ?? part.input,
108
+ result: part.state?.output ?? part.output,
109
+ status: part.state?.status,
110
+ });
111
+ }
112
+ // reasoning / step-start / step-finish / patch / file parts carry no loss signal.
113
+ }
114
+ if (texts.length) events.push({ kind: "message", role, text: texts.join("\n") });
115
+ }
116
+
117
+ return { events, model };
118
+ } finally {
119
+ db.close();
120
+ }
121
+ }
122
+
123
+ /** Pre-sqlite opencode kept JSON files under storage/. Best-effort, never fatal. */
124
+ function legacyDiscover({ cutoffMs }) {
125
+ const projectsDir = path.join(storeRoot(), "storage", "project");
126
+ const out = [];
127
+ for (const dir of listDirs(projectsDir)) {
128
+ const meta = readJsonFile(path.join(dir, "project.json"));
129
+ const stat = statOrNull(dir);
130
+ if (!meta?.worktree || !stat) continue;
131
+ if (cutoffMs && stat.mtimeMs < cutoffMs) continue;
132
+ out.push({
133
+ key: `opencode-legacy:${dir}`,
134
+ id: path.basename(dir),
135
+ path: dir,
136
+ cwd: meta.worktree,
137
+ remotes: [],
138
+ startedAt: stat.birthtimeMs || stat.mtimeMs,
139
+ mtimeMs: stat.mtimeMs,
140
+ bytes: 0,
141
+ extra: { legacy: true },
142
+ });
143
+ }
144
+ return out;
145
+ }
146
+
147
+ function legacyRead() {
148
+ // The legacy layout stores messages per project in a shape that changed across
149
+ // releases; rather than guess, report an empty trace so the run stays fail-soft.
150
+ return { events: [], model: null };
151
+ }
@@ -0,0 +1,87 @@
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
+ * pi: ~/.pi/agent/sessions/<escaped-cwd>/<ISO-ts>_<uuid>.jsonl
17
+ *
18
+ * Line 1 is `{type:"session", cwd, id}`. Entries form a parent/child tree but arrive in
19
+ * order, so a linear read is faithful. `model_change` / `thinking_level_change` records
20
+ * give the model actually used. No remote is recorded - dead worktrees reach tier 3 only.
21
+ */
22
+
23
+ export const name = "pi";
24
+
25
+ export function storeRoot() {
26
+ return home(".pi", "agent", "sessions");
27
+ }
28
+
29
+ export function enumerate() {
30
+ const out = [];
31
+ for (const dir of listDirs(storeRoot())) {
32
+ for (const file of listFiles(dir, ".jsonl")) {
33
+ const stat = statOrNull(file);
34
+ if (!stat) continue;
35
+ out.push({ key: file, path: file, mtimeMs: stat.mtimeMs, bytes: stat.size });
36
+ }
37
+ }
38
+ return out;
39
+ }
40
+
41
+ export function classify(candidate) {
42
+ const [first] = readHeadLines(candidate.path, 1);
43
+ const entry = first && parseJsonLine(first);
44
+ if (!entry || entry.type !== "session" || !entry.cwd) return null;
45
+ return {
46
+ id: entry.id || path.basename(candidate.path, ".jsonl"),
47
+ cwd: entry.cwd,
48
+ gitBranch: null,
49
+ remotes: [],
50
+ startedAt: entry.timestamp ? Date.parse(entry.timestamp) : candidate.mtimeMs,
51
+ model: null,
52
+ };
53
+ }
54
+
55
+ export function read(ref) {
56
+ const entries = readJsonl(ref.path);
57
+ const events = [];
58
+ let model = null;
59
+
60
+ for (const entry of entries) {
61
+ if (entry.type === "model_change") {
62
+ model = entry.modelId || model;
63
+ continue;
64
+ }
65
+ if (entry.type !== "message" || !entry.message) continue;
66
+ const message = entry.message;
67
+ const role = message.role;
68
+
69
+ if (role === "toolResult") {
70
+ events.push({ kind: "tool-result", id: message.toolCallId ?? message.id, result: textOf(message.content) });
71
+ continue;
72
+ }
73
+ if (role !== "user" && role !== "assistant") continue;
74
+ contentToEvents(role, message.content, events);
75
+ }
76
+
77
+ return { events: attachToolResults(events), model };
78
+ }
79
+
80
+ function textOf(content) {
81
+ if (typeof content === "string") return content;
82
+ if (!Array.isArray(content)) return content;
83
+ return content
84
+ .map((b) => (typeof b === "string" ? b : (b?.text ?? "")))
85
+ .filter(Boolean)
86
+ .join("\n");
87
+ }
@@ -0,0 +1,195 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+
5
+ /** Read at most `maxLines` lines (or `maxBytes`) from the head of a file. */
6
+ export function readHeadLines(file, maxLines = 1, maxBytes = 512 * 1024) {
7
+ let fd;
8
+ try {
9
+ fd = fs.openSync(file, "r");
10
+ const size = fs.fstatSync(fd).size;
11
+ const length = Math.min(size, maxBytes);
12
+ const buffer = Buffer.alloc(length);
13
+ fs.readSync(fd, buffer, 0, length, 0);
14
+ const text = buffer.toString("utf8");
15
+ const lines = text.split("\n");
16
+ // A final partial line is only safe to use when we read the whole file.
17
+ if (length < size) lines.pop();
18
+ return lines.filter((l) => l.trim()).slice(0, maxLines);
19
+ } catch {
20
+ return [];
21
+ } finally {
22
+ if (fd !== undefined) fs.closeSync(fd);
23
+ }
24
+ }
25
+
26
+ export function parseJsonLine(line) {
27
+ try {
28
+ return JSON.parse(line);
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ export function readJsonFile(file) {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(file, "utf8"));
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function readJsonl(file, { maxBytes = 64 * 1024 * 1024 } = {}) {
43
+ let text;
44
+ try {
45
+ const stat = fs.statSync(file);
46
+ if (stat.size > maxBytes) {
47
+ // Very large sessions are read tail-first: recent turns carry the loss signal.
48
+ const fd = fs.openSync(file, "r");
49
+ const buffer = Buffer.alloc(maxBytes);
50
+ fs.readSync(fd, buffer, 0, maxBytes, stat.size - maxBytes);
51
+ fs.closeSync(fd);
52
+ text = buffer.toString("utf8");
53
+ text = text.slice(text.indexOf("\n") + 1);
54
+ } else {
55
+ text = fs.readFileSync(file, "utf8");
56
+ }
57
+ } catch {
58
+ return [];
59
+ }
60
+ const out = [];
61
+ for (const line of text.split("\n")) {
62
+ if (!line.trim()) continue;
63
+ const value = parseJsonLine(line);
64
+ if (value) out.push(value);
65
+ }
66
+ return out;
67
+ }
68
+
69
+ export function statOrNull(file) {
70
+ try {
71
+ return fs.statSync(file);
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ export function listDirs(dir) {
78
+ try {
79
+ return fs
80
+ .readdirSync(dir, { withFileTypes: true })
81
+ .filter((e) => e.isDirectory())
82
+ .map((e) => path.join(dir, e.name));
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ export function listFiles(dir, suffix) {
89
+ try {
90
+ return fs
91
+ .readdirSync(dir, { withFileTypes: true })
92
+ .filter((e) => e.isFile() && (!suffix || e.name.endsWith(suffix)))
93
+ .map((e) => path.join(dir, e.name));
94
+ } catch {
95
+ return [];
96
+ }
97
+ }
98
+
99
+ export function home(...segments) {
100
+ return path.join(os.homedir(), ...segments);
101
+ }
102
+
103
+ /**
104
+ * Normalize an assistant/user `content` value into distiller events. Every harness
105
+ * settled on some variant of "string, or array of typed blocks", so one tolerant
106
+ * reader covers claude, codex, pi, grok and cursor with adapter-specific tweaks
107
+ * layered on top.
108
+ */
109
+ export function contentToEvents(role, content, events) {
110
+ if (content === null || content === undefined) return;
111
+ if (typeof content === "string") {
112
+ if (content.trim()) events.push({ kind: "message", role, text: content });
113
+ return;
114
+ }
115
+ if (!Array.isArray(content)) {
116
+ if (typeof content.text === "string") events.push({ kind: "message", role, text: content.text });
117
+ return;
118
+ }
119
+ const texts = [];
120
+ for (const block of content) {
121
+ if (!block || typeof block !== "object") {
122
+ if (typeof block === "string") texts.push(block);
123
+ continue;
124
+ }
125
+ switch (block.type) {
126
+ case "text":
127
+ case "input_text":
128
+ case "output_text":
129
+ if (block.text) texts.push(block.text);
130
+ break;
131
+ case "tool_use":
132
+ case "toolCall":
133
+ events.push({
134
+ kind: "tool",
135
+ name: block.name,
136
+ input: block.input ?? block.arguments,
137
+ pendingId: block.id ?? block.call_id,
138
+ });
139
+ break;
140
+ case "tool_result":
141
+ case "toolResult":
142
+ events.push({
143
+ kind: "tool-result",
144
+ id: block.tool_use_id ?? block.id,
145
+ result: block.content ?? block.output ?? block.text,
146
+ status: block.is_error ? "error" : "completed",
147
+ });
148
+ break;
149
+ case "thinking":
150
+ case "reasoning":
151
+ break;
152
+ default:
153
+ if (typeof block.text === "string") texts.push(block.text);
154
+ break;
155
+ }
156
+ }
157
+ if (texts.length) {
158
+ const joined = texts.join("\n").trim();
159
+ if (joined) events.push({ kind: "message", role, text: joined });
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Fold `tool-result` events back into the `tool` call they answer, so the distiller
165
+ * emits one line per tool call rather than two.
166
+ */
167
+ export function attachToolResults(events) {
168
+ const out = [];
169
+ const byId = new Map();
170
+ for (const event of events) {
171
+ if (event.kind === "tool") {
172
+ out.push(event);
173
+ if (event.pendingId) byId.set(event.pendingId, event);
174
+ continue;
175
+ }
176
+ if (event.kind === "tool-result") {
177
+ const target = event.id && byId.get(event.id);
178
+ if (target) {
179
+ target.result = event.result;
180
+ target.status = event.status;
181
+ } else {
182
+ // Orphan result (truncated log, or a harness that does not correlate ids).
183
+ const last = [...out].reverse().find((e) => e.kind === "tool" && e.result === undefined);
184
+ if (last) {
185
+ last.result = event.result;
186
+ last.status = event.status;
187
+ }
188
+ }
189
+ continue;
190
+ }
191
+ out.push(event);
192
+ }
193
+ for (const event of out) delete event.pendingId;
194
+ return out;
195
+ }