feat: TypeScript SDK (agentlens-sdk) and OpenCode plugin (opencode-agentlens)
- packages/sdk-ts: BatchTransport, TraceBuilder, models, decision helpers Zero external deps, native fetch, ESM+CJS output - packages/opencode-plugin: OpenCode plugin with hooks for: - Session lifecycle (create/idle/error/delete/diff) - Tool execution capture (before/after -> TOOL_CALL spans + TOOL_SELECTION decisions) - LLM call tracking (chat.message -> LLM_CALL spans with model/provider) - Permission flow (permission.ask -> ESCALATION decisions) - File edit events - Model cost estimation (Claude, GPT-4o, o3-mini pricing)
This commit is contained in:
183
packages/opencode-plugin/src/state.ts
Normal file
183
packages/opencode-plugin/src/state.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
TraceBuilder,
|
||||
SpanType,
|
||||
SpanStatus,
|
||||
DecisionType,
|
||||
nowISO,
|
||||
} from "agentlens-sdk";
|
||||
import type { JsonValue, TraceStatus } from "agentlens-sdk";
|
||||
import { extractToolMetadata, safeJsonValue } from "./utils.js";
|
||||
|
||||
interface ToolCallState {
|
||||
startTime: number;
|
||||
tool: string;
|
||||
args: unknown;
|
||||
sessionID: string;
|
||||
}
|
||||
|
||||
export class SessionState {
|
||||
private traces = new Map<string, TraceBuilder>();
|
||||
private toolCalls = new Map<string, ToolCallState>();
|
||||
private rootSpans = new Map<string, string>();
|
||||
|
||||
startSession(
|
||||
sessionId: string,
|
||||
metadata?: Record<string, unknown>,
|
||||
): TraceBuilder {
|
||||
const trace = new TraceBuilder("opencode-session", {
|
||||
sessionId,
|
||||
tags: ["opencode", "coding-agent"],
|
||||
metadata: metadata ? (safeJsonValue(metadata) as JsonValue) : undefined,
|
||||
});
|
||||
|
||||
const rootSpanId = trace.addSpan({
|
||||
name: "session",
|
||||
type: SpanType.AGENT,
|
||||
startedAt: nowISO(),
|
||||
metadata: metadata ? (safeJsonValue(metadata) as JsonValue) : undefined,
|
||||
});
|
||||
|
||||
this.traces.set(sessionId, trace);
|
||||
this.rootSpans.set(sessionId, rootSpanId);
|
||||
return trace;
|
||||
}
|
||||
|
||||
getTrace(sessionId: string): TraceBuilder | undefined {
|
||||
return this.traces.get(sessionId);
|
||||
}
|
||||
|
||||
endSession(sessionId: string, status?: TraceStatus): void {
|
||||
const trace = this.traces.get(sessionId);
|
||||
if (!trace) return;
|
||||
|
||||
const rootSpanId = this.rootSpans.get(sessionId);
|
||||
if (rootSpanId) {
|
||||
trace.addSpan({
|
||||
id: rootSpanId,
|
||||
name: "session",
|
||||
type: SpanType.AGENT,
|
||||
status: status === "ERROR" ? SpanStatus.ERROR : SpanStatus.COMPLETED,
|
||||
endedAt: nowISO(),
|
||||
});
|
||||
}
|
||||
|
||||
trace.end({ status: status ?? "COMPLETED" });
|
||||
this.traces.delete(sessionId);
|
||||
this.rootSpans.delete(sessionId);
|
||||
}
|
||||
|
||||
startToolCall(
|
||||
callID: string,
|
||||
tool: string,
|
||||
args: unknown,
|
||||
sessionID: string,
|
||||
): void {
|
||||
this.toolCalls.set(callID, {
|
||||
startTime: Date.now(),
|
||||
tool,
|
||||
args,
|
||||
sessionID,
|
||||
});
|
||||
}
|
||||
|
||||
endToolCall(
|
||||
callID: string,
|
||||
output: string,
|
||||
title: string,
|
||||
metadata: unknown,
|
||||
): void {
|
||||
const call = this.toolCalls.get(callID);
|
||||
if (!call) return;
|
||||
this.toolCalls.delete(callID);
|
||||
|
||||
const trace = this.traces.get(call.sessionID);
|
||||
if (!trace) return;
|
||||
|
||||
const durationMs = Date.now() - call.startTime;
|
||||
const rootSpanId = this.rootSpans.get(call.sessionID);
|
||||
const toolMeta = extractToolMetadata(call.tool, call.args);
|
||||
|
||||
trace.addSpan({
|
||||
name: title,
|
||||
type: SpanType.TOOL_CALL,
|
||||
parentSpanId: rootSpanId,
|
||||
input: safeJsonValue(call.args),
|
||||
output: output as JsonValue,
|
||||
durationMs,
|
||||
status: SpanStatus.COMPLETED,
|
||||
startedAt: new Date(call.startTime).toISOString(),
|
||||
endedAt: nowISO(),
|
||||
metadata: safeJsonValue({ ...toolMeta, rawMetadata: metadata }),
|
||||
});
|
||||
|
||||
trace.addDecision({
|
||||
type: DecisionType.TOOL_SELECTION,
|
||||
chosen: call.tool as JsonValue,
|
||||
alternatives: [],
|
||||
reasoning: title,
|
||||
durationMs,
|
||||
parentSpanId: rootSpanId,
|
||||
});
|
||||
}
|
||||
|
||||
recordLLMCall(
|
||||
sessionId: string,
|
||||
options: {
|
||||
model?: { providerID: string; modelID: string };
|
||||
agent?: string;
|
||||
messageID?: string;
|
||||
},
|
||||
): void {
|
||||
const trace = this.traces.get(sessionId);
|
||||
if (!trace) return;
|
||||
|
||||
const rootSpanId = this.rootSpans.get(sessionId);
|
||||
const agentName = options.agent ?? "assistant";
|
||||
const modelName = options.model?.modelID ?? "unknown";
|
||||
|
||||
trace.addSpan({
|
||||
name: `${agentName} → ${modelName}`,
|
||||
type: SpanType.LLM_CALL,
|
||||
parentSpanId: rootSpanId,
|
||||
status: SpanStatus.COMPLETED,
|
||||
startedAt: nowISO(),
|
||||
endedAt: nowISO(),
|
||||
metadata: safeJsonValue({
|
||||
provider: options.model?.providerID,
|
||||
model: options.model?.modelID,
|
||||
agent: options.agent,
|
||||
messageID: options.messageID,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
recordPermission(
|
||||
sessionId: string,
|
||||
permission: unknown,
|
||||
status: string,
|
||||
): void {
|
||||
const trace = this.traces.get(sessionId);
|
||||
if (!trace) return;
|
||||
|
||||
const rootSpanId = this.rootSpans.get(sessionId);
|
||||
const p = permission as Record<string, unknown> | null;
|
||||
const title = (p?.["title"] as string) ?? "permission";
|
||||
const permType = (p?.["type"] as string) ?? "unknown";
|
||||
|
||||
trace.addDecision({
|
||||
type: DecisionType.ESCALATION,
|
||||
chosen: safeJsonValue({ action: status }),
|
||||
alternatives: [
|
||||
"allow" as JsonValue,
|
||||
"deny" as JsonValue,
|
||||
"ask" as JsonValue,
|
||||
],
|
||||
reasoning: `${permType}: ${title}`,
|
||||
parentSpanId: rootSpanId,
|
||||
});
|
||||
}
|
||||
|
||||
getRootSpanId(sessionId: string): string | undefined {
|
||||
return this.rootSpans.get(sessionId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user