@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
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session summary generator.
|
|
3
|
+
*
|
|
4
|
+
* Transforms structured SessionLog data into human-readable markdown summaries
|
|
5
|
+
* for post-session analysis. All functions are pure (no I/O, no side effects).
|
|
6
|
+
*
|
|
7
|
+
* Summaries include: metadata, decisions, errors, fallbacks, model switches,
|
|
8
|
+
* and a strategic one-paragraph analysis (D-03, D-11, D-42, D-43).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { SessionEvent, SessionLog } from "./types";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Computes session duration in milliseconds from startedAt/endedAt.
|
|
15
|
+
* Returns 0 when endedAt is null (session still in progress).
|
|
16
|
+
*/
|
|
17
|
+
export function computeDuration(log: SessionLog): number {
|
|
18
|
+
if (!log.endedAt) return 0;
|
|
19
|
+
return new Date(log.endedAt).getTime() - new Date(log.startedAt).getTime();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Formats duration in milliseconds to human-readable string.
|
|
24
|
+
* Uses "Xh Ym" for durations >= 1 hour, "Xm Ys" for >= 1 minute, "Xs" otherwise.
|
|
25
|
+
*/
|
|
26
|
+
export function formatDuration(ms: number): string {
|
|
27
|
+
const totalSeconds = Math.floor(ms / 1000);
|
|
28
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
29
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
30
|
+
const seconds = totalSeconds % 60;
|
|
31
|
+
|
|
32
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
33
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
34
|
+
return `${seconds}s`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Formats cost as USD string with 4 decimal places.
|
|
39
|
+
*/
|
|
40
|
+
export function formatCost(cost: number): string {
|
|
41
|
+
return `$${cost.toFixed(4)}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Generates a complete markdown summary from a SessionLog.
|
|
46
|
+
*
|
|
47
|
+
* Includes sections for: metadata, decisions, errors, fallbacks,
|
|
48
|
+
* model switches, and strategic summary. Sections are omitted when
|
|
49
|
+
* no relevant data exists.
|
|
50
|
+
*/
|
|
51
|
+
export function generateSessionSummary(log: SessionLog): string {
|
|
52
|
+
const sections: string[] = [];
|
|
53
|
+
|
|
54
|
+
// Header
|
|
55
|
+
sections.push(renderHeader(log));
|
|
56
|
+
|
|
57
|
+
// Decisions section (conditional)
|
|
58
|
+
if (log.decisions.length > 0) {
|
|
59
|
+
sections.push(renderDecisions(log));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Errors section (conditional)
|
|
63
|
+
const errorEvents = log.events.filter(
|
|
64
|
+
(e): e is SessionEvent & { type: "error" } => e.type === "error",
|
|
65
|
+
);
|
|
66
|
+
if (errorEvents.length > 0) {
|
|
67
|
+
sections.push(renderErrors(errorEvents, log.errorSummary));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Fallbacks section (conditional)
|
|
71
|
+
const fallbackEvents = log.events.filter(
|
|
72
|
+
(e): e is SessionEvent & { type: "fallback" } => e.type === "fallback",
|
|
73
|
+
);
|
|
74
|
+
if (fallbackEvents.length > 0) {
|
|
75
|
+
sections.push(renderFallbacks(fallbackEvents));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Model switches section (conditional)
|
|
79
|
+
const switchEvents = log.events.filter(
|
|
80
|
+
(e): e is SessionEvent & { type: "model_switch" } => e.type === "model_switch",
|
|
81
|
+
);
|
|
82
|
+
if (switchEvents.length > 0) {
|
|
83
|
+
sections.push(renderModelSwitches(switchEvents));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Strategic summary (always present, varies by content)
|
|
87
|
+
sections.push(renderStrategicSummary(log));
|
|
88
|
+
|
|
89
|
+
return sections.join("\n\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- Internal renderers ---
|
|
93
|
+
|
|
94
|
+
function renderHeader(log: SessionLog): string {
|
|
95
|
+
const durationMs = computeDuration(log);
|
|
96
|
+
const durationStr = log.endedAt ? formatDuration(durationMs) : "In progress";
|
|
97
|
+
|
|
98
|
+
return [
|
|
99
|
+
`# Session Summary: ${log.sessionId}`,
|
|
100
|
+
"",
|
|
101
|
+
"| Field | Value |",
|
|
102
|
+
"|-------|-------|",
|
|
103
|
+
`| Started | ${log.startedAt} |`,
|
|
104
|
+
`| Ended | ${log.endedAt ?? "In progress"} |`,
|
|
105
|
+
`| Duration | ${durationStr} |`,
|
|
106
|
+
`| Events | ${log.events.length} |`,
|
|
107
|
+
`| Decisions | ${log.decisions.length} |`,
|
|
108
|
+
].join("\n");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function renderDecisions(log: SessionLog): string {
|
|
112
|
+
const lines = ["## Decisions", ""];
|
|
113
|
+
|
|
114
|
+
for (let i = 0; i < log.decisions.length; i++) {
|
|
115
|
+
const d = log.decisions[i];
|
|
116
|
+
lines.push(`${i + 1}. **[${d.phase}] ${d.agent}**: ${d.decision}`);
|
|
117
|
+
lines.push(` - _Rationale:_ ${d.rationale}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return lines.join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function renderErrors(
|
|
124
|
+
events: readonly (SessionEvent & { type: "error" })[],
|
|
125
|
+
errorSummary: Record<string, number>,
|
|
126
|
+
): string {
|
|
127
|
+
const lines = [
|
|
128
|
+
"## Errors",
|
|
129
|
+
"",
|
|
130
|
+
"| Timestamp | Type | Model | Message |",
|
|
131
|
+
"|-----------|------|-------|---------|",
|
|
132
|
+
];
|
|
133
|
+
|
|
134
|
+
for (const e of events) {
|
|
135
|
+
const safeMsg = (e.message ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
136
|
+
const safeModel = (e.model ?? "").replace(/\|/g, "\\|");
|
|
137
|
+
lines.push(`| ${e.timestamp} | ${e.errorType} | ${safeModel} | ${safeMsg} |`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Error summary counts
|
|
141
|
+
const summaryEntries = Object.entries(errorSummary);
|
|
142
|
+
if (summaryEntries.length > 0) {
|
|
143
|
+
lines.push("");
|
|
144
|
+
lines.push("**Error Summary:**");
|
|
145
|
+
for (const [type, count] of summaryEntries) {
|
|
146
|
+
lines.push(`- ${type}: ${count}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return lines.join("\n");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderFallbacks(events: readonly (SessionEvent & { type: "fallback" })[]): string {
|
|
154
|
+
const lines = [
|
|
155
|
+
"## Fallbacks",
|
|
156
|
+
"",
|
|
157
|
+
"| Timestamp | Failed Model | Next Model | Reason | Success |",
|
|
158
|
+
"|-----------|-------------|------------|--------|---------|",
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
for (const e of events) {
|
|
162
|
+
lines.push(
|
|
163
|
+
`| ${e.timestamp} | ${e.failedModel} | ${e.nextModel} | ${e.reason} | ${e.success ? "Yes" : "No"} |`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return lines.join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function renderModelSwitches(events: readonly (SessionEvent & { type: "model_switch" })[]): string {
|
|
171
|
+
const lines = [
|
|
172
|
+
"## Model Switches",
|
|
173
|
+
"",
|
|
174
|
+
"| Timestamp | From | To | Trigger |",
|
|
175
|
+
"|-----------|------|----|---------|",
|
|
176
|
+
];
|
|
177
|
+
|
|
178
|
+
for (const e of events) {
|
|
179
|
+
lines.push(`| ${e.timestamp} | ${e.fromModel} | ${e.toModel} | ${e.trigger} |`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return lines.join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function renderStrategicSummary(log: SessionLog): string {
|
|
186
|
+
const errorCount = log.events.filter((e) => e.type === "error").length;
|
|
187
|
+
const fallbackCount = log.events.filter((e) => e.type === "fallback").length;
|
|
188
|
+
const durationMs = computeDuration(log);
|
|
189
|
+
const durationStr = log.endedAt ? formatDuration(durationMs) : "unknown (in progress)";
|
|
190
|
+
|
|
191
|
+
const parts: string[] = [];
|
|
192
|
+
parts.push(
|
|
193
|
+
`Session ran for ${durationStr} with ${log.events.length} events and ${log.decisions.length} autonomous decisions.`,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (errorCount > 0) {
|
|
197
|
+
const successfulFallbacks = log.events.filter((e) => e.type === "fallback" && e.success).length;
|
|
198
|
+
parts.push(
|
|
199
|
+
`Encountered ${errorCount} errors; ${fallbackCount} fallback attempts (${successfulFallbacks} successful).`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (log.decisions.length > 0) {
|
|
204
|
+
const phases = [...new Set(log.decisions.map((d) => d.phase))];
|
|
205
|
+
parts.push(`Decisions spanned ${phases.length} phase(s): ${phases.join(", ")}.`);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return ["## Summary", "", parts.join(" ")].join("\n");
|
|
209
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token accumulation utilities for session observability.
|
|
3
|
+
*
|
|
4
|
+
* Pure functions that accumulate token/cost data from AssistantMessage shapes.
|
|
5
|
+
* Returns new objects (immutable, per CLAUDE.md) -- never mutates inputs.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Aggregated token and cost data for a session.
|
|
12
|
+
*/
|
|
13
|
+
export interface TokenAggregate {
|
|
14
|
+
readonly inputTokens: number;
|
|
15
|
+
readonly outputTokens: number;
|
|
16
|
+
readonly reasoningTokens: number;
|
|
17
|
+
readonly cacheReadTokens: number;
|
|
18
|
+
readonly cacheWriteTokens: number;
|
|
19
|
+
readonly totalCost: number;
|
|
20
|
+
readonly messageCount: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Returns a zero-initialized TokenAggregate.
|
|
25
|
+
*/
|
|
26
|
+
export function createEmptyTokenAggregate(): TokenAggregate {
|
|
27
|
+
return Object.freeze({
|
|
28
|
+
inputTokens: 0,
|
|
29
|
+
outputTokens: 0,
|
|
30
|
+
reasoningTokens: 0,
|
|
31
|
+
cacheReadTokens: 0,
|
|
32
|
+
cacheWriteTokens: 0,
|
|
33
|
+
totalCost: 0,
|
|
34
|
+
messageCount: 0,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Accumulates token/cost data into a new TokenAggregate.
|
|
40
|
+
* Missing fields in `incoming` default to 0.
|
|
41
|
+
* Returns a new frozen object (immutable).
|
|
42
|
+
*
|
|
43
|
+
* @param current - The current aggregate to add onto
|
|
44
|
+
* @param incoming - Partial aggregate with fields to add
|
|
45
|
+
*/
|
|
46
|
+
export function accumulateTokens(
|
|
47
|
+
current: TokenAggregate,
|
|
48
|
+
incoming: Partial<TokenAggregate>,
|
|
49
|
+
): TokenAggregate {
|
|
50
|
+
return Object.freeze({
|
|
51
|
+
inputTokens: current.inputTokens + (incoming.inputTokens ?? 0),
|
|
52
|
+
outputTokens: current.outputTokens + (incoming.outputTokens ?? 0),
|
|
53
|
+
reasoningTokens: current.reasoningTokens + (incoming.reasoningTokens ?? 0),
|
|
54
|
+
cacheReadTokens: current.cacheReadTokens + (incoming.cacheReadTokens ?? 0),
|
|
55
|
+
cacheWriteTokens: current.cacheWriteTokens + (incoming.cacheWriteTokens ?? 0),
|
|
56
|
+
totalCost: current.totalCost + (incoming.totalCost ?? 0),
|
|
57
|
+
messageCount: current.messageCount + (incoming.messageCount ?? 0),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Shape of token data within an AssistantMessage from the OpenCode SDK.
|
|
63
|
+
*/
|
|
64
|
+
export interface AssistantMessageTokens {
|
|
65
|
+
readonly tokens: {
|
|
66
|
+
readonly input: number;
|
|
67
|
+
readonly output: number;
|
|
68
|
+
readonly reasoning: number;
|
|
69
|
+
readonly cache: {
|
|
70
|
+
readonly read: number;
|
|
71
|
+
readonly write: number;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
readonly cost: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Extracts token/cost data from an AssistantMessage shape and accumulates
|
|
79
|
+
* it into a new TokenAggregate. Increments messageCount by 1.
|
|
80
|
+
*
|
|
81
|
+
* @param current - The current aggregate to add onto
|
|
82
|
+
* @param msg - The AssistantMessage-shaped object with tokens and cost
|
|
83
|
+
*/
|
|
84
|
+
export function accumulateTokensFromMessage(
|
|
85
|
+
current: TokenAggregate,
|
|
86
|
+
msg: AssistantMessageTokens,
|
|
87
|
+
): TokenAggregate {
|
|
88
|
+
return accumulateTokens(current, {
|
|
89
|
+
inputTokens: msg.tokens.input ?? 0,
|
|
90
|
+
outputTokens: msg.tokens.output ?? 0,
|
|
91
|
+
reasoningTokens: msg.tokens.reasoning ?? 0,
|
|
92
|
+
cacheReadTokens: msg.tokens.cache.read ?? 0,
|
|
93
|
+
cacheWriteTokens: msg.tokens.cache.write ?? 0,
|
|
94
|
+
totalCost: msg.cost ?? 0,
|
|
95
|
+
messageCount: 1,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type {
|
|
3
|
+
baseEventSchema,
|
|
4
|
+
decisionEventSchema,
|
|
5
|
+
errorEventSchema,
|
|
6
|
+
fallbackEventSchema,
|
|
7
|
+
loggingConfigSchema,
|
|
8
|
+
modelSwitchEventSchema,
|
|
9
|
+
sessionDecisionSchema,
|
|
10
|
+
sessionEventSchema,
|
|
11
|
+
sessionLogSchema,
|
|
12
|
+
} from "./schemas";
|
|
13
|
+
|
|
14
|
+
export type SessionEventType = "fallback" | "error" | "decision" | "model_switch";
|
|
15
|
+
|
|
16
|
+
export type BaseEvent = z.infer<typeof baseEventSchema>;
|
|
17
|
+
export type FallbackEvent = z.infer<typeof fallbackEventSchema>;
|
|
18
|
+
export type ErrorEvent = z.infer<typeof errorEventSchema>;
|
|
19
|
+
export type DecisionEvent = z.infer<typeof decisionEventSchema>;
|
|
20
|
+
export type ModelSwitchEvent = z.infer<typeof modelSwitchEventSchema>;
|
|
21
|
+
export type SessionEvent = z.infer<typeof sessionEventSchema>;
|
|
22
|
+
export type LoggingConfig = z.infer<typeof loggingConfigSchema>;
|
|
23
|
+
export type SessionDecision = z.infer<typeof sessionDecisionSchema>;
|
|
24
|
+
export type SessionLog = z.infer<typeof sessionLogSchema>;
|
package/src/tools/configure.ts
CHANGED
|
@@ -85,15 +85,25 @@ interface ConfigureArgs {
|
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
87
|
* Discover available models from the stored provider data.
|
|
88
|
-
* Returns a map of provider ID -> list of
|
|
88
|
+
* Returns a map of provider ID -> list of fully-qualified model ID strings.
|
|
89
|
+
*
|
|
90
|
+
* Uses the model's own `id` field (which may contain sub-provider paths like
|
|
91
|
+
* "anthropic/claude-opus-4-6" for a Zen provider) to construct the full
|
|
92
|
+
* "provider/model" path. This ensures Zen-proxied models display as
|
|
93
|
+
* "zen/anthropic/claude-opus-4-6" matching OpenCode's native `/models` output.
|
|
89
94
|
*/
|
|
90
95
|
function discoverAvailableModels(): Map<string, string[]> {
|
|
91
96
|
const modelsByProvider = new Map<string, string[]>();
|
|
92
97
|
|
|
93
98
|
for (const provider of availableProviders) {
|
|
94
99
|
const modelIds: string[] = [];
|
|
95
|
-
for (const modelKey of Object.
|
|
96
|
-
|
|
100
|
+
for (const [modelKey, modelData] of Object.entries(provider.models)) {
|
|
101
|
+
// Prefer the model's id field — it carries sub-provider paths
|
|
102
|
+
// (e.g. "anthropic/claude-opus-4-6" under a "zen" provider).
|
|
103
|
+
// Fall back to the record key when the id is absent or empty.
|
|
104
|
+
const modelId = modelData.id || modelKey;
|
|
105
|
+
const fullId = `${provider.id}/${modelId}`;
|
|
106
|
+
modelIds.push(fullId);
|
|
97
107
|
}
|
|
98
108
|
if (modelIds.length > 0) {
|
|
99
109
|
modelsByProvider.set(provider.id, modelIds);
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Config } from "@opencode-ai/plugin";
|
|
2
|
+
import { tool } from "@opencode-ai/plugin";
|
|
3
|
+
import { runHealthChecks } from "../health/runner";
|
|
4
|
+
import type { HealthResult } from "../health/types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A single check in the doctor report, with an optional fix suggestion.
|
|
8
|
+
*/
|
|
9
|
+
interface DoctorCheck {
|
|
10
|
+
readonly name: string;
|
|
11
|
+
readonly status: "pass" | "fail";
|
|
12
|
+
readonly message: string;
|
|
13
|
+
readonly fixSuggestion: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Options for doctorCore — all optional for testability.
|
|
18
|
+
*/
|
|
19
|
+
interface DoctorOptions {
|
|
20
|
+
readonly configPath?: string;
|
|
21
|
+
readonly openCodeConfig?: Config | null;
|
|
22
|
+
readonly assetsDir?: string;
|
|
23
|
+
readonly targetDir?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Map check names to actionable fix suggestions (per D-11).
|
|
28
|
+
*/
|
|
29
|
+
const FIX_SUGGESTIONS: Readonly<Record<string, string>> = Object.freeze({
|
|
30
|
+
"config-validity":
|
|
31
|
+
"Run /oc-configure to reconfigure, or delete ~/.config/opencode/opencode-autopilot.json to reset",
|
|
32
|
+
"agent-injection": "Restart OpenCode to trigger agent re-injection via config hook",
|
|
33
|
+
"asset-directories": "Restart OpenCode to trigger asset reinstallation",
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function getFixSuggestion(checkName: string): string {
|
|
37
|
+
return FIX_SUGGESTIONS[checkName] ?? "Restart OpenCode to trigger auto-repair";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function formatCheck(result: HealthResult): DoctorCheck {
|
|
41
|
+
return Object.freeze({
|
|
42
|
+
name: result.name,
|
|
43
|
+
status: result.status,
|
|
44
|
+
message: result.message,
|
|
45
|
+
fixSuggestion: result.status === "fail" ? getFixSuggestion(result.name) : null,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build human-readable pass/fail display text (like `brew doctor` output).
|
|
51
|
+
*/
|
|
52
|
+
function buildDisplayText(checks: readonly DoctorCheck[], duration: number): string {
|
|
53
|
+
const lines = checks.map((c) => {
|
|
54
|
+
const icon = c.status === "pass" ? "OK" : "FAIL";
|
|
55
|
+
const line = `[${icon}] ${c.name}: ${c.message}`;
|
|
56
|
+
return c.fixSuggestion ? `${line}\n Fix: ${c.fixSuggestion}` : line;
|
|
57
|
+
});
|
|
58
|
+
lines.push("", `Completed in ${duration}ms`);
|
|
59
|
+
return lines.join("\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Core diagnostic function — runs all health checks and returns a structured
|
|
64
|
+
* JSON report. Follows the *Core + tool() wrapper pattern per CLAUDE.md.
|
|
65
|
+
*
|
|
66
|
+
* The hook-registration check is informational: if oc_doctor is callable,
|
|
67
|
+
* the plugin is registered. Always passes.
|
|
68
|
+
*/
|
|
69
|
+
export async function doctorCore(options?: DoctorOptions): Promise<string> {
|
|
70
|
+
const report = await runHealthChecks({
|
|
71
|
+
configPath: options?.configPath,
|
|
72
|
+
openCodeConfig: options?.openCodeConfig,
|
|
73
|
+
assetsDir: options?.assetsDir,
|
|
74
|
+
targetDir: options?.targetDir,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Map health results to doctor checks with fix suggestions
|
|
78
|
+
const healthChecks = report.results.map(formatCheck);
|
|
79
|
+
|
|
80
|
+
// Hook-registration check: if oc_doctor is callable, hooks are registered
|
|
81
|
+
const hookCheck: DoctorCheck = Object.freeze({
|
|
82
|
+
name: "hook-registration",
|
|
83
|
+
status: "pass" as const,
|
|
84
|
+
message: "Plugin tools registered (oc_doctor callable)",
|
|
85
|
+
fixSuggestion: null,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const allChecks = [...healthChecks, hookCheck];
|
|
89
|
+
const allPassed = report.allPassed && hookCheck.status === "pass";
|
|
90
|
+
const displayText = buildDisplayText(allChecks, report.duration);
|
|
91
|
+
|
|
92
|
+
return JSON.stringify({
|
|
93
|
+
action: "doctor",
|
|
94
|
+
checks: allChecks,
|
|
95
|
+
allPassed,
|
|
96
|
+
displayText,
|
|
97
|
+
duration: report.duration,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Tool wrapper ---
|
|
102
|
+
|
|
103
|
+
export const ocDoctor = tool({
|
|
104
|
+
description:
|
|
105
|
+
"Run plugin health diagnostics. Reports pass/fail status for config, agents, " +
|
|
106
|
+
"assets, and hooks. Like `brew doctor` for opencode-autopilot.",
|
|
107
|
+
args: {},
|
|
108
|
+
async execute() {
|
|
109
|
+
return doctorCore();
|
|
110
|
+
},
|
|
111
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* oc_logs tool - Session log dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Provides three modes:
|
|
5
|
+
* - "list": List all session logs with summary info
|
|
6
|
+
* - "detail": View full session log with markdown summary
|
|
7
|
+
* - "search": Filter events by type and time range
|
|
8
|
+
*
|
|
9
|
+
* Follows the *Core + tool() wrapper pattern per CLAUDE.md.
|
|
10
|
+
* Returns JSON with displayText field following oc_doctor pattern.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { tool } from "@opencode-ai/plugin";
|
|
16
|
+
import { z } from "zod";
|
|
17
|
+
import {
|
|
18
|
+
listSessionLogs,
|
|
19
|
+
readLatestSessionLog,
|
|
20
|
+
readSessionLog,
|
|
21
|
+
searchEvents,
|
|
22
|
+
} from "../observability/log-reader";
|
|
23
|
+
import { generateSessionSummary } from "../observability/summary-generator";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Options for logsCore search/detail modes.
|
|
27
|
+
*/
|
|
28
|
+
interface LogsOptions {
|
|
29
|
+
readonly sessionID?: string;
|
|
30
|
+
readonly eventType?: string;
|
|
31
|
+
readonly after?: string;
|
|
32
|
+
readonly before?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Formats a session list as a human-readable table.
|
|
37
|
+
*/
|
|
38
|
+
function formatSessionTable(
|
|
39
|
+
sessions: readonly {
|
|
40
|
+
readonly sessionId: string;
|
|
41
|
+
readonly startedAt: string;
|
|
42
|
+
readonly endedAt: string | null;
|
|
43
|
+
readonly eventCount: number;
|
|
44
|
+
readonly decisionCount: number;
|
|
45
|
+
readonly errorCount: number;
|
|
46
|
+
}[],
|
|
47
|
+
): string {
|
|
48
|
+
if (sessions.length === 0) {
|
|
49
|
+
return "No session logs found.";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lines = [
|
|
53
|
+
"Session Logs",
|
|
54
|
+
"",
|
|
55
|
+
"| Session ID | Started | Events | Decisions | Errors |",
|
|
56
|
+
"|------------|---------|--------|-----------|--------|",
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
for (const s of sessions) {
|
|
60
|
+
const started = s.startedAt.replace("T", " ").replace(/\.\d+Z$/, "Z");
|
|
61
|
+
lines.push(
|
|
62
|
+
`| ${s.sessionId} | ${started} | ${s.eventCount} | ${s.decisionCount} | ${s.errorCount} |`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
lines.push("", `${sessions.length} session(s) total`);
|
|
67
|
+
return lines.join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Core function for the oc_logs tool.
|
|
72
|
+
*
|
|
73
|
+
* @param mode - "list", "detail", or "search"
|
|
74
|
+
* @param options - Optional filters (sessionID, eventType, after, before)
|
|
75
|
+
* @param logsDir - Optional override for logs directory (for testing)
|
|
76
|
+
*/
|
|
77
|
+
export async function logsCore(
|
|
78
|
+
mode: "list" | "detail" | "search",
|
|
79
|
+
options?: LogsOptions,
|
|
80
|
+
logsDir?: string,
|
|
81
|
+
): Promise<string> {
|
|
82
|
+
switch (mode) {
|
|
83
|
+
case "list": {
|
|
84
|
+
const sessions = await listSessionLogs(logsDir);
|
|
85
|
+
|
|
86
|
+
return JSON.stringify({
|
|
87
|
+
action: "logs_list",
|
|
88
|
+
sessions,
|
|
89
|
+
displayText: formatSessionTable(sessions),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
case "detail": {
|
|
94
|
+
const log = options?.sessionID
|
|
95
|
+
? await readSessionLog(options.sessionID, logsDir)
|
|
96
|
+
: await readLatestSessionLog(logsDir);
|
|
97
|
+
|
|
98
|
+
if (!log) {
|
|
99
|
+
const target = options?.sessionID
|
|
100
|
+
? `Session "${options.sessionID}" not found.`
|
|
101
|
+
: "No session logs found.";
|
|
102
|
+
return JSON.stringify({
|
|
103
|
+
action: "error",
|
|
104
|
+
message: target,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const summary = generateSessionSummary(log);
|
|
109
|
+
|
|
110
|
+
return JSON.stringify({
|
|
111
|
+
action: "logs_detail",
|
|
112
|
+
sessionLog: log,
|
|
113
|
+
summary,
|
|
114
|
+
displayText: summary,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
case "search": {
|
|
119
|
+
const log = options?.sessionID
|
|
120
|
+
? await readSessionLog(options.sessionID, logsDir)
|
|
121
|
+
: await readLatestSessionLog(logsDir);
|
|
122
|
+
|
|
123
|
+
if (!log) {
|
|
124
|
+
const target = options?.sessionID
|
|
125
|
+
? `Session "${options.sessionID}" not found.`
|
|
126
|
+
: "No session logs found.";
|
|
127
|
+
return JSON.stringify({
|
|
128
|
+
action: "error",
|
|
129
|
+
message: target,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const filtered = searchEvents(log.events, {
|
|
134
|
+
type: options?.eventType,
|
|
135
|
+
after: options?.after,
|
|
136
|
+
before: options?.before,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const displayLines = [
|
|
140
|
+
`Search Results (${filtered.length} event(s))`,
|
|
141
|
+
"",
|
|
142
|
+
...filtered.map((e) => `[${e.timestamp}] ${e.type}: ${JSON.stringify(e)}`),
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
return JSON.stringify({
|
|
146
|
+
action: "logs_search",
|
|
147
|
+
sessionId: log.sessionId,
|
|
148
|
+
events: filtered,
|
|
149
|
+
displayText: displayLines.join("\n"),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// --- Tool wrapper ---
|
|
156
|
+
|
|
157
|
+
export const ocLogs = tool({
|
|
158
|
+
description:
|
|
159
|
+
"View session logs. Modes: 'list' shows all sessions, 'detail' shows full log with " +
|
|
160
|
+
"summary, 'search' filters events by type/time. Use to inspect session history and errors.",
|
|
161
|
+
args: {
|
|
162
|
+
mode: z.enum(["list", "detail", "search"]).describe("View mode: list, detail, or search"),
|
|
163
|
+
sessionID: z
|
|
164
|
+
.string()
|
|
165
|
+
.regex(/^[a-zA-Z0-9_-]{1,256}$/)
|
|
166
|
+
.optional()
|
|
167
|
+
.describe("Session ID to view (uses latest if omitted)"),
|
|
168
|
+
eventType: z.string().optional().describe("Filter events by type (for search mode)"),
|
|
169
|
+
after: z.string().optional().describe("Only events after this ISO timestamp (for search mode)"),
|
|
170
|
+
before: z
|
|
171
|
+
.string()
|
|
172
|
+
.optional()
|
|
173
|
+
.describe("Only events before this ISO timestamp (for search mode)"),
|
|
174
|
+
},
|
|
175
|
+
async execute({ mode, sessionID, eventType, after, before }) {
|
|
176
|
+
return logsCore(mode, { sessionID, eventType, after, before });
|
|
177
|
+
},
|
|
178
|
+
});
|