@kodrunhq/opencode-autopilot 1.18.0 → 1.19.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/README.md +95 -13
- package/assets/commands/oc-update-docs.md +1 -1
- package/package.json +1 -1
- package/src/agents/index.ts +0 -12
- package/src/agents/pipeline/index.ts +0 -4
- package/src/autonomy/completion.ts +52 -0
- package/src/autonomy/controller.ts +144 -0
- package/src/autonomy/index.ts +25 -0
- package/src/autonomy/injector.ts +49 -0
- package/src/autonomy/state.ts +91 -0
- package/src/autonomy/types.ts +30 -0
- package/src/autonomy/verification.ts +86 -0
- package/src/background/database.ts +170 -0
- package/src/background/executor.ts +174 -0
- package/src/background/index.ts +8 -0
- package/src/background/manager.ts +232 -0
- package/src/background/repository.ts +174 -0
- package/src/background/schema.ts +24 -0
- package/src/background/sdk-runner.ts +40 -0
- package/src/background/slot-manager.ts +41 -0
- package/src/background/state-machine.ts +19 -0
- package/src/context/budget.ts +45 -0
- package/src/context/compaction-handler.ts +58 -0
- package/src/context/discovery.ts +94 -0
- package/src/context/index.ts +14 -0
- package/src/context/injector.ts +119 -0
- package/src/context/types.ts +24 -0
- package/src/health/checks.ts +145 -2
- package/src/health/index.ts +7 -1
- package/src/health/runner.ts +6 -0
- package/src/index.ts +113 -6
- package/src/installer.ts +13 -0
- package/src/kernel/index.ts +6 -0
- package/src/kernel/migrations.ts +50 -0
- package/src/kernel/retry.ts +49 -0
- package/src/kernel/schema.ts +9 -1
- package/src/kernel/transaction.ts +40 -12
- package/src/logging/forensic-writer.ts +6 -2
- package/src/logging/index.ts +2 -0
- package/src/mcp/index.ts +34 -0
- package/src/mcp/manager.ts +206 -0
- package/src/mcp/scope-filter.ts +44 -0
- package/src/mcp/types.ts +38 -0
- package/src/orchestrator/arena.ts +7 -1
- package/src/orchestrator/fallback/event-handler.ts +12 -1
- package/src/orchestrator/handlers/challenge.ts +8 -1
- package/src/orchestrator/handlers/plan.ts +8 -1
- package/src/orchestrator/handlers/recon.ts +8 -1
- package/src/orchestrator/handlers/types.ts +2 -2
- package/src/orchestrator/lesson-memory.ts +6 -1
- package/src/orchestrator/orchestration-logger.ts +15 -3
- package/src/orchestrator/skill-injection.ts +7 -1
- package/src/orchestrator/state.ts +6 -1
- package/src/recovery/classifier.ts +127 -0
- package/src/recovery/event-handler.ts +263 -0
- package/src/recovery/index.ts +20 -0
- package/src/recovery/orchestrator.ts +180 -0
- package/src/recovery/persistence.ts +87 -0
- package/src/recovery/strategies.ts +107 -0
- package/src/recovery/types.ts +31 -0
- package/src/registry/model-groups.ts +2 -19
- package/src/registry/resolver.ts +38 -9
- package/src/review/agent-catalog.ts +83 -251
- package/src/review/agents/architecture-verifier.ts +41 -0
- package/src/review/agents/code-hygiene-auditor.ts +40 -0
- package/src/review/agents/correctness-auditor.ts +41 -0
- package/src/review/agents/frontend-auditor.ts +39 -0
- package/src/review/agents/index.ts +15 -42
- package/src/review/agents/language-idioms-auditor.ts +39 -0
- package/src/review/agents/security-auditor.ts +12 -8
- package/src/review/stack-gate.ts +2 -6
- package/src/routing/categories.ts +111 -0
- package/src/routing/classifier.ts +152 -0
- package/src/routing/engine.ts +89 -0
- package/src/routing/index.ts +4 -0
- package/src/routing/types.ts +14 -0
- package/src/skills/adaptive-injector.ts +34 -3
- package/src/skills/loader.ts +4 -0
- package/src/tools/background.ts +196 -0
- package/src/tools/delegate.ts +205 -0
- package/src/tools/loop.ts +94 -0
- package/src/tools/recover.ts +172 -0
- package/src/types/recovery.ts +10 -0
- package/src/ux/context-warnings.ts +81 -0
- package/src/ux/error-hints.ts +38 -0
- package/src/ux/index.ts +7 -0
- package/src/ux/notifications.ts +67 -0
- package/src/ux/progress.ts +77 -0
- package/src/ux/session-summary.ts +67 -0
- package/src/ux/task-status.ts +109 -0
- package/src/ux/types.ts +24 -0
- package/src/agents/db-specialist.ts +0 -295
- package/src/agents/devops.ts +0 -352
- package/src/agents/documenter.ts +0 -44
- package/src/agents/frontend-engineer.ts +0 -541
- package/src/agents/pipeline/oc-explorer.ts +0 -46
- package/src/agents/pipeline/oc-retrospector.ts +0 -42
- package/src/review/agents/auth-flow-verifier.ts +0 -47
- package/src/review/agents/concurrency-checker.ts +0 -47
- package/src/review/agents/dead-code-scanner.ts +0 -47
- package/src/review/agents/go-idioms-auditor.ts +0 -46
- package/src/review/agents/python-django-auditor.ts +0 -46
- package/src/review/agents/react-patterns-auditor.ts +0 -46
- package/src/review/agents/rust-safety-auditor.ts +0 -46
- package/src/review/agents/scope-intent-verifier.ts +0 -45
- package/src/review/agents/silent-failure-hunter.ts +0 -45
- package/src/review/agents/spec-checker.ts +0 -45
- package/src/review/agents/state-mgmt-auditor.ts +0 -46
- package/src/review/agents/type-soundness.ts +0 -46
- package/src/review/agents/wiring-inspector.ts +0 -46
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { withTransaction } from "../kernel/transaction";
|
|
3
|
+
import type { TaskStatus } from "../types/background";
|
|
4
|
+
import {
|
|
5
|
+
type BackgroundTaskRecord,
|
|
6
|
+
type BackgroundTaskResultRecord,
|
|
7
|
+
type CreateBackgroundTaskInput,
|
|
8
|
+
createTaskId,
|
|
9
|
+
createTimestamp,
|
|
10
|
+
getTaskByIdRow,
|
|
11
|
+
listBackgroundTasks,
|
|
12
|
+
} from "./database";
|
|
13
|
+
import { assertTransition } from "./state-machine";
|
|
14
|
+
|
|
15
|
+
export interface TaskUpdatePayload {
|
|
16
|
+
readonly result?: string | null;
|
|
17
|
+
readonly error?: string | null;
|
|
18
|
+
readonly now?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createTask(db: Database, task: CreateBackgroundTaskInput): BackgroundTaskRecord {
|
|
22
|
+
return withTransaction(db, () => {
|
|
23
|
+
const timestamp = task.createdAt ?? createTimestamp();
|
|
24
|
+
const nextTask = Object.freeze({
|
|
25
|
+
id: task.id ?? createTaskId(),
|
|
26
|
+
sessionId: task.sessionId,
|
|
27
|
+
description: task.description,
|
|
28
|
+
category: task.category ?? null,
|
|
29
|
+
status: task.status ?? "pending",
|
|
30
|
+
result: task.result ?? null,
|
|
31
|
+
error: task.error ?? null,
|
|
32
|
+
agent: task.agent ?? null,
|
|
33
|
+
model: task.model ?? null,
|
|
34
|
+
priority: task.priority ?? 50,
|
|
35
|
+
createdAt: timestamp,
|
|
36
|
+
updatedAt: task.updatedAt ?? timestamp,
|
|
37
|
+
startedAt: task.startedAt ?? null,
|
|
38
|
+
completedAt: task.completedAt ?? null,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
db.run(
|
|
42
|
+
`INSERT INTO background_tasks (
|
|
43
|
+
id, session_id, description, category, status, result, error, agent, model, priority,
|
|
44
|
+
created_at, updated_at, started_at, completed_at
|
|
45
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
46
|
+
[
|
|
47
|
+
nextTask.id,
|
|
48
|
+
nextTask.sessionId,
|
|
49
|
+
nextTask.description,
|
|
50
|
+
nextTask.category,
|
|
51
|
+
nextTask.status,
|
|
52
|
+
nextTask.result,
|
|
53
|
+
nextTask.error,
|
|
54
|
+
nextTask.agent,
|
|
55
|
+
nextTask.model,
|
|
56
|
+
nextTask.priority,
|
|
57
|
+
nextTask.createdAt,
|
|
58
|
+
nextTask.updatedAt,
|
|
59
|
+
nextTask.startedAt,
|
|
60
|
+
nextTask.completedAt,
|
|
61
|
+
],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const createdTask = getTaskByIdRow(db, nextTask.id);
|
|
65
|
+
if (!createdTask) {
|
|
66
|
+
throw new Error(`Failed to create background task '${nextTask.id}'`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return createdTask;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function updateStatus(
|
|
74
|
+
db: Database,
|
|
75
|
+
taskId: string,
|
|
76
|
+
newStatus: TaskStatus,
|
|
77
|
+
payload: TaskUpdatePayload = {},
|
|
78
|
+
): BackgroundTaskRecord {
|
|
79
|
+
return withTransaction(db, () => {
|
|
80
|
+
const currentTask = getTaskByIdRow(db, taskId);
|
|
81
|
+
if (!currentTask) {
|
|
82
|
+
throw new Error(`Background task '${taskId}' not found`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
assertTransition(currentTask.status, newStatus);
|
|
86
|
+
|
|
87
|
+
const timestamp = payload.now ?? createTimestamp();
|
|
88
|
+
const startedAt =
|
|
89
|
+
newStatus === "running" ? (currentTask.startedAt ?? timestamp) : currentTask.startedAt;
|
|
90
|
+
const completedAt =
|
|
91
|
+
newStatus === "completed" || newStatus === "failed" || newStatus === "cancelled"
|
|
92
|
+
? timestamp
|
|
93
|
+
: currentTask.completedAt;
|
|
94
|
+
const result =
|
|
95
|
+
newStatus === "completed" ? (payload.result ?? currentTask.result) : currentTask.result;
|
|
96
|
+
const error = newStatus === "failed" ? (payload.error ?? currentTask.error) : null;
|
|
97
|
+
|
|
98
|
+
db.run(
|
|
99
|
+
`UPDATE background_tasks
|
|
100
|
+
SET status = ?, result = ?, error = ?, updated_at = ?, started_at = ?, completed_at = ?
|
|
101
|
+
WHERE id = ?`,
|
|
102
|
+
[newStatus, result, error, timestamp, startedAt, completedAt, taskId],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
const updatedTask = getTaskByIdRow(db, taskId);
|
|
106
|
+
if (!updatedTask) {
|
|
107
|
+
throw new Error(`Failed to update background task '${taskId}'`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return updatedTask;
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function getActiveTasks(db: Database, sessionId?: string): readonly BackgroundTaskRecord[] {
|
|
115
|
+
return listBackgroundTasks(db, {
|
|
116
|
+
sessionId,
|
|
117
|
+
statuses: ["pending", "running"],
|
|
118
|
+
prioritizePending: true,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function getTaskById(db: Database, taskId: string): BackgroundTaskRecord | null {
|
|
123
|
+
return getTaskByIdRow(db, taskId);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function getTaskResult(db: Database, taskId: string): BackgroundTaskResultRecord | null {
|
|
127
|
+
const task = getTaskByIdRow(db, taskId);
|
|
128
|
+
if (!task) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return Object.freeze({
|
|
133
|
+
status: task.status,
|
|
134
|
+
result: task.result,
|
|
135
|
+
error: task.error,
|
|
136
|
+
completedAt: task.completedAt,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function cancelTask(db: Database, taskId: string): BackgroundTaskRecord | null {
|
|
141
|
+
const task = getTaskByIdRow(db, taskId);
|
|
142
|
+
if (!task) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (task.status !== "pending" && task.status !== "running") {
|
|
147
|
+
return task;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return updateStatus(db, taskId, "cancelled");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function countByStatus(db: Database, status: TaskStatus): number {
|
|
154
|
+
const row = db
|
|
155
|
+
.query("SELECT COUNT(*) as count FROM background_tasks WHERE status = ?")
|
|
156
|
+
.get(status) as { count?: number } | null;
|
|
157
|
+
return row?.count ?? 0;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function enforceMaxConcurrent(db: Database, maxConcurrent: number): void {
|
|
161
|
+
const runningCount = countByStatus(db, "running");
|
|
162
|
+
if (runningCount >= maxConcurrent) {
|
|
163
|
+
throw new Error(
|
|
164
|
+
`Background concurrency limit reached: ${runningCount} running task(s), max ${maxConcurrent}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function listTasks(
|
|
170
|
+
db: Database,
|
|
171
|
+
filters: { readonly sessionId?: string; readonly status?: TaskStatus } = {},
|
|
172
|
+
): readonly BackgroundTaskRecord[] {
|
|
173
|
+
return listBackgroundTasks(db, filters);
|
|
174
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export const BACKGROUND_TASKS_TABLE_STATEMENT = `CREATE TABLE IF NOT EXISTS background_tasks (
|
|
2
|
+
id TEXT PRIMARY KEY,
|
|
3
|
+
session_id TEXT NOT NULL,
|
|
4
|
+
description TEXT NOT NULL,
|
|
5
|
+
category TEXT,
|
|
6
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
7
|
+
result TEXT,
|
|
8
|
+
error TEXT,
|
|
9
|
+
agent TEXT,
|
|
10
|
+
model TEXT,
|
|
11
|
+
priority INTEGER NOT NULL DEFAULT 50,
|
|
12
|
+
created_at TEXT NOT NULL,
|
|
13
|
+
updated_at TEXT NOT NULL,
|
|
14
|
+
started_at TEXT,
|
|
15
|
+
completed_at TEXT
|
|
16
|
+
)`;
|
|
17
|
+
|
|
18
|
+
export const BACKGROUND_TASKS_INDEX_STATEMENT =
|
|
19
|
+
"CREATE INDEX IF NOT EXISTS idx_background_tasks_status_created_at ON background_tasks(status, created_at)";
|
|
20
|
+
|
|
21
|
+
export const BACKGROUND_TASKS_SCHEMA_STATEMENTS: readonly string[] = Object.freeze([
|
|
22
|
+
BACKGROUND_TASKS_TABLE_STATEMENT,
|
|
23
|
+
BACKGROUND_TASKS_INDEX_STATEMENT,
|
|
24
|
+
]);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getLogger } from "../logging/domains";
|
|
2
|
+
import type { BackgroundTaskRecord } from "./database";
|
|
3
|
+
|
|
4
|
+
export interface BackgroundSdkOperations {
|
|
5
|
+
readonly promptAsync: (
|
|
6
|
+
sessionId: string,
|
|
7
|
+
model: string | undefined,
|
|
8
|
+
parts: ReadonlyArray<{ type: "text"; text: string }>,
|
|
9
|
+
) => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const logger = getLogger("background", "sdk-runner");
|
|
13
|
+
|
|
14
|
+
export function createSdkRunner(
|
|
15
|
+
sdk: BackgroundSdkOperations,
|
|
16
|
+
): (task: BackgroundTaskRecord, signal: AbortSignal) => Promise<string> {
|
|
17
|
+
return async (task, signal) => {
|
|
18
|
+
if (signal.aborted) {
|
|
19
|
+
throw signal.reason ?? new Error("Aborted");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const model = task.model ?? undefined;
|
|
23
|
+
const parts: ReadonlyArray<{ type: "text"; text: string }> = [
|
|
24
|
+
{ type: "text", text: task.description },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
logger.info("Dispatching background task via SDK", {
|
|
28
|
+
backgroundTaskId: task.id,
|
|
29
|
+
sessionId: task.sessionId,
|
|
30
|
+
agent: task.agent,
|
|
31
|
+
model: task.model,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
await sdk.promptAsync(task.sessionId, model, parts);
|
|
35
|
+
|
|
36
|
+
const agentLabel = task.agent ? ` via ${task.agent}` : "";
|
|
37
|
+
const modelLabel = task.model ? ` (${task.model})` : "";
|
|
38
|
+
return `Dispatched${agentLabel}${modelLabel}: ${task.description}`;
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export class SlotManager {
|
|
2
|
+
private readonly occupiedSlots = new Set<string>();
|
|
3
|
+
|
|
4
|
+
constructor(private readonly maxSlots: number) {
|
|
5
|
+
if (!Number.isInteger(maxSlots) || maxSlots < 1) {
|
|
6
|
+
throw new Error("SlotManager requires at least one slot");
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
acquire(): string | null {
|
|
11
|
+
if (this.isFull()) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
for (let index = 1; index <= this.maxSlots; index += 1) {
|
|
16
|
+
const slotId = `slot-${index}`;
|
|
17
|
+
if (!this.occupiedSlots.has(slotId)) {
|
|
18
|
+
this.occupiedSlots.add(slotId);
|
|
19
|
+
return slotId;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
release(slotId: string): void {
|
|
27
|
+
this.occupiedSlots.delete(slotId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
getActiveCount(): number {
|
|
31
|
+
return this.occupiedSlots.size;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
isFull(): boolean {
|
|
35
|
+
return this.occupiedSlots.size >= this.maxSlots;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getCapacity(): number {
|
|
39
|
+
return this.maxSlots;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { TaskStatus } from "../types/background";
|
|
2
|
+
|
|
3
|
+
const VALID_TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = Object.freeze({
|
|
4
|
+
pending: Object.freeze<TaskStatus[]>(["running", "cancelled"]),
|
|
5
|
+
running: Object.freeze<TaskStatus[]>(["completed", "failed", "cancelled"]),
|
|
6
|
+
completed: Object.freeze<TaskStatus[]>([]),
|
|
7
|
+
failed: Object.freeze<TaskStatus[]>([]),
|
|
8
|
+
cancelled: Object.freeze<TaskStatus[]>([]),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export function validateTransition(from: TaskStatus, to: TaskStatus): boolean {
|
|
12
|
+
return VALID_TRANSITIONS[from].includes(to);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function assertTransition(from: TaskStatus, to: TaskStatus): void {
|
|
16
|
+
if (!validateTransition(from, to)) {
|
|
17
|
+
throw new Error(`Invalid background task transition from '${from}' to '${to}'`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ContextSource } from "./types";
|
|
2
|
+
|
|
3
|
+
const CHARS_PER_TOKEN = 4;
|
|
4
|
+
const DEFAULT_TOTAL_BUDGET = 4000;
|
|
5
|
+
const TRUNCATION_SUFFIX = "... [truncated]";
|
|
6
|
+
|
|
7
|
+
export function truncateToTokens(content: string, maxTokens: number): string {
|
|
8
|
+
const maxChars = Math.max(0, Math.floor(maxTokens * CHARS_PER_TOKEN));
|
|
9
|
+
if (content.length <= maxChars) {
|
|
10
|
+
return content;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (maxChars === 0) {
|
|
14
|
+
return "";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (maxChars <= TRUNCATION_SUFFIX.length) {
|
|
18
|
+
return TRUNCATION_SUFFIX.slice(0, maxChars);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const truncatedContent = content.slice(0, maxChars - TRUNCATION_SUFFIX.length).trimEnd();
|
|
22
|
+
return `${truncatedContent}${TRUNCATION_SUFFIX}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function allocateBudget(
|
|
26
|
+
sources: readonly ContextSource[],
|
|
27
|
+
totalBudget: number = DEFAULT_TOTAL_BUDGET,
|
|
28
|
+
): { readonly allocations: ReadonlyMap<string, number>; readonly totalUsed: number } {
|
|
29
|
+
const orderedSources = [...sources].sort(
|
|
30
|
+
(left, right) => right.priority - left.priority || left.filePath.localeCompare(right.filePath),
|
|
31
|
+
);
|
|
32
|
+
const allocations = new Map<string, number>();
|
|
33
|
+
let remainingBudget = Math.max(0, totalBudget);
|
|
34
|
+
|
|
35
|
+
for (const source of orderedSources) {
|
|
36
|
+
const allocation = Math.min(source.tokenEstimate, remainingBudget);
|
|
37
|
+
allocations.set(source.filePath, allocation);
|
|
38
|
+
remainingBudget -= allocation;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
allocations,
|
|
43
|
+
totalUsed: Math.max(0, totalBudget) - remainingBudget,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { getLogger } from "../logging/domains";
|
|
2
|
+
import type { createContextInjector } from "./injector";
|
|
3
|
+
|
|
4
|
+
const logger = getLogger("context", "compaction-handler");
|
|
5
|
+
|
|
6
|
+
interface EventProperties {
|
|
7
|
+
readonly sessionID?: string;
|
|
8
|
+
readonly info?: {
|
|
9
|
+
readonly sessionID?: string;
|
|
10
|
+
readonly id?: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function extractSessionID(properties: unknown): string | undefined {
|
|
15
|
+
if (!properties || typeof properties !== "object") {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const eventProperties = properties as EventProperties;
|
|
20
|
+
if (typeof eventProperties.sessionID === "string") {
|
|
21
|
+
return eventProperties.sessionID;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (typeof eventProperties.info?.sessionID === "string") {
|
|
25
|
+
return eventProperties.info.sessionID;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (typeof eventProperties.info?.id === "string") {
|
|
29
|
+
return eventProperties.info.id;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createCompactionHandler(injector: ReturnType<typeof createContextInjector>) {
|
|
36
|
+
return async (input: {
|
|
37
|
+
readonly event: {
|
|
38
|
+
readonly type: string;
|
|
39
|
+
readonly properties?: unknown;
|
|
40
|
+
};
|
|
41
|
+
}): Promise<void> => {
|
|
42
|
+
try {
|
|
43
|
+
if (input.event.type !== "session.compacted") {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const sessionID = extractSessionID(input.event.properties);
|
|
48
|
+
if (!sessionID) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
injector.clearCache(sessionID);
|
|
53
|
+
await injector({ sessionID }, { system: [] });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
logger.warn("context compaction handling failed", { error: String(error) });
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import type { ContextSource, DiscoveryOptions } from "./types";
|
|
4
|
+
|
|
5
|
+
const CHARS_PER_TOKEN = 4;
|
|
6
|
+
const DEFAULT_MAX_DEPTH = 3;
|
|
7
|
+
const PRIORITY_DEPTH_BONUS = 10;
|
|
8
|
+
|
|
9
|
+
const CONTEXT_FILE_DEFINITIONS = Object.freeze([
|
|
10
|
+
Object.freeze({ name: "AGENTS.md", relativePath: "AGENTS.md", priority: 90 }),
|
|
11
|
+
Object.freeze({ name: "CLAUDE.md", relativePath: "CLAUDE.md", priority: 85 }),
|
|
12
|
+
Object.freeze({ name: "README.md", relativePath: "README.md", priority: 50 }),
|
|
13
|
+
Object.freeze({ name: ".opencode/agents.md", relativePath: ".opencode/agents.md", priority: 80 }),
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const discoveryCache = new Map<string, readonly ContextSource[]>();
|
|
17
|
+
|
|
18
|
+
function createCacheKey(projectRoot: string, maxDepth: number): string {
|
|
19
|
+
return `${resolve(projectRoot)}::${maxDepth}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function buildSearchRoots(projectRoot: string, maxDepth: number): readonly string[] {
|
|
23
|
+
const roots: string[] = [];
|
|
24
|
+
let currentRoot = resolve(projectRoot);
|
|
25
|
+
|
|
26
|
+
for (let depth = 0; depth <= maxDepth; depth += 1) {
|
|
27
|
+
roots.push(currentRoot);
|
|
28
|
+
const parentRoot = dirname(currentRoot);
|
|
29
|
+
if (parentRoot === currentRoot) {
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
currentRoot = parentRoot;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return roots;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function estimateTokens(content: string): number {
|
|
39
|
+
return Math.ceil(content.length / CHARS_PER_TOKEN);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function clearContextDiscoveryCache(projectRoot?: string): void {
|
|
43
|
+
if (!projectRoot) {
|
|
44
|
+
discoveryCache.clear();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const projectRootPrefix = `${resolve(projectRoot)}::`;
|
|
49
|
+
for (const key of discoveryCache.keys()) {
|
|
50
|
+
if (key.startsWith(projectRootPrefix)) {
|
|
51
|
+
discoveryCache.delete(key);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function discoverContextFiles(
|
|
57
|
+
options: DiscoveryOptions,
|
|
58
|
+
): Promise<readonly ContextSource[]> {
|
|
59
|
+
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
60
|
+
const cacheKey = createCacheKey(options.projectRoot, maxDepth);
|
|
61
|
+
const cached = discoveryCache.get(cacheKey);
|
|
62
|
+
if (cached !== undefined) {
|
|
63
|
+
return cached;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const roots = buildSearchRoots(options.projectRoot, maxDepth);
|
|
67
|
+
const sources: ContextSource[] = [];
|
|
68
|
+
|
|
69
|
+
for (const [depth, root] of roots.entries()) {
|
|
70
|
+
for (const definition of CONTEXT_FILE_DEFINITIONS) {
|
|
71
|
+
const filePath = join(root, definition.relativePath);
|
|
72
|
+
try {
|
|
73
|
+
const content = await readFile(filePath, "utf-8");
|
|
74
|
+
sources.push({
|
|
75
|
+
name: definition.name,
|
|
76
|
+
filePath,
|
|
77
|
+
content,
|
|
78
|
+
priority: definition.priority + (maxDepth - depth + 1) * PRIORITY_DEPTH_BONUS,
|
|
79
|
+
tokenEstimate: estimateTokens(content),
|
|
80
|
+
});
|
|
81
|
+
} catch (error: unknown) {
|
|
82
|
+
if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) {
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const orderedSources = [...sources].sort(
|
|
90
|
+
(left, right) => right.priority - left.priority || left.filePath.localeCompare(right.filePath),
|
|
91
|
+
);
|
|
92
|
+
discoveryCache.set(cacheKey, orderedSources);
|
|
93
|
+
return orderedSources;
|
|
94
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { allocateBudget, truncateToTokens } from "./budget";
|
|
2
|
+
export { createCompactionHandler } from "./compaction-handler";
|
|
3
|
+
export { clearContextDiscoveryCache, discoverContextFiles } from "./discovery";
|
|
4
|
+
export {
|
|
5
|
+
type ContextInjector,
|
|
6
|
+
type ContextInjectorOptions,
|
|
7
|
+
createContextInjector,
|
|
8
|
+
} from "./injector";
|
|
9
|
+
export type {
|
|
10
|
+
ContextBudget,
|
|
11
|
+
ContextInjectionResult,
|
|
12
|
+
ContextSource,
|
|
13
|
+
DiscoveryOptions,
|
|
14
|
+
} from "./types";
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { getLogger } from "../logging/domains";
|
|
2
|
+
import { allocateBudget, truncateToTokens } from "./budget";
|
|
3
|
+
import { clearContextDiscoveryCache, discoverContextFiles } from "./discovery";
|
|
4
|
+
import type { ContextInjectionResult, ContextSource, DiscoveryOptions } from "./types";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TOTAL_BUDGET = 4000;
|
|
7
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
8
|
+
const logger = getLogger("context", "injector");
|
|
9
|
+
|
|
10
|
+
export interface ContextInjectorOptions {
|
|
11
|
+
readonly projectRoot: string;
|
|
12
|
+
readonly totalBudget?: number;
|
|
13
|
+
readonly ttlMs?: number;
|
|
14
|
+
readonly now?: () => number;
|
|
15
|
+
readonly discover?: (options: DiscoveryOptions) => Promise<readonly ContextSource[]>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface InjectorInput {
|
|
19
|
+
readonly sessionID?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface InjectorOutput {
|
|
23
|
+
system: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface CacheEntry {
|
|
27
|
+
readonly result: ContextInjectionResult;
|
|
28
|
+
readonly expiresAt: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ContextInjector {
|
|
32
|
+
(input: InjectorInput, output: InjectorOutput): Promise<void>;
|
|
33
|
+
clearCache(sessionID?: string): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildInjectionText(
|
|
37
|
+
sources: readonly ContextSource[],
|
|
38
|
+
allocations: ReadonlyMap<string, number>,
|
|
39
|
+
): {
|
|
40
|
+
readonly injectedText: string;
|
|
41
|
+
readonly truncated: boolean;
|
|
42
|
+
} {
|
|
43
|
+
let truncated = false;
|
|
44
|
+
const sections: string[] = [];
|
|
45
|
+
|
|
46
|
+
for (const source of sources) {
|
|
47
|
+
const allocation = allocations.get(source.filePath) ?? 0;
|
|
48
|
+
if (allocation <= 0) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (allocation < source.tokenEstimate) {
|
|
53
|
+
truncated = true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
sections.push(
|
|
57
|
+
`---\n[Source: ${source.name}]\n${truncateToTokens(source.content, allocation)}\n---`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
injectedText: sections.length > 0 ? `\n${sections.join("\n")}\n` : "",
|
|
63
|
+
truncated,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createContextInjector(options: ContextInjectorOptions): ContextInjector {
|
|
68
|
+
const discover = options.discover ?? discoverContextFiles;
|
|
69
|
+
const now = options.now ?? Date.now;
|
|
70
|
+
const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
|
|
71
|
+
const totalBudget = options.totalBudget ?? DEFAULT_TOTAL_BUDGET;
|
|
72
|
+
const cache = new Map<string, CacheEntry>();
|
|
73
|
+
|
|
74
|
+
const injector: ContextInjector = async (input, output) => {
|
|
75
|
+
try {
|
|
76
|
+
if (!input.sessionID) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const cached = cache.get(input.sessionID);
|
|
81
|
+
if (cached !== undefined && cached.expiresAt > now()) {
|
|
82
|
+
if (cached.result.injectedText.length > 0) {
|
|
83
|
+
output.system.push(cached.result.injectedText);
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const sources = await discover({ projectRoot: options.projectRoot, maxDepth: 3 });
|
|
89
|
+
const { allocations, totalUsed } = allocateBudget(sources, totalBudget);
|
|
90
|
+
const { injectedText, truncated } = buildInjectionText(sources, allocations);
|
|
91
|
+
|
|
92
|
+
const result: ContextInjectionResult = {
|
|
93
|
+
injectedText,
|
|
94
|
+
sources,
|
|
95
|
+
totalTokens: totalUsed,
|
|
96
|
+
truncated,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
cache.set(input.sessionID, { result, expiresAt: now() + ttlMs });
|
|
100
|
+
|
|
101
|
+
if (injectedText.length > 0) {
|
|
102
|
+
output.system.push(injectedText);
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
logger.warn("context injection failed", { error: String(error) });
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
injector.clearCache = (sessionID?: string) => {
|
|
110
|
+
if (sessionID) {
|
|
111
|
+
cache.delete(sessionID);
|
|
112
|
+
} else {
|
|
113
|
+
cache.clear();
|
|
114
|
+
}
|
|
115
|
+
clearContextDiscoveryCache(options.projectRoot);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return injector;
|
|
119
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export interface ContextSource {
|
|
2
|
+
readonly name: string;
|
|
3
|
+
readonly filePath: string;
|
|
4
|
+
readonly content: string;
|
|
5
|
+
readonly priority: number;
|
|
6
|
+
readonly tokenEstimate: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ContextBudget {
|
|
10
|
+
readonly totalTokens: number;
|
|
11
|
+
readonly allocations: ReadonlyMap<string, number>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ContextInjectionResult {
|
|
15
|
+
readonly injectedText: string;
|
|
16
|
+
readonly sources: readonly ContextSource[];
|
|
17
|
+
readonly totalTokens: number;
|
|
18
|
+
readonly truncated: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DiscoveryOptions {
|
|
22
|
+
readonly projectRoot: string;
|
|
23
|
+
readonly maxDepth?: number;
|
|
24
|
+
}
|