@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.
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/bin/agentbridge.js +424 -0
- package/package.json +45 -0
- package/src/converters/claude-to-opencode.js +283 -0
- package/src/converters/opencode-to-claude.js +170 -0
- package/src/ledger/git-ledger.js +220 -0
- package/src/readers/claude-reader.js +186 -0
- package/src/readers/opencode-reader.js +270 -0
- package/src/sync/sync.js +337 -0
- package/src/sync/watch.js +136 -0
- package/src/writers/claude-writer.js +40 -0
- package/src/writers/opencode-import.js +27 -0
|
@@ -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
|
+
}
|