micode 0.7.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/package.json +7 -13
- 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 -17089
- 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/tools/pty/buffer.d.ts +0 -11
- package/dist/tools/pty/index.d.ts +0 -74
- package/dist/tools/pty/manager.d.ts +0 -14
- package/dist/tools/pty/tools/kill.d.ts +0 -12
- package/dist/tools/pty/tools/list.d.ts +0 -6
- package/dist/tools/pty/tools/read.d.ts +0 -18
- package/dist/tools/pty/tools/spawn.d.ts +0 -20
- package/dist/tools/pty/tools/write.d.ts +0 -12
- package/dist/tools/pty/types.d.ts +0 -54
- 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,183 @@
|
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|