@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.
- package/assets/commands/quick.md +7 -0
- package/package.json +1 -1
- package/src/health/checks.ts +125 -0
- package/src/health/index.ts +3 -0
- package/src/health/runner.ts +56 -0
- package/src/health/types.ts +20 -0
- package/src/index.ts +78 -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/configure.ts +13 -3
- package/src/tools/doctor.ts +111 -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/quick.ts +126 -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": [
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import type { Config } from "@opencode-ai/plugin";
|
|
3
|
+
import { loadConfig } from "../config";
|
|
4
|
+
import { AGENT_NAMES } from "../orchestrator/handlers/types";
|
|
5
|
+
import { getAssetsDir, getGlobalConfigDir } from "../utils/paths";
|
|
6
|
+
import type { HealthResult } from "./types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check that the plugin config file exists and passes Zod validation.
|
|
10
|
+
* loadConfig returns null when the file is missing, and throws on invalid JSON/schema.
|
|
11
|
+
*/
|
|
12
|
+
export async function configHealthCheck(configPath?: string): Promise<HealthResult> {
|
|
13
|
+
try {
|
|
14
|
+
const config = await loadConfig(configPath);
|
|
15
|
+
if (config === null) {
|
|
16
|
+
return Object.freeze({
|
|
17
|
+
name: "config-validity",
|
|
18
|
+
status: "fail" as const,
|
|
19
|
+
message: "Plugin config file not found",
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
return Object.freeze({
|
|
23
|
+
name: "config-validity",
|
|
24
|
+
status: "pass" as const,
|
|
25
|
+
message: `Config v${config.version} loaded and valid`,
|
|
26
|
+
});
|
|
27
|
+
} catch (error: unknown) {
|
|
28
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
29
|
+
return Object.freeze({
|
|
30
|
+
name: "config-validity",
|
|
31
|
+
status: "fail" as const,
|
|
32
|
+
message: `Config validation failed: ${msg}`,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Standard agent names, derived from the agents barrel export. */
|
|
38
|
+
const STANDARD_AGENT_NAMES: readonly string[] = Object.freeze([
|
|
39
|
+
"researcher",
|
|
40
|
+
"metaprompter",
|
|
41
|
+
"documenter",
|
|
42
|
+
"pr-reviewer",
|
|
43
|
+
"autopilot",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/** Pipeline agent names, derived from AGENT_NAMES in the orchestrator. */
|
|
47
|
+
const PIPELINE_AGENT_NAMES: readonly string[] = Object.freeze(Object.values(AGENT_NAMES));
|
|
48
|
+
|
|
49
|
+
/** All expected agent names (standard + pipeline). */
|
|
50
|
+
const EXPECTED_AGENTS: readonly string[] = Object.freeze([
|
|
51
|
+
...STANDARD_AGENT_NAMES,
|
|
52
|
+
...PIPELINE_AGENT_NAMES,
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check that all expected agents are injected into the OpenCode config.
|
|
57
|
+
* Requires the OpenCode config object (from the config hook).
|
|
58
|
+
*/
|
|
59
|
+
export async function agentHealthCheck(config: Config | null): Promise<HealthResult> {
|
|
60
|
+
if (!config?.agent) {
|
|
61
|
+
return Object.freeze({
|
|
62
|
+
name: "agent-injection",
|
|
63
|
+
status: "fail" as const,
|
|
64
|
+
message: "No OpenCode config or agent map available",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const agentMap = config.agent;
|
|
69
|
+
const missing = EXPECTED_AGENTS.filter((name) => !(name in agentMap));
|
|
70
|
+
|
|
71
|
+
if (missing.length > 0) {
|
|
72
|
+
return Object.freeze({
|
|
73
|
+
name: "agent-injection",
|
|
74
|
+
status: "fail" as const,
|
|
75
|
+
message: `${missing.length} agent(s) missing: ${missing.join(", ")}`,
|
|
76
|
+
details: Object.freeze(missing),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return Object.freeze({
|
|
81
|
+
name: "agent-injection",
|
|
82
|
+
status: "pass" as const,
|
|
83
|
+
message: `All ${EXPECTED_AGENTS.length} agents injected`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check that the source and target asset directories exist and are accessible.
|
|
89
|
+
*/
|
|
90
|
+
export async function assetHealthCheck(
|
|
91
|
+
assetsDir?: string,
|
|
92
|
+
targetDir?: string,
|
|
93
|
+
): Promise<HealthResult> {
|
|
94
|
+
const source = assetsDir ?? getAssetsDir();
|
|
95
|
+
const target = targetDir ?? getGlobalConfigDir();
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await access(source);
|
|
99
|
+
} catch (error: unknown) {
|
|
100
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
101
|
+
const detail = code === "ENOENT" ? "missing" : `inaccessible (${code})`;
|
|
102
|
+
return Object.freeze({
|
|
103
|
+
name: "asset-directories",
|
|
104
|
+
status: "fail" as const,
|
|
105
|
+
message: `Asset source directory ${detail}: ${source}`,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
await access(target);
|
|
111
|
+
return Object.freeze({
|
|
112
|
+
name: "asset-directories",
|
|
113
|
+
status: "pass" as const,
|
|
114
|
+
message: `Asset directories exist: source=${source}, target=${target}`,
|
|
115
|
+
});
|
|
116
|
+
} catch (error: unknown) {
|
|
117
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
118
|
+
const detail = code === "ENOENT" ? "missing" : `inaccessible (${code})`;
|
|
119
|
+
return Object.freeze({
|
|
120
|
+
name: "asset-directories",
|
|
121
|
+
status: "fail" as const,
|
|
122
|
+
message: `Asset target directory ${detail}: ${target}`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Config } from "@opencode-ai/plugin";
|
|
2
|
+
import { agentHealthCheck, assetHealthCheck, configHealthCheck } from "./checks";
|
|
3
|
+
import type { HealthReport, HealthResult } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Map a settled promise result to a HealthResult.
|
|
7
|
+
* Fulfilled results pass through; rejected results become fail entries.
|
|
8
|
+
*/
|
|
9
|
+
function settledToResult(
|
|
10
|
+
outcome: PromiseSettledResult<HealthResult>,
|
|
11
|
+
fallbackName: string,
|
|
12
|
+
): HealthResult {
|
|
13
|
+
if (outcome.status === "fulfilled") {
|
|
14
|
+
return outcome.value;
|
|
15
|
+
}
|
|
16
|
+
const msg = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
|
|
17
|
+
return Object.freeze({
|
|
18
|
+
name: fallbackName,
|
|
19
|
+
status: "fail" as const,
|
|
20
|
+
message: `Check threw unexpectedly: ${msg}`,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Run all health checks and aggregate into a HealthReport.
|
|
26
|
+
* Each check runs independently — a failure in one does not skip others.
|
|
27
|
+
* Uses Promise.allSettled so a throwing check cannot kill the entire report.
|
|
28
|
+
*/
|
|
29
|
+
export async function runHealthChecks(options?: {
|
|
30
|
+
configPath?: string;
|
|
31
|
+
openCodeConfig?: Config | null;
|
|
32
|
+
assetsDir?: string;
|
|
33
|
+
targetDir?: string;
|
|
34
|
+
}): Promise<HealthReport> {
|
|
35
|
+
const start = Date.now();
|
|
36
|
+
|
|
37
|
+
const settled = await Promise.allSettled([
|
|
38
|
+
configHealthCheck(options?.configPath),
|
|
39
|
+
agentHealthCheck(options?.openCodeConfig ?? null),
|
|
40
|
+
assetHealthCheck(options?.assetsDir, options?.targetDir),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
const fallbackNames = ["config-validity", "agent-injection", "asset-directories"];
|
|
44
|
+
const results: readonly HealthResult[] = Object.freeze(
|
|
45
|
+
settled.map((outcome, i) => settledToResult(outcome, fallbackNames[i])),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
const allPassed = results.every((r) => r.status === "pass");
|
|
49
|
+
const duration = Date.now() - start;
|
|
50
|
+
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
results,
|
|
53
|
+
allPassed,
|
|
54
|
+
duration,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health check result for a single diagnostic check.
|
|
3
|
+
* Immutable — frozen on creation by each check function.
|
|
4
|
+
*/
|
|
5
|
+
export interface HealthResult {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly status: "pass" | "fail";
|
|
8
|
+
readonly message: string;
|
|
9
|
+
readonly details?: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Aggregated health report from running all checks.
|
|
14
|
+
* Immutable — frozen on creation by runHealthChecks.
|
|
15
|
+
*/
|
|
16
|
+
export interface HealthReport {
|
|
17
|
+
readonly results: readonly HealthResult[];
|
|
18
|
+
readonly allPassed: boolean;
|
|
19
|
+
readonly duration: number;
|
|
20
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
import type { Config, Plugin } from "@opencode-ai/plugin";
|
|
2
2
|
import { configHook } from "./agents";
|
|
3
3
|
import { isFirstLoad, loadConfig } from "./config";
|
|
4
|
+
import { runHealthChecks } from "./health/runner";
|
|
4
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";
|
|
5
16
|
import type { SdkOperations } from "./orchestrator/fallback";
|
|
6
17
|
import {
|
|
7
18
|
createChatMessageHandler,
|
|
@@ -21,11 +32,17 @@ import {
|
|
|
21
32
|
import { ocCreateAgent } from "./tools/create-agent";
|
|
22
33
|
import { ocCreateCommand } from "./tools/create-command";
|
|
23
34
|
import { ocCreateSkill } from "./tools/create-skill";
|
|
35
|
+
import { ocDoctor } from "./tools/doctor";
|
|
24
36
|
import { ocForensics } from "./tools/forensics";
|
|
37
|
+
import { ocLogs } from "./tools/logs";
|
|
38
|
+
import { ocMockFallback } from "./tools/mock-fallback";
|
|
25
39
|
import { ocOrchestrate } from "./tools/orchestrate";
|
|
26
40
|
import { ocPhase } from "./tools/phase";
|
|
41
|
+
import { ocPipelineReport } from "./tools/pipeline-report";
|
|
27
42
|
import { ocPlan } from "./tools/plan";
|
|
43
|
+
import { ocQuick } from "./tools/quick";
|
|
28
44
|
import { ocReview } from "./tools/review";
|
|
45
|
+
import { ocSessionStats } from "./tools/session-stats";
|
|
29
46
|
import { ocState } from "./tools/state";
|
|
30
47
|
|
|
31
48
|
let openCodeConfig: Config | null = null;
|
|
@@ -62,6 +79,20 @@ const plugin: Plugin = async (input) => {
|
|
|
62
79
|
const config = await loadConfig();
|
|
63
80
|
const fallbackConfig = config?.fallback ?? fallbackDefaults;
|
|
64
81
|
|
|
82
|
+
// Self-healing health checks on every load (non-blocking, <100ms target)
|
|
83
|
+
runHealthChecks().catch(() => {
|
|
84
|
+
// Health check failures are non-fatal — oc_doctor provides manual diagnostics
|
|
85
|
+
});
|
|
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
|
+
|
|
65
96
|
// --- Fallback subsystem initialization ---
|
|
66
97
|
const sdkOps: SdkOperations = {
|
|
67
98
|
abortSession: async (sessionID) => {
|
|
@@ -115,6 +146,32 @@ const plugin: Plugin = async (input) => {
|
|
|
115
146
|
const chatMessageHandler = createChatMessageHandler(manager);
|
|
116
147
|
const toolExecuteAfterHandler = createToolExecuteAfterHandler(manager);
|
|
117
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
|
+
|
|
118
175
|
return {
|
|
119
176
|
tool: {
|
|
120
177
|
oc_configure: ocConfigure,
|
|
@@ -126,10 +183,20 @@ const plugin: Plugin = async (input) => {
|
|
|
126
183
|
oc_phase: ocPhase,
|
|
127
184
|
oc_plan: ocPlan,
|
|
128
185
|
oc_orchestrate: ocOrchestrate,
|
|
186
|
+
oc_doctor: ocDoctor,
|
|
187
|
+
oc_quick: ocQuick,
|
|
129
188
|
oc_forensics: ocForensics,
|
|
130
189
|
oc_review: ocReview,
|
|
190
|
+
oc_logs: ocLogs,
|
|
191
|
+
oc_session_stats: ocSessionStats,
|
|
192
|
+
oc_pipeline_report: ocPipelineReport,
|
|
193
|
+
oc_mock_fallback: ocMockFallback,
|
|
131
194
|
},
|
|
132
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
|
|
133
200
|
if (event.type === "session.created" && isFirstLoad(config)) {
|
|
134
201
|
await sdkOps.showToast(
|
|
135
202
|
"Welcome to OpenCode Autopilot!",
|
|
@@ -138,7 +205,7 @@ const plugin: Plugin = async (input) => {
|
|
|
138
205
|
);
|
|
139
206
|
}
|
|
140
207
|
|
|
141
|
-
// Fallback event handling
|
|
208
|
+
// 3. Fallback event handling
|
|
142
209
|
if (fallbackConfig.enabled) {
|
|
143
210
|
await fallbackEventHandler({ event });
|
|
144
211
|
}
|
|
@@ -163,6 +230,12 @@ const plugin: Plugin = async (input) => {
|
|
|
163
230
|
await chatMessageHandler(hookInput, output);
|
|
164
231
|
}
|
|
165
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
|
+
},
|
|
166
239
|
"tool.execute.after": async (
|
|
167
240
|
hookInput: {
|
|
168
241
|
readonly tool: string;
|
|
@@ -172,6 +245,10 @@ const plugin: Plugin = async (input) => {
|
|
|
172
245
|
},
|
|
173
246
|
output: { title: string; output: string; metadata: unknown },
|
|
174
247
|
) => {
|
|
248
|
+
// Observability: record tool execution (pure observer)
|
|
249
|
+
obsToolAfterHandler(hookInput, output);
|
|
250
|
+
|
|
251
|
+
// Fallback handling
|
|
175
252
|
if (fallbackConfig.enabled) {
|
|
176
253
|
await toolExecuteAfterHandler(hookInput, output);
|
|
177
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
|
+
}
|