@kodrunhq/opencode-autopilot 1.3.0 → 1.5.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/assets/commands/brainstorm.md +7 -0
- package/assets/commands/stocktake.md +7 -0
- package/assets/commands/tdd.md +7 -0
- package/assets/commands/update-docs.md +7 -0
- package/assets/commands/write-plan.md +7 -0
- package/assets/skills/brainstorming/SKILL.md +295 -0
- package/assets/skills/code-review/SKILL.md +241 -0
- package/assets/skills/e2e-testing/SKILL.md +266 -0
- package/assets/skills/git-worktrees/SKILL.md +296 -0
- package/assets/skills/go-patterns/SKILL.md +240 -0
- package/assets/skills/plan-executing/SKILL.md +258 -0
- package/assets/skills/plan-writing/SKILL.md +278 -0
- package/assets/skills/python-patterns/SKILL.md +255 -0
- package/assets/skills/rust-patterns/SKILL.md +293 -0
- package/assets/skills/strategic-compaction/SKILL.md +217 -0
- package/assets/skills/systematic-debugging/SKILL.md +299 -0
- package/assets/skills/tdd-workflow/SKILL.md +311 -0
- package/assets/skills/typescript-patterns/SKILL.md +278 -0
- package/assets/skills/verification/SKILL.md +240 -0
- package/package.json +1 -1
- package/src/index.ts +72 -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/orchestrator/skill-injection.ts +38 -0
- package/src/review/sanitize.ts +1 -1
- package/src/skills/adaptive-injector.ts +122 -0
- package/src/skills/dependency-resolver.ts +88 -0
- package/src/skills/linter.ts +113 -0
- package/src/skills/loader.ts +88 -0
- package/src/templates/skill-template.ts +4 -0
- package/src/tools/create-skill.ts +12 -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/src/tools/stocktake.ts +170 -0
- package/src/tools/update-docs.ts +116 -0
|
@@ -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
|
+
}
|