fix: plugin event shape mismatches, nav highlight bug, and README update
- Fix session.created/deleted reading props.info.id instead of props.id - Fix session.diff reading FileDiff[] array instead of string - Fix file.edited reading props.file instead of props.filePath - Add auto-session creation fallback from tool/chat hooks - Add flushSession() for intermediate trace sends on session.idle - Fix dashboard nav: /dashboard exact match prevents false active state - Update README with TypeScript SDK and OpenCode plugin sections
This commit is contained in:
75
README.md
75
README.md
@@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://pypi.org/project/vectry-agentlens/"><img src="https://img.shields.io/pypi/v/vectry-agentlens?color=blue" alt="PyPI"></a>
|
<a href="https://pypi.org/project/vectry-agentlens/"><img src="https://img.shields.io/pypi/v/vectry-agentlens?color=blue" alt="PyPI"></a>
|
||||||
|
<a href="https://www.npmjs.com/package/agentlens-sdk"><img src="https://img.shields.io/npm/v/agentlens-sdk?color=blue" alt="npm"></a>
|
||||||
|
<a href="https://www.npmjs.com/package/opencode-agentlens"><img src="https://img.shields.io/npm/v/opencode-agentlens?color=blue&label=opencode-plugin" alt="OpenCode Plugin"></a>
|
||||||
<a href="https://gitea.repi.fun/repi/agentlens/src/branch/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
|
<a href="https://gitea.repi.fun/repi/agentlens/src/branch/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="License"></a>
|
||||||
<a href="https://agentlens.vectry.tech"><img src="https://img.shields.io/badge/demo-live-brightgreen" alt="Demo"></a>
|
<a href="https://agentlens.vectry.tech"><img src="https://img.shields.io/badge/demo-live-brightgreen" alt="Demo"></a>
|
||||||
</p>
|
</p>
|
||||||
@@ -44,6 +46,8 @@ Open `https://agentlens.vectry.tech/dashboard` to see your traces.
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Decision Tracing** -- Log every decision point with reasoning, alternatives, and confidence scores
|
- **Decision Tracing** -- Log every decision point with reasoning, alternatives, and confidence scores
|
||||||
|
- **OpenCode Plugin** -- Trace your coding agent sessions with `opencode-agentlens`
|
||||||
|
- **TypeScript SDK** -- First-class TypeScript support with `agentlens-sdk`
|
||||||
- **OpenAI Integration** -- Auto-instrument OpenAI calls with one line: `wrap_openai(client)`
|
- **OpenAI Integration** -- Auto-instrument OpenAI calls with one line: `wrap_openai(client)`
|
||||||
- **LangChain Integration** -- Drop-in callback handler for LangChain agents
|
- **LangChain Integration** -- Drop-in callback handler for LangChain agents
|
||||||
- **Nested Traces** -- Multi-agent workflows with parent-child span relationships
|
- **Nested Traces** -- Multi-agent workflows with parent-child span relationships
|
||||||
@@ -52,13 +56,62 @@ Open `https://agentlens.vectry.tech/dashboard` to see your traces.
|
|||||||
- **Analytics** -- Token usage, cost tracking, duration timelines per trace
|
- **Analytics** -- Token usage, cost tracking, duration timelines per trace
|
||||||
- **Self-Hostable** -- Docker Compose deployment, bring your own Postgres + Redis
|
- **Self-Hostable** -- Docker Compose deployment, bring your own Postgres + Redis
|
||||||
|
|
||||||
|
## OpenCode Plugin
|
||||||
|
|
||||||
|
Trace your [OpenCode](https://opencode.ai) coding agent sessions automatically.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g opencode-agentlens
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to your `opencode.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"plugin": ["opencode-agentlens"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Set environment variables:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export AGENTLENS_API_KEY="your-key"
|
||||||
|
export AGENTLENS_ENDPOINT="https://agentlens.vectry.tech"
|
||||||
|
```
|
||||||
|
|
||||||
|
Every coding session automatically captures tool calls, LLM interactions, file edits, and permission flows.
|
||||||
|
|
||||||
|
## TypeScript SDK
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install agentlens-sdk
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { init, TraceBuilder, SpanType, SpanStatus } from "agentlens-sdk";
|
||||||
|
|
||||||
|
init({ apiKey: "your-key", endpoint: "https://agentlens.vectry.tech" });
|
||||||
|
|
||||||
|
const trace = new TraceBuilder("my-agent-task", {
|
||||||
|
tags: ["production"],
|
||||||
|
});
|
||||||
|
|
||||||
|
trace.addSpan({
|
||||||
|
name: "tool-call",
|
||||||
|
type: SpanType.TOOL_CALL,
|
||||||
|
status: SpanStatus.COMPLETED,
|
||||||
|
});
|
||||||
|
|
||||||
|
trace.end();
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
SDK (Python) API (Next.js) Dashboard (React)
|
SDK (Python/TS) API (Next.js) Dashboard (React)
|
||||||
agentlens.trace() ------> POST /api/traces ------> Real-time SSE stream
|
agentlens.trace() ------> POST /api/traces ------> Real-time SSE stream
|
||||||
agentlens.log_decision() Prisma + Postgres Decision tree viz
|
TraceBuilder.end() Prisma + Postgres Decision tree viz
|
||||||
wrap_openai(client) Redis pub/sub Analytics & filters
|
OpenCode plugin Redis pub/sub Analytics & filters
|
||||||
```
|
```
|
||||||
|
|
||||||
## Integrations
|
## Integrations
|
||||||
@@ -142,16 +195,20 @@ The dashboard will be available at `http://localhost:4200`.
|
|||||||
|
|
||||||
```
|
```
|
||||||
agentlens/
|
agentlens/
|
||||||
apps/web/ # Next.js 15 dashboard + API
|
apps/web/ # Next.js 15 dashboard + API
|
||||||
packages/database/ # Prisma schema + client
|
packages/database/ # Prisma schema + client
|
||||||
packages/sdk-python/ # Python SDK (PyPI: vectry-agentlens)
|
packages/sdk-python/ # Python SDK (PyPI: vectry-agentlens)
|
||||||
examples/ # Example agent scripts
|
packages/sdk-ts/ # TypeScript SDK (npm: agentlens-sdk)
|
||||||
docker-compose.yml # Production deployment
|
packages/opencode-plugin/ # OpenCode plugin (npm: opencode-agentlens)
|
||||||
|
examples/ # Example agent scripts
|
||||||
|
docker-compose.yml # Production deployment
|
||||||
```
|
```
|
||||||
|
|
||||||
## SDK Reference
|
## SDK Reference
|
||||||
|
|
||||||
See the full [Python SDK documentation](packages/sdk-python/README.md).
|
- [Python SDK documentation](packages/sdk-python/README.md)
|
||||||
|
- [TypeScript SDK documentation](packages/sdk-ts/README.md)
|
||||||
|
- [OpenCode plugin documentation](packages/opencode-plugin/README.md)
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,11 @@ function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
|||||||
<nav className="flex-1 p-4 space-y-1">
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
const isActive =
|
||||||
|
item.href === "/dashboard"
|
||||||
|
? pathname === "/dashboard"
|
||||||
|
: pathname === item.href ||
|
||||||
|
pathname.startsWith(`${item.href}/`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "opencode-agentlens",
|
"name": "opencode-agentlens",
|
||||||
"version": "0.1.0",
|
"version": "0.1.1",
|
||||||
"description": "OpenCode plugin for AgentLens — trace your coding agent's decisions, tool calls, and sessions",
|
"description": "OpenCode plugin for AgentLens — trace your coding agent's decisions, tool calls, and sessions",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.cjs",
|
"main": "./dist/index.cjs",
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ import { loadConfig } from "./config.js";
|
|||||||
import { SessionState } from "./state.js";
|
import { SessionState } from "./state.js";
|
||||||
import { truncate, safeJsonValue } from "./utils.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 plugin: Plugin = async ({ project, directory, worktree }) => {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
|
|
||||||
@@ -13,6 +26,8 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`[agentlens] Plugin enabled — endpoint: ${config.endpoint}`);
|
||||||
|
|
||||||
init({
|
init({
|
||||||
apiKey: config.apiKey,
|
apiKey: config.apiKey,
|
||||||
endpoint: config.endpoint,
|
endpoint: config.endpoint,
|
||||||
@@ -22,6 +37,11 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
|
|||||||
|
|
||||||
const state = new SessionState();
|
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 {
|
return {
|
||||||
event: async ({ event }) => {
|
event: async ({ event }) => {
|
||||||
const type = event.type;
|
const type = event.type;
|
||||||
@@ -29,66 +49,118 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
|
|||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
if (type === "session.created" && props?.["id"]) {
|
if (type === "session.created") {
|
||||||
state.startSession(String(props["id"]), {
|
// props.info is a Session object with { id, projectID, ... }
|
||||||
project: project.id,
|
const info = props?.["info"] as
|
||||||
directory,
|
| Record<string, unknown>
|
||||||
worktree,
|
| undefined;
|
||||||
});
|
const sessionId = info?.["id"] as string | undefined;
|
||||||
|
if (sessionId) {
|
||||||
|
state.startSession(sessionId, {
|
||||||
|
project: project.id,
|
||||||
|
directory,
|
||||||
|
worktree,
|
||||||
|
title: info?.["title"] as string | undefined,
|
||||||
|
});
|
||||||
|
console.log(`[agentlens] Session started: ${sessionId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "session.idle") {
|
if (type === "session.idle") {
|
||||||
const sessionId = props?.["sessionID"] ?? props?.["id"];
|
// props.sessionID is the session ID string
|
||||||
if (sessionId) await flush();
|
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") {
|
if (type === "session.error") {
|
||||||
const sessionId = String(props?.["sessionID"] ?? props?.["id"] ?? "");
|
const sessionId =
|
||||||
|
(props?.["sessionID"] as string) || getAnySessionId() || "";
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const trace = state.getTrace(sessionId);
|
const trace = state.getTrace(sessionId);
|
||||||
if (trace) {
|
if (trace) {
|
||||||
|
const error = props?.["error"] as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
trace.addEvent({
|
trace.addEvent({
|
||||||
type: EventTypeValues.ERROR,
|
type: EventTypeValues.ERROR,
|
||||||
name: String(props?.["error"] ?? "session error"),
|
name: String(
|
||||||
metadata: safeJsonValue(props) as JsonValue,
|
error?.["name"] ?? error?.["message"] ?? "session error",
|
||||||
|
),
|
||||||
|
metadata: safeJsonValue(error ?? props) as JsonValue,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "session.deleted") {
|
if (type === "session.deleted") {
|
||||||
const sessionId = String(props?.["sessionID"] ?? props?.["id"] ?? "");
|
// props.info is a Session object with { id, ... }
|
||||||
if (sessionId) state.endSession(sessionId);
|
const info = props?.["info"] as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
const sessionId =
|
||||||
|
(info?.["id"] as string) || getAnySessionId() || "";
|
||||||
|
if (sessionId) {
|
||||||
|
state.endSession(sessionId);
|
||||||
|
await flush();
|
||||||
|
console.log(`[agentlens] Session ended and flushed: ${sessionId}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "session.diff") {
|
if (type === "session.diff") {
|
||||||
const sessionId = String(props?.["sessionID"] ?? props?.["id"] ?? "");
|
// props.sessionID + props.diff (FileDiff[])
|
||||||
|
const sessionId =
|
||||||
|
(props?.["sessionID"] as string) || getAnySessionId() || "";
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const trace = state.getTrace(sessionId);
|
const trace = state.getTrace(sessionId);
|
||||||
if (trace) {
|
if (trace) {
|
||||||
trace.setMetadata({
|
const diffs = props?.["diff"];
|
||||||
diff: truncate(String(props?.["diff"] ?? ""), 5000),
|
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") {
|
if (type === "file.edited") {
|
||||||
const sessionId = String(props?.["sessionID"] ?? props?.["id"] ?? "");
|
// 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;
|
const trace = sessionId ? state.getTrace(sessionId) : undefined;
|
||||||
if (trace) {
|
if (trace && file) {
|
||||||
trace.addEvent({
|
trace.addEvent({
|
||||||
type: EventTypeValues.CUSTOM,
|
type: EventTypeValues.CUSTOM,
|
||||||
name: "file.edited",
|
name: "file.edited",
|
||||||
metadata: safeJsonValue({
|
metadata: safeJsonValue({ filePath: file }) as JsonValue,
|
||||||
filePath: props?.["filePath"],
|
|
||||||
}) as JsonValue,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"tool.execute.before": async (input, output) => {
|
"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,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[agentlens] Auto-created session from tool call: ${input.sessionID}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
state.startToolCall(
|
state.startToolCall(
|
||||||
input.callID,
|
input.callID,
|
||||||
input.tool,
|
input.tool,
|
||||||
@@ -107,6 +179,17 @@ const plugin: Plugin = async ({ project, directory, worktree }) => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
"chat.message": async (input) => {
|
"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,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`[agentlens] Auto-created session from chat.message: ${input.sessionID}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (input.model) {
|
if (input.model) {
|
||||||
state.recordLLMCall(input.sessionID, {
|
state.recordLLMCall(input.sessionID, {
|
||||||
model: input.model,
|
model: input.model,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
SpanStatus,
|
SpanStatus,
|
||||||
DecisionType,
|
DecisionType,
|
||||||
nowISO,
|
nowISO,
|
||||||
|
getClient,
|
||||||
} from "agentlens-sdk";
|
} from "agentlens-sdk";
|
||||||
import type { JsonValue, TraceStatus } from "agentlens-sdk";
|
import type { JsonValue, TraceStatus } from "agentlens-sdk";
|
||||||
import { extractToolMetadata, safeJsonValue } from "./utils.js";
|
import { extractToolMetadata, safeJsonValue } from "./utils.js";
|
||||||
@@ -180,4 +181,22 @@ export class SessionState {
|
|||||||
getRootSpanId(sessionId: string): string | undefined {
|
getRootSpanId(sessionId: string): string | undefined {
|
||||||
return this.rootSpans.get(sessionId);
|
return this.rootSpans.get(sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getActiveSessionIds(): string[] {
|
||||||
|
return Array.from(this.traces.keys());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the current trace state without ending the session.
|
||||||
|
* This creates a snapshot so data isn't lost if the process exits unexpectedly.
|
||||||
|
*/
|
||||||
|
flushSession(sessionId: string): void {
|
||||||
|
const trace = this.traces.get(sessionId);
|
||||||
|
if (!trace) return;
|
||||||
|
|
||||||
|
const transport = getClient();
|
||||||
|
if (transport) {
|
||||||
|
transport.add(trace.toPayload());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user