95 lines
6.7 KiB
TypeScript
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.`,
|
|
};
|
|
}
|