Apply the internal initiator marker to automated continuation, recovery, babysitter, stop-hook, and hook-message injections so Copilot attribution consistently sets x-initiator=agent for system-generated prompts.
376 lines
11 KiB
TypeScript
376 lines
11 KiB
TypeScript
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<string, ToolPermission>
|
|
}
|
|
|
|
type OpencodeClient = PluginInput["client"]
|
|
|
|
interface SDKMessage {
|
|
info?: {
|
|
agent?: string
|
|
model?: {
|
|
providerID?: string
|
|
modelID?: string
|
|
variant?: string
|
|
}
|
|
providerID?: string
|
|
modelID?: string
|
|
tools?: Record<string, ToolPermission>
|
|
}
|
|
}
|
|
|
|
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<StoredMessage | null> {
|
|
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<string | null> {
|
|
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 }
|
|
}
|