Files
mem0/integrations/opencode-plugin/handoff.ts
T

95 lines
6.7 KiB
TypeScript

import {tool, type PluginInput} from "@opencode-ai/plugin";
import type {SessionMessagesResponse} from "@opencode-ai/sdk";
import {readFile} from "node:fs/promises";
import {buildHandoffBundle, runHandoff} from "../agent-plugin-core/typescript/src/handoff.ts";
type NativeMessage = SessionMessagesResponse[number];
/** OpenCode's native filterCompacted order: latest summary, retained tail, later turns. */
export function activeMessages(messages: NativeMessage[]): NativeMessage[] {
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info;
if (info.role !== "assistant" || !info.summary || !info.finish || info.error) continue;
const boundary = messages.findIndex(m => m.info.id === info.parentID && m.parts.some(p => p.type === "compaction"));
if (boundary < 0) throw new Error("OpenCode compaction boundary is unavailable.");
const part = messages[boundary].parts.find(p => p.type === "compaction") as {tail_start_id?: string};
if (!part.tail_start_id) return messages.slice(boundary);
const tail = messages.findIndex(m => m.info.id === part.tail_start_id);
if (tail < 0 || tail >= boundary) throw new Error("OpenCode retained compaction history is unavailable.");
return [...messages.slice(boundary, i + 1), ...messages.slice(tail, boundary), ...messages.slice(i + 1)];
}
return messages;
}
export function createHandoffTool(client: PluginInput["client"]) {
return tool({
description: "Only on explicit user request, continue this OpenCode session in a new Codex task with its full active conversation and completed tool results. Requires Python 3.11+ and an installed, signed-in Codex CLI.",
args: {},
async execute(_args, context) {
const [session, history] = await Promise.all([
client.session.get({path: {id: context.sessionID}, throwOnError: true}),
client.session.messages({path: {id: context.sessionID}, throwOnError: true}),
]);
if (!session.data || !history.data) throw new Error("The active OpenCode session is unavailable.");
const messages: {role: string; content: unknown}[] = [];
let omittedInvocation = false;
let pruned = false;
for (const message of activeMessages(history.data)) {
if (message.info.role === "assistant" && message.info.error) {
throw new Error("OpenCode assistant response was interrupted or failed; complete it before handoff.");
}
if (message.info.role === "assistant" && message.info.id !== context.messageID && !message.info.time.completed) {
throw new Error("OpenCode has another unfinished assistant response; finish it before handoff.");
}
for (const part of message.parts) {
if (part.type === "text") {
if (!part.ignored) messages.push({role: message.info.role, content: part.text});
} else if (part.type === "reasoning") {
messages.push({role: message.info.role, content: [{type: "reasoning", text: part.text}]});
} else if (part.type === "tool") {
if (message.info.id === context.messageID && part.tool === "mem0_handoff" && part.state.status === "running") {
if (omittedInvocation) throw new Error("Multiple active handoff calls; invoke one handoff at a time.");
omittedInvocation = true;
continue;
}
if (part.state.status !== "completed" && part.state.status !== "error") throw new Error(`OpenCode tool ${part.tool} is unfinished; finish it before handoff.`);
messages.push({role: "assistant", content: [{type: "toolCall", id: part.callID, name: part.tool, arguments: part.state.input}]});
const compacted = part.state.status === "completed" && Boolean(part.state.time.compacted);
pruned ||= compacted;
const interruptedOutput = part.state.status === "error" && part.state.metadata?.interrupted === true && typeof part.state.metadata.output === "string" ? part.state.metadata.output : undefined;
const content: unknown[] = [{type: "text", text: part.state.status === "error" ? interruptedOutput ?? part.state.error : compacted ? "[Old tool result content cleared]" : part.state.output}];
if (part.state.status === "completed" && !compacted) for (const attachment of part.state.attachments ?? []) content.push(await fileContent(attachment));
messages.push({role: "toolResult", content, ...{toolCallId: part.callID, isError: part.state.status === "error" && interruptedOutput === undefined}});
} else if (part.type === "file") {
// OpenCode expands these descriptors into adjacent text parts before saving.
if (message.info.role !== "user" || !["text/plain", "application/x-directory"].includes(part.mime)) {
messages.push({role: message.info.role, content: [await fileContent(part)]});
}
} else if (part.type === "compaction") {
messages.push({role: "user", content: "What did we do so far?"});
} else if (part.type === "subtask") {
messages.push({role: "user", content: "The following tool was executed by the user"});
} else if (!["step-start", "step-finish", "snapshot", "patch", "agent", "retry"].includes(part.type)) {
throw new Error(`Unsupported OpenCode session part: ${part.type}`);
}
}
}
const bundle = await buildHandoffBundle({host: "opencode", session_id: context.sessionID, title: session.data.title, cwd: session.data.directory}, messages);
if (pruned) bundle.warnings.push("OpenCode already cleared older tool outputs; its active-context placeholders were preserved.");
return runHandoff(new URL("./session_handoff.py", import.meta.url), bundle);
},
});
}
async function fileContent(file: {mime: string; url: string}) {
if (!file.mime.startsWith("image/")) throw new Error(`Unsupported OpenCode attachment type: ${file.mime}`);
const url = file.url.startsWith("data:") ? file.url : file.url.startsWith("file:") ? `data:${file.mime};base64,${(await readFile(new URL(file.url))).toString("base64")}` : undefined;
if (!url) throw new Error("OpenCode attachment bytes are unavailable locally.");
return {type: "image", image_url: url};
}
export function registerHandoffCommand(config: {command?: Record<string, {template: string; description?: string}>}) {
config.command ??= {};
config.command["mem0-handoff"] = {
description: "Continue this OpenCode session in Codex",
template: `Transfer this OpenCode session to Codex using mem0_handoff. Usage: /mem0-handoff codex\nArguments: $ARGUMENTS\nRequire the single target codex. If invalid, show usage and do not invoke the tool. Do not summarize or read other sessions. Call mem0_handoff directly, alone, and report its result.`,
};
}