@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
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
|
+
}
|