import type { Plugin } from "@opencode-ai/plugin"; import type { JsonValue } from "agentlens-sdk"; import { init, flush, EventType as EventTypeValues } from "agentlens-sdk"; import { loadConfig } from "./config.js"; import { SessionState } from "./state.js"; import { truncate, safeJsonValue } from "./utils.js"; /** * OpenCode Event shapes (from @opencode-ai/sdk): * * session.created → { type, properties: { info: Session } } * session.idle → { type, properties: { sessionID: string } } * session.deleted → { type, properties: { info: Session } } * session.error → { type, properties: { sessionID?: string, error?: ... } } * session.diff → { type, properties: { sessionID: string, diff: FileDiff[] } } * file.edited → { type, properties: { file: string } } * * Session = { id, projectID, directory, title, ... } */ const plugin: Plugin = async ({ project, directory, worktree }) => { const config = loadConfig(); if (!config.enabled || !config.apiKey) { return {}; } init({ apiKey: config.apiKey, endpoint: config.endpoint, flushInterval: config.flushInterval, maxBatchSize: config.maxBatchSize, }); const state = new SessionState(); /** Helper: get a session ID from the active traces (fallback for events that lack one) */ function getAnySessionId(): string | undefined { return state.getActiveSessionIds()[0]; } return { event: async ({ event }) => { const type = event.type; const props = (event as Record).properties as | Record | undefined; if (type === "session.created") { // props.info is a Session object with { id, projectID, ... } const info = props?.["info"] as | Record | undefined; const sessionId = info?.["id"] as string | undefined; if (sessionId) { state.startSession(sessionId, { project: project.id, directory, worktree, title: info?.["title"] as string | undefined, }); } } if (type === "session.idle") { // props.sessionID is the session ID string const sessionId = (props?.["sessionID"] as string) || getAnySessionId(); if (sessionId) { // Flush intermediate trace so data isn't lost if session ends abruptly state.flushSession(sessionId); await flush(); } } if (type === "session.error") { const sessionId = (props?.["sessionID"] as string) || getAnySessionId() || ""; if (sessionId) { const trace = state.getTrace(sessionId); if (trace) { const error = props?.["error"] as | Record | undefined; trace.addEvent({ type: EventTypeValues.ERROR, name: String( error?.["name"] ?? error?.["message"] ?? "session error", ), metadata: safeJsonValue(error ?? props) as JsonValue, }); } } } if (type === "session.deleted") { // props.info is a Session object with { id, ... } const info = props?.["info"] as | Record | undefined; const sessionId = (info?.["id"] as string) || getAnySessionId() || ""; if (sessionId) { state.endSession(sessionId); await flush(); } } if (type === "session.diff") { // props.sessionID + props.diff (FileDiff[]) const sessionId = (props?.["sessionID"] as string) || getAnySessionId() || ""; if (sessionId) { const trace = state.getTrace(sessionId); if (trace) { const diffs = props?.["diff"]; trace.setMetadata( safeJsonValue({ diff: Array.isArray(diffs) ? diffs.map((d: Record) => ({ path: d?.["path"], additions: d?.["additions"], deletions: d?.["deletions"], })) : diffs, }) as JsonValue, ); } } } if (type === "file.edited") { // props.file is a string (file path), no sessionID on this event const file = props?.["file"] as string | undefined; const sessionId = getAnySessionId(); const trace = sessionId ? state.getTrace(sessionId) : undefined; if (trace && file) { trace.addEvent({ type: EventTypeValues.CUSTOM, name: "file.edited", metadata: safeJsonValue({ filePath: file }) as JsonValue, }); } } }, "tool.execute.before": async (input, output) => { // Auto-create session if we missed session.created event if (!state.getTrace(input.sessionID)) { state.startSession(input.sessionID, { project: project.id, directory, worktree, }); } state.startToolCall( input.callID, input.tool, output.args as unknown, input.sessionID, ); }, "tool.execute.after": async (input, output) => { state.endToolCall( input.callID, truncate(output.output ?? "", config.maxOutputLength), output.title ?? input.tool ?? "unknown-tool", output.metadata as unknown, ); }, "chat.message": async (input) => { // Auto-create session if we missed session.created event if (!state.getTrace(input.sessionID)) { state.startSession(input.sessionID, { project: project.id, directory, worktree, }); } if (input.model) { state.recordLLMCall(input.sessionID, { model: input.model, agent: input.agent, messageID: input.messageID, }); } }, "chat.params": async (input, output) => { const trace = state.getTrace(input.sessionID); if (trace) { trace.addEvent({ type: EventTypeValues.CUSTOM, name: "chat.params", metadata: safeJsonValue({ agent: input.agent, model: input.model?.id, provider: input.provider?.info?.id, temperature: output.temperature, topP: output.topP, topK: output.topK, }) as JsonValue, }); } }, "permission.ask": async (input, output) => { state.recordPermission(input.sessionID, input, output.status); }, }; }; export default plugin; export { plugin as AgentLensPlugin }; export type { PluginConfig } from "./config.js"; export { loadConfig } from "./config.js";