@neerajvipparla/agentbridge 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.
@@ -0,0 +1,220 @@
1
+ // src/ledger/git-ledger.js
2
+ //
3
+ // A tiny local git repo used purely as a versioned ledger of every fork
4
+ // agentbridge performs. It is NOT your project's own git repo - it lives
5
+ // under .agentbridge/ledger inside the project directory (add
6
+ // .agentbridge/ to .gitignore if you don't want it alongside your code repo,
7
+ // or point --ledger elsewhere).
8
+ //
9
+ // Each fork produces one commit containing:
10
+ // claude/<session-id>.jsonl - byte-for-byte copy of the source transcript
11
+ // opencode/<session-id>.json - the converted OpenCode import payload
12
+ //
13
+ // This gives you a diffable, revertible history of every fork, and is the
14
+ // foundation phase 2 (bidirectional sync) will build on: sync conflicts can
15
+ // be reasoned about as diffs between ledger commits instead of ad hoc state.
16
+
17
+ import { execFileSync } from "node:child_process";
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+
21
+ export function ledgerPath(projectDir) {
22
+ return path.join(projectDir, ".agentbridge", "ledger");
23
+ }
24
+
25
+ function writeLedgerData(ledgerDir, subdir, sessionId, ext, source) {
26
+ const dest = path.join(ledgerDir, subdir, `${sessionId}.${ext}`);
27
+ if (source.path) {
28
+ // Raw source: byte-for-byte copy.
29
+ fs.copyFileSync(source.path, dest);
30
+ } else if (ext === "jsonl") {
31
+ // Claude-format data: an array of JSONL entries.
32
+ const lines = Array.isArray(source.data)
33
+ ? source.data.map((e) => JSON.stringify(e)).join("\n") + "\n"
34
+ : source.data + "\n";
35
+ fs.writeFileSync(dest, lines);
36
+ } else {
37
+ // OpenCode-format data: a JSON object.
38
+ fs.writeFileSync(dest, JSON.stringify(source.data, null, 2) + "\n");
39
+ }
40
+ return dest;
41
+ }
42
+
43
+ function countMessages(claudeSource, opencodeSource) {
44
+ if (claudeSource.data && Array.isArray(claudeSource.data)) {
45
+ return claudeSource.data.length;
46
+ }
47
+ if (opencodeSource.data && Array.isArray(opencodeSource.data.messages)) {
48
+ return opencodeSource.data.messages.length;
49
+ }
50
+ return 0;
51
+ }
52
+
53
+ const MAPPING_FILE = "mapping.json";
54
+
55
+ function git(cwd, args) {
56
+ return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] })
57
+ .toString()
58
+ .trim();
59
+ }
60
+
61
+ function readMapping(ledgerDir) {
62
+ const p = path.join(ledgerDir, MAPPING_FILE);
63
+ if (!fs.existsSync(p)) return { pairs: [] };
64
+ try {
65
+ const data = JSON.parse(fs.readFileSync(p, "utf8"));
66
+ return { pairs: Array.isArray(data.pairs) ? data.pairs : [] };
67
+ } catch {
68
+ return { pairs: [] };
69
+ }
70
+ }
71
+
72
+ function writeMapping(ledgerDir, mapping) {
73
+ const p = path.join(ledgerDir, MAPPING_FILE);
74
+ fs.writeFileSync(p, JSON.stringify(mapping, null, 2) + "\n");
75
+ }
76
+
77
+ function updateMapping(mapping, claudeId, opencodeId) {
78
+ if (!claudeId || !opencodeId) return;
79
+ const existing = mapping.pairs.find(
80
+ (p) => p.claudeId === claudeId || p.opencodeId === opencodeId
81
+ );
82
+ if (existing) {
83
+ existing.claudeId = claudeId;
84
+ existing.opencodeId = opencodeId;
85
+ } else {
86
+ mapping.pairs.push({ claudeId, opencodeId });
87
+ }
88
+ }
89
+
90
+ export function findMapping(ledgerDir, { claudeId, opencodeId } = {}) {
91
+ const mapping = readMapping(ledgerDir);
92
+ return mapping.pairs.find(
93
+ (p) => (claudeId && p.claudeId === claudeId) || (opencodeId && p.opencodeId === opencodeId)
94
+ );
95
+ }
96
+
97
+ export function ensureLedger(ledgerDir) {
98
+ fs.mkdirSync(ledgerDir, { recursive: true });
99
+ fs.mkdirSync(path.join(ledgerDir, "claude"), { recursive: true });
100
+ fs.mkdirSync(path.join(ledgerDir, "opencode"), { recursive: true });
101
+ if (!fs.existsSync(path.join(ledgerDir, ".git"))) {
102
+ git(ledgerDir, ["init", "-q"]);
103
+ // Local, tool-only identity so this works even with no global git config.
104
+ git(ledgerDir, ["config", "user.email", "agentbridge@local"]);
105
+ git(ledgerDir, ["config", "user.name", "agentbridge"]);
106
+ }
107
+ }
108
+
109
+ export function readLedgerFiles(ledgerDir, sessionId) {
110
+ const claudePath = path.join(ledgerDir, "claude", `${sessionId}.jsonl`);
111
+ const opencodePath = path.join(ledgerDir, "opencode", `${sessionId}.json`);
112
+ const claude = fs.existsSync(claudePath)
113
+ ? fs.readFileSync(claudePath, "utf8").split("\n").filter(Boolean).map(JSON.parse)
114
+ : null;
115
+ const opencode = fs.existsSync(opencodePath)
116
+ ? JSON.parse(fs.readFileSync(opencodePath, "utf8"))
117
+ : null;
118
+ return { claude, opencode };
119
+ }
120
+
121
+ /**
122
+ * Read the ledger state for a session pair, trying both the Claude and OpenCode
123
+ * ids. Forward forks key files by Claude id; reverse forks key by OpenCode id.
124
+ */
125
+ export function readLedgerPair(ledgerDir, claudeId, opencodeId) {
126
+ const a = readLedgerFiles(ledgerDir, claudeId);
127
+ const b = readLedgerFiles(ledgerDir, opencodeId);
128
+ // Prefer the more recently modified claude file if both exist.
129
+ const aClaudePath = path.join(ledgerDir, "claude", `${claudeId}.jsonl`);
130
+ const bClaudePath = path.join(ledgerDir, "claude", `${opencodeId}.jsonl`);
131
+ const aTime = fs.existsSync(aClaudePath) ? fs.statSync(aClaudePath).mtimeMs : 0;
132
+ const bTime = fs.existsSync(bClaudePath) ? fs.statSync(bClaudePath).mtimeMs : 0;
133
+ const preferB = bTime > aTime;
134
+ return {
135
+ claude: (preferB ? b.claude : null) ?? a.claude,
136
+ opencode: (preferB ? b.opencode : null) ?? a.opencode,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Write the raw + converted files and commit them.
142
+ *
143
+ * One of the two sources is always the raw source (provided as a file path) and
144
+ * the other is the converted form (provided as data), but the ledger stores
145
+ * both representations keyed by the source session id regardless of direction.
146
+ *
147
+ * @param {object} claudeSource - either { path: string } or { data: object[] }
148
+ * @param {object} opencodeSource - either { path: string } or { data: object }
149
+ * @returns {{hash:string, changed:boolean}}
150
+ */
151
+ export function commitFork(ledgerDir, { sessionId, claudeId, opencodeId, claudeSource, opencodeSource, direction }) {
152
+ ensureLedger(ledgerDir);
153
+
154
+ writeLedgerData(ledgerDir, "claude", sessionId, "jsonl", claudeSource);
155
+ writeLedgerData(ledgerDir, "opencode", sessionId, "json", opencodeSource);
156
+
157
+ // Record the bidirectional id mapping for sync.
158
+ let resolvedClaudeId = claudeId;
159
+ if (!resolvedClaudeId) {
160
+ if (Array.isArray(claudeSource.data)) {
161
+ resolvedClaudeId = claudeSource.data[0]?.sessionId;
162
+ } else if (claudeSource.path) {
163
+ resolvedClaudeId = path.basename(claudeSource.path).replace(/\.jsonl$/, "");
164
+ }
165
+ }
166
+ let resolvedOpenCodeId = opencodeId;
167
+ if (!resolvedOpenCodeId) {
168
+ if (opencodeSource.data && typeof opencodeSource.data === "object" && opencodeSource.data.info?.id) {
169
+ resolvedOpenCodeId = opencodeSource.data.info.id;
170
+ }
171
+ }
172
+ const mapping = readMapping(ledgerDir);
173
+ updateMapping(mapping, resolvedClaudeId, resolvedOpenCodeId);
174
+ writeMapping(ledgerDir, mapping);
175
+
176
+ git(ledgerDir, ["add", "-A"]);
177
+
178
+ const messageCount = countMessages(claudeSource, opencodeSource);
179
+ const summary = `fork ${direction}: ${sessionId} (${messageCount} messages)`;
180
+
181
+ // Nothing to commit (re-forking an unchanged session) shouldn't be an error.
182
+ try {
183
+ git(ledgerDir, ["commit", "-q", "-m", summary]);
184
+ } catch (err) {
185
+ const msg = err.stdout?.toString() ?? err.message;
186
+ if (/nothing to commit/i.test(msg)) {
187
+ return { hash: git(ledgerDir, ["rev-parse", "HEAD"]), changed: false };
188
+ }
189
+ throw err;
190
+ }
191
+
192
+ return { hash: git(ledgerDir, ["rev-parse", "HEAD"]), changed: true };
193
+ }
194
+
195
+ export function log(ledgerDir, limit = 20) {
196
+ // Reading history shouldn't create anything: if no ledger exists yet, there
197
+ // are simply no forks to show.
198
+ if (!fs.existsSync(path.join(ledgerDir, ".git"))) return [];
199
+
200
+ let out;
201
+ try {
202
+ out = git(ledgerDir, [
203
+ "log",
204
+ `-${limit}`,
205
+ "--pretty=format:%H|%ad|%s",
206
+ "--date=iso-strict",
207
+ ]);
208
+ } catch (err) {
209
+ // A freshly `git init`ed repo with no commits makes `git log` exit 128
210
+ // ("does not have any commits yet") - treat that as an empty history.
211
+ const msg = (err.stderr?.toString() ?? "") + (err.stdout?.toString() ?? "") + (err.message ?? "");
212
+ if (/does not have any commits|unknown revision|bad default revision/i.test(msg)) return [];
213
+ throw err;
214
+ }
215
+ if (!out) return [];
216
+ return out.split("\n").map((line) => {
217
+ const [hash, date, ...rest] = line.split("|");
218
+ return { hash, date, message: rest.join("|") };
219
+ });
220
+ }
@@ -0,0 +1,186 @@
1
+ // src/readers/claude-reader.js
2
+ //
3
+ // Reads Claude Code's local session storage.
4
+ //
5
+ // Claude Code stores one JSONL file per session under:
6
+ // ~/.claude/projects/<encoded-cwd>/<session-uuid>.jsonl
7
+ // where <encoded-cwd> is the absolute working directory with "/" (and other
8
+ // path separators) replaced by "-".
9
+ //
10
+ // Each line is one JSON object ("entry"). The fields we rely on:
11
+ // type "user" | "assistant" | "summary" | ...
12
+ // uuid this entry's id
13
+ // parentUuid previous entry's id (linear chain, ignoring branches)
14
+ // sessionId the session this entry belongs to
15
+ // timestamp ISO-8601 string
16
+ // isSidechain true for subagent/background-task turns
17
+ // isMeta true for Claude Code's own system/meta messages
18
+ // cwd working directory Claude Code was run from
19
+ // gitBranch git branch at the time, if any
20
+ // version Claude Code version string
21
+ // message { role, content, model, usage, ... }
22
+ // content is either a plain string or an array of content blocks:
23
+ // { type: "text", text }
24
+ // { type: "thinking", thinking }
25
+ // { type: "tool_use", id, name, input }
26
+ // { type: "tool_result", tool_use_id, content, is_error }
27
+ // toolUseResult sometimes carries the raw tool result payload for a
28
+ // preceding tool_use (attached to the *next* "user" entry)
29
+
30
+ import fs from "node:fs";
31
+ import path from "node:path";
32
+ import os from "node:os";
33
+
34
+ export function claudeProjectsDir() {
35
+ return path.join(os.homedir(), ".claude", "projects");
36
+ }
37
+
38
+ /**
39
+ * Claude Code's own encoding of an absolute path into a directory name.
40
+ *
41
+ * Claude Code names each project directory after its absolute cwd with EVERY
42
+ * non-alphanumeric character replaced by "-" (not just the path separators).
43
+ * Dots, underscores, spaces, etc. all become "-", and runs are NOT collapsed:
44
+ * /Users/me/.config/my_app -> -Users-me--config-my-app
45
+ * Verified against real directory names under ~/.claude/projects. An earlier
46
+ * version replaced only "/" and "\", which silently found nothing for any
47
+ * project whose path contained a ".", "_", space, or other punctuation.
48
+ */
49
+ export function encodeProjectPath(absPath) {
50
+ return absPath.replace(/[^a-zA-Z0-9]/g, "-");
51
+ }
52
+
53
+ /** Find the project directory for a given cwd, or null if none exists. */
54
+ export function findProjectDir(cwd = process.cwd()) {
55
+ const base = claudeProjectsDir();
56
+ const candidate = path.join(base, encodeProjectPath(path.resolve(cwd)));
57
+ return fs.existsSync(candidate) ? candidate : null;
58
+ }
59
+
60
+ /** List session files (main sessions only, not agent-*.jsonl subagent files) for a project dir. */
61
+ export function listSessions(projectDir) {
62
+ if (!fs.existsSync(projectDir)) return [];
63
+ return fs
64
+ .readdirSync(projectDir)
65
+ .filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-"))
66
+ .map((f) => {
67
+ const full = path.join(projectDir, f);
68
+ const stat = fs.statSync(full);
69
+ return { id: f.replace(/\.jsonl$/, ""), file: full, mtime: stat.mtimeMs };
70
+ })
71
+ .sort((a, b) => b.mtime - a.mtime); // newest first
72
+ }
73
+
74
+ /** Find the most recently modified session for a project dir. */
75
+ export function findLatestSession(cwd = process.cwd()) {
76
+ const dir = findProjectDir(cwd);
77
+ if (!dir) return null;
78
+ const sessions = listSessions(dir);
79
+ return sessions[0] || null;
80
+ }
81
+
82
+ /** Locate a specific session by id, searching all projects if needed. */
83
+ export function findSessionById(sessionId, cwd = process.cwd()) {
84
+ const dir = findProjectDir(cwd);
85
+ if (dir) {
86
+ const direct = path.join(dir, `${sessionId}.jsonl`);
87
+ if (fs.existsSync(direct)) return { id: sessionId, file: direct };
88
+ }
89
+ // Fall back to scanning every project directory.
90
+ const base = claudeProjectsDir();
91
+ if (!fs.existsSync(base)) return null;
92
+ for (const projectName of fs.readdirSync(base)) {
93
+ const full = path.join(base, projectName, `${sessionId}.jsonl`);
94
+ if (fs.existsSync(full)) return { id: sessionId, file: full };
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /** Parse a session JSONL file into an ordered list of entries. Skips malformed lines. */
100
+ export function parseSessionFile(filePath) {
101
+ const raw = fs.readFileSync(filePath, "utf8");
102
+ const entries = [];
103
+ for (const line of raw.split("\n")) {
104
+ const trimmed = line.trim();
105
+ if (!trimmed) continue;
106
+ try {
107
+ entries.push(JSON.parse(trimmed));
108
+ } catch {
109
+ // Corrupt/partial line (e.g. a crash mid-write) - skip it rather than fail the whole fork.
110
+ }
111
+ }
112
+ return entries;
113
+ }
114
+
115
+ /**
116
+ * Reduce raw entries to the linear conversation Claude Code would render:
117
+ * drop sidechains (subagent turns) and meta entries, keep user/assistant only.
118
+ */
119
+ export function toConversation(entries) {
120
+ return entries.filter(
121
+ (e) => !e.isSidechain && !e.isMeta && (e.type === "user" || e.type === "assistant")
122
+ );
123
+ }
124
+
125
+ /** List every project directory Claude Code has recorded sessions for. */
126
+ export function listAllProjectDirs() {
127
+ const base = claudeProjectsDir();
128
+ if (!fs.existsSync(base)) return [];
129
+ return fs
130
+ .readdirSync(base)
131
+ .map((name) => path.join(base, name))
132
+ .filter((full) => fs.statSync(full).isDirectory());
133
+ }
134
+
135
+ /** Best-effort plain-text preview of a single content block or string. */
136
+ function previewText(content) {
137
+ if (typeof content === "string") return content;
138
+ if (Array.isArray(content)) {
139
+ const textBlock = content.find((b) => b.type === "text" && b.text);
140
+ if (textBlock) return textBlock.text;
141
+ const toolBlock = content.find((b) => b.type === "tool_use");
142
+ if (toolBlock) return `[used tool: ${toolBlock.name}]`;
143
+ }
144
+ return "";
145
+ }
146
+
147
+ /**
148
+ * Build a short, human-readable summary of a session for browsing:
149
+ * first user message, how many turns, when it was last touched.
150
+ */
151
+ export function summarizeSession(entries) {
152
+ const convo = toConversation(entries);
153
+ const firstUser = convo.find((e) => e.type === "user");
154
+ const lastEntry = convo[convo.length - 1];
155
+ const firstLine = firstUser ? previewText(firstUser.message?.content).split("\n")[0] : "(empty)";
156
+ return {
157
+ messageCount: convo.length,
158
+ firstMessage: firstLine.slice(0, 120),
159
+ lastTimestamp: lastEntry?.timestamp ?? null,
160
+ cwd: entries.find((e) => e.cwd)?.cwd ?? null,
161
+ gitBranch: entries.find((e) => e.gitBranch)?.gitBranch ?? null,
162
+ };
163
+ }
164
+
165
+ /** Render a full transcript preview (for `agentbridge show`). */
166
+ export function renderTranscript(entries) {
167
+ const convo = toConversation(entries);
168
+ const lines = [];
169
+ for (const e of convo) {
170
+ const who = e.type === "user" ? "You" : "Claude";
171
+ const blocks = Array.isArray(e.message?.content) ? e.message.content : null;
172
+ if (blocks) {
173
+ for (const b of blocks) {
174
+ if (b.type === "text" && b.text) lines.push(`[${who}] ${b.text}`);
175
+ else if (b.type === "tool_use") lines.push(`[${who}] → tool: ${b.name}(${JSON.stringify(b.input)})`);
176
+ else if (b.type === "tool_result") lines.push(`[tool result] ${previewText(b.content).slice(0, 300)}`);
177
+ else if (b.type === "thinking") lines.push(`[${who} thinking] ${b.thinking}`);
178
+ }
179
+ } else if (typeof e.message?.content === "string" && e.message.content) {
180
+ lines.push(`[${who}] ${e.message.content}`);
181
+ }
182
+ }
183
+ return lines.join("\n");
184
+ }
185
+
186
+
@@ -0,0 +1,270 @@
1
+ // src/readers/opencode-reader.js
2
+ //
3
+ // Reads OpenCode's local session storage via the `opencode` CLI.
4
+ //
5
+ // OpenCode's internal storage format (SQLite files under ~/.config/opencode/
6
+ // etc.) has changed before and may change again. The stable read surface is the
7
+ // `opencode` CLI: `session list --format json` and `export <id>` both return
8
+ // JSON matching the import/export contract this tool already uses.
9
+
10
+ import { execFileSync } from "node:child_process";
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { createRequire } from "node:module";
15
+
16
+ let DatabaseSync;
17
+ function getDatabaseSync() {
18
+ if (DatabaseSync) return DatabaseSync;
19
+ try {
20
+ const require = createRequire(import.meta.url);
21
+ ({ DatabaseSync } = require("node:sqlite"));
22
+ } catch {
23
+ // node:sqlite is only available in Node.js 22.5+. Fall back to CLI export.
24
+ }
25
+ return DatabaseSync;
26
+ }
27
+
28
+ function run(args, maxBytes = 100 * 1024 * 1024) {
29
+ try {
30
+ return execFileSync("opencode", args, {
31
+ stdio: ["ignore", "pipe", "pipe"],
32
+ maxBuffer: maxBytes,
33
+ })
34
+ .toString()
35
+ .trim();
36
+ } catch (err) {
37
+ const stderr = err.stderr?.toString() ?? "";
38
+ const stdout = err.stdout?.toString() ?? "";
39
+ throw new Error(
40
+ `opencode ${args.join(" ")} failed: ${stderr || stdout || err.message}`
41
+ );
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Run an opencode subcommand whose stdout can be large and parse it from a
47
+ * temp file. execFileSync's pipe buffer sometimes truncates multi-hundred-KB
48
+ * JSON; writing straight to a file descriptor avoids that.
49
+ */
50
+ function runToFile(args) {
51
+ const tmpFile = path.join(os.tmpdir(), `agentbridge-${args.join("-")}-${Date.now()}.json`);
52
+ const fd = fs.openSync(tmpFile, "w");
53
+ try {
54
+ execFileSync("opencode", args, {
55
+ stdio: ["ignore", fd, "pipe"],
56
+ });
57
+ } catch (err) {
58
+ const stderr = err.stderr?.toString() ?? "";
59
+ throw new Error(`opencode ${args.join(" ")} failed: ${stderr || err.message}`);
60
+ } finally {
61
+ fs.closeSync(fd);
62
+ }
63
+ try {
64
+ const content = fs.readFileSync(tmpFile, "utf8");
65
+ fs.unlinkSync(tmpFile);
66
+ return content;
67
+ } catch (err) {
68
+ throw new Error(`Failed to read opencode output from ${tmpFile}: ${err.message}`);
69
+ }
70
+ }
71
+
72
+ /** List all OpenCode sessions, newest first (by `updated` time). */
73
+ export function listSessions() {
74
+ const out = run(["session", "list", "--format", "json"]);
75
+ const sessions = out ? JSON.parse(out) : [];
76
+ if (!Array.isArray(sessions)) return [];
77
+ return sessions
78
+ .map((s) => ({
79
+ id: s.id,
80
+ title: s.title ?? "Untitled",
81
+ directory: s.directory,
82
+ created: s.created,
83
+ updated: s.updated,
84
+ session: exportSession(s.id),
85
+ }))
86
+ .sort((a, b) => b.updated - a.updated);
87
+ }
88
+
89
+ /** List OpenCode sessions whose directory matches the given project path. */
90
+ export function listSessionsForDir(dir) {
91
+ const resolved = path.resolve(dir);
92
+ return listSessions().filter((s) => s.directory === resolved);
93
+ }
94
+
95
+ /** Find the most recently updated OpenCode session. */
96
+ export function findLatestSession() {
97
+ const sessions = listSessions();
98
+ return sessions[0] ? { id: sessions[0].id, session: exportSession(sessions[0].id) } : null;
99
+ }
100
+
101
+ function openCodeDbPath() {
102
+ const home = os.homedir();
103
+ if (process.platform === "darwin" || process.platform === "linux") {
104
+ return path.join(home, ".local", "share", "opencode", "opencode.db");
105
+ }
106
+ if (process.platform === "win32") {
107
+ return path.join(process.env.LOCALAPPDATA || home, "opencode", "opencode.db");
108
+ }
109
+ return path.join(home, ".local", "share", "opencode", "opencode.db");
110
+ }
111
+
112
+ /**
113
+ * Read a session straight from OpenCode's SQLite database.
114
+ *
115
+ * This is a fallback / augmentation for `opencode export`. The CLI export has
116
+ * proven unreliable in live situations (while the OpenCode server is running,
117
+ * or when an imported session is continued): it can return empty parts, drop
118
+ * imported messages, or only reflect the server's in-memory state. The SQLite
119
+ * file is the durable source of truth, so reading it directly is more robust.
120
+ *
121
+ * Returns `null` if the database is unavailable, the schema is unexpected, or
122
+ * the session is not found, so callers can fall back to CLI export.
123
+ */
124
+ export function readSessionFromDatabase(sessionId, dbPathOverride) {
125
+ const DatabaseSync = getDatabaseSync();
126
+ if (!DatabaseSync) return null;
127
+ const dbPath = dbPathOverride || openCodeDbPath();
128
+ if (!fs.existsSync(dbPath)) return null;
129
+
130
+ let db;
131
+ try {
132
+ db = new DatabaseSync(dbPath, { readOnly: true });
133
+ } catch {
134
+ return null;
135
+ }
136
+
137
+ try {
138
+ // Message rows are stored as JSON blobs with a shape like:
139
+ // {"role":"user", "time":{"created":...}, "agent":..., "model":...}
140
+ // {"role":"assistant", "parentID":"...", "time":{...}, "tokens":...}
141
+ // The `id` column is the message id, but it is not duplicated inside the
142
+ // JSON blob, so we inject it as `info.id` (and `info.sessionID`, etc.) to
143
+ // match the public export shape.
144
+ const messageStmt = db.prepare(
145
+ "SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created, id"
146
+ );
147
+ const messageRows = messageStmt.all(sessionId);
148
+ if (messageRows.length === 0) return null;
149
+
150
+ const partStmt = db.prepare(
151
+ "SELECT id, message_id, data FROM part WHERE session_id = ? ORDER BY time_created, id"
152
+ );
153
+ const partRows = partStmt.all(sessionId);
154
+
155
+ const partsByMessage = new Map();
156
+ for (const p of partRows) {
157
+ const list = partsByMessage.get(p.message_id) || [];
158
+ const partData = JSON.parse(p.data);
159
+ list.push({ ...partData, id: p.id, sessionID: sessionId, messageID: p.message_id });
160
+ partsByMessage.set(p.message_id, list);
161
+ }
162
+
163
+ const messages = [];
164
+ for (const row of messageRows) {
165
+ const msgData = JSON.parse(row.data);
166
+ const info = {
167
+ id: row.id,
168
+ sessionID: sessionId,
169
+ ...msgData,
170
+ time: msgData.time,
171
+ };
172
+ const parts = partsByMessage.get(row.id) || [];
173
+ messages.push({ info, parts });
174
+ }
175
+
176
+ const sessionStmt = db.prepare(
177
+ "SELECT id, title, directory, version, time_created, time_updated FROM session WHERE id = ?"
178
+ );
179
+ const sessionRow = sessionStmt.get(sessionId);
180
+ if (!sessionRow) return null;
181
+
182
+ const info = {
183
+ id: sessionRow.id,
184
+ title: sessionRow.title,
185
+ directory: sessionRow.directory,
186
+ version: sessionRow.version,
187
+ time: {
188
+ created: sessionRow.time_created,
189
+ updated: sessionRow.time_updated,
190
+ },
191
+ };
192
+
193
+ return { info, messages };
194
+ } catch {
195
+ // Schema or data shape changed; fall back to CLI export.
196
+ return null;
197
+ } finally {
198
+ try {
199
+ db.close();
200
+ } catch {
201
+ /* ignore */
202
+ }
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Export one OpenCode session as JSON (the same shape `opencode import` accepts).
208
+ *
209
+ * We prefer the SQLite database reader because the CLI `export` is lossy when
210
+ * the OpenCode server is running or when an imported session is continued. The
211
+ * CLI export is kept as a fallback for portability / future schema changes.
212
+ */
213
+ export function exportSession(sessionId) {
214
+ const fromDb = readSessionFromDatabase(sessionId);
215
+ if (fromDb) return fromDb;
216
+ const out = runToFile(["export", sessionId]);
217
+ return JSON.parse(out);
218
+ }
219
+
220
+ /** Find a specific OpenCode session by id and export it. */
221
+ export function findSessionById(sessionId) {
222
+ const s = listSessions().find((x) => x.id === sessionId);
223
+ if (s) return { id: s.id, session: exportSession(s.id) };
224
+ // OpenCode's session list can lag behind imports; try exporting directly.
225
+ try {
226
+ return { id: sessionId, session: exportSession(sessionId) };
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+
232
+ /** Best-effort plain-text preview of a single OpenCode part. */
233
+ function previewText(part) {
234
+ if (part.type === "text" && part.text) return part.text;
235
+ if (part.type === "reasoning" && part.text) return part.text;
236
+ if (part.type === "tool") return `[used tool: ${part.tool}]`;
237
+ return "";
238
+ }
239
+
240
+ /**
241
+ * Build a short, human-readable summary of an OpenCode session for browsing:
242
+ * first user message, how many messages, when it was last touched.
243
+ */
244
+ export function summarizeSession(session) {
245
+ const messages = session.messages ?? [];
246
+ const firstUser = messages.find((m) => m.info?.role === "user");
247
+ const firstPart = firstUser?.parts?.find((p) => previewText(p));
248
+ const firstLine = firstPart ? previewText(firstPart).split("\n")[0] : "(empty)";
249
+ return {
250
+ messageCount: messages.length,
251
+ firstMessage: firstLine.slice(0, 120),
252
+ lastTimestamp: session.info?.time?.updated ? new Date(session.info.time.updated).toISOString() : null,
253
+ cwd: session.info?.directory ?? null,
254
+ };
255
+ }
256
+
257
+ /** Render a full transcript preview (for `agentbridge show`). */
258
+ export function renderTranscript(session) {
259
+ const lines = [];
260
+ for (const m of session.messages ?? []) {
261
+ const who = m.info?.role === "user" ? "You" : "OpenCode";
262
+ for (const p of m.parts ?? []) {
263
+ if (p.type === "text" && p.text) lines.push(`[${who}] ${p.text}`);
264
+ else if (p.type === "reasoning" && p.text) lines.push(`[${who} thinking] ${p.text}`);
265
+ else if (p.type === "tool") lines.push(`[${who}] → tool: ${p.tool}(${JSON.stringify(p.state?.input ?? {})})`);
266
+ else if (p.type === "tool" && p.state?.output) lines.push(`[tool result] ${String(p.state.output).slice(0, 300)}`);
267
+ }
268
+ }
269
+ return lines.join("\n");
270
+ }