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.
Files changed (92) hide show
  1. package/package.json +7 -13
  2. package/src/agents/artifact-searcher.ts +46 -0
  3. package/src/agents/brainstormer.ts +145 -0
  4. package/src/agents/codebase-analyzer.ts +75 -0
  5. package/src/agents/codebase-locator.ts +71 -0
  6. package/src/agents/commander.ts +138 -0
  7. package/src/agents/executor.ts +215 -0
  8. package/src/agents/implementer.ts +99 -0
  9. package/src/agents/index.ts +44 -0
  10. package/src/agents/ledger-creator.ts +113 -0
  11. package/src/agents/pattern-finder.ts +70 -0
  12. package/src/agents/planner.ts +230 -0
  13. package/src/agents/project-initializer.ts +264 -0
  14. package/src/agents/reviewer.ts +102 -0
  15. package/src/config-loader.ts +89 -0
  16. package/src/hooks/artifact-auto-index.ts +111 -0
  17. package/src/hooks/auto-clear-ledger.ts +230 -0
  18. package/src/hooks/auto-compact.ts +241 -0
  19. package/src/hooks/comment-checker.ts +120 -0
  20. package/src/hooks/context-injector.ts +163 -0
  21. package/src/hooks/context-window-monitor.ts +106 -0
  22. package/src/hooks/file-ops-tracker.ts +96 -0
  23. package/src/hooks/ledger-loader.ts +78 -0
  24. package/src/hooks/preemptive-compaction.ts +183 -0
  25. package/src/hooks/session-recovery.ts +258 -0
  26. package/src/hooks/token-aware-truncation.ts +189 -0
  27. package/src/index.ts +258 -0
  28. package/src/tools/artifact-index/index.ts +269 -0
  29. package/src/tools/artifact-index/schema.sql +44 -0
  30. package/src/tools/artifact-search.ts +49 -0
  31. package/src/tools/ast-grep/index.ts +189 -0
  32. package/src/tools/background-task/manager.ts +374 -0
  33. package/src/tools/background-task/tools.ts +145 -0
  34. package/src/tools/background-task/types.ts +68 -0
  35. package/src/tools/btca/index.ts +82 -0
  36. package/src/tools/look-at.ts +210 -0
  37. package/src/tools/pty/buffer.ts +49 -0
  38. package/src/tools/pty/index.ts +34 -0
  39. package/src/tools/pty/manager.ts +159 -0
  40. package/src/tools/pty/tools/kill.ts +68 -0
  41. package/src/tools/pty/tools/list.ts +55 -0
  42. package/src/tools/pty/tools/read.ts +152 -0
  43. package/src/tools/pty/tools/spawn.ts +78 -0
  44. package/src/tools/pty/tools/write.ts +97 -0
  45. package/src/tools/pty/types.ts +62 -0
  46. package/src/utils/model-limits.ts +36 -0
  47. package/dist/agents/artifact-searcher.d.ts +0 -2
  48. package/dist/agents/brainstormer.d.ts +0 -2
  49. package/dist/agents/codebase-analyzer.d.ts +0 -2
  50. package/dist/agents/codebase-locator.d.ts +0 -2
  51. package/dist/agents/commander.d.ts +0 -3
  52. package/dist/agents/executor.d.ts +0 -2
  53. package/dist/agents/implementer.d.ts +0 -2
  54. package/dist/agents/index.d.ts +0 -15
  55. package/dist/agents/ledger-creator.d.ts +0 -2
  56. package/dist/agents/pattern-finder.d.ts +0 -2
  57. package/dist/agents/planner.d.ts +0 -2
  58. package/dist/agents/project-initializer.d.ts +0 -2
  59. package/dist/agents/reviewer.d.ts +0 -2
  60. package/dist/config-loader.d.ts +0 -20
  61. package/dist/hooks/artifact-auto-index.d.ts +0 -19
  62. package/dist/hooks/auto-clear-ledger.d.ts +0 -11
  63. package/dist/hooks/auto-compact.d.ts +0 -9
  64. package/dist/hooks/comment-checker.d.ts +0 -9
  65. package/dist/hooks/context-injector.d.ts +0 -15
  66. package/dist/hooks/context-window-monitor.d.ts +0 -15
  67. package/dist/hooks/file-ops-tracker.d.ts +0 -26
  68. package/dist/hooks/ledger-loader.d.ts +0 -16
  69. package/dist/hooks/preemptive-compaction.d.ts +0 -9
  70. package/dist/hooks/session-recovery.d.ts +0 -9
  71. package/dist/hooks/token-aware-truncation.d.ts +0 -15
  72. package/dist/index.d.ts +0 -3
  73. package/dist/index.js +0 -17089
  74. package/dist/tools/artifact-index/index.d.ts +0 -38
  75. package/dist/tools/artifact-search.d.ts +0 -17
  76. package/dist/tools/ast-grep/index.d.ts +0 -88
  77. package/dist/tools/background-task/manager.d.ts +0 -27
  78. package/dist/tools/background-task/tools.d.ts +0 -41
  79. package/dist/tools/background-task/types.d.ts +0 -53
  80. package/dist/tools/btca/index.d.ts +0 -19
  81. package/dist/tools/look-at.d.ts +0 -11
  82. package/dist/tools/pty/buffer.d.ts +0 -11
  83. package/dist/tools/pty/index.d.ts +0 -74
  84. package/dist/tools/pty/manager.d.ts +0 -14
  85. package/dist/tools/pty/tools/kill.d.ts +0 -12
  86. package/dist/tools/pty/tools/list.d.ts +0 -6
  87. package/dist/tools/pty/tools/read.d.ts +0 -18
  88. package/dist/tools/pty/tools/spawn.d.ts +0 -20
  89. package/dist/tools/pty/tools/write.d.ts +0 -12
  90. package/dist/tools/pty/types.d.ts +0 -54
  91. package/dist/utils/model-limits.d.ts +0 -7
  92. /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
+ }