Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
36 changes: 30 additions & 6 deletions cmd/entire/cli/agent/opencode/cli_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package opencode

import (
"context"
"errors"
"fmt"
"os/exec"
"strings"
Expand All @@ -11,25 +12,48 @@ import (
// openCodeCommandTimeout is the maximum time to wait for opencode CLI commands.
const openCodeCommandTimeout = 30 * time.Second

// 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", "export", sessionID)
output, err := cmd.Output()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return nil, fmt.Errorf("opencode export timed out after %s", openCodeCommandTimeout)
}
// 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 nil, fmt.Errorf("opencode export failed: %w", err)
}

return output, nil
}

// runOpenCodeSessionDelete runs `opencode session delete <sessionID>` to remove
// a session from OpenCode's database. Treats "Session not found" as success
// (nothing to delete).
// a session from OpenCode's database. Returns nil on success or if the session
// doesn't exist (nothing to delete).
func runOpenCodeSessionDelete(sessionID string) error {
ctx, cancel := context.WithTimeout(context.Background(), openCodeCommandTimeout)
defer cancel()

cmd := exec.CommandContext(ctx, "opencode", "session", "delete", sessionID)
output, err := cmd.CombinedOutput()
if err != nil {
if output, err := cmd.CombinedOutput(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("opencode session delete timed out after %s", openCodeCommandTimeout)
}
// Treat "Session not found" as success — nothing to delete.
if strings.Contains(string(output), "Session not found") {
// "Session not found" means the session doesn't exist — nothing to delete.
if strings.Contains(strings.ToLower(string(output)), "session not found") {
return nil
}
return fmt.Errorf("opencode session delete failed: %w (output: %s)", err, string(output))
}

return nil
}

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