225 lines
6.7 KiB
TypeScript
225 lines
6.7 KiB
TypeScript
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<string, unknown>).properties as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
|
|
if (type === "session.created") {
|
|
// props.info is a Session object with { id, projectID, ... }
|
|
const info = props?.["info"] as
|
|
| Record<string, unknown>
|
|
| 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<string, unknown>
|
|
| 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<string, unknown>
|
|
| 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<string, unknown>) => ({
|
|
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";
|