@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/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@neerajvipparla/agentbridge",
3
+ "version": "0.1.0",
4
+ "description": "Fork Claude Code chat sessions into OpenCode and back, versioned as local git commits. A bidirectional Claude Code <-> OpenCode context bridge.",
5
+ "type": "module",
6
+ "bin": {
7
+ "agentbridge": "bin/agentbridge.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "scripts": {
22
+ "test": "node --test tests/*.test.js"
23
+ },
24
+ "dependencies": {
25
+ "commander": "^12.1.0"
26
+ },
27
+ "keywords": [
28
+ "claude-code",
29
+ "opencode",
30
+ "cli",
31
+ "ai-agent",
32
+ "context-bridge",
33
+ "session-sync"
34
+ ],
35
+ "author": "neerajvipparla",
36
+ "license": "MIT",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/neerajvipparla/Agentbridge.git"
40
+ },
41
+ "homepage": "https://github.com/neerajvipparla/Agentbridge#readme",
42
+ "bugs": {
43
+ "url": "https://github.com/neerajvipparla/Agentbridge/issues"
44
+ }
45
+ }
@@ -0,0 +1,283 @@
1
+ // src/converters/claude-to-opencode.js
2
+ //
3
+ // Converts a Claude Code conversation (see readers/claude-reader.js) into the JSON
4
+ // shape that `opencode import` accepts:
5
+ //
6
+ // {
7
+ // "info": Session,
8
+ // "messages": [ { "info": Message, "parts": Part[] }, ... ]
9
+ // }
10
+ //
11
+ // This shape was NOT taken from public docs - it was reverse-verified by
12
+ // installing opencode@1.18.5 locally, round-tripping synthetic sessions
13
+ // through `opencode import` / `opencode export`, and reading opencode's own
14
+ // generated SDK types (@opencode-ai/sdk/dist/gen/types.gen.d.ts) for the
15
+ // per-field shapes of Session, UserMessage, AssistantMessage and Part.
16
+ // If opencode changes this shape in a future version, re-run that check
17
+ // (see README) before trusting this file again.
18
+
19
+ import crypto from "node:crypto";
20
+
21
+ // IDs (and the session's created/updated timestamps) are derived
22
+ // deterministically from Claude Code's own uuids/timestamps rather than
23
+ // randomly generated or read from the wall clock. That makes
24
+ // `agentbridge fork` idempotent: forking the *same*, *unchanged* Claude
25
+ // session twice produces byte-identical output, so the git ledger records
26
+ // "nothing changed" instead of a spurious new commit, and `opencode import`
27
+ // updates the same OpenCode session in place instead of creating a
28
+ // duplicate every time you re-run it.
29
+ function deriveId(prefix, ...parts) {
30
+ const hash = crypto.createHash("sha256").update(parts.join(":")).digest("hex");
31
+ return `${prefix}_${hash.slice(0, 24)}`;
32
+ }
33
+
34
+ function toEpochMs(isoTimestamp, fallback) {
35
+ const t = isoTimestamp ? Date.parse(isoTimestamp) : NaN;
36
+ return Number.isFinite(t) ? t : fallback ?? Date.now();
37
+ }
38
+
39
+ /** Normalize Claude's message.content into an array of content blocks. */
40
+ function contentBlocks(message) {
41
+ if (!message) return [];
42
+ if (typeof message.content === "string") {
43
+ return message.content.length ? [{ type: "text", text: message.content }] : [];
44
+ }
45
+ return Array.isArray(message.content) ? message.content : [];
46
+ }
47
+
48
+ /**
49
+ * Extract a plain-text rendering of a tool_result content block's payload,
50
+ * since OpenCode's ToolPart wants a string `output`.
51
+ */
52
+ function stringifyToolResult(content) {
53
+ if (content == null) return "";
54
+ if (typeof content === "string") return content;
55
+ if (Array.isArray(content)) {
56
+ return content
57
+ .map((c) => (typeof c === "string" ? c : c?.text ?? JSON.stringify(c)))
58
+ .join("\n");
59
+ }
60
+ return JSON.stringify(content);
61
+ }
62
+
63
+ /**
64
+ * Convert a linear Claude Code conversation (already filtered to
65
+ * user/assistant, non-sidechain, non-meta entries) into OpenCode's
66
+ * import JSON.
67
+ *
68
+ * @param {object[]} entries - from claude-reader.toConversation()
69
+ * @param {object} opts
70
+ * @param {string} opts.directory - project directory to attach the session to
71
+ * @param {string} opts.title - session title
72
+ * @param {string} [opts.providerID] - OpenCode provider id to attribute assistant turns to (default "anthropic")
73
+ * @param {string} [opts.agent] - OpenCode agent name (default "build")
74
+ */
75
+ export function convertToOpenCode(entries, opts) {
76
+ const { directory, title, providerID = "anthropic", agent = "build", opencodeSessionId } = opts;
77
+
78
+ const claudeSessionId = entries.find((e) => e.sessionId)?.sessionId ?? "unknown-session";
79
+ // During sync, the caller can override with the existing OpenCode session id
80
+ // so the new turns are written into the same OpenCode session.
81
+ const sessionId = opencodeSessionId || deriveId("ses", claudeSessionId);
82
+
83
+ const timestamps = entries.map((e) => toEpochMs(e.timestamp)).filter(Number.isFinite);
84
+ const created = timestamps.length ? Math.min(...timestamps) : Date.now();
85
+ const updated = timestamps.length ? Math.max(...timestamps) : created;
86
+
87
+ // FIFO queue of "tool_use id awaiting a result" - used as a fallback when
88
+ // a tool result isn't explicitly tagged with tool_use_id (older/plainer
89
+ // toolUseResult entries).
90
+ const pendingToolUseIds = [];
91
+ const toolResultById = new Map(); // tool_use_id -> { output, isError }
92
+
93
+ // First pass: harvest every tool_result we can find, keyed by tool_use_id
94
+ // when possible, else paired positionally (FIFO) with the earliest tool_use
95
+ // still awaiting a result. We must walk assistant entries here too, so that
96
+ // a tool_use's id lands in the pending queue *before* we reach the later
97
+ // user entry that carries an untagged result for it. (An earlier version
98
+ // only populated the queue in the second pass below, so this fallback never
99
+ // fired and untagged results were silently dropped.)
100
+ for (const entry of entries) {
101
+ const blocks = contentBlocks(entry.message);
102
+
103
+ if (entry.type === "assistant") {
104
+ for (const block of blocks) {
105
+ if (block.type === "tool_use") pendingToolUseIds.push(block.id);
106
+ }
107
+ continue;
108
+ }
109
+ if (entry.type !== "user") continue;
110
+
111
+ for (const block of blocks) {
112
+ if (block.type === "tool_result") {
113
+ toolResultById.set(block.tool_use_id, {
114
+ output: stringifyToolResult(block.content),
115
+ isError: Boolean(block.is_error),
116
+ });
117
+ }
118
+ }
119
+ // Some Claude Code versions attach the structured result directly to the
120
+ // entry instead of (or in addition to) a tagged content block. Pair it
121
+ // with the oldest tool_use that doesn't already have a (tagged) result.
122
+ // A tagged result always wins, since it is set unconditionally above.
123
+ if (entry.toolUseResult !== undefined) {
124
+ let pendingId;
125
+ do {
126
+ pendingId = pendingToolUseIds.shift();
127
+ } while (pendingId !== undefined && toolResultById.has(pendingId));
128
+ if (pendingId !== undefined && !toolResultById.has(pendingId)) {
129
+ toolResultById.set(pendingId, {
130
+ output: stringifyToolResult(entry.toolUseResult),
131
+ isError: typeof entry.toolUseResult === "string" && /^error/i.test(entry.toolUseResult),
132
+ });
133
+ }
134
+ }
135
+ }
136
+
137
+ const messages = [];
138
+ let previousId = null;
139
+
140
+ for (const entry of entries) {
141
+ const role = entry.type; // "user" | "assistant"
142
+ const blocks = contentBlocks(entry.message);
143
+ const entryKey = entry.uuid ?? `${role}-${entry.timestamp}`;
144
+ const msgId = deriveId("msg", sessionId, entryKey);
145
+ const createdMs = toEpochMs(entry.timestamp, created);
146
+
147
+ const parts = [];
148
+ let partIndex = 0;
149
+ const nextPartId = () => deriveId("prt", msgId, String(partIndex++));
150
+
151
+ for (const block of blocks) {
152
+ if (block.type === "text" && block.text) {
153
+ parts.push({
154
+ id: nextPartId(),
155
+ sessionID: sessionId,
156
+ messageID: msgId,
157
+ type: "text",
158
+ text: block.text,
159
+ });
160
+ } else if (block.type === "thinking" && block.thinking) {
161
+ parts.push({
162
+ id: nextPartId(),
163
+ sessionID: sessionId,
164
+ messageID: msgId,
165
+ type: "reasoning",
166
+ text: block.thinking,
167
+ time: { start: createdMs },
168
+ });
169
+ } else if (block.type === "tool_use") {
170
+ const result = toolResultById.get(block.id);
171
+ parts.push({
172
+ id: nextPartId(),
173
+ sessionID: sessionId,
174
+ messageID: msgId,
175
+ type: "tool",
176
+ callID: block.id,
177
+ tool: block.name,
178
+ state: result
179
+ ? {
180
+ status: result.isError ? "error" : "completed",
181
+ input: block.input ?? {},
182
+ ...(result.isError
183
+ ? { error: result.output }
184
+ : { output: result.output, title: block.name, metadata: {} }),
185
+ time: { start: createdMs, end: createdMs },
186
+ }
187
+ : {
188
+ // No matching result found in the transcript (e.g. the tool
189
+ // call never finished). Mark it pending rather than invent one.
190
+ status: "pending",
191
+ input: block.input ?? {},
192
+ raw: JSON.stringify(block.input ?? {}),
193
+ },
194
+ });
195
+ }
196
+ // tool_result blocks are consumed above, not rendered as their own part
197
+ // (OpenCode folds a tool's result into the ToolPart's `state`, it
198
+ // doesn't have a separate "tool result" part type).
199
+ }
200
+
201
+ // Skip any turn that produced no renderable parts - emitting one shows up
202
+ // as a blank bubble in OpenCode's UI. This covers several real cases:
203
+ // - Claude's synthetic "here's your tool result" user turn (the result
204
+ // is folded into the ToolPart's state above, not a separate message);
205
+ // - assistant turns whose only content is a thinking block with its text
206
+ // stripped to "" (encrypted/redacted reasoning kept only as a
207
+ // `signature`) - real sessions contain these in bulk;
208
+ // - otherwise-empty user/assistant turns.
209
+ // We deliberately do NOT advance previousId when skipping, so the next
210
+ // assistant's parentID chains to the last message we actually emitted
211
+ // rather than to a message id that never appears in `messages`.
212
+ if (parts.length === 0) {
213
+ continue;
214
+ }
215
+
216
+ if (role === "user") {
217
+ messages.push({
218
+ info: {
219
+ id: msgId,
220
+ sessionID: sessionId,
221
+ role: "user",
222
+ time: { created: createdMs },
223
+ agent,
224
+ model: {
225
+ providerID,
226
+ modelID: entry.message?.model ?? "unknown",
227
+ },
228
+ },
229
+ parts,
230
+ });
231
+ } else {
232
+ const usage = entry.message?.usage ?? {};
233
+ messages.push({
234
+ info: {
235
+ id: msgId,
236
+ sessionID: sessionId,
237
+ role: "assistant",
238
+ time: { created: createdMs, completed: createdMs },
239
+ parentID: previousId ?? sessionId,
240
+ modelID: entry.message?.model ?? "unknown",
241
+ providerID,
242
+ mode: agent,
243
+ agent,
244
+ path: { cwd: directory, root: directory },
245
+ cost: 0,
246
+ tokens: {
247
+ input: usage.input_tokens ?? 0,
248
+ output: usage.output_tokens ?? 0,
249
+ reasoning: 0,
250
+ cache: {
251
+ read: usage.cache_read_input_tokens ?? 0,
252
+ write: usage.cache_creation_input_tokens ?? 0,
253
+ },
254
+ },
255
+ },
256
+ parts,
257
+ });
258
+ }
259
+
260
+ previousId = msgId;
261
+ }
262
+
263
+ const info = {
264
+ id: sessionId,
265
+ slug: slugify(title),
266
+ title,
267
+ version: "imported-from-claude-code",
268
+ directory,
269
+ time: { created, updated },
270
+ };
271
+
272
+ return { info, messages };
273
+ }
274
+
275
+ function slugify(title) {
276
+ return (
277
+ title
278
+ .toLowerCase()
279
+ .replace(/[^a-z0-9]+/g, "-")
280
+ .replace(/^-+|-+$/g, "")
281
+ .slice(0, 60) || "session"
282
+ );
283
+ }
@@ -0,0 +1,170 @@
1
+ // src/converters/opencode-to-claude.js
2
+ //
3
+ // Converts an OpenCode session (the `opencode export` JSON shape, which is the
4
+ // same shape `opencode import` accepts) into a Claude Code JSONL transcript.
5
+ //
6
+ // This is the reverse of `src/converters/claude-to-opencode.js`. Where the forward
7
+ // converter folds a tool_use + tool_result pair into a single OpenCode `tool`
8
+ // part, this module expands each `tool` part back into a Claude assistant
9
+ // `tool_use` block plus a synthetic user entry carrying the matching
10
+ // `tool_result` block (and a `toolUseResult` fallback for older Claude Code readers).
11
+
12
+ import crypto from "node:crypto";
13
+
14
+ // Deterministic IDs are what make reverse-forking idempotent: importing the
15
+ // same OpenCode session twice produces the same Claude session id, the same
16
+ // entry uuids, and byte-identical JSONL, so the git ledger sees no diff.
17
+ function deriveUuid(...parts) {
18
+ const hash = crypto.createHash("sha256").update(parts.join(":")).digest("hex");
19
+ return [
20
+ hash.slice(0, 8),
21
+ hash.slice(8, 12),
22
+ hash.slice(12, 16),
23
+ hash.slice(16, 20),
24
+ hash.slice(20, 32),
25
+ ].join("-");
26
+ }
27
+
28
+ function toIso(ms) {
29
+ return new Date(ms).toISOString();
30
+ }
31
+
32
+ /**
33
+ * Convert an OpenCode export JSON object into an array of Claude Code JSONL
34
+ * entries.
35
+ *
36
+ * @param {object} session - `opencode export <id>` output
37
+ * @param {object} opts
38
+ * @param {string} [opts.directory] - cwd to stamp on each entry (defaults to the session's own directory)
39
+ * @param {string} [opts.model] - Claude model string to use when OpenCode didn't record one
40
+ * @param {string} [opts.version] - version marker written into each entry
41
+ */
42
+ export function convertToClaude(session, opts = {}) {
43
+ const {
44
+ directory,
45
+ model = "claude-opus-4-8",
46
+ version = "imported-from-opencode",
47
+ sessionId: sessionIdOverride,
48
+ } = opts;
49
+
50
+ const opencodeSessionId = session?.info?.id;
51
+ if (!opencodeSessionId) {
52
+ throw new Error("OpenCode session is missing info.id");
53
+ }
54
+
55
+ // Generate a fresh, deterministic Claude session UUID rather than reusing the
56
+ // OpenCode `ses_...` id (the formats are intentionally different). Re-importing
57
+ // the same OpenCode session produces the same Claude id, so this stays
58
+ // idempotent.
59
+ // During sync, the caller can override with the existing Claude session id so
60
+ // the new turns are written into the same Claude transcript.
61
+ const sessionId = sessionIdOverride || deriveUuid(opencodeSessionId);
62
+ const cwd = directory || session.info?.directory || process.cwd();
63
+
64
+ const entries = [];
65
+ let previousUuid = null;
66
+
67
+ for (const msg of session.messages ?? []) {
68
+ const info = msg.info;
69
+ if (!info || !info.id || !info.role) {
70
+ // Defensive: skip malformed message rows rather than crash the whole fork.
71
+ continue;
72
+ }
73
+
74
+ const msgUuid = deriveUuid(opencodeSessionId, info.id);
75
+ const timestamp = toIso(info.time?.completed ?? info.time?.created ?? Date.now());
76
+
77
+ const contentBlocks = [];
78
+ const toolUses = [];
79
+
80
+ for (const part of msg.parts ?? []) {
81
+ if (part.type === "text" && part.text) {
82
+ contentBlocks.push({ type: "text", text: part.text });
83
+ } else if (part.type === "reasoning" && part.text) {
84
+ contentBlocks.push({ type: "thinking", thinking: part.text });
85
+ } else if (part.type === "tool") {
86
+ // Preserve the original call id if OpenCode has it; fall back to a
87
+ // deterministic id so the matching tool_result below is still paired.
88
+ const callId = part.callID || deriveUuid(opencodeSessionId, info.id, part.id || "tool", "call");
89
+ contentBlocks.push({
90
+ type: "tool_use",
91
+ id: callId,
92
+ name: part.tool,
93
+ input: part.state?.input ?? {},
94
+ });
95
+ toolUses.push({ part, resolvedCallId: callId });
96
+ }
97
+ }
98
+
99
+ const entry = {
100
+ type: info.role,
101
+ uuid: msgUuid,
102
+ parentUuid: previousUuid,
103
+ sessionId,
104
+ timestamp,
105
+ isSidechain: false,
106
+ isMeta: false,
107
+ cwd,
108
+ version,
109
+ message: {
110
+ role: info.role,
111
+ content: contentBlocks.length ? contentBlocks : "",
112
+ },
113
+ };
114
+
115
+ if (info.role === "assistant") {
116
+ entry.message.model = info.modelID || model;
117
+ entry.message.usage = {
118
+ input_tokens: info.tokens?.input ?? 0,
119
+ output_tokens: info.tokens?.output ?? 0,
120
+ };
121
+ }
122
+
123
+ entries.push(entry);
124
+ previousUuid = msgUuid;
125
+
126
+ // Claude Code represents a finished tool call as a separate user entry
127
+ // containing one or more tool_result blocks. Recreate that here.
128
+ if (info.role === "assistant" && toolUses.length > 0) {
129
+ const resultUuid = deriveUuid(opencodeSessionId, info.id, "tool-result");
130
+ const resultTimestamp = toIso(info.time?.completed ?? info.time?.created ?? Date.now());
131
+ const resultBlocks = toolUses.map((tu) => {
132
+ const status = tu.part.state?.status;
133
+ return {
134
+ type: "tool_result",
135
+ tool_use_id: tu.resolvedCallId,
136
+ content: status === "error" ? (tu.part.state?.error || "error") : (tu.part.state?.output ?? ""),
137
+ is_error: status === "error",
138
+ };
139
+ });
140
+
141
+ const resultEntry = {
142
+ type: "user",
143
+ uuid: resultUuid,
144
+ parentUuid: previousUuid,
145
+ sessionId,
146
+ timestamp: resultTimestamp,
147
+ isSidechain: false,
148
+ isMeta: false,
149
+ cwd,
150
+ version,
151
+ message: {
152
+ role: "user",
153
+ content: resultBlocks,
154
+ },
155
+ };
156
+
157
+ // For a single tool result, also attach the raw fallback OpenCode's own
158
+ // forward converter sometimes relies on.
159
+ if (toolUses.length === 1) {
160
+ const st = toolUses[0].part.state;
161
+ resultEntry.toolUseResult = st?.status === "error" ? (st?.error || "error") : (st?.output ?? "");
162
+ }
163
+
164
+ entries.push(resultEntry);
165
+ previousUuid = resultUuid;
166
+ }
167
+ }
168
+
169
+ return entries;
170
+ }