@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,83 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const baseEventSchema = z.object({
|
|
4
|
+
timestamp: z.string().max(128),
|
|
5
|
+
sessionId: z
|
|
6
|
+
.string()
|
|
7
|
+
.max(256)
|
|
8
|
+
.regex(/^[a-zA-Z0-9_-]{1,256}$/, "Invalid session ID"),
|
|
9
|
+
type: z.enum(["fallback", "error", "decision", "model_switch"]),
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
export const fallbackEventSchema = baseEventSchema.extend({
|
|
13
|
+
type: z.literal("fallback"),
|
|
14
|
+
failedModel: z.string().max(256),
|
|
15
|
+
nextModel: z.string().max(256),
|
|
16
|
+
reason: z.string().max(1024),
|
|
17
|
+
success: z.boolean(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const errorEventSchema = baseEventSchema.extend({
|
|
21
|
+
type: z.literal("error"),
|
|
22
|
+
errorType: z.enum([
|
|
23
|
+
"rate_limit",
|
|
24
|
+
"quota_exceeded",
|
|
25
|
+
"service_unavailable",
|
|
26
|
+
"missing_api_key",
|
|
27
|
+
"model_not_found",
|
|
28
|
+
"content_filter",
|
|
29
|
+
"context_length",
|
|
30
|
+
"unknown",
|
|
31
|
+
]),
|
|
32
|
+
model: z.string().max(256),
|
|
33
|
+
message: z.string().max(4096),
|
|
34
|
+
statusCode: z.number().optional(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const decisionEventSchema = baseEventSchema.extend({
|
|
38
|
+
type: z.literal("decision"),
|
|
39
|
+
phase: z.string().max(128),
|
|
40
|
+
agent: z.string().max(128),
|
|
41
|
+
decision: z.string().max(2048),
|
|
42
|
+
rationale: z.string().max(2048),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const modelSwitchEventSchema = baseEventSchema.extend({
|
|
46
|
+
type: z.literal("model_switch"),
|
|
47
|
+
fromModel: z.string().max(256),
|
|
48
|
+
toModel: z.string().max(256),
|
|
49
|
+
trigger: z.enum(["fallback", "config", "user"]),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export const sessionEventSchema = z.discriminatedUnion("type", [
|
|
53
|
+
fallbackEventSchema,
|
|
54
|
+
errorEventSchema,
|
|
55
|
+
decisionEventSchema,
|
|
56
|
+
modelSwitchEventSchema,
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
export const loggingConfigSchema = z.object({
|
|
60
|
+
enabled: z.boolean().default(true),
|
|
61
|
+
retentionDays: z.number().min(1).max(365).default(30),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const sessionDecisionSchema = z.object({
|
|
65
|
+
timestamp: z.string().optional(),
|
|
66
|
+
phase: z.string(),
|
|
67
|
+
agent: z.string(),
|
|
68
|
+
decision: z.string(),
|
|
69
|
+
rationale: z.string(),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const sessionLogSchema = z.object({
|
|
73
|
+
schemaVersion: z.number().default(1),
|
|
74
|
+
sessionId: z.string().regex(/^[a-zA-Z0-9_-]{1,256}$/, "Invalid session ID"),
|
|
75
|
+
startedAt: z.string(),
|
|
76
|
+
endedAt: z.string().nullable().default(null),
|
|
77
|
+
events: z.array(sessionEventSchema),
|
|
78
|
+
decisions: z.array(sessionDecisionSchema).default([]),
|
|
79
|
+
errorSummary: z.record(z.string(), z.number()).default({}),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Pre-compute defaults for Zod v4 nested default compatibility
|
|
83
|
+
export const loggingDefaults = loggingConfigSchema.parse({});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { appendFile, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ensureDir, isEnoentError } from "../utils/fs-helpers";
|
|
4
|
+
import { getGlobalConfigDir } from "../utils/paths";
|
|
5
|
+
import { sessionEventSchema } from "./schemas";
|
|
6
|
+
import type { SessionEvent } from "./types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Returns the global logs directory path.
|
|
10
|
+
* Logs are user-scoped, not project-scoped (D-08, D-09).
|
|
11
|
+
*/
|
|
12
|
+
export function getLogsDir(): string {
|
|
13
|
+
return join(getGlobalConfigDir(), "logs");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Returns the JSONL log file path for a given session ID.
|
|
18
|
+
*/
|
|
19
|
+
function getSessionLogPath(sessionId: string, logsDir?: string): string {
|
|
20
|
+
return join(logsDir ?? getLogsDir(), `${sessionId}.jsonl`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Logs a structured event to the session's JSONL log file.
|
|
25
|
+
* Validates the event against the Zod schema before writing.
|
|
26
|
+
* Creates the logs directory if it does not exist.
|
|
27
|
+
*
|
|
28
|
+
* @param event - The session event to log (D-02, D-04-D-07)
|
|
29
|
+
* @param logsDir - Optional override for the logs directory (for testing)
|
|
30
|
+
*/
|
|
31
|
+
export async function logEvent(event: SessionEvent, logsDir?: string): Promise<void> {
|
|
32
|
+
// Validate against schema -- throws on invalid events
|
|
33
|
+
const validated = sessionEventSchema.parse(event);
|
|
34
|
+
|
|
35
|
+
const dir = logsDir ?? getLogsDir();
|
|
36
|
+
await ensureDir(dir);
|
|
37
|
+
|
|
38
|
+
const logPath = getSessionLogPath(validated.sessionId, dir);
|
|
39
|
+
const line = `${JSON.stringify(validated)}\n`;
|
|
40
|
+
|
|
41
|
+
await appendFile(logPath, line, "utf-8");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Reads and parses all events from a session's JSONL log file.
|
|
46
|
+
* Returns an empty array if the session log does not exist.
|
|
47
|
+
*
|
|
48
|
+
* @param sessionId - The session ID to read logs for
|
|
49
|
+
* @param logsDir - Optional override for the logs directory (for testing)
|
|
50
|
+
*/
|
|
51
|
+
export async function getSessionLog(sessionId: string, logsDir?: string): Promise<SessionEvent[]> {
|
|
52
|
+
const logPath = getSessionLogPath(sessionId, logsDir);
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const content = await readFile(logPath, "utf-8");
|
|
56
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
57
|
+
|
|
58
|
+
return lines.map((line) => sessionEventSchema.parse(JSON.parse(line)));
|
|
59
|
+
} catch (error: unknown) {
|
|
60
|
+
if (isEnoentError(error)) return [];
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -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>;
|
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
import { readFile } from "node:fs/promises";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import { sanitizeTemplateContent } from "../review/sanitize";
|
|
13
|
+
import {
|
|
14
|
+
buildMultiSkillContext,
|
|
15
|
+
detectProjectStackTags,
|
|
16
|
+
filterSkillsByStack,
|
|
17
|
+
} from "../skills/adaptive-injector";
|
|
18
|
+
import { loadAllSkills } from "../skills/loader";
|
|
13
19
|
import { isEnoentError } from "../utils/fs-helpers";
|
|
14
20
|
|
|
15
21
|
const MAX_SKILL_LENGTH = 2048;
|
|
@@ -50,3 +56,35 @@ export function buildSkillContext(skillContent: string): string {
|
|
|
50
56
|
const header = "Coding standards for this project (follow these conventions):";
|
|
51
57
|
return `\n\n${header}\n${sanitized}`;
|
|
52
58
|
}
|
|
59
|
+
|
|
60
|
+
// --- Adaptive multi-skill loading ---
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Load and inject all matching skills for the current project.
|
|
64
|
+
* Detects project stack from manifest files, filters skills by stack,
|
|
65
|
+
* resolves dependencies, and builds a token-budgeted context string.
|
|
66
|
+
*
|
|
67
|
+
* Returns empty string on any error (best-effort, same as lesson injection).
|
|
68
|
+
*/
|
|
69
|
+
export async function loadAdaptiveSkillContext(
|
|
70
|
+
baseDir: string,
|
|
71
|
+
projectRoot: string,
|
|
72
|
+
tokenBudget?: number,
|
|
73
|
+
): Promise<string> {
|
|
74
|
+
try {
|
|
75
|
+
const skillsDir = join(baseDir, "skills");
|
|
76
|
+
const [allSkills, projectTags] = await Promise.all([
|
|
77
|
+
loadAllSkills(skillsDir),
|
|
78
|
+
detectProjectStackTags(projectRoot),
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
if (allSkills.size === 0) return "";
|
|
82
|
+
|
|
83
|
+
const matchingSkills = filterSkillsByStack(allSkills, projectTags);
|
|
84
|
+
return buildMultiSkillContext(matchingSkills, tokenBudget);
|
|
85
|
+
} catch (error: unknown) {
|
|
86
|
+
// Best-effort for I/O errors; re-throw programmer errors
|
|
87
|
+
if (error instanceof TypeError || error instanceof RangeError) throw error;
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/review/sanitize.ts
CHANGED
|
@@ -4,5 +4,5 @@
|
|
|
4
4
|
* {{PRIOR_FINDINGS}} or similar tokens that get substituted in a subsequent .replace() call.
|
|
5
5
|
*/
|
|
6
6
|
export function sanitizeTemplateContent(content: string): string {
|
|
7
|
-
return content.replace(/\{\{[
|
|
7
|
+
return content.replace(/\{\{[\w]+\}\}/gi, "[REDACTED]");
|
|
8
8
|
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive skill injection: project stack detection, skill filtering, and
|
|
3
|
+
* multi-skill context building with dependency ordering and token budget.
|
|
4
|
+
*
|
|
5
|
+
* Complements detectStackTags (which works on file paths from git diff)
|
|
6
|
+
* by checking the project root for manifest files. This enables skill
|
|
7
|
+
* filtering even before any git diff is available.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { access } from "node:fs/promises";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { sanitizeTemplateContent } from "../review/sanitize";
|
|
13
|
+
import { resolveDependencyOrder } from "./dependency-resolver";
|
|
14
|
+
import type { LoadedSkill } from "./loader";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_TOKEN_BUDGET = 8000;
|
|
17
|
+
/** Rough estimate: 1 token ~ 4 chars */
|
|
18
|
+
const CHARS_PER_TOKEN = 4;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Manifest files that indicate project stack.
|
|
22
|
+
* Checks project root for these files to detect the stack.
|
|
23
|
+
*/
|
|
24
|
+
const MANIFEST_TAGS: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
|
25
|
+
"package.json": Object.freeze(["javascript"]),
|
|
26
|
+
"tsconfig.json": Object.freeze(["typescript"]),
|
|
27
|
+
"bunfig.toml": Object.freeze(["bun", "typescript"]),
|
|
28
|
+
"bun.lockb": Object.freeze(["bun"]),
|
|
29
|
+
"go.mod": Object.freeze(["go"]),
|
|
30
|
+
"Cargo.toml": Object.freeze(["rust"]),
|
|
31
|
+
"pyproject.toml": Object.freeze(["python"]),
|
|
32
|
+
"requirements.txt": Object.freeze(["python"]),
|
|
33
|
+
Pipfile: Object.freeze(["python"]),
|
|
34
|
+
Gemfile: Object.freeze(["ruby"]),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detect project stack tags from manifest files in the project root.
|
|
39
|
+
* Complements detectStackTags (which works on file paths from git diff).
|
|
40
|
+
*/
|
|
41
|
+
export async function detectProjectStackTags(projectRoot: string): Promise<readonly string[]> {
|
|
42
|
+
const results = await Promise.all(
|
|
43
|
+
Object.entries(MANIFEST_TAGS).map(async ([manifest, manifestTags]) => {
|
|
44
|
+
try {
|
|
45
|
+
await access(join(projectRoot, manifest));
|
|
46
|
+
return [...manifestTags];
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return [...new Set(results.flat())];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Filter skills by detected stack tags.
|
|
58
|
+
* Skills with empty stacks are ALWAYS included (methodology skills).
|
|
59
|
+
* Skills with non-empty stacks are included only if at least one tag matches.
|
|
60
|
+
*/
|
|
61
|
+
export function filterSkillsByStack(
|
|
62
|
+
skills: ReadonlyMap<string, LoadedSkill>,
|
|
63
|
+
tags: readonly string[],
|
|
64
|
+
): ReadonlyMap<string, LoadedSkill> {
|
|
65
|
+
const tagSet = new Set(tags);
|
|
66
|
+
const filtered = new Map<string, LoadedSkill>();
|
|
67
|
+
|
|
68
|
+
for (const [name, skill] of skills) {
|
|
69
|
+
if (skill.frontmatter.stacks.length === 0) {
|
|
70
|
+
filtered.set(name, skill);
|
|
71
|
+
} else if (skill.frontmatter.stacks.some((s) => tagSet.has(s))) {
|
|
72
|
+
filtered.set(name, skill);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return filtered;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build multi-skill context string with dependency ordering and token budget.
|
|
81
|
+
* Skills are ordered by dependency (prerequisites first), then concatenated
|
|
82
|
+
* until the token budget is exhausted.
|
|
83
|
+
*/
|
|
84
|
+
export function buildMultiSkillContext(
|
|
85
|
+
skills: ReadonlyMap<string, LoadedSkill>,
|
|
86
|
+
tokenBudget: number = DEFAULT_TOKEN_BUDGET,
|
|
87
|
+
): string {
|
|
88
|
+
if (skills.size === 0) return "";
|
|
89
|
+
|
|
90
|
+
// Resolve dependency order
|
|
91
|
+
const depMap = new Map(
|
|
92
|
+
[...skills.entries()].map(([name, skill]) => [name, { requires: skill.frontmatter.requires }]),
|
|
93
|
+
);
|
|
94
|
+
const { ordered, cycles } = resolveDependencyOrder(depMap);
|
|
95
|
+
|
|
96
|
+
// Skip cycle participants (graceful degradation)
|
|
97
|
+
const cycleSet = new Set(cycles);
|
|
98
|
+
const validOrder = ordered.filter((name) => !cycleSet.has(name));
|
|
99
|
+
|
|
100
|
+
// Build context with token budget enforcement
|
|
101
|
+
const charBudget = tokenBudget * CHARS_PER_TOKEN;
|
|
102
|
+
let totalChars = 0;
|
|
103
|
+
const sections: string[] = [];
|
|
104
|
+
|
|
105
|
+
for (const name of validOrder) {
|
|
106
|
+
const skill = skills.get(name);
|
|
107
|
+
if (!skill) continue;
|
|
108
|
+
|
|
109
|
+
const collapsed = skill.content.replace(/[\r\n]+/g, " ");
|
|
110
|
+
const header = `[Skill: ${name}]\n`;
|
|
111
|
+
const separator = sections.length > 0 ? 2 : 0; // "\n\n"
|
|
112
|
+
const sectionCost = collapsed.length + header.length + separator;
|
|
113
|
+
if (totalChars + sectionCost > charBudget) break;
|
|
114
|
+
|
|
115
|
+
const sanitized = sanitizeTemplateContent(collapsed);
|
|
116
|
+
sections.push(`${header}${sanitized}`);
|
|
117
|
+
totalChars += sectionCost;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (sections.length === 0) return "";
|
|
121
|
+
return `\n\nSkills context (follow these conventions and methodologies):\n${sections.join("\n\n")}`;
|
|
122
|
+
}
|