@kodrunhq/opencode-autopilot 1.3.0 → 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.
- package/package.json +1 -1
- package/src/index.ts +68 -1
- package/src/observability/context-monitor.ts +102 -0
- package/src/observability/event-emitter.ts +136 -0
- package/src/observability/event-handlers.ts +322 -0
- package/src/observability/event-store.ts +226 -0
- package/src/observability/index.ts +53 -0
- package/src/observability/log-reader.ts +152 -0
- package/src/observability/log-writer.ts +93 -0
- package/src/observability/mock/mock-provider.ts +72 -0
- package/src/observability/mock/types.ts +31 -0
- package/src/observability/retention.ts +57 -0
- package/src/observability/schemas.ts +83 -0
- package/src/observability/session-logger.ts +63 -0
- package/src/observability/summary-generator.ts +209 -0
- package/src/observability/token-tracker.ts +97 -0
- package/src/observability/types.ts +24 -0
- package/src/tools/logs.ts +178 -0
- package/src/tools/mock-fallback.ts +100 -0
- package/src/tools/pipeline-report.ts +148 -0
- package/src/tools/session-stats.ts +185 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kodrunhq/opencode-autopilot",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Curated agents, skills, and commands for the OpenCode AI coding CLI — autonomous orchestrator, multi-agent code review, model fallback, and in-session asset creation tools.",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"keywords": [
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,16 @@ import { configHook } from "./agents";
|
|
|
3
3
|
import { isFirstLoad, loadConfig } from "./config";
|
|
4
4
|
import { runHealthChecks } from "./health/runner";
|
|
5
5
|
import { installAssets } from "./installer";
|
|
6
|
+
import { ContextMonitor } from "./observability/context-monitor";
|
|
7
|
+
import {
|
|
8
|
+
createObservabilityEventHandler,
|
|
9
|
+
createToolExecuteAfterHandler as createObsToolAfterHandler,
|
|
10
|
+
createToolExecuteBeforeHandler,
|
|
11
|
+
} from "./observability/event-handlers";
|
|
12
|
+
import { SessionEventStore } from "./observability/event-store";
|
|
13
|
+
import { writeSessionLog } from "./observability/log-writer";
|
|
14
|
+
import { pruneOldLogs } from "./observability/retention";
|
|
15
|
+
import type { SessionEvent } from "./observability/types";
|
|
6
16
|
import type { SdkOperations } from "./orchestrator/fallback";
|
|
7
17
|
import {
|
|
8
18
|
createChatMessageHandler,
|
|
@@ -24,11 +34,15 @@ import { ocCreateCommand } from "./tools/create-command";
|
|
|
24
34
|
import { ocCreateSkill } from "./tools/create-skill";
|
|
25
35
|
import { ocDoctor } from "./tools/doctor";
|
|
26
36
|
import { ocForensics } from "./tools/forensics";
|
|
37
|
+
import { ocLogs } from "./tools/logs";
|
|
38
|
+
import { ocMockFallback } from "./tools/mock-fallback";
|
|
27
39
|
import { ocOrchestrate } from "./tools/orchestrate";
|
|
28
40
|
import { ocPhase } from "./tools/phase";
|
|
41
|
+
import { ocPipelineReport } from "./tools/pipeline-report";
|
|
29
42
|
import { ocPlan } from "./tools/plan";
|
|
30
43
|
import { ocQuick } from "./tools/quick";
|
|
31
44
|
import { ocReview } from "./tools/review";
|
|
45
|
+
import { ocSessionStats } from "./tools/session-stats";
|
|
32
46
|
import { ocState } from "./tools/state";
|
|
33
47
|
|
|
34
48
|
let openCodeConfig: Config | null = null;
|
|
@@ -70,6 +84,15 @@ const plugin: Plugin = async (input) => {
|
|
|
70
84
|
// Health check failures are non-fatal — oc_doctor provides manual diagnostics
|
|
71
85
|
});
|
|
72
86
|
|
|
87
|
+
// --- Observability subsystem initialization ---
|
|
88
|
+
const eventStore = new SessionEventStore();
|
|
89
|
+
const contextMonitor = new ContextMonitor();
|
|
90
|
+
|
|
91
|
+
// Retention pruning on load (non-blocking per D-14)
|
|
92
|
+
pruneOldLogs().catch((err) => {
|
|
93
|
+
console.error("[opencode-autopilot]", err);
|
|
94
|
+
});
|
|
95
|
+
|
|
73
96
|
// --- Fallback subsystem initialization ---
|
|
74
97
|
const sdkOps: SdkOperations = {
|
|
75
98
|
abortSession: async (sessionID) => {
|
|
@@ -123,6 +146,32 @@ const plugin: Plugin = async (input) => {
|
|
|
123
146
|
const chatMessageHandler = createChatMessageHandler(manager);
|
|
124
147
|
const toolExecuteAfterHandler = createToolExecuteAfterHandler(manager);
|
|
125
148
|
|
|
149
|
+
// --- Observability handlers ---
|
|
150
|
+
const toolStartTimes = new Map<string, number>();
|
|
151
|
+
const observabilityEventHandler = createObservabilityEventHandler({
|
|
152
|
+
eventStore,
|
|
153
|
+
contextMonitor,
|
|
154
|
+
showToast: sdkOps.showToast,
|
|
155
|
+
writeSessionLog: async (sessionData) => {
|
|
156
|
+
if (!sessionData) return;
|
|
157
|
+
// Filter to schema-valid event types that match SessionEvent discriminated union
|
|
158
|
+
const schemaEvents: SessionEvent[] = sessionData.events.filter(
|
|
159
|
+
(e): e is SessionEvent =>
|
|
160
|
+
e.type === "fallback" ||
|
|
161
|
+
e.type === "error" ||
|
|
162
|
+
e.type === "decision" ||
|
|
163
|
+
e.type === "model_switch",
|
|
164
|
+
);
|
|
165
|
+
await writeSessionLog({
|
|
166
|
+
sessionId: sessionData.sessionId,
|
|
167
|
+
startedAt: sessionData.startedAt,
|
|
168
|
+
events: schemaEvents,
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
const obsToolBeforeHandler = createToolExecuteBeforeHandler(toolStartTimes);
|
|
173
|
+
const obsToolAfterHandler = createObsToolAfterHandler(eventStore, toolStartTimes);
|
|
174
|
+
|
|
126
175
|
return {
|
|
127
176
|
tool: {
|
|
128
177
|
oc_configure: ocConfigure,
|
|
@@ -138,8 +187,16 @@ const plugin: Plugin = async (input) => {
|
|
|
138
187
|
oc_quick: ocQuick,
|
|
139
188
|
oc_forensics: ocForensics,
|
|
140
189
|
oc_review: ocReview,
|
|
190
|
+
oc_logs: ocLogs,
|
|
191
|
+
oc_session_stats: ocSessionStats,
|
|
192
|
+
oc_pipeline_report: ocPipelineReport,
|
|
193
|
+
oc_mock_fallback: ocMockFallback,
|
|
141
194
|
},
|
|
142
195
|
event: async ({ event }) => {
|
|
196
|
+
// 1. Observability: collect (pure observer, no side effects on session)
|
|
197
|
+
await observabilityEventHandler({ event });
|
|
198
|
+
|
|
199
|
+
// 2. First-load toast
|
|
143
200
|
if (event.type === "session.created" && isFirstLoad(config)) {
|
|
144
201
|
await sdkOps.showToast(
|
|
145
202
|
"Welcome to OpenCode Autopilot!",
|
|
@@ -148,7 +205,7 @@ const plugin: Plugin = async (input) => {
|
|
|
148
205
|
);
|
|
149
206
|
}
|
|
150
207
|
|
|
151
|
-
// Fallback event handling
|
|
208
|
+
// 3. Fallback event handling
|
|
152
209
|
if (fallbackConfig.enabled) {
|
|
153
210
|
await fallbackEventHandler({ event });
|
|
154
211
|
}
|
|
@@ -173,6 +230,12 @@ const plugin: Plugin = async (input) => {
|
|
|
173
230
|
await chatMessageHandler(hookInput, output);
|
|
174
231
|
}
|
|
175
232
|
},
|
|
233
|
+
"tool.execute.before": async (
|
|
234
|
+
input: { tool: string; sessionID: string; callID: string },
|
|
235
|
+
output: { args: unknown },
|
|
236
|
+
) => {
|
|
237
|
+
obsToolBeforeHandler({ ...input, args: output.args });
|
|
238
|
+
},
|
|
176
239
|
"tool.execute.after": async (
|
|
177
240
|
hookInput: {
|
|
178
241
|
readonly tool: string;
|
|
@@ -182,6 +245,10 @@ const plugin: Plugin = async (input) => {
|
|
|
182
245
|
},
|
|
183
246
|
output: { title: string; output: string; metadata: unknown },
|
|
184
247
|
) => {
|
|
248
|
+
// Observability: record tool execution (pure observer)
|
|
249
|
+
obsToolAfterHandler(hookInput, output);
|
|
250
|
+
|
|
251
|
+
// Fallback handling
|
|
185
252
|
if (fallbackConfig.enabled) {
|
|
186
253
|
await toolExecuteAfterHandler(hookInput, output);
|
|
187
254
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context utilization tracking with one-time warning per session.
|
|
3
|
+
*
|
|
4
|
+
* Pure function `checkContextUtilization` computes utilization ratio and warning signal.
|
|
5
|
+
* `ContextMonitor` class tracks per-session warned state and context limits.
|
|
6
|
+
*
|
|
7
|
+
* The toast itself is NOT fired here -- that happens in the event handler
|
|
8
|
+
* (separation of concerns). This module only computes whether to warn.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Threshold at which context utilization triggers a warning (80%). */
|
|
14
|
+
const CONTEXT_WARNING_THRESHOLD = 0.8;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Result of a context utilization check.
|
|
18
|
+
*/
|
|
19
|
+
export interface ContextUtilizationResult {
|
|
20
|
+
readonly utilization: number;
|
|
21
|
+
readonly shouldWarn: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Pure function that computes context utilization and whether to warn.
|
|
26
|
+
*
|
|
27
|
+
* - Returns utilization as a ratio (0.0 - 1.0)
|
|
28
|
+
* - Returns shouldWarn=true when utilization >= 0.80 and not already warned
|
|
29
|
+
* - Returns shouldWarn=false when already warned (fires once per D-36)
|
|
30
|
+
* - Handles zero contextLimit gracefully (returns 0 utilization)
|
|
31
|
+
*
|
|
32
|
+
* @param latestInputTokens - Current cumulative input tokens for the session
|
|
33
|
+
* @param contextLimit - The model's context window size in tokens
|
|
34
|
+
* @param alreadyWarned - Whether this session has already been warned
|
|
35
|
+
*/
|
|
36
|
+
export function checkContextUtilization(
|
|
37
|
+
latestInputTokens: number,
|
|
38
|
+
contextLimit: number,
|
|
39
|
+
alreadyWarned: boolean,
|
|
40
|
+
): ContextUtilizationResult {
|
|
41
|
+
if (contextLimit <= 0) {
|
|
42
|
+
return { utilization: 0, shouldWarn: false };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const utilization = latestInputTokens / contextLimit;
|
|
46
|
+
const shouldWarn = !alreadyWarned && utilization >= CONTEXT_WARNING_THRESHOLD;
|
|
47
|
+
|
|
48
|
+
return { utilization, shouldWarn };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Per-session state for context monitoring.
|
|
53
|
+
*/
|
|
54
|
+
interface SessionContextState {
|
|
55
|
+
readonly contextLimit: number;
|
|
56
|
+
warned: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Tracks context utilization per session with one-time warning.
|
|
61
|
+
*
|
|
62
|
+
* - `initSession` sets the context limit for a session
|
|
63
|
+
* - `processMessage` checks utilization and updates warned state
|
|
64
|
+
* - `cleanup` removes session tracking data
|
|
65
|
+
*/
|
|
66
|
+
export class ContextMonitor {
|
|
67
|
+
private readonly sessions: Map<string, SessionContextState> = new Map();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Initializes tracking for a session with its model's context limit.
|
|
71
|
+
*/
|
|
72
|
+
initSession(sessionID: string, contextLimit: number): void {
|
|
73
|
+
this.sessions.set(sessionID, { contextLimit, warned: false });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Checks context utilization for a session and updates warned state.
|
|
78
|
+
* Returns utilization 0 for unknown sessions.
|
|
79
|
+
*/
|
|
80
|
+
processMessage(sessionID: string, inputTokens: number): ContextUtilizationResult {
|
|
81
|
+
const state = this.sessions.get(sessionID);
|
|
82
|
+
if (!state) {
|
|
83
|
+
return { utilization: 0, shouldWarn: false };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const result = checkContextUtilization(inputTokens, state.contextLimit, state.warned);
|
|
87
|
+
|
|
88
|
+
// Update warned flag if warning triggered (one-time per session)
|
|
89
|
+
if (result.shouldWarn) {
|
|
90
|
+
this.sessions.set(sessionID, { ...state, warned: true });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Removes session tracking data.
|
|
98
|
+
*/
|
|
99
|
+
cleanup(sessionID: string): void {
|
|
100
|
+
this.sessions.delete(sessionID);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed event emitter helper functions for observability events.
|
|
3
|
+
*
|
|
4
|
+
* Each function constructs a frozen ObservabilityEvent with a timestamp.
|
|
5
|
+
* Pure functions: take args, return frozen event object.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ObservabilityEvent } from "./event-store";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Constructs a fallback event.
|
|
14
|
+
*/
|
|
15
|
+
export function emitFallbackEvent(
|
|
16
|
+
sessionId: string,
|
|
17
|
+
failedModel: string,
|
|
18
|
+
nextModel: string,
|
|
19
|
+
reason: string,
|
|
20
|
+
success: boolean,
|
|
21
|
+
): ObservabilityEvent {
|
|
22
|
+
return Object.freeze({
|
|
23
|
+
type: "fallback" as const,
|
|
24
|
+
timestamp: new Date().toISOString(),
|
|
25
|
+
sessionId,
|
|
26
|
+
failedModel,
|
|
27
|
+
nextModel,
|
|
28
|
+
reason,
|
|
29
|
+
success,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Constructs an error event.
|
|
35
|
+
*/
|
|
36
|
+
export function emitErrorEvent(
|
|
37
|
+
sessionId: string,
|
|
38
|
+
errorType:
|
|
39
|
+
| "rate_limit"
|
|
40
|
+
| "quota_exceeded"
|
|
41
|
+
| "service_unavailable"
|
|
42
|
+
| "missing_api_key"
|
|
43
|
+
| "model_not_found"
|
|
44
|
+
| "content_filter"
|
|
45
|
+
| "context_length"
|
|
46
|
+
| "unknown",
|
|
47
|
+
message: string,
|
|
48
|
+
model = "unknown",
|
|
49
|
+
statusCode?: number,
|
|
50
|
+
): ObservabilityEvent {
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
type: "error" as const,
|
|
53
|
+
timestamp: new Date().toISOString(),
|
|
54
|
+
sessionId,
|
|
55
|
+
errorType,
|
|
56
|
+
message,
|
|
57
|
+
model,
|
|
58
|
+
...(statusCode !== undefined ? { statusCode } : {}),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Constructs a decision event (per D-27, D-28).
|
|
64
|
+
*/
|
|
65
|
+
export function emitDecisionEvent(
|
|
66
|
+
sessionId: string,
|
|
67
|
+
phase: string,
|
|
68
|
+
agent: string,
|
|
69
|
+
decision: string,
|
|
70
|
+
rationale: string,
|
|
71
|
+
): ObservabilityEvent {
|
|
72
|
+
return Object.freeze({
|
|
73
|
+
type: "decision" as const,
|
|
74
|
+
timestamp: new Date().toISOString(),
|
|
75
|
+
sessionId,
|
|
76
|
+
phase,
|
|
77
|
+
agent,
|
|
78
|
+
decision,
|
|
79
|
+
rationale,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Constructs a model_switch event.
|
|
85
|
+
*/
|
|
86
|
+
export function emitModelSwitchEvent(
|
|
87
|
+
sessionId: string,
|
|
88
|
+
fromModel: string,
|
|
89
|
+
toModel: string,
|
|
90
|
+
trigger: "fallback" | "config" | "user",
|
|
91
|
+
): ObservabilityEvent {
|
|
92
|
+
return Object.freeze({
|
|
93
|
+
type: "model_switch" as const,
|
|
94
|
+
timestamp: new Date().toISOString(),
|
|
95
|
+
sessionId,
|
|
96
|
+
fromModel,
|
|
97
|
+
toModel,
|
|
98
|
+
trigger,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Constructs a tool_complete event.
|
|
104
|
+
*/
|
|
105
|
+
export function emitToolCompleteEvent(
|
|
106
|
+
sessionId: string,
|
|
107
|
+
tool: string,
|
|
108
|
+
durationMs: number,
|
|
109
|
+
success: boolean,
|
|
110
|
+
): ObservabilityEvent {
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
type: "tool_complete" as const,
|
|
113
|
+
timestamp: new Date().toISOString(),
|
|
114
|
+
sessionId,
|
|
115
|
+
tool,
|
|
116
|
+
durationMs,
|
|
117
|
+
success,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Constructs a phase_transition event.
|
|
123
|
+
*/
|
|
124
|
+
export function emitPhaseTransition(
|
|
125
|
+
sessionId: string,
|
|
126
|
+
fromPhase: string,
|
|
127
|
+
toPhase: string,
|
|
128
|
+
): ObservabilityEvent {
|
|
129
|
+
return Object.freeze({
|
|
130
|
+
type: "phase_transition" as const,
|
|
131
|
+
timestamp: new Date().toISOString(),
|
|
132
|
+
sessionId,
|
|
133
|
+
fromPhase,
|
|
134
|
+
toPhase,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -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
|
+
}
|