import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs" import { join } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" import { MESSAGE_STORAGE, PART_STORAGE } from "./constants" import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types" import { log } from "../../shared/logger" import { isSqliteBackend } from "../../shared/opencode-storage-detection" import { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared" export interface StoredMessage { agent?: string model?: { providerID?: string; modelID?: string; variant?: string } tools?: Record } type OpencodeClient = PluginInput["client"] interface SDKMessage { info?: { agent?: string model?: { providerID?: string modelID?: string variant?: string } providerID?: string modelID?: string tools?: Record } } function convertSDKMessageToStoredMessage(msg: SDKMessage): StoredMessage | null { const info = msg.info if (!info) return null const providerID = info.model?.providerID ?? info.providerID const modelID = info.model?.modelID ?? info.modelID const variant = info.model?.variant if (!info.agent && !providerID && !modelID) { return null } return { agent: info.agent, model: providerID && modelID ? { providerID, modelID, ...(variant ? { variant } : {}) } : undefined, tools: info.tools, } } // TODO: These SDK-based functions are exported for future use when hooks migrate to async. // Currently, callers still use the sync JSON-based functions which return null on beta. // Migration requires making callers async, which is a larger refactoring. // See: https://github.com/code-yeongyu/oh-my-opencode/pull/1837 /** * Finds the nearest message with required fields using SDK (for beta/SQLite backend). * Uses client.session.messages() to fetch message data from SQLite. */ export async function findNearestMessageWithFieldsFromSDK( client: OpencodeClient, sessionID: string ): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) for (let i = messages.length - 1; i >= 0; i--) { const stored = convertSDKMessageToStoredMessage(messages[i]) if (stored?.agent && stored.model?.providerID && stored.model?.modelID) { return stored } } for (let i = messages.length - 1; i >= 0; i--) { const stored = convertSDKMessageToStoredMessage(messages[i]) if (stored?.agent || (stored?.model?.providerID && stored?.model?.modelID)) { return stored } } } catch (error) { log("[hook-message-injector] SDK message fetch failed", { sessionID, error: String(error), }) } return null } /** * Finds the FIRST (oldest) message with agent field using SDK (for beta/SQLite backend). */ export async function findFirstMessageWithAgentFromSDK( client: OpencodeClient, sessionID: string ): Promise { try { const response = await client.session.messages({ path: { id: sessionID } }) const messages = normalizeSDKResponse(response, [] as SDKMessage[], { preferResponseOnMissingData: true }) for (const msg of messages) { const stored = convertSDKMessageToStoredMessage(msg) if (stored?.agent) { return stored.agent } } } catch (error) { log("[hook-message-injector] SDK agent fetch failed", { sessionID, error: String(error), }) } return null } /** * Finds the nearest message with required fields (agent, model.providerID, model.modelID). * Reads from JSON files - for stable (JSON) backend. * * **Version-gated behavior:** * - On beta (SQLite backend): Returns null immediately (no JSON storage) * - On stable (JSON backend): Reads from JSON files in messageDir * * @deprecated Use findNearestMessageWithFieldsFromSDK for beta/SQLite backend */ export function findNearestMessageWithFields(messageDir: string): StoredMessage | null { // On beta SQLite backend, skip JSON file reads entirely if (isSqliteBackend()) { return null } try { const files = readdirSync(messageDir) .filter((f) => f.endsWith(".json")) .sort() .reverse() for (const file of files) { try { const content = readFileSync(join(messageDir, file), "utf-8") const msg = JSON.parse(content) as StoredMessage if (msg.agent && msg.model?.providerID && msg.model?.modelID) { return msg } } catch { continue } } for (const file of files) { try { const content = readFileSync(join(messageDir, file), "utf-8") const msg = JSON.parse(content) as StoredMessage if (msg.agent || (msg.model?.providerID && msg.model?.modelID)) { return msg } } catch { continue } } } catch { return null } return null } /** * Finds the FIRST (oldest) message in the session with agent field. * Reads from JSON files - for stable (JSON) backend. * * **Version-gated behavior:** * - On beta (SQLite backend): Returns null immediately (no JSON storage) * - On stable (JSON backend): Reads from JSON files in messageDir * * @deprecated Use findFirstMessageWithAgentFromSDK for beta/SQLite backend */ export function findFirstMessageWithAgent(messageDir: string): string | null { // On beta SQLite backend, skip JSON file reads entirely if (isSqliteBackend()) { return null } try { const files = readdirSync(messageDir) .filter((f) => f.endsWith(".json")) .sort() for (const file of files) { try { const content = readFileSync(join(messageDir, file), "utf-8") const msg = JSON.parse(content) as StoredMessage if (msg.agent) { return msg.agent } } catch { continue } } } catch { return null } return null } function generateMessageId(): string { const timestamp = Date.now().toString(16) const random = Math.random().toString(36).substring(2, 14) return `msg_${timestamp}${random}` } function generatePartId(): string { const timestamp = Date.now().toString(16) const random = Math.random().toString(36).substring(2, 10) return `prt_${timestamp}${random}` } function getOrCreateMessageDir(sessionID: string): string { if (!existsSync(MESSAGE_STORAGE)) { mkdirSync(MESSAGE_STORAGE, { recursive: true }) } const directPath = join(MESSAGE_STORAGE, sessionID) if (existsSync(directPath)) { return directPath } for (const dir of readdirSync(MESSAGE_STORAGE)) { const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) if (existsSync(sessionPath)) { return sessionPath } } mkdirSync(directPath, { recursive: true }) return directPath } /** * Injects a hook message into the session storage. * * **Version-gated behavior:** * - On beta (SQLite backend): Logs warning and skips injection (writes are invisible to SQLite) * - On stable (JSON backend): Writes message and part JSON files * * Features degraded on beta: * - Hook message injection (e.g., continuation prompts, context injection) won't persist * - Atlas hook's injected messages won't be visible in SQLite backend * - Todo continuation enforcer's injected prompts won't persist * - Ralph loop's continuation prompts won't persist * * @param sessionID - Target session ID * @param hookContent - Content to inject * @param originalMessage - Context from the original message * @returns true if injection succeeded, false otherwise */ export function injectHookMessage( sessionID: string, hookContent: string, originalMessage: OriginalMessageContext ): boolean { if (!hookContent || hookContent.trim().length === 0) { log("[hook-message-injector] Attempted to inject empty hook content, skipping injection", { sessionID, hasAgent: !!originalMessage.agent, hasModel: !!(originalMessage.model?.providerID && originalMessage.model?.modelID) }) return false } if (isSqliteBackend()) { log("[hook-message-injector] Skipping JSON message injection on SQLite backend. " + "In-flight injection is handled via experimental.chat.messages.transform hook. " + "JSON write path is not needed when SQLite is the storage backend.", { sessionID, agent: originalMessage.agent, }) return false } const messageDir = getOrCreateMessageDir(sessionID) const needsFallback = !originalMessage.agent || !originalMessage.model?.providerID || !originalMessage.model?.modelID const fallback = needsFallback ? findNearestMessageWithFields(messageDir) : null const now = Date.now() const messageID = generateMessageId() const partID = generatePartId() const resolvedAgent = originalMessage.agent ?? fallback?.agent ?? "general" const resolvedModel = originalMessage.model?.providerID && originalMessage.model?.modelID ? { providerID: originalMessage.model.providerID, modelID: originalMessage.model.modelID, ...(originalMessage.model.variant ? { variant: originalMessage.model.variant } : {}) } : fallback?.model?.providerID && fallback?.model?.modelID ? { providerID: fallback.model.providerID, modelID: fallback.model.modelID, ...(fallback.model.variant ? { variant: fallback.model.variant } : {}) } : undefined const resolvedTools = originalMessage.tools ?? fallback?.tools const messageMeta: MessageMeta = { id: messageID, sessionID, role: "user", time: { created: now, }, agent: resolvedAgent, model: resolvedModel, path: originalMessage.path?.cwd ? { cwd: originalMessage.path.cwd, root: originalMessage.path.root ?? "/", } : undefined, tools: resolvedTools, } const textPart: TextPart = { id: partID, type: "text", text: createInternalAgentTextPart(hookContent).text, synthetic: true, time: { start: now, end: now, }, messageID, sessionID, } try { writeFileSync(join(messageDir, `${messageID}.json`), JSON.stringify(messageMeta, null, 2)) const partDir = join(PART_STORAGE, messageID) if (!existsSync(partDir)) { mkdirSync(partDir, { recursive: true }) } writeFileSync(join(partDir, `${partID}.json`), JSON.stringify(textPart, null, 2)) return true } catch { return false } } export async function resolveMessageContext( sessionID: string, client: OpencodeClient, messageDir: string | null ): Promise<{ prevMessage: StoredMessage | null; firstMessageAgent: string | null }> { const [prevMessage, firstMessageAgent] = isSqliteBackend() ? await Promise.all([ findNearestMessageWithFieldsFromSDK(client, sessionID), findFirstMessageWithAgentFromSDK(client, sessionID), ]) : [ messageDir ? findNearestMessageWithFields(messageDir) : null, messageDir ? findFirstMessageWithAgent(messageDir) : null, ] return { prevMessage, firstMessageAgent } }