Skip to content
14 changes: 7 additions & 7 deletions cmd/entire/cli/agent/geminicli/transcript.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,19 +266,19 @@ func GetLastMessageIDFromFile(path string) (string, error) {
// startMessageIndex. This is the Gemini equivalent of transcript.SliceFromLine —
// for Gemini's single JSON blob, scoping is done by message index rather than line offset.
// Returns the original data if startMessageIndex <= 0.
// Returns nil if startMessageIndex exceeds the number of messages.
func SliceFromMessage(data []byte, startMessageIndex int) []byte {
// Returns nil, nil if startMessageIndex exceeds the number of messages.
func SliceFromMessage(data []byte, startMessageIndex int) ([]byte, error) {
if len(data) == 0 || startMessageIndex <= 0 {
return data
return data, nil
}

t, err := ParseTranscript(data)
if err != nil {
return nil
return nil, fmt.Errorf("failed to parse transcript for slicing: %w", err)
}

if startMessageIndex >= len(t.Messages) {
return nil
return nil, nil
}

scoped := &GeminiTranscript{
Expand All @@ -287,9 +287,9 @@ func SliceFromMessage(data []byte, startMessageIndex int) []byte {

out, err := json.Marshal(scoped)
if err != nil {
return nil
return nil, fmt.Errorf("failed to marshal scoped transcript: %w", err)
}
return out
return out, nil
}

// CalculateTokenUsage calculates token usage from a Gemini transcript.
Expand Down
27 changes: 14 additions & 13 deletions cmd/entire/cli/agent/opencode/cli_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,36 @@ package opencode

import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
)

// openCodeCommandTimeout is the maximum time to wait for opencode CLI commands.
const openCodeCommandTimeout = 30 * time.Second

// runOpenCodeSessionDelete runs `opencode session delete <sessionID>` to remove
// a session from OpenCode's database. Treats "Session not found" as success
// (nothing to delete).
func runOpenCodeSessionDelete(sessionID string) error {
// runOpenCodeExport runs `opencode export <sessionID>` to export a session
// from OpenCode's database. Returns the JSON export data as bytes.
func runOpenCodeExport(sessionID string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), openCodeCommandTimeout)
defer cancel()

cmd := exec.CommandContext(ctx, "opencode", "session", "delete", sessionID)
output, err := cmd.CombinedOutput()
cmd := exec.CommandContext(ctx, "opencode", "export", sessionID)
output, err := cmd.Output()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("opencode session delete timed out after %s", openCodeCommandTimeout)
return nil, fmt.Errorf("opencode export timed out after %s", openCodeCommandTimeout)
}
// Treat "Session not found" as success — nothing to delete.
if strings.Contains(string(output), "Session not found") {
return nil
// Get stderr for better error message
exitErr := &exec.ExitError{}
if errors.As(err, &exitErr) {
return nil, fmt.Errorf("opencode export failed: %w (stderr: %s)", err, string(exitErr.Stderr))
}
return fmt.Errorf("opencode session delete failed: %w (output: %s)", err, string(output))
return nil, fmt.Errorf("opencode export failed: %w", err)
}
return nil

return output, nil
}

// runOpenCodeImport runs `opencode import <file>` to import a session into
Expand Down
169 changes: 11 additions & 158 deletions cmd/entire/cli/agent/opencode/entire_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,15 @@
// Do not edit manually — changes will be overwritten on next install.
// Requires Bun runtime (used by OpenCode's plugin system for loading ESM plugins).
import type { Plugin } from "@opencode-ai/plugin"
import { tmpdir } from "node:os"

export const EntirePlugin: Plugin = async ({ client, directory, $ }) => {
export const EntirePlugin: Plugin = async ({ $, directory }) => {
const ENTIRE_CMD = "__ENTIRE_CMD__"
// Store transcripts in a temp directory — these are ephemeral handoff files
// between the plugin and the Go hook handler. Once checkpointed, the data
// lives on git refs and the file is disposable.
const sanitized = directory.replace(/[^a-zA-Z0-9]/g, "-")
const transcriptDir = `${tmpdir()}/entire-opencode/${sanitized}`
// Track seen user messages to fire turn-start only once per message
const seenUserMessages = new Set<string>()

// In-memory stores — used to write transcripts without relying on the SDK API,
// which may be unavailable during shutdown.
// messageStore: keyed by message ID, stores message metadata (role, time, tokens, etc.)
const messageStore = new Map<string, any>()
// partStore: keyed by message ID, stores accumulated parts from message.part.updated events
const partStore = new Map<string, any[]>()
// Track current session ID for message events (which don't include sessionID)
let currentSessionID: string | null = null
// Full session info from session.created — needed for OpenCode export format on resume/rewind
let currentSessionInfo: any = null

// Ensure transcript directory exists
await $`mkdir -p ${transcriptDir}`.quiet().nothrow()
// In-memory store for message metadata (role, tokens, etc.)
const messageStore = new Map<string, any>()

/**
* Pipe JSON payload to an entire hooks command.
Expand All @@ -40,129 +26,20 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => {
}
}

/** Extract text content from a list of parts. */
function textFromParts(parts: any[]): string {
return parts
.filter((p: any) => p.type === "text")
.map((p: any) => p.text ?? "")
.join("\n")
}

/** Format a message object from its accumulated parts. */
function formatMessageFromStore(msg: any) {
const parts = partStore.get(msg.id) ?? []
return {
id: msg.id,
role: msg.role,
content: textFromParts(parts),
time: msg.time,
...(msg.role === "assistant" ? {
tokens: msg.tokens,
cost: msg.cost,
parts: parts.map((p: any) => ({
type: p.type,
...(p.type === "text" ? { text: p.text } : {}),
...(p.type === "tool" ? { tool: p.tool, callID: p.callID, state: p.state } : {}),
})),
} : {}),
}
}

/** Format a message from an API response (which includes parts inline). */
function formatMessageFromAPI(info: any, parts: any[]) {
return {
id: info.id,
role: info.role,
content: textFromParts(parts),
time: info.time,
...(info.role === "assistant" ? {
tokens: info.tokens,
cost: info.cost,
parts: parts.map((p: any) => ({
type: p.type,
...(p.type === "text" ? { text: p.text } : {}),
...(p.type === "tool" ? { tool: p.tool, callID: p.callID, state: p.state } : {}),
})),
} : {}),
}
}

/**
* Write transcript as JSONL (one message per line) from in-memory stores.
* This does NOT call the SDK API, so it works even during shutdown.
*/
async function writeTranscriptFromMemory(sessionID: string): Promise<string> {
const transcriptPath = `${transcriptDir}/${sessionID}.jsonl`
try {
const messages = Array.from(messageStore.values())
.sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0))

const lines = messages.map(msg => JSON.stringify(formatMessageFromStore(msg)))
await Bun.write(transcriptPath, lines.join("\n") + "\n")
} catch {
// Silently ignore write failures
}
return transcriptPath
}

/**
* Try to fetch messages via the SDK API (returns messages with parts inline)
* and write transcript as JSONL. Falls back to in-memory stores if the API is unavailable.
*/
async function writeTranscriptWithFallback(sessionID: string): Promise<string> {
const transcriptPath = `${transcriptDir}/${sessionID}.jsonl`
try {
const response = await client.session.message.list({ path: { id: sessionID } })
// API returns Array<{ info: Message, parts: Array<Part> }>
const items = response.data ?? []

const lines = items.map((item: any) =>
JSON.stringify(formatMessageFromAPI(item.info, item.parts ?? []))
)
await Bun.write(transcriptPath, lines.join("\n") + "\n")
return transcriptPath
} catch {
// API unavailable (likely shutting down) — fall back to in-memory stores
return writeTranscriptFromMemory(sessionID)
}
}

/**
* Write session in OpenCode's native export format (JSON).
* This file is used by `opencode import` during resume/rewind to restore
* the session into OpenCode's SQLite database with the original session ID.
*/
async function writeExportJSON(sessionID: string): Promise<string> {
const exportPath = `${transcriptDir}/${sessionID}.export.json`
try {
const messages = Array.from(messageStore.values())
.sort((a, b) => (a.time?.created ?? 0) - (b.time?.created ?? 0))

const exportData = {
info: currentSessionInfo ?? { id: sessionID },
messages: messages.map(msg => ({
info: msg,
parts: (partStore.get(msg.id) ?? []),
})),
}
await Bun.write(exportPath, JSON.stringify(exportData))
} catch {
// Silently ignore — plugin failures must not crash OpenCode
}
return exportPath
}

return {
event: async ({ event }) => {
switch (event.type) {
case "session.created": {
const session = (event as any).properties?.info
if (!session?.id) break
// Reset per-session tracking state when switching sessions.
if (currentSessionID !== session.id) {
seenUserMessages.clear()
messageStore.clear()
}
currentSessionID = session.id
currentSessionInfo = session
await callHook("session-start", {
session_id: session.id,
transcript_path: `${transcriptDir}/${session.id}.jsonl`,
})
break
}
Expand All @@ -171,7 +48,6 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => {
const msg = (event as any).properties?.info
if (!msg) break
// Store message metadata (role, time, tokens, etc.)
// Content is NOT on the message — it arrives via message.part.updated events.
messageStore.set(msg.id, msg)
break
}
Expand All @@ -180,17 +56,6 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => {
const part = (event as any).properties?.part
if (!part?.messageID) break

// Accumulate parts per message
const existing = partStore.get(part.messageID) ?? []
// Replace existing part with same id, or append new one
const idx = existing.findIndex((p: any) => p.id === part.id)
if (idx >= 0) {
existing[idx] = part
} else {
existing.push(part)
}
partStore.set(part.messageID, existing)

// Fire turn-start on the first text part of a new user message
const msg = messageStore.get(part.messageID)
if (msg?.role === "user" && part.type === "text" && !seenUserMessages.has(msg.id)) {
Expand All @@ -199,7 +64,6 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => {
if (sessionID) {
await callHook("turn-start", {
session_id: sessionID,
transcript_path: `${transcriptDir}/${sessionID}.jsonl`,
prompt: part.text ?? "",
})
}
Expand All @@ -210,11 +74,9 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => {
case "session.idle": {
const sessionID = (event as any).properties?.sessionID
if (!sessionID) break
const transcriptPath = await writeTranscriptWithFallback(sessionID)
await writeExportJSON(sessionID)
// Go hook handler will call `opencode export` to get the transcript
await callHook("turn-end", {
session_id: sessionID,
transcript_path: transcriptPath,
})
break
}
Expand All @@ -224,27 +86,18 @@ export const EntirePlugin: Plugin = async ({ client, directory, $ }) => {
if (!sessionID) break
await callHook("compaction", {
session_id: sessionID,
transcript_path: `${transcriptDir}/${sessionID}.jsonl`,
})
break
}

case "session.deleted": {
const session = (event as any).properties?.info
if (!session?.id) break
// Write final transcript + export JSON before signaling session end
if (messageStore.size > 0) {
await writeTranscriptFromMemory(session.id)
await writeExportJSON(session.id)
}
seenUserMessages.clear()
messageStore.clear()
partStore.clear()
currentSessionID = null
currentSessionInfo = null
await callHook("session-end", {
session_id: session.id,
transcript_path: `${transcriptDir}/${session.id}.jsonl`,
})
break
}
Expand Down
Loading