micode 0.6.0 → 0.7.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/README.md +64 -331
- package/package.json +9 -14
- package/src/agents/artifact-searcher.ts +46 -0
- package/src/agents/brainstormer.ts +145 -0
- package/src/agents/codebase-analyzer.ts +75 -0
- package/src/agents/codebase-locator.ts +71 -0
- package/src/agents/commander.ts +138 -0
- package/src/agents/executor.ts +215 -0
- package/src/agents/implementer.ts +99 -0
- package/src/agents/index.ts +44 -0
- package/src/agents/ledger-creator.ts +113 -0
- package/src/agents/pattern-finder.ts +70 -0
- package/src/agents/planner.ts +230 -0
- package/src/agents/project-initializer.ts +264 -0
- package/src/agents/reviewer.ts +102 -0
- package/src/config-loader.ts +89 -0
- package/src/hooks/artifact-auto-index.ts +111 -0
- package/src/hooks/auto-clear-ledger.ts +230 -0
- package/src/hooks/auto-compact.ts +241 -0
- package/src/hooks/comment-checker.ts +120 -0
- package/src/hooks/context-injector.ts +163 -0
- package/src/hooks/context-window-monitor.ts +106 -0
- package/src/hooks/file-ops-tracker.ts +96 -0
- package/src/hooks/ledger-loader.ts +78 -0
- package/src/hooks/preemptive-compaction.ts +183 -0
- package/src/hooks/session-recovery.ts +258 -0
- package/src/hooks/token-aware-truncation.ts +189 -0
- package/src/index.ts +258 -0
- package/src/tools/artifact-index/index.ts +269 -0
- package/src/tools/artifact-index/schema.sql +44 -0
- package/src/tools/artifact-search.ts +49 -0
- package/src/tools/ast-grep/index.ts +189 -0
- package/src/tools/background-task/manager.ts +374 -0
- package/src/tools/background-task/tools.ts +145 -0
- package/src/tools/background-task/types.ts +68 -0
- package/src/tools/btca/index.ts +82 -0
- package/src/tools/look-at.ts +210 -0
- package/src/tools/pty/buffer.ts +49 -0
- package/src/tools/pty/index.ts +34 -0
- package/src/tools/pty/manager.ts +159 -0
- package/src/tools/pty/tools/kill.ts +68 -0
- package/src/tools/pty/tools/list.ts +55 -0
- package/src/tools/pty/tools/read.ts +152 -0
- package/src/tools/pty/tools/spawn.ts +78 -0
- package/src/tools/pty/tools/write.ts +97 -0
- package/src/tools/pty/types.ts +62 -0
- package/src/utils/model-limits.ts +36 -0
- package/dist/agents/artifact-searcher.d.ts +0 -2
- package/dist/agents/brainstormer.d.ts +0 -2
- package/dist/agents/codebase-analyzer.d.ts +0 -2
- package/dist/agents/codebase-locator.d.ts +0 -2
- package/dist/agents/commander.d.ts +0 -3
- package/dist/agents/executor.d.ts +0 -2
- package/dist/agents/implementer.d.ts +0 -2
- package/dist/agents/index.d.ts +0 -15
- package/dist/agents/ledger-creator.d.ts +0 -2
- package/dist/agents/pattern-finder.d.ts +0 -2
- package/dist/agents/planner.d.ts +0 -2
- package/dist/agents/project-initializer.d.ts +0 -2
- package/dist/agents/reviewer.d.ts +0 -2
- package/dist/config-loader.d.ts +0 -20
- package/dist/hooks/artifact-auto-index.d.ts +0 -19
- package/dist/hooks/auto-clear-ledger.d.ts +0 -11
- package/dist/hooks/auto-compact.d.ts +0 -9
- package/dist/hooks/comment-checker.d.ts +0 -9
- package/dist/hooks/context-injector.d.ts +0 -15
- package/dist/hooks/context-window-monitor.d.ts +0 -15
- package/dist/hooks/file-ops-tracker.d.ts +0 -26
- package/dist/hooks/ledger-loader.d.ts +0 -16
- package/dist/hooks/preemptive-compaction.d.ts +0 -9
- package/dist/hooks/session-recovery.d.ts +0 -9
- package/dist/hooks/token-aware-truncation.d.ts +0 -15
- package/dist/index.d.ts +0 -3
- package/dist/index.js +0 -16267
- package/dist/tools/artifact-index/index.d.ts +0 -38
- package/dist/tools/artifact-search.d.ts +0 -17
- package/dist/tools/ast-grep/index.d.ts +0 -88
- package/dist/tools/background-task/manager.d.ts +0 -27
- package/dist/tools/background-task/tools.d.ts +0 -41
- package/dist/tools/background-task/types.d.ts +0 -53
- package/dist/tools/btca/index.d.ts +0 -19
- package/dist/tools/look-at.d.ts +0 -11
- package/dist/utils/model-limits.d.ts +0 -7
- /package/{dist/tools/background-task/index.d.ts → src/tools/background-task/index.ts} +0 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
|
|
3
|
+
// Error patterns we can recover from
|
|
4
|
+
const RECOVERABLE_ERRORS = {
|
|
5
|
+
TOOL_RESULT_MISSING: "tool_result block(s) missing",
|
|
6
|
+
THINKING_BLOCK_ORDER: "thinking blocks must be at the start",
|
|
7
|
+
THINKING_DISABLED: "thinking is not enabled",
|
|
8
|
+
EMPTY_CONTENT: "content cannot be empty",
|
|
9
|
+
INVALID_TOOL_RESULT: "tool_result must follow tool_use",
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
type RecoverableErrorType = keyof typeof RECOVERABLE_ERRORS;
|
|
13
|
+
|
|
14
|
+
interface RecoveryState {
|
|
15
|
+
processingErrors: Set<string>;
|
|
16
|
+
recoveryAttempts: Map<string, number>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MAX_RECOVERY_ATTEMPTS = 3;
|
|
20
|
+
|
|
21
|
+
function extractErrorInfo(error: unknown): { message: string; messageIndex?: number } | null {
|
|
22
|
+
if (!error) return null;
|
|
23
|
+
|
|
24
|
+
let errorStr: string;
|
|
25
|
+
if (typeof error === "string") {
|
|
26
|
+
errorStr = error;
|
|
27
|
+
} else if (error instanceof Error) {
|
|
28
|
+
errorStr = error.message;
|
|
29
|
+
} else {
|
|
30
|
+
errorStr = JSON.stringify(error);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const errorLower = errorStr.toLowerCase();
|
|
34
|
+
|
|
35
|
+
// Extract message index if present (e.g., "messages.5" or "message 5")
|
|
36
|
+
const indexMatch = errorStr.match(/messages?[.\s](\d+)/i);
|
|
37
|
+
const messageIndex = indexMatch ? parseInt(indexMatch[1], 10) : undefined;
|
|
38
|
+
|
|
39
|
+
return { message: errorLower, messageIndex };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function identifyErrorType(errorMessage: string): RecoverableErrorType | null {
|
|
43
|
+
for (const [type, pattern] of Object.entries(RECOVERABLE_ERRORS)) {
|
|
44
|
+
if (errorMessage.includes(pattern.toLowerCase())) {
|
|
45
|
+
return type as RecoverableErrorType;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createSessionRecoveryHook(ctx: PluginInput) {
|
|
52
|
+
const state: RecoveryState = {
|
|
53
|
+
processingErrors: new Set(),
|
|
54
|
+
recoveryAttempts: new Map(),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
async function getSessionMessages(sessionID: string): Promise<unknown[]> {
|
|
58
|
+
try {
|
|
59
|
+
const resp = await ctx.client.session.messages({
|
|
60
|
+
path: { id: sessionID },
|
|
61
|
+
query: { directory: ctx.directory },
|
|
62
|
+
});
|
|
63
|
+
return (resp as { data?: unknown[] }).data || [];
|
|
64
|
+
} catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function abortSession(sessionID: string): Promise<void> {
|
|
70
|
+
try {
|
|
71
|
+
await ctx.client.session.abort({
|
|
72
|
+
path: { id: sessionID },
|
|
73
|
+
query: { directory: ctx.directory },
|
|
74
|
+
});
|
|
75
|
+
} catch {
|
|
76
|
+
// Ignore abort errors
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function resumeSession(
|
|
81
|
+
sessionID: string,
|
|
82
|
+
providerID?: string,
|
|
83
|
+
modelID?: string,
|
|
84
|
+
agent?: string,
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
try {
|
|
87
|
+
// Find last user message to resume from
|
|
88
|
+
const messages = await getSessionMessages(sessionID);
|
|
89
|
+
const lastUserMsg = [...messages].reverse().find((m) => {
|
|
90
|
+
const msg = m as Record<string, unknown>;
|
|
91
|
+
const info = msg.info as Record<string, unknown> | undefined;
|
|
92
|
+
return info?.role === "user";
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (!lastUserMsg) return;
|
|
96
|
+
|
|
97
|
+
const parts = (lastUserMsg as Record<string, unknown>).parts as Array<{
|
|
98
|
+
type: string;
|
|
99
|
+
text?: string;
|
|
100
|
+
}>;
|
|
101
|
+
const text = parts?.find((p) => p.type === "text")?.text;
|
|
102
|
+
|
|
103
|
+
if (!text) return;
|
|
104
|
+
|
|
105
|
+
// Resume with continue prompt
|
|
106
|
+
await ctx.client.session.prompt({
|
|
107
|
+
path: { id: sessionID },
|
|
108
|
+
body: {
|
|
109
|
+
parts: [{ type: "text", text: "Continue from where you left off." }],
|
|
110
|
+
...(providerID && modelID ? { providerID, modelID } : {}),
|
|
111
|
+
...(agent ? { agent } : {}),
|
|
112
|
+
},
|
|
113
|
+
query: { directory: ctx.directory },
|
|
114
|
+
});
|
|
115
|
+
} catch {
|
|
116
|
+
// Resume failed - user will need to manually continue
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function attemptRecovery(
|
|
121
|
+
sessionID: string,
|
|
122
|
+
errorType: RecoverableErrorType,
|
|
123
|
+
providerID?: string,
|
|
124
|
+
modelID?: string,
|
|
125
|
+
agent?: string,
|
|
126
|
+
): Promise<boolean> {
|
|
127
|
+
const recoveryKey = `${sessionID}:${errorType}`;
|
|
128
|
+
|
|
129
|
+
// Check recovery attempts
|
|
130
|
+
const attempts = state.recoveryAttempts.get(recoveryKey) || 0;
|
|
131
|
+
if (attempts >= MAX_RECOVERY_ATTEMPTS) {
|
|
132
|
+
await ctx.client.tui
|
|
133
|
+
.showToast({
|
|
134
|
+
body: {
|
|
135
|
+
title: "Recovery Failed",
|
|
136
|
+
message: `Max attempts reached for ${errorType}. Manual intervention needed.`,
|
|
137
|
+
variant: "error",
|
|
138
|
+
duration: 5000,
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
.catch(() => {});
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
state.recoveryAttempts.set(recoveryKey, attempts + 1);
|
|
146
|
+
|
|
147
|
+
await ctx.client.tui
|
|
148
|
+
.showToast({
|
|
149
|
+
body: {
|
|
150
|
+
title: "Session Recovery",
|
|
151
|
+
message: `Recovering from ${errorType.toLowerCase().replace(/_/g, " ")}...`,
|
|
152
|
+
variant: "warning",
|
|
153
|
+
duration: 3000,
|
|
154
|
+
},
|
|
155
|
+
})
|
|
156
|
+
.catch(() => {});
|
|
157
|
+
|
|
158
|
+
// Abort current session to stop the error state
|
|
159
|
+
await abortSession(sessionID);
|
|
160
|
+
|
|
161
|
+
// Wait a moment for abort to complete
|
|
162
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
163
|
+
|
|
164
|
+
// Attempt resume
|
|
165
|
+
await resumeSession(sessionID, providerID, modelID, agent);
|
|
166
|
+
|
|
167
|
+
await ctx.client.tui
|
|
168
|
+
.showToast({
|
|
169
|
+
body: {
|
|
170
|
+
title: "Recovery Complete",
|
|
171
|
+
message: "Session resumed. Continuing...",
|
|
172
|
+
variant: "success",
|
|
173
|
+
duration: 3000,
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
.catch(() => {});
|
|
177
|
+
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
|
183
|
+
const props = event.properties as Record<string, unknown> | undefined;
|
|
184
|
+
|
|
185
|
+
// Cleanup on session delete
|
|
186
|
+
if (event.type === "session.deleted") {
|
|
187
|
+
const sessionInfo = props?.info as { id?: string } | undefined;
|
|
188
|
+
if (sessionInfo?.id) {
|
|
189
|
+
// Clean up all recovery attempts for this session
|
|
190
|
+
for (const key of state.recoveryAttempts.keys()) {
|
|
191
|
+
if (key.startsWith(sessionInfo.id)) {
|
|
192
|
+
state.recoveryAttempts.delete(key);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const key of state.processingErrors) {
|
|
196
|
+
if (key.startsWith(sessionInfo.id)) {
|
|
197
|
+
state.processingErrors.delete(key);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Handle session errors
|
|
205
|
+
if (event.type === "session.error") {
|
|
206
|
+
const sessionID = props?.sessionID as string | undefined;
|
|
207
|
+
const error = props?.error;
|
|
208
|
+
|
|
209
|
+
if (!sessionID || !error) return;
|
|
210
|
+
|
|
211
|
+
const errorInfo = extractErrorInfo(error);
|
|
212
|
+
if (!errorInfo) return;
|
|
213
|
+
|
|
214
|
+
const errorType = identifyErrorType(errorInfo.message);
|
|
215
|
+
if (!errorType) return;
|
|
216
|
+
|
|
217
|
+
// Prevent duplicate processing
|
|
218
|
+
const errorKey = `${sessionID}:${errorType}:${Date.now()}`;
|
|
219
|
+
if (state.processingErrors.has(errorKey)) return;
|
|
220
|
+
state.processingErrors.add(errorKey);
|
|
221
|
+
|
|
222
|
+
// Clear old error keys after 10 seconds
|
|
223
|
+
setTimeout(() => state.processingErrors.delete(errorKey), 10000);
|
|
224
|
+
|
|
225
|
+
// Attempt recovery
|
|
226
|
+
await attemptRecovery(sessionID, errorType);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Handle message errors
|
|
230
|
+
if (event.type === "message.updated") {
|
|
231
|
+
const info = props?.info as Record<string, unknown> | undefined;
|
|
232
|
+
const sessionID = info?.sessionID as string | undefined;
|
|
233
|
+
const error = info?.error;
|
|
234
|
+
|
|
235
|
+
if (!sessionID || !error) return;
|
|
236
|
+
|
|
237
|
+
const errorInfo = extractErrorInfo(error);
|
|
238
|
+
if (!errorInfo) return;
|
|
239
|
+
|
|
240
|
+
const errorType = identifyErrorType(errorInfo.message);
|
|
241
|
+
if (!errorType) return;
|
|
242
|
+
|
|
243
|
+
// Prevent duplicate processing
|
|
244
|
+
const errorKey = `${sessionID}:${errorType}:${Date.now()}`;
|
|
245
|
+
if (state.processingErrors.has(errorKey)) return;
|
|
246
|
+
state.processingErrors.add(errorKey);
|
|
247
|
+
|
|
248
|
+
setTimeout(() => state.processingErrors.delete(errorKey), 10000);
|
|
249
|
+
|
|
250
|
+
const providerID = info.providerID as string | undefined;
|
|
251
|
+
const modelID = info.modelID as string | undefined;
|
|
252
|
+
const agent = info.agent as string | undefined;
|
|
253
|
+
|
|
254
|
+
await attemptRecovery(sessionID, errorType, providerID, modelID, agent);
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
|
|
3
|
+
// Tools that benefit from truncation
|
|
4
|
+
const TRUNCATABLE_TOOLS = ["grep", "Grep", "glob", "Glob", "ast_grep_search"];
|
|
5
|
+
|
|
6
|
+
// Token estimation (conservative: 4 chars = 1 token)
|
|
7
|
+
const CHARS_PER_TOKEN = 4;
|
|
8
|
+
const DEFAULT_CONTEXT_LIMIT = 200_000;
|
|
9
|
+
const DEFAULT_MAX_OUTPUT_TOKENS = 50_000;
|
|
10
|
+
const SAFETY_MARGIN = 0.5; // Keep 50% headroom
|
|
11
|
+
const PRESERVE_HEADER_LINES = 3;
|
|
12
|
+
|
|
13
|
+
function estimateTokens(text: string): number {
|
|
14
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function truncateToTokenLimit(
|
|
18
|
+
output: string,
|
|
19
|
+
maxTokens: number,
|
|
20
|
+
preserveLines: number = PRESERVE_HEADER_LINES,
|
|
21
|
+
): string {
|
|
22
|
+
const currentTokens = estimateTokens(output);
|
|
23
|
+
|
|
24
|
+
if (currentTokens <= maxTokens) {
|
|
25
|
+
return output;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const lines = output.split("\n");
|
|
29
|
+
|
|
30
|
+
// Preserve header lines
|
|
31
|
+
const headerLines = lines.slice(0, preserveLines);
|
|
32
|
+
const remainingLines = lines.slice(preserveLines);
|
|
33
|
+
|
|
34
|
+
// Calculate available tokens for content
|
|
35
|
+
const headerTokens = estimateTokens(headerLines.join("\n"));
|
|
36
|
+
const truncationMsgTokens = 50; // Reserve for truncation message
|
|
37
|
+
const availableTokens = maxTokens - headerTokens - truncationMsgTokens;
|
|
38
|
+
|
|
39
|
+
if (availableTokens <= 0) {
|
|
40
|
+
return `${headerLines.join("\n")}\n\n[Output truncated - context window limit reached]`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Accumulate lines until we hit the limit
|
|
44
|
+
const resultLines: string[] = [];
|
|
45
|
+
let usedTokens = 0;
|
|
46
|
+
let truncatedCount = 0;
|
|
47
|
+
|
|
48
|
+
for (const line of remainingLines) {
|
|
49
|
+
const lineTokens = estimateTokens(line);
|
|
50
|
+
if (usedTokens + lineTokens > availableTokens) {
|
|
51
|
+
truncatedCount = remainingLines.length - resultLines.length;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
resultLines.push(line);
|
|
55
|
+
usedTokens += lineTokens;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (truncatedCount === 0) {
|
|
59
|
+
return output;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return [
|
|
63
|
+
...headerLines,
|
|
64
|
+
...resultLines,
|
|
65
|
+
"",
|
|
66
|
+
`[${truncatedCount} more lines truncated due to context window limit]`,
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface TruncationState {
|
|
71
|
+
sessionTokenUsage: Map<string, { used: number; limit: number }>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createTokenAwareTruncationHook(ctx: PluginInput) {
|
|
75
|
+
const state: TruncationState = {
|
|
76
|
+
sessionTokenUsage: new Map(),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
async function updateTokenUsage(sessionID: string): Promise<{ used: number; limit: number }> {
|
|
80
|
+
try {
|
|
81
|
+
const resp = await ctx.client.session.messages({
|
|
82
|
+
path: { id: sessionID },
|
|
83
|
+
query: { directory: ctx.directory },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const messages = (resp as { data?: unknown[] }).data;
|
|
87
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
88
|
+
return { used: 0, limit: DEFAULT_CONTEXT_LIMIT };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Find last assistant message with usage info
|
|
92
|
+
const lastAssistant = [...messages].reverse().find((m) => {
|
|
93
|
+
const msg = m as Record<string, unknown>;
|
|
94
|
+
const info = msg.info as Record<string, unknown> | undefined;
|
|
95
|
+
return info?.role === "assistant";
|
|
96
|
+
}) as Record<string, unknown> | undefined;
|
|
97
|
+
|
|
98
|
+
if (!lastAssistant) {
|
|
99
|
+
return { used: 0, limit: DEFAULT_CONTEXT_LIMIT };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const info = lastAssistant.info as Record<string, unknown> | undefined;
|
|
103
|
+
const usage = info?.usage as Record<string, unknown> | undefined;
|
|
104
|
+
|
|
105
|
+
const inputTokens = (usage?.inputTokens as number) || 0;
|
|
106
|
+
const cacheRead = (usage?.cacheReadInputTokens as number) || 0;
|
|
107
|
+
const used = inputTokens + cacheRead;
|
|
108
|
+
|
|
109
|
+
// Get model limit (simplified - use default for now)
|
|
110
|
+
const limit = DEFAULT_CONTEXT_LIMIT;
|
|
111
|
+
|
|
112
|
+
const result = { used, limit };
|
|
113
|
+
state.sessionTokenUsage.set(sessionID, result);
|
|
114
|
+
return result;
|
|
115
|
+
} catch {
|
|
116
|
+
return state.sessionTokenUsage.get(sessionID) || { used: 0, limit: DEFAULT_CONTEXT_LIMIT };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function calculateMaxOutputTokens(used: number, limit: number): number {
|
|
121
|
+
const remaining = limit - used;
|
|
122
|
+
const available = Math.floor(remaining * SAFETY_MARGIN);
|
|
123
|
+
|
|
124
|
+
if (available <= 0) {
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return Math.min(available, DEFAULT_MAX_OUTPUT_TOKENS);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
// Update token usage when assistant messages are received
|
|
133
|
+
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
|
134
|
+
const props = event.properties as Record<string, unknown> | undefined;
|
|
135
|
+
|
|
136
|
+
if (event.type === "session.deleted") {
|
|
137
|
+
const sessionInfo = props?.info as { id?: string } | undefined;
|
|
138
|
+
if (sessionInfo?.id) {
|
|
139
|
+
state.sessionTokenUsage.delete(sessionInfo.id);
|
|
140
|
+
}
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Update usage on message updates
|
|
145
|
+
if (event.type === "message.updated") {
|
|
146
|
+
const info = props?.info as Record<string, unknown> | undefined;
|
|
147
|
+
const sessionID = info?.sessionID as string | undefined;
|
|
148
|
+
if (sessionID && info?.role === "assistant") {
|
|
149
|
+
await updateTokenUsage(sessionID);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
// Truncate tool output
|
|
155
|
+
"tool.execute.after": async (input: { name: string; sessionID: string }, output: { output?: string }) => {
|
|
156
|
+
// Only truncate specific tools
|
|
157
|
+
if (!TRUNCATABLE_TOOLS.includes(input.name)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!output.output || typeof output.output !== "string") {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
// Get current token usage
|
|
167
|
+
const { used, limit } = await updateTokenUsage(input.sessionID);
|
|
168
|
+
const maxTokens = calculateMaxOutputTokens(used, limit);
|
|
169
|
+
|
|
170
|
+
if (maxTokens <= 0) {
|
|
171
|
+
output.output = "[Output suppressed - context window exhausted. Consider compacting.]";
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Truncate if needed
|
|
176
|
+
const currentTokens = estimateTokens(output.output);
|
|
177
|
+
if (currentTokens > maxTokens) {
|
|
178
|
+
output.output = truncateToTokenLimit(output.output, maxTokens);
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// On error, apply static truncation as fallback
|
|
182
|
+
const currentTokens = estimateTokens(output.output);
|
|
183
|
+
if (currentTokens > DEFAULT_MAX_OUTPUT_TOKENS) {
|
|
184
|
+
output.output = truncateToTokenLimit(output.output, DEFAULT_MAX_OUTPUT_TOKENS);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
2
|
+
import type { McpLocalConfig } from "@opencode-ai/sdk";
|
|
3
|
+
|
|
4
|
+
// Agents
|
|
5
|
+
import { agents, PRIMARY_AGENT_NAME } from "./agents";
|
|
6
|
+
|
|
7
|
+
// Tools
|
|
8
|
+
import { ast_grep_search, ast_grep_replace, checkAstGrepAvailable } from "./tools/ast-grep";
|
|
9
|
+
import { btca_ask, checkBtcaAvailable } from "./tools/btca";
|
|
10
|
+
import { look_at } from "./tools/look-at";
|
|
11
|
+
import { artifact_search } from "./tools/artifact-search";
|
|
12
|
+
|
|
13
|
+
// Hooks
|
|
14
|
+
import { createAutoCompactHook } from "./hooks/auto-compact";
|
|
15
|
+
import { createContextInjectorHook } from "./hooks/context-injector";
|
|
16
|
+
import { createSessionRecoveryHook } from "./hooks/session-recovery";
|
|
17
|
+
import { createTokenAwareTruncationHook } from "./hooks/token-aware-truncation";
|
|
18
|
+
import { createContextWindowMonitorHook } from "./hooks/context-window-monitor";
|
|
19
|
+
import { createCommentCheckerHook } from "./hooks/comment-checker";
|
|
20
|
+
import { createAutoClearLedgerHook } from "./hooks/auto-clear-ledger";
|
|
21
|
+
import { createLedgerLoaderHook } from "./hooks/ledger-loader";
|
|
22
|
+
import { createArtifactAutoIndexHook } from "./hooks/artifact-auto-index";
|
|
23
|
+
import { createFileOpsTrackerHook } from "./hooks/file-ops-tracker";
|
|
24
|
+
|
|
25
|
+
// Background Task System
|
|
26
|
+
import { BackgroundTaskManager, createBackgroundTaskTools } from "./tools/background-task";
|
|
27
|
+
|
|
28
|
+
// PTY System
|
|
29
|
+
import { PTYManager, createPtyTools } from "./tools/pty";
|
|
30
|
+
|
|
31
|
+
// Config loader
|
|
32
|
+
import { loadMicodeConfig, mergeAgentConfigs } from "./config-loader";
|
|
33
|
+
|
|
34
|
+
// Think mode: detect keywords and enable extended thinking
|
|
35
|
+
const THINK_KEYWORDS = [
|
|
36
|
+
/\bthink\s*(hard|deeply|carefully|through)\b/i,
|
|
37
|
+
/\bthink\b.*\b(about|on|through)\b/i,
|
|
38
|
+
/\b(deeply|carefully)\s*think\b/i,
|
|
39
|
+
/\blet('s|s)?\s*think\b/i,
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function detectThinkKeyword(text: string): boolean {
|
|
43
|
+
return THINK_KEYWORDS.some((pattern) => pattern.test(text));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// MCP server configurations
|
|
47
|
+
const MCP_SERVERS: Record<string, McpLocalConfig> = {
|
|
48
|
+
context7: {
|
|
49
|
+
type: "local",
|
|
50
|
+
command: ["npx", "-y", "@upstash/context7-mcp@latest"],
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Environment-gated research MCP servers
|
|
55
|
+
if (process.env.PERPLEXITY_API_KEY) {
|
|
56
|
+
MCP_SERVERS.perplexity = {
|
|
57
|
+
type: "local",
|
|
58
|
+
command: ["npx", "-y", "@anthropic/mcp-perplexity"],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (process.env.FIRECRAWL_API_KEY) {
|
|
63
|
+
MCP_SERVERS.firecrawl = {
|
|
64
|
+
type: "local",
|
|
65
|
+
command: ["npx", "-y", "firecrawl-mcp"],
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const OpenCodeConfigPlugin: Plugin = async (ctx) => {
|
|
70
|
+
// Validate external tool dependencies at startup
|
|
71
|
+
const astGrepStatus = await checkAstGrepAvailable();
|
|
72
|
+
if (!astGrepStatus.available) {
|
|
73
|
+
console.warn(`[micode] ${astGrepStatus.message}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const btcaStatus = await checkBtcaAvailable();
|
|
77
|
+
if (!btcaStatus.available) {
|
|
78
|
+
console.warn(`[micode] ${btcaStatus.message}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Load user config for model overrides
|
|
82
|
+
const userConfig = await loadMicodeConfig();
|
|
83
|
+
if (userConfig?.agents) {
|
|
84
|
+
console.log(`[micode] Loaded model overrides for: ${Object.keys(userConfig.agents).join(", ")}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Think mode state per session
|
|
88
|
+
const thinkModeState = new Map<string, boolean>();
|
|
89
|
+
|
|
90
|
+
// Hooks
|
|
91
|
+
const autoCompactHook = createAutoCompactHook(ctx);
|
|
92
|
+
const contextInjectorHook = createContextInjectorHook(ctx);
|
|
93
|
+
const autoClearLedgerHook = createAutoClearLedgerHook(ctx);
|
|
94
|
+
const ledgerLoaderHook = createLedgerLoaderHook(ctx);
|
|
95
|
+
const sessionRecoveryHook = createSessionRecoveryHook(ctx);
|
|
96
|
+
const tokenAwareTruncationHook = createTokenAwareTruncationHook(ctx);
|
|
97
|
+
const contextWindowMonitorHook = createContextWindowMonitorHook(ctx);
|
|
98
|
+
const commentCheckerHook = createCommentCheckerHook(ctx);
|
|
99
|
+
const artifactAutoIndexHook = createArtifactAutoIndexHook(ctx);
|
|
100
|
+
const fileOpsTrackerHook = createFileOpsTrackerHook(ctx);
|
|
101
|
+
|
|
102
|
+
// Background Task System
|
|
103
|
+
const backgroundTaskManager = new BackgroundTaskManager(ctx);
|
|
104
|
+
const backgroundTaskTools = createBackgroundTaskTools(backgroundTaskManager);
|
|
105
|
+
|
|
106
|
+
// PTY System
|
|
107
|
+
const ptyManager = new PTYManager();
|
|
108
|
+
const ptyTools = createPtyTools(ptyManager);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
// Tools
|
|
112
|
+
tool: {
|
|
113
|
+
ast_grep_search,
|
|
114
|
+
ast_grep_replace,
|
|
115
|
+
btca_ask,
|
|
116
|
+
look_at,
|
|
117
|
+
artifact_search,
|
|
118
|
+
...backgroundTaskTools,
|
|
119
|
+
...ptyTools,
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
config: async (config) => {
|
|
123
|
+
// Allow all permissions globally - no prompts
|
|
124
|
+
config.permission = {
|
|
125
|
+
...config.permission,
|
|
126
|
+
edit: "allow",
|
|
127
|
+
bash: "allow",
|
|
128
|
+
webfetch: "allow",
|
|
129
|
+
doom_loop: "allow",
|
|
130
|
+
external_directory: "allow",
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Merge user config overrides into plugin agents
|
|
134
|
+
const mergedAgents = mergeAgentConfigs(agents, userConfig);
|
|
135
|
+
|
|
136
|
+
// Add our agents - our agents override OpenCode defaults, demote built-in build/plan to subagent
|
|
137
|
+
config.agent = {
|
|
138
|
+
...config.agent, // OpenCode defaults first
|
|
139
|
+
build: { ...config.agent?.build, mode: "subagent" },
|
|
140
|
+
plan: { ...config.agent?.plan, mode: "subagent" },
|
|
141
|
+
triage: { ...config.agent?.triage, mode: "subagent" },
|
|
142
|
+
docs: { ...config.agent?.docs, mode: "subagent" },
|
|
143
|
+
// Our agents override - spread these LAST so they take precedence
|
|
144
|
+
...Object.fromEntries(Object.entries(mergedAgents).filter(([k]) => k !== PRIMARY_AGENT_NAME)),
|
|
145
|
+
[PRIMARY_AGENT_NAME]: mergedAgents[PRIMARY_AGENT_NAME],
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Add MCP servers (plugin servers override defaults)
|
|
149
|
+
config.mcp = {
|
|
150
|
+
...config.mcp,
|
|
151
|
+
...MCP_SERVERS,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Add commands
|
|
155
|
+
config.command = {
|
|
156
|
+
...config.command,
|
|
157
|
+
init: {
|
|
158
|
+
description: "Initialize project with ARCHITECTURE.md and CODE_STYLE.md",
|
|
159
|
+
agent: "project-initializer",
|
|
160
|
+
template: `Initialize this project. $ARGUMENTS`,
|
|
161
|
+
},
|
|
162
|
+
ledger: {
|
|
163
|
+
description: "Create or update continuity ledger for session state",
|
|
164
|
+
agent: "ledger-creator",
|
|
165
|
+
template: `Update the continuity ledger. $ARGUMENTS`,
|
|
166
|
+
},
|
|
167
|
+
search: {
|
|
168
|
+
description: "Search past handoffs, plans, and ledgers",
|
|
169
|
+
agent: "artifact-searcher",
|
|
170
|
+
template: `Search for: $ARGUMENTS`,
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
"chat.message": async (input, output) => {
|
|
176
|
+
// Extract text from user message
|
|
177
|
+
const text = output.parts
|
|
178
|
+
.filter((p) => p.type === "text" && "text" in p)
|
|
179
|
+
.map((p) => (p as { text: string }).text)
|
|
180
|
+
.join(" ");
|
|
181
|
+
|
|
182
|
+
// Track if think mode was requested
|
|
183
|
+
thinkModeState.set(input.sessionID, detectThinkKeyword(text));
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
"chat.params": async (input, output) => {
|
|
187
|
+
// Inject ledger context first (highest priority)
|
|
188
|
+
await ledgerLoaderHook["chat.params"](input, output);
|
|
189
|
+
|
|
190
|
+
// Inject project context files
|
|
191
|
+
await contextInjectorHook["chat.params"](input, output);
|
|
192
|
+
|
|
193
|
+
// Inject context window status
|
|
194
|
+
await contextWindowMonitorHook["chat.params"](input, output);
|
|
195
|
+
|
|
196
|
+
// If think mode was requested, increase thinking budget
|
|
197
|
+
if (thinkModeState.get(input.sessionID)) {
|
|
198
|
+
output.options = {
|
|
199
|
+
...output.options,
|
|
200
|
+
thinking: {
|
|
201
|
+
type: "enabled",
|
|
202
|
+
budget_tokens: 32000,
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
// Tool output processing
|
|
209
|
+
"tool.execute.after": async (
|
|
210
|
+
input: { tool: string; sessionID: string; callID: string; args?: Record<string, unknown> },
|
|
211
|
+
output: { output?: string },
|
|
212
|
+
) => {
|
|
213
|
+
// Token-aware truncation
|
|
214
|
+
await tokenAwareTruncationHook["tool.execute.after"]({ name: input.tool, sessionID: input.sessionID }, output);
|
|
215
|
+
|
|
216
|
+
// Comment checker for Edit tool
|
|
217
|
+
await commentCheckerHook["tool.execute.after"]({ tool: input.tool, args: input.args }, output);
|
|
218
|
+
|
|
219
|
+
// Directory-aware context injection for Read/Edit
|
|
220
|
+
await contextInjectorHook["tool.execute.after"]({ tool: input.tool, args: input.args }, output);
|
|
221
|
+
|
|
222
|
+
// Auto-index artifacts when written to thoughts/ directories
|
|
223
|
+
await artifactAutoIndexHook["tool.execute.after"]({ tool: input.tool, args: input.args }, output);
|
|
224
|
+
|
|
225
|
+
// Track file operations for ledger
|
|
226
|
+
await fileOpsTrackerHook["tool.execute.after"](
|
|
227
|
+
{ tool: input.tool, sessionID: input.sessionID, args: input.args },
|
|
228
|
+
output,
|
|
229
|
+
);
|
|
230
|
+
},
|
|
231
|
+
|
|
232
|
+
event: async ({ event }) => {
|
|
233
|
+
// Session cleanup (think mode + PTY)
|
|
234
|
+
if (event.type === "session.deleted") {
|
|
235
|
+
const props = event.properties as { info?: { id?: string } } | undefined;
|
|
236
|
+
if (props?.info?.id) {
|
|
237
|
+
thinkModeState.delete(props.info.id);
|
|
238
|
+
ptyManager.cleanupBySession(props.info.id);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Run all event hooks
|
|
243
|
+
await autoCompactHook.event({ event });
|
|
244
|
+
await autoClearLedgerHook.event({ event });
|
|
245
|
+
await sessionRecoveryHook.event({ event });
|
|
246
|
+
await tokenAwareTruncationHook.event({ event });
|
|
247
|
+
await contextWindowMonitorHook.event({ event });
|
|
248
|
+
|
|
249
|
+
// Background task manager event handling
|
|
250
|
+
backgroundTaskManager.handleEvent(event);
|
|
251
|
+
|
|
252
|
+
// File ops tracker cleanup
|
|
253
|
+
await fileOpsTrackerHook.event({ event });
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export default OpenCodeConfigPlugin;
|