micode 0.7.6 → 0.8.1
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/package.json +1 -1
- package/src/agents/artifact-searcher.ts +6 -1
- package/src/agents/brainstormer.ts +34 -2
- package/src/agents/codebase-analyzer.ts +6 -1
- package/src/agents/codebase-locator.ts +6 -1
- package/src/agents/commander.ts +14 -2
- package/src/agents/executor.ts +38 -22
- package/src/agents/implementer.ts +6 -1
- package/src/agents/ledger-creator.ts +6 -1
- package/src/agents/pattern-finder.ts +6 -1
- package/src/agents/planner.ts +20 -11
- package/src/agents/project-initializer.ts +27 -21
- package/src/agents/reviewer.ts +6 -1
- package/src/hooks/auto-compact.ts +138 -184
- package/src/hooks/context-injector.ts +4 -4
- package/src/hooks/context-window-monitor.ts +3 -3
- package/src/index.ts +76 -4
- package/src/tools/spawn-agent.ts +93 -0
- package/src/hooks/auto-clear-ledger.ts +0 -230
- package/src/hooks/preemptive-compaction.ts +0 -183
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
// src/hooks/auto-clear-ledger.ts
|
|
2
|
-
import type { PluginInput } from "@opencode-ai/plugin";
|
|
3
|
-
import { findCurrentLedger, formatLedgerInjection } from "./ledger-loader";
|
|
4
|
-
import { getFileOps, clearFileOps, formatFileOpsForPrompt } from "./file-ops-tracker";
|
|
5
|
-
import { getContextLimit } from "../utils/model-limits";
|
|
6
|
-
|
|
7
|
-
export const DEFAULT_THRESHOLD = 0.6; // 60% of context window
|
|
8
|
-
const MIN_TOKENS_FOR_CLEAR = 50_000;
|
|
9
|
-
export const CLEAR_COOLDOWN_MS = 60_000;
|
|
10
|
-
|
|
11
|
-
interface ClearState {
|
|
12
|
-
lastClearTime: Map<string, number>;
|
|
13
|
-
clearInProgress: Set<string>;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function createAutoClearLedgerHook(ctx: PluginInput) {
|
|
17
|
-
const state: ClearState = {
|
|
18
|
-
lastClearTime: new Map(),
|
|
19
|
-
clearInProgress: new Set(),
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
async function checkAndClear(sessionID: string, _providerID?: string, modelID?: string): Promise<void> {
|
|
23
|
-
// Skip if clear in progress
|
|
24
|
-
if (state.clearInProgress.has(sessionID)) return;
|
|
25
|
-
|
|
26
|
-
// Respect cooldown
|
|
27
|
-
const lastTime = state.lastClearTime.get(sessionID) || 0;
|
|
28
|
-
if (Date.now() - lastTime < CLEAR_COOLDOWN_MS) return;
|
|
29
|
-
|
|
30
|
-
try {
|
|
31
|
-
// Get session messages to calculate token usage
|
|
32
|
-
const resp = await ctx.client.session.messages({
|
|
33
|
-
path: { id: sessionID },
|
|
34
|
-
query: { directory: ctx.directory },
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
const messages = (resp as { data?: unknown[] }).data;
|
|
38
|
-
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
39
|
-
|
|
40
|
-
// Find last assistant message with token info
|
|
41
|
-
const lastAssistant = [...messages].reverse().find((m) => {
|
|
42
|
-
const msg = m as Record<string, unknown>;
|
|
43
|
-
const info = msg.info as Record<string, unknown> | undefined;
|
|
44
|
-
return info?.role === "assistant";
|
|
45
|
-
}) as Record<string, unknown> | undefined;
|
|
46
|
-
|
|
47
|
-
if (!lastAssistant) return;
|
|
48
|
-
|
|
49
|
-
const info = lastAssistant.info as Record<string, unknown> | undefined;
|
|
50
|
-
const usage = info?.usage as Record<string, unknown> | undefined;
|
|
51
|
-
|
|
52
|
-
// Calculate token usage
|
|
53
|
-
const inputTokens = (usage?.inputTokens as number) || 0;
|
|
54
|
-
const cacheRead = (usage?.cacheReadInputTokens as number) || 0;
|
|
55
|
-
const totalUsed = inputTokens + cacheRead;
|
|
56
|
-
|
|
57
|
-
if (totalUsed < MIN_TOKENS_FOR_CLEAR) return;
|
|
58
|
-
|
|
59
|
-
// Get model context limit
|
|
60
|
-
const model = modelID || (info?.modelID as string) || "";
|
|
61
|
-
const contextLimit = getContextLimit(model);
|
|
62
|
-
const usageRatio = totalUsed / contextLimit;
|
|
63
|
-
|
|
64
|
-
if (usageRatio < DEFAULT_THRESHOLD) return;
|
|
65
|
-
|
|
66
|
-
// Start clear process
|
|
67
|
-
state.clearInProgress.add(sessionID);
|
|
68
|
-
state.lastClearTime.set(sessionID, Date.now());
|
|
69
|
-
|
|
70
|
-
await ctx.client.tui
|
|
71
|
-
.showToast({
|
|
72
|
-
body: {
|
|
73
|
-
title: "Context Window",
|
|
74
|
-
message: `${Math.round(usageRatio * 100)}% used - saving ledger and clearing...`,
|
|
75
|
-
variant: "warning",
|
|
76
|
-
duration: 3000,
|
|
77
|
-
},
|
|
78
|
-
})
|
|
79
|
-
.catch(() => {});
|
|
80
|
-
|
|
81
|
-
// Step 1: Get file operations and existing ledger (don't clear yet)
|
|
82
|
-
const fileOps = getFileOps(sessionID);
|
|
83
|
-
const existingLedger = await findCurrentLedger(ctx.directory);
|
|
84
|
-
|
|
85
|
-
// Step 2: Spawn ledger-creator agent to update ledger
|
|
86
|
-
const ledgerSessionResp = await ctx.client.session.create({
|
|
87
|
-
body: {},
|
|
88
|
-
query: { directory: ctx.directory },
|
|
89
|
-
});
|
|
90
|
-
const ledgerSessionID = (ledgerSessionResp as { data?: { id?: string } }).data?.id;
|
|
91
|
-
|
|
92
|
-
if (ledgerSessionID) {
|
|
93
|
-
// Build prompt with previous ledger and file ops
|
|
94
|
-
let promptText = "";
|
|
95
|
-
|
|
96
|
-
if (existingLedger) {
|
|
97
|
-
promptText += `<previous-ledger>\n${existingLedger.content}\n</previous-ledger>\n\n`;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
promptText += formatFileOpsForPrompt(fileOps);
|
|
101
|
-
promptText += "\n\n<instruction>\n";
|
|
102
|
-
promptText += existingLedger
|
|
103
|
-
? "Update the ledger with the current session state. Merge the file operations above with any existing ones in the previous ledger."
|
|
104
|
-
: "Create a new continuity ledger for this session.";
|
|
105
|
-
promptText += "\n</instruction>";
|
|
106
|
-
|
|
107
|
-
await ctx.client.session.prompt({
|
|
108
|
-
path: { id: ledgerSessionID },
|
|
109
|
-
body: {
|
|
110
|
-
parts: [{ type: "text", text: promptText }],
|
|
111
|
-
agent: "ledger-creator",
|
|
112
|
-
},
|
|
113
|
-
query: { directory: ctx.directory },
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
// Wait for ledger completion (poll for idle)
|
|
117
|
-
let attempts = 0;
|
|
118
|
-
let ledgerCompleted = false;
|
|
119
|
-
while (attempts < 30) {
|
|
120
|
-
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
121
|
-
const statusResp = await ctx.client.session.get({
|
|
122
|
-
path: { id: ledgerSessionID },
|
|
123
|
-
query: { directory: ctx.directory },
|
|
124
|
-
});
|
|
125
|
-
if ((statusResp as { data?: { status?: string } }).data?.status === "idle") {
|
|
126
|
-
ledgerCompleted = true;
|
|
127
|
-
break;
|
|
128
|
-
}
|
|
129
|
-
attempts++;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Only clear file ops after ledger-creator successfully completed
|
|
133
|
-
if (ledgerCompleted) {
|
|
134
|
-
clearFileOps(sessionID);
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Step 3: Get first message ID for revert
|
|
139
|
-
const firstMessage = messages[0] as Record<string, unknown> | undefined;
|
|
140
|
-
const firstMessageID = (firstMessage?.info as Record<string, unknown> | undefined)?.id as string | undefined;
|
|
141
|
-
|
|
142
|
-
if (!firstMessageID) {
|
|
143
|
-
throw new Error("Could not find first message ID for revert");
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Step 4: Revert session to first message
|
|
147
|
-
await ctx.client.session.revert({
|
|
148
|
-
path: { id: sessionID },
|
|
149
|
-
body: { messageID: firstMessageID },
|
|
150
|
-
query: { directory: ctx.directory },
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
// Step 5: Inject ledger context
|
|
154
|
-
const ledger = await findCurrentLedger(ctx.directory);
|
|
155
|
-
if (ledger) {
|
|
156
|
-
const injection = formatLedgerInjection(ledger);
|
|
157
|
-
await ctx.client.session.prompt({
|
|
158
|
-
path: { id: sessionID },
|
|
159
|
-
body: {
|
|
160
|
-
parts: [{ type: "text", text: injection }],
|
|
161
|
-
noReply: true,
|
|
162
|
-
},
|
|
163
|
-
query: { directory: ctx.directory },
|
|
164
|
-
});
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
await ctx.client.tui
|
|
168
|
-
.showToast({
|
|
169
|
-
body: {
|
|
170
|
-
title: "Context Cleared",
|
|
171
|
-
message: "Ledger saved. Session ready to continue.",
|
|
172
|
-
variant: "success",
|
|
173
|
-
duration: 5000,
|
|
174
|
-
},
|
|
175
|
-
})
|
|
176
|
-
.catch(() => {});
|
|
177
|
-
} catch (e) {
|
|
178
|
-
// Log error but don't interrupt user flow
|
|
179
|
-
console.error("[auto-clear-ledger] Error:", e);
|
|
180
|
-
await ctx.client.tui
|
|
181
|
-
.showToast({
|
|
182
|
-
body: {
|
|
183
|
-
title: "Clear Failed",
|
|
184
|
-
message: "Could not complete context clear. Continuing normally.",
|
|
185
|
-
variant: "error",
|
|
186
|
-
duration: 5000,
|
|
187
|
-
},
|
|
188
|
-
})
|
|
189
|
-
.catch(() => {});
|
|
190
|
-
} finally {
|
|
191
|
-
state.clearInProgress.delete(sessionID);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return {
|
|
196
|
-
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
|
197
|
-
const props = event.properties as Record<string, unknown> | undefined;
|
|
198
|
-
|
|
199
|
-
// Cleanup on session delete
|
|
200
|
-
if (event.type === "session.deleted") {
|
|
201
|
-
const sessionInfo = props?.info as { id?: string } | undefined;
|
|
202
|
-
if (sessionInfo?.id) {
|
|
203
|
-
state.lastClearTime.delete(sessionInfo.id);
|
|
204
|
-
state.clearInProgress.delete(sessionInfo.id);
|
|
205
|
-
}
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Check on message update (assistant finished)
|
|
210
|
-
if (event.type === "message.updated") {
|
|
211
|
-
const info = props?.info as Record<string, unknown> | undefined;
|
|
212
|
-
const sessionID = info?.sessionID as string | undefined;
|
|
213
|
-
|
|
214
|
-
if (sessionID && info?.role === "assistant") {
|
|
215
|
-
const providerID = info.providerID as string | undefined;
|
|
216
|
-
const modelID = info.modelID as string | undefined;
|
|
217
|
-
await checkAndClear(sessionID, providerID, modelID);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Check when session goes idle
|
|
222
|
-
if (event.type === "session.idle") {
|
|
223
|
-
const sessionID = props?.sessionID as string | undefined;
|
|
224
|
-
if (sessionID) {
|
|
225
|
-
await checkAndClear(sessionID);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
},
|
|
229
|
-
};
|
|
230
|
-
}
|
|
@@ -1,183 +0,0 @@
|
|
|
1
|
-
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
-
|
|
3
|
-
// Model context limits (tokens)
|
|
4
|
-
const MODEL_CONTEXT_LIMITS: Record<string, number> = {
|
|
5
|
-
// Anthropic
|
|
6
|
-
"claude-opus": 200_000,
|
|
7
|
-
"claude-sonnet": 200_000,
|
|
8
|
-
"claude-haiku": 200_000,
|
|
9
|
-
"claude-3": 200_000,
|
|
10
|
-
"claude-4": 200_000,
|
|
11
|
-
// OpenAI
|
|
12
|
-
"gpt-4o": 128_000,
|
|
13
|
-
"gpt-4-turbo": 128_000,
|
|
14
|
-
"gpt-4": 128_000,
|
|
15
|
-
"gpt-5": 200_000,
|
|
16
|
-
o1: 200_000,
|
|
17
|
-
o3: 200_000,
|
|
18
|
-
// Google
|
|
19
|
-
gemini: 1_000_000,
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const DEFAULT_CONTEXT_LIMIT = 200_000;
|
|
23
|
-
const DEFAULT_THRESHOLD = 0.6; // 60% of context window
|
|
24
|
-
const MIN_TOKENS_FOR_COMPACTION = 50_000;
|
|
25
|
-
const COMPACTION_COOLDOWN_MS = 60_000; // 60 seconds
|
|
26
|
-
|
|
27
|
-
function getContextLimit(modelID: string): number {
|
|
28
|
-
const modelLower = modelID.toLowerCase();
|
|
29
|
-
for (const [pattern, limit] of Object.entries(MODEL_CONTEXT_LIMITS)) {
|
|
30
|
-
if (modelLower.includes(pattern)) {
|
|
31
|
-
return limit;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
return DEFAULT_CONTEXT_LIMIT;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface CompactionState {
|
|
38
|
-
lastCompactionTime: Map<string, number>;
|
|
39
|
-
compactionInProgress: Set<string>;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function createPreemptiveCompactionHook(ctx: PluginInput) {
|
|
43
|
-
const state: CompactionState = {
|
|
44
|
-
lastCompactionTime: new Map(),
|
|
45
|
-
compactionInProgress: new Set(),
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
async function checkAndCompact(sessionID: string, providerID?: string, modelID?: string): Promise<void> {
|
|
49
|
-
// Skip if compaction in progress
|
|
50
|
-
if (state.compactionInProgress.has(sessionID)) return;
|
|
51
|
-
|
|
52
|
-
// Respect cooldown
|
|
53
|
-
const lastTime = state.lastCompactionTime.get(sessionID) || 0;
|
|
54
|
-
if (Date.now() - lastTime < COMPACTION_COOLDOWN_MS) return;
|
|
55
|
-
|
|
56
|
-
try {
|
|
57
|
-
// Get session messages to calculate token usage
|
|
58
|
-
const resp = await ctx.client.session.messages({
|
|
59
|
-
path: { id: sessionID },
|
|
60
|
-
query: { directory: ctx.directory },
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const messages = (resp as { data?: unknown[] }).data;
|
|
64
|
-
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
65
|
-
|
|
66
|
-
// Find last assistant message with token info
|
|
67
|
-
const lastAssistant = [...messages].reverse().find((m) => {
|
|
68
|
-
const msg = m as Record<string, unknown>;
|
|
69
|
-
const info = msg.info as Record<string, unknown> | undefined;
|
|
70
|
-
return info?.role === "assistant";
|
|
71
|
-
}) as Record<string, unknown> | undefined;
|
|
72
|
-
|
|
73
|
-
if (!lastAssistant) return;
|
|
74
|
-
|
|
75
|
-
const info = lastAssistant.info as Record<string, unknown> | undefined;
|
|
76
|
-
const usage = info?.usage as Record<string, unknown> | undefined;
|
|
77
|
-
|
|
78
|
-
// Calculate token usage
|
|
79
|
-
const inputTokens = (usage?.inputTokens as number) || 0;
|
|
80
|
-
const cacheRead = (usage?.cacheReadInputTokens as number) || 0;
|
|
81
|
-
const totalUsed = inputTokens + cacheRead;
|
|
82
|
-
|
|
83
|
-
if (totalUsed < MIN_TOKENS_FOR_COMPACTION) return;
|
|
84
|
-
|
|
85
|
-
// Get model context limit
|
|
86
|
-
const model = modelID || (info?.modelID as string) || "";
|
|
87
|
-
const contextLimit = getContextLimit(model);
|
|
88
|
-
const usageRatio = totalUsed / contextLimit;
|
|
89
|
-
|
|
90
|
-
if (usageRatio < DEFAULT_THRESHOLD) return;
|
|
91
|
-
|
|
92
|
-
// Skip if last message was already a summary
|
|
93
|
-
const lastUserMsg = [...messages].reverse().find((m) => {
|
|
94
|
-
const msg = m as Record<string, unknown>;
|
|
95
|
-
const msgInfo = msg.info as Record<string, unknown> | undefined;
|
|
96
|
-
return msgInfo?.role === "user";
|
|
97
|
-
}) as Record<string, unknown> | undefined;
|
|
98
|
-
|
|
99
|
-
if (lastUserMsg) {
|
|
100
|
-
const parts = lastUserMsg.parts as Array<{ type: string; text?: string }> | undefined;
|
|
101
|
-
const text = parts?.find((p) => p.type === "text")?.text || "";
|
|
102
|
-
if (text.includes("summarized") || text.includes("compacted")) return;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Trigger compaction
|
|
106
|
-
state.compactionInProgress.add(sessionID);
|
|
107
|
-
state.lastCompactionTime.set(sessionID, Date.now());
|
|
108
|
-
|
|
109
|
-
await ctx.client.tui
|
|
110
|
-
.showToast({
|
|
111
|
-
body: {
|
|
112
|
-
title: "Context Window",
|
|
113
|
-
message: `${Math.round(usageRatio * 100)}% used - auto-compacting...`,
|
|
114
|
-
variant: "warning",
|
|
115
|
-
duration: 3000,
|
|
116
|
-
},
|
|
117
|
-
})
|
|
118
|
-
.catch(() => {});
|
|
119
|
-
|
|
120
|
-
const provider = providerID || (info?.providerID as string);
|
|
121
|
-
const modelToUse = modelID || (info?.modelID as string);
|
|
122
|
-
|
|
123
|
-
if (provider && modelToUse) {
|
|
124
|
-
await ctx.client.session.summarize({
|
|
125
|
-
path: { id: sessionID },
|
|
126
|
-
body: { providerID: provider, modelID: modelToUse },
|
|
127
|
-
query: { directory: ctx.directory },
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
await ctx.client.tui
|
|
131
|
-
.showToast({
|
|
132
|
-
body: {
|
|
133
|
-
title: "Compacted",
|
|
134
|
-
message: "Session summarized successfully",
|
|
135
|
-
variant: "success",
|
|
136
|
-
duration: 3000,
|
|
137
|
-
},
|
|
138
|
-
})
|
|
139
|
-
.catch(() => {});
|
|
140
|
-
}
|
|
141
|
-
} catch (_e) {
|
|
142
|
-
// Silent failure - don't interrupt user flow
|
|
143
|
-
} finally {
|
|
144
|
-
state.compactionInProgress.delete(sessionID);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return {
|
|
149
|
-
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
|
150
|
-
const props = event.properties as Record<string, unknown> | undefined;
|
|
151
|
-
|
|
152
|
-
// Cleanup on session delete
|
|
153
|
-
if (event.type === "session.deleted") {
|
|
154
|
-
const sessionInfo = props?.info as { id?: string } | undefined;
|
|
155
|
-
if (sessionInfo?.id) {
|
|
156
|
-
state.lastCompactionTime.delete(sessionInfo.id);
|
|
157
|
-
state.compactionInProgress.delete(sessionInfo.id);
|
|
158
|
-
}
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Check on message update (assistant finished)
|
|
163
|
-
if (event.type === "message.updated") {
|
|
164
|
-
const info = props?.info as Record<string, unknown> | undefined;
|
|
165
|
-
const sessionID = info?.sessionID as string | undefined;
|
|
166
|
-
|
|
167
|
-
if (sessionID && info?.role === "assistant") {
|
|
168
|
-
const providerID = info.providerID as string | undefined;
|
|
169
|
-
const modelID = info.modelID as string | undefined;
|
|
170
|
-
await checkAndCompact(sessionID, providerID, modelID);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Check when session goes idle
|
|
175
|
-
if (event.type === "session.idle") {
|
|
176
|
-
const sessionID = props?.sessionID as string | undefined;
|
|
177
|
-
if (sessionID) {
|
|
178
|
-
await checkAndCompact(sessionID);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
},
|
|
182
|
-
};
|
|
183
|
-
}
|