@kodrunhq/opencode-autopilot 1.2.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Hook handler factories for OpenCode event system integration.
3
+ *
4
+ * Creates handlers for:
5
+ * - event hook: session.created/error/idle/deleted/compacted, message.updated
6
+ * - tool.execute.before: records start timestamp
7
+ * - tool.execute.after: computes duration, records metrics
8
+ *
9
+ * All handlers are pure observers: they read event data, append to store,
10
+ * and never modify session state or output objects (Pitfall 5).
11
+ *
12
+ * @module
13
+ */
14
+
15
+ import { classifyErrorType, getErrorMessage } from "../orchestrator/fallback/error-classifier";
16
+ import type { ContextMonitor } from "./context-monitor";
17
+ import { emitErrorEvent, emitToolCompleteEvent } from "./event-emitter";
18
+ import type { ObservabilityEvent, SessionEventStore, SessionEvents } from "./event-store";
19
+ import { accumulateTokensFromMessage, createEmptyTokenAggregate } from "./token-tracker";
20
+
21
+ /**
22
+ * Dependencies for the observability event handler.
23
+ */
24
+ export interface ObservabilityHandlerDeps {
25
+ readonly eventStore: SessionEventStore;
26
+ readonly contextMonitor: ContextMonitor;
27
+ readonly showToast: (
28
+ title: string,
29
+ message: string,
30
+ variant: "info" | "warning" | "error",
31
+ ) => Promise<void>;
32
+ readonly writeSessionLog: (sessionData: SessionEvents | undefined) => Promise<void>;
33
+ }
34
+
35
+ /**
36
+ * Extracts a session ID from event properties.
37
+ * Supports properties.sessionID, properties.info.sessionID, and properties.info.id.
38
+ */
39
+ function extractSessionId(properties: Record<string, unknown>): string | undefined {
40
+ if (typeof properties.sessionID === "string") return properties.sessionID;
41
+ if (properties.info !== null && typeof properties.info === "object") {
42
+ const info = properties.info as Record<string, unknown>;
43
+ if (typeof info.sessionID === "string") return info.sessionID;
44
+ if (typeof info.id === "string") return info.id;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ /**
50
+ * Checks if an object has AssistantMessage token shape.
51
+ */
52
+ function hasTokenShape(obj: unknown): obj is {
53
+ tokens: {
54
+ input: number;
55
+ output: number;
56
+ reasoning: number;
57
+ cache: { read: number; write: number };
58
+ };
59
+ cost: number;
60
+ } {
61
+ if (obj === null || typeof obj !== "object") return false;
62
+ const o = obj as Record<string, unknown>;
63
+ if (typeof o.cost !== "number") return false;
64
+ if (o.tokens === null || typeof o.tokens !== "object") return false;
65
+ const tokens = o.tokens as Record<string, unknown>;
66
+ if (typeof tokens.input !== "number") return false;
67
+ if (typeof tokens.output !== "number") return false;
68
+ if (typeof tokens.reasoning !== "number") return false;
69
+ if (tokens.cache === null || typeof tokens.cache !== "object") return false;
70
+ const cache = tokens.cache as Record<string, unknown>;
71
+ return typeof cache.read === "number" && typeof cache.write === "number";
72
+ }
73
+
74
+ /**
75
+ * Creates the main observability event handler.
76
+ *
77
+ * Routes OpenCode events to the session event store:
78
+ * - session.created: init session, append session_start
79
+ * - session.error: classify error, append error event (D-37, D-38)
80
+ * - message.updated: accumulate tokens, check context utilization
81
+ * - session.idle: flush to disk (fire-and-forget, Pitfall 2)
82
+ * - session.deleted: final flush + cleanup
83
+ * - session.compacted: append event, intermediate flush
84
+ */
85
+ export function createObservabilityEventHandler(deps: ObservabilityHandlerDeps) {
86
+ const { eventStore, contextMonitor, showToast, writeSessionLog } = deps;
87
+
88
+ return async (input: {
89
+ readonly event: { readonly type: string; readonly [key: string]: unknown };
90
+ }): Promise<void> => {
91
+ const { event } = input;
92
+ const properties = (event.properties ?? {}) as Record<string, unknown>;
93
+
94
+ switch (event.type) {
95
+ case "session.created": {
96
+ const info = properties.info as { id?: string; model?: string } | undefined;
97
+ if (!info?.id) return;
98
+
99
+ eventStore.initSession(info.id);
100
+
101
+ // Init context monitor with a default context limit.
102
+ // The actual limit would come from provider metadata if available.
103
+ contextMonitor.initSession(info.id, 200000);
104
+
105
+ // Append session_start event
106
+ const startEvent: ObservabilityEvent = Object.freeze({
107
+ type: "session_start" as const,
108
+ timestamp: new Date().toISOString(),
109
+ sessionId: info.id,
110
+ });
111
+ eventStore.appendEvent(info.id, startEvent);
112
+ return;
113
+ }
114
+
115
+ case "session.error": {
116
+ const sessionId = extractSessionId(properties);
117
+ if (!sessionId) return;
118
+
119
+ const error = properties.error;
120
+ const rawErrorType = classifyErrorType(error);
121
+ const errorType = (
122
+ [
123
+ "rate_limit",
124
+ "quota_exceeded",
125
+ "service_unavailable",
126
+ "missing_api_key",
127
+ "model_not_found",
128
+ "content_filter",
129
+ "context_length",
130
+ ] as const
131
+ ).includes(rawErrorType as never)
132
+ ? (rawErrorType as
133
+ | "rate_limit"
134
+ | "quota_exceeded"
135
+ | "service_unavailable"
136
+ | "missing_api_key"
137
+ | "model_not_found"
138
+ | "content_filter"
139
+ | "context_length")
140
+ : ("unknown" as const);
141
+ const message = getErrorMessage(error);
142
+
143
+ const errorEvent = emitErrorEvent(sessionId, errorType, message);
144
+ eventStore.appendEvent(sessionId, errorEvent);
145
+ return;
146
+ }
147
+
148
+ case "message.updated": {
149
+ const info = properties.info as Record<string, unknown> | undefined;
150
+ if (!info) return;
151
+
152
+ const sessionId = typeof info.sessionID === "string" ? info.sessionID : undefined;
153
+ if (!sessionId) return;
154
+
155
+ // Accumulate tokens if message has AssistantMessage shape
156
+ if (hasTokenShape(info)) {
157
+ const empty = createEmptyTokenAggregate();
158
+ const accumulated = accumulateTokensFromMessage(empty, info);
159
+ eventStore.accumulateTokens(sessionId, accumulated);
160
+
161
+ // Check context utilization
162
+ const utilResult = contextMonitor.processMessage(sessionId, info.tokens.input);
163
+ if (utilResult.shouldWarn) {
164
+ const pct = Math.round(utilResult.utilization * 100);
165
+ // Append context_warning event
166
+ const warningEvent: ObservabilityEvent = Object.freeze({
167
+ type: "context_warning" as const,
168
+ timestamp: new Date().toISOString(),
169
+ sessionId,
170
+ utilization: utilResult.utilization,
171
+ contextLimit: 200000,
172
+ inputTokens: info.tokens.input,
173
+ });
174
+ eventStore.appendEvent(sessionId, warningEvent);
175
+
176
+ // Fire toast (per D-35)
177
+ showToast(
178
+ "Context Warning",
179
+ `Context at ${pct}% -- consider compacting`,
180
+ "warning",
181
+ ).catch((err) => {
182
+ console.error("[opencode-autopilot]", err);
183
+ });
184
+ }
185
+ }
186
+ return;
187
+ }
188
+
189
+ case "session.idle": {
190
+ const sessionId = extractSessionId(properties);
191
+ if (!sessionId) return;
192
+
193
+ // Snapshot to disk (fire-and-forget per Pitfall 2) — session continues
194
+ const sessionData = eventStore.getSession(sessionId);
195
+ writeSessionLog(sessionData).catch((err) => {
196
+ console.error("[opencode-autopilot]", err);
197
+ });
198
+ return;
199
+ }
200
+
201
+ case "session.deleted": {
202
+ const sessionId = extractSessionId(properties);
203
+ if (!sessionId) return;
204
+
205
+ // Final flush — session is done, remove from store
206
+ const sessionData = eventStore.flush(sessionId);
207
+ writeSessionLog(sessionData).catch((err) => {
208
+ console.error("[opencode-autopilot]", err);
209
+ });
210
+
211
+ // Clean up context monitor
212
+ contextMonitor.cleanup(sessionId);
213
+ return;
214
+ }
215
+
216
+ case "session.compacted": {
217
+ const sessionId = extractSessionId(properties);
218
+ if (!sessionId) return;
219
+
220
+ // Append compaction decision event (not session_start)
221
+ const compactEvent: ObservabilityEvent = Object.freeze({
222
+ type: "decision" as const,
223
+ timestamp: new Date().toISOString(),
224
+ sessionId,
225
+ phase: "COMPACT",
226
+ agent: "system",
227
+ decision: "Session compacted",
228
+ rationale: "Context window compaction triggered",
229
+ });
230
+ eventStore.appendEvent(sessionId, compactEvent);
231
+
232
+ // Snapshot to disk — session continues after compaction
233
+ const sessionData = eventStore.getSession(sessionId);
234
+ writeSessionLog(sessionData).catch((err) => {
235
+ console.error("[opencode-autopilot]", err);
236
+ });
237
+ return;
238
+ }
239
+
240
+ default:
241
+ return;
242
+ }
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Creates a tool.execute.before handler that records start timestamps.
248
+ *
249
+ * @param startTimes - Map to store callID -> start timestamp
250
+ */
251
+ export function createToolExecuteBeforeHandler(startTimes: Map<string, number>) {
252
+ return (hookInput: {
253
+ readonly tool: string;
254
+ readonly sessionID: string;
255
+ readonly callID: string;
256
+ readonly args: unknown;
257
+ }): void => {
258
+ startTimes.set(hookInput.callID, Date.now());
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Determines if a tool execution succeeded based on the output.
264
+ */
265
+ function isToolSuccess(output: {
266
+ readonly title: string;
267
+ readonly output: string;
268
+ readonly metadata: unknown;
269
+ }): boolean {
270
+ // Check metadata for explicit error flag
271
+ if (output.metadata !== null && typeof output.metadata === "object") {
272
+ const meta = output.metadata as Record<string, unknown>;
273
+ if (meta.error === true) return false;
274
+ }
275
+ // Check title for error indicator
276
+ if (output.title.toLowerCase().startsWith("error")) return false;
277
+ // Check output for error prefix
278
+ if (output.output.startsWith("Error:")) return false;
279
+ return true;
280
+ }
281
+
282
+ /**
283
+ * Creates a tool.execute.after handler that computes duration and records metrics.
284
+ *
285
+ * Never modifies the output object (pure observer per Pitfall 5).
286
+ *
287
+ * @param eventStore - The session event store
288
+ * @param startTimes - Map of callID -> start timestamp (shared with before handler)
289
+ */
290
+ export function createToolExecuteAfterHandler(
291
+ eventStore: SessionEventStore,
292
+ startTimes: Map<string, number>,
293
+ ) {
294
+ return (
295
+ hookInput: {
296
+ readonly tool: string;
297
+ readonly sessionID: string;
298
+ readonly callID: string;
299
+ readonly args: unknown;
300
+ },
301
+ output: { readonly title: string; readonly output: string; readonly metadata: unknown },
302
+ ): void => {
303
+ const startTime = startTimes.get(hookInput.callID);
304
+ const durationMs = startTime !== undefined ? Date.now() - startTime : 0;
305
+ const success = isToolSuccess(output);
306
+
307
+ // Clean up start time entry
308
+ startTimes.delete(hookInput.callID);
309
+
310
+ // Record tool execution metric (per D-39, D-40)
311
+ eventStore.recordToolExecution(hookInput.sessionID, hookInput.tool, durationMs, success);
312
+
313
+ // Append tool_complete event
314
+ const toolEvent = emitToolCompleteEvent(
315
+ hookInput.sessionID,
316
+ hookInput.tool,
317
+ durationMs,
318
+ success,
319
+ );
320
+ eventStore.appendEvent(hookInput.sessionID, toolEvent);
321
+ };
322
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * In-memory session event store for observability data.
3
+ *
4
+ * Accumulates events, tokens, and tool metrics per session.
5
+ * Data is flushed to disk by the event handler layer on session end.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import type { TokenAggregate } from "./token-tracker";
11
+ import { accumulateTokens, createEmptyTokenAggregate } from "./token-tracker";
12
+
13
+ /**
14
+ * Unified event type for the in-memory store.
15
+ * Extends the Plan 01 SessionEvent types with additional observability events.
16
+ */
17
+ export type ObservabilityEvent =
18
+ | {
19
+ readonly type: "fallback";
20
+ readonly timestamp: string;
21
+ readonly sessionId: string;
22
+ readonly failedModel: string;
23
+ readonly nextModel: string;
24
+ readonly reason: string;
25
+ readonly success: boolean;
26
+ }
27
+ | {
28
+ readonly type: "error";
29
+ readonly timestamp: string;
30
+ readonly sessionId: string;
31
+ readonly errorType:
32
+ | "rate_limit"
33
+ | "quota_exceeded"
34
+ | "service_unavailable"
35
+ | "missing_api_key"
36
+ | "model_not_found"
37
+ | "content_filter"
38
+ | "context_length"
39
+ | "unknown";
40
+ readonly message: string;
41
+ readonly model: string;
42
+ readonly statusCode?: number;
43
+ }
44
+ | {
45
+ readonly type: "decision";
46
+ readonly timestamp: string;
47
+ readonly sessionId: string;
48
+ readonly phase: string;
49
+ readonly agent: string;
50
+ readonly decision: string;
51
+ readonly rationale: string;
52
+ }
53
+ | {
54
+ readonly type: "model_switch";
55
+ readonly timestamp: string;
56
+ readonly sessionId: string;
57
+ readonly fromModel: string;
58
+ readonly toModel: string;
59
+ readonly trigger: "fallback" | "config" | "user";
60
+ }
61
+ | {
62
+ readonly type: "context_warning";
63
+ readonly timestamp: string;
64
+ readonly sessionId: string;
65
+ readonly utilization: number;
66
+ readonly contextLimit: number;
67
+ readonly inputTokens: number;
68
+ }
69
+ | {
70
+ readonly type: "tool_complete";
71
+ readonly timestamp: string;
72
+ readonly sessionId: string;
73
+ readonly tool: string;
74
+ readonly durationMs: number;
75
+ readonly success: boolean;
76
+ }
77
+ | {
78
+ readonly type: "phase_transition";
79
+ readonly timestamp: string;
80
+ readonly sessionId: string;
81
+ readonly fromPhase: string;
82
+ readonly toPhase: string;
83
+ }
84
+ | { readonly type: "session_start"; readonly timestamp: string; readonly sessionId: string }
85
+ | {
86
+ readonly type: "session_end";
87
+ readonly timestamp: string;
88
+ readonly sessionId: string;
89
+ readonly durationMs: number;
90
+ readonly totalCost: number;
91
+ };
92
+
93
+ /**
94
+ * Per-tool execution metrics accumulated during a session.
95
+ */
96
+ export interface ToolMetrics {
97
+ readonly invocations: number;
98
+ readonly totalDurationMs: number;
99
+ readonly successes: number;
100
+ readonly failures: number;
101
+ }
102
+
103
+ /**
104
+ * All collected data for a single session.
105
+ */
106
+ export interface SessionEvents {
107
+ readonly sessionId: string;
108
+ readonly events: readonly ObservabilityEvent[];
109
+ readonly tokens: TokenAggregate;
110
+ readonly toolMetrics: ReadonlyMap<string, ToolMetrics>;
111
+ readonly currentPhase: string | null;
112
+ readonly startedAt: string;
113
+ }
114
+
115
+ /**
116
+ * Mutable internal state for a session (used within the store only).
117
+ */
118
+ interface MutableSessionData {
119
+ sessionId: string;
120
+ events: ObservabilityEvent[];
121
+ tokens: TokenAggregate;
122
+ toolMetrics: Map<string, ToolMetrics>;
123
+ currentPhase: string | null;
124
+ startedAt: string;
125
+ }
126
+
127
+ /**
128
+ * In-memory store for session observability events.
129
+ * Accumulates events, tokens, and tool metrics per session.
130
+ */
131
+ export class SessionEventStore {
132
+ private readonly sessions: Map<string, MutableSessionData> = new Map();
133
+
134
+ /**
135
+ * Initializes a new session in the store.
136
+ */
137
+ initSession(sessionId: string): void {
138
+ this.sessions.set(sessionId, {
139
+ sessionId,
140
+ events: [],
141
+ tokens: createEmptyTokenAggregate(),
142
+ toolMetrics: new Map(),
143
+ currentPhase: null,
144
+ startedAt: new Date().toISOString(),
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Appends an event to a session. Silently returns if session is not initialized.
150
+ */
151
+ appendEvent(sessionId: string, event: ObservabilityEvent): void {
152
+ const session = this.sessions.get(sessionId);
153
+ if (!session) return;
154
+ session.events.push(event);
155
+ }
156
+
157
+ /**
158
+ * Accumulates token data for a session.
159
+ */
160
+ accumulateTokens(sessionId: string, tokens: Partial<TokenAggregate>): void {
161
+ const session = this.sessions.get(sessionId);
162
+ if (!session) return;
163
+ session.tokens = accumulateTokens(session.tokens, tokens);
164
+ }
165
+
166
+ /**
167
+ * Records a tool execution metric.
168
+ */
169
+ recordToolExecution(sessionId: string, tool: string, durationMs: number, success: boolean): void {
170
+ const session = this.sessions.get(sessionId);
171
+ if (!session) return;
172
+
173
+ const existing = session.toolMetrics.get(tool);
174
+ const updated: ToolMetrics = {
175
+ invocations: (existing?.invocations ?? 0) + 1,
176
+ totalDurationMs: (existing?.totalDurationMs ?? 0) + durationMs,
177
+ successes: (existing?.successes ?? 0) + (success ? 1 : 0),
178
+ failures: (existing?.failures ?? 0) + (success ? 0 : 1),
179
+ };
180
+ session.toolMetrics.set(tool, updated);
181
+ }
182
+
183
+ /**
184
+ * Sets the current pipeline phase for a session.
185
+ */
186
+ setCurrentPhase(sessionId: string, phase: string): void {
187
+ const session = this.sessions.get(sessionId);
188
+ if (!session) return;
189
+ session.currentPhase = phase;
190
+ }
191
+
192
+ /**
193
+ * Gets the current pipeline phase for a session.
194
+ */
195
+ getCurrentPhase(sessionId: string): string | null {
196
+ return this.sessions.get(sessionId)?.currentPhase ?? null;
197
+ }
198
+
199
+ /**
200
+ * Returns a snapshot of session data without removing it.
201
+ */
202
+ getSession(sessionId: string): SessionEvents | undefined {
203
+ const session = this.sessions.get(sessionId);
204
+ if (!session) return undefined;
205
+
206
+ return {
207
+ sessionId: session.sessionId,
208
+ events: [...session.events],
209
+ tokens: session.tokens,
210
+ toolMetrics: new Map(session.toolMetrics),
211
+ currentPhase: session.currentPhase,
212
+ startedAt: session.startedAt,
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Returns session data and removes it from the store (for disk flush).
218
+ */
219
+ flush(sessionId: string): SessionEvents | undefined {
220
+ const session = this.getSession(sessionId);
221
+ if (session) {
222
+ this.sessions.delete(sessionId);
223
+ }
224
+ return session;
225
+ }
226
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Observability module barrel export.
3
+ *
4
+ * Re-exports all public APIs from the session observability system:
5
+ * - Schemas and types for structured events
6
+ * - Session logger for event capture (JSONL append)
7
+ * - Log writer for complete session persistence (atomic JSON)
8
+ * - Log reader for querying persisted sessions
9
+ * - Summary generator for human-readable markdown reports
10
+ * - Retention for time-based log pruning
11
+ */
12
+
13
+ export type { EventSearchFilters, SessionLogEntry } from "./log-reader";
14
+ export {
15
+ listSessionLogs,
16
+ readLatestSessionLog,
17
+ readSessionLog,
18
+ searchEvents,
19
+ } from "./log-reader";
20
+ export { convertToSessionLog, getLogsDir, writeSessionLog } from "./log-writer";
21
+ export { pruneOldLogs } from "./retention";
22
+ export {
23
+ baseEventSchema,
24
+ decisionEventSchema,
25
+ errorEventSchema,
26
+ fallbackEventSchema,
27
+ loggingConfigSchema,
28
+ loggingDefaults,
29
+ modelSwitchEventSchema,
30
+ sessionDecisionSchema,
31
+ sessionEventSchema,
32
+ sessionLogSchema,
33
+ } from "./schemas";
34
+ export { getLogsDir as getEventLogsDir, getSessionLog, logEvent } from "./session-logger";
35
+
36
+ export {
37
+ computeDuration,
38
+ formatCost,
39
+ formatDuration,
40
+ generateSessionSummary,
41
+ } from "./summary-generator";
42
+ export type {
43
+ BaseEvent,
44
+ DecisionEvent,
45
+ ErrorEvent,
46
+ FallbackEvent,
47
+ LoggingConfig,
48
+ ModelSwitchEvent,
49
+ SessionDecision,
50
+ SessionEvent,
51
+ SessionEventType,
52
+ SessionLog,
53
+ } from "./types";