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.
Files changed (84) hide show
  1. package/README.md +64 -331
  2. package/package.json +9 -14
  3. package/src/agents/artifact-searcher.ts +46 -0
  4. package/src/agents/brainstormer.ts +145 -0
  5. package/src/agents/codebase-analyzer.ts +75 -0
  6. package/src/agents/codebase-locator.ts +71 -0
  7. package/src/agents/commander.ts +138 -0
  8. package/src/agents/executor.ts +215 -0
  9. package/src/agents/implementer.ts +99 -0
  10. package/src/agents/index.ts +44 -0
  11. package/src/agents/ledger-creator.ts +113 -0
  12. package/src/agents/pattern-finder.ts +70 -0
  13. package/src/agents/planner.ts +230 -0
  14. package/src/agents/project-initializer.ts +264 -0
  15. package/src/agents/reviewer.ts +102 -0
  16. package/src/config-loader.ts +89 -0
  17. package/src/hooks/artifact-auto-index.ts +111 -0
  18. package/src/hooks/auto-clear-ledger.ts +230 -0
  19. package/src/hooks/auto-compact.ts +241 -0
  20. package/src/hooks/comment-checker.ts +120 -0
  21. package/src/hooks/context-injector.ts +163 -0
  22. package/src/hooks/context-window-monitor.ts +106 -0
  23. package/src/hooks/file-ops-tracker.ts +96 -0
  24. package/src/hooks/ledger-loader.ts +78 -0
  25. package/src/hooks/preemptive-compaction.ts +183 -0
  26. package/src/hooks/session-recovery.ts +258 -0
  27. package/src/hooks/token-aware-truncation.ts +189 -0
  28. package/src/index.ts +258 -0
  29. package/src/tools/artifact-index/index.ts +269 -0
  30. package/src/tools/artifact-index/schema.sql +44 -0
  31. package/src/tools/artifact-search.ts +49 -0
  32. package/src/tools/ast-grep/index.ts +189 -0
  33. package/src/tools/background-task/manager.ts +374 -0
  34. package/src/tools/background-task/tools.ts +145 -0
  35. package/src/tools/background-task/types.ts +68 -0
  36. package/src/tools/btca/index.ts +82 -0
  37. package/src/tools/look-at.ts +210 -0
  38. package/src/tools/pty/buffer.ts +49 -0
  39. package/src/tools/pty/index.ts +34 -0
  40. package/src/tools/pty/manager.ts +159 -0
  41. package/src/tools/pty/tools/kill.ts +68 -0
  42. package/src/tools/pty/tools/list.ts +55 -0
  43. package/src/tools/pty/tools/read.ts +152 -0
  44. package/src/tools/pty/tools/spawn.ts +78 -0
  45. package/src/tools/pty/tools/write.ts +97 -0
  46. package/src/tools/pty/types.ts +62 -0
  47. package/src/utils/model-limits.ts +36 -0
  48. package/dist/agents/artifact-searcher.d.ts +0 -2
  49. package/dist/agents/brainstormer.d.ts +0 -2
  50. package/dist/agents/codebase-analyzer.d.ts +0 -2
  51. package/dist/agents/codebase-locator.d.ts +0 -2
  52. package/dist/agents/commander.d.ts +0 -3
  53. package/dist/agents/executor.d.ts +0 -2
  54. package/dist/agents/implementer.d.ts +0 -2
  55. package/dist/agents/index.d.ts +0 -15
  56. package/dist/agents/ledger-creator.d.ts +0 -2
  57. package/dist/agents/pattern-finder.d.ts +0 -2
  58. package/dist/agents/planner.d.ts +0 -2
  59. package/dist/agents/project-initializer.d.ts +0 -2
  60. package/dist/agents/reviewer.d.ts +0 -2
  61. package/dist/config-loader.d.ts +0 -20
  62. package/dist/hooks/artifact-auto-index.d.ts +0 -19
  63. package/dist/hooks/auto-clear-ledger.d.ts +0 -11
  64. package/dist/hooks/auto-compact.d.ts +0 -9
  65. package/dist/hooks/comment-checker.d.ts +0 -9
  66. package/dist/hooks/context-injector.d.ts +0 -15
  67. package/dist/hooks/context-window-monitor.d.ts +0 -15
  68. package/dist/hooks/file-ops-tracker.d.ts +0 -26
  69. package/dist/hooks/ledger-loader.d.ts +0 -16
  70. package/dist/hooks/preemptive-compaction.d.ts +0 -9
  71. package/dist/hooks/session-recovery.d.ts +0 -9
  72. package/dist/hooks/token-aware-truncation.d.ts +0 -15
  73. package/dist/index.d.ts +0 -3
  74. package/dist/index.js +0 -16267
  75. package/dist/tools/artifact-index/index.d.ts +0 -38
  76. package/dist/tools/artifact-search.d.ts +0 -17
  77. package/dist/tools/ast-grep/index.d.ts +0 -88
  78. package/dist/tools/background-task/manager.d.ts +0 -27
  79. package/dist/tools/background-task/tools.d.ts +0 -41
  80. package/dist/tools/background-task/types.d.ts +0 -53
  81. package/dist/tools/btca/index.d.ts +0 -19
  82. package/dist/tools/look-at.d.ts +0 -11
  83. package/dist/utils/model-limits.d.ts +0 -7
  84. /package/{dist/tools/background-task/index.d.ts → src/tools/background-task/index.ts} +0 -0
@@ -0,0 +1,163 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join, dirname, resolve } from "node:path";
4
+
5
+ // Files to inject at project root level
6
+ const ROOT_CONTEXT_FILES = ["ARCHITECTURE.md", "CODE_STYLE.md", "AGENTS.md", "CLAUDE.md", "README.md"] as const;
7
+
8
+ // Files to collect when walking up directories
9
+ const DIRECTORY_CONTEXT_FILES = ["AGENTS.md", "README.md"] as const;
10
+
11
+ // Tools that trigger directory-aware context injection
12
+ const FILE_ACCESS_TOOLS = ["Read", "read", "Edit", "edit"];
13
+
14
+ // Cache for file contents
15
+ interface ContextCache {
16
+ rootContent: Map<string, string>;
17
+ directoryContent: Map<string, Map<string, string>>; // path -> filename -> content
18
+ lastRootCheck: number;
19
+ }
20
+
21
+ const CACHE_TTL = 30_000; // 30 seconds
22
+
23
+ export function createContextInjectorHook(ctx: PluginInput) {
24
+ const cache: ContextCache = {
25
+ rootContent: new Map(),
26
+ directoryContent: new Map(),
27
+ lastRootCheck: 0,
28
+ };
29
+
30
+ async function loadRootContextFiles(): Promise<Map<string, string>> {
31
+ const now = Date.now();
32
+
33
+ if (now - cache.lastRootCheck < CACHE_TTL && cache.rootContent.size > 0) {
34
+ return cache.rootContent;
35
+ }
36
+
37
+ cache.rootContent.clear();
38
+ cache.lastRootCheck = now;
39
+
40
+ for (const filename of ROOT_CONTEXT_FILES) {
41
+ try {
42
+ const filepath = join(ctx.directory, filename);
43
+ const content = await readFile(filepath, "utf-8");
44
+ if (content.trim()) {
45
+ cache.rootContent.set(filename, content);
46
+ }
47
+ } catch {
48
+ // File doesn't exist - skip
49
+ }
50
+ }
51
+
52
+ return cache.rootContent;
53
+ }
54
+
55
+ async function walkUpForContextFiles(filePath: string): Promise<Map<string, string>> {
56
+ const absPath = resolve(filePath);
57
+ const projectRoot = resolve(ctx.directory);
58
+
59
+ // Check cache
60
+ const cacheKey = dirname(absPath);
61
+ if (cache.directoryContent.has(cacheKey)) {
62
+ return cache.directoryContent.get(cacheKey)!;
63
+ }
64
+
65
+ const collected = new Map<string, string>();
66
+ let currentDir = dirname(absPath);
67
+
68
+ // Walk up from file directory to project root
69
+ while (currentDir.startsWith(projectRoot) || currentDir === projectRoot) {
70
+ for (const filename of DIRECTORY_CONTEXT_FILES) {
71
+ const contextPath = join(currentDir, filename);
72
+ const relPath = currentDir.replace(projectRoot, "").replace(/^\//, "") || ".";
73
+ const key = `${relPath}/${filename}`;
74
+
75
+ // Skip if already have this file from a closer directory
76
+ if (!collected.has(key)) {
77
+ try {
78
+ const content = await readFile(contextPath, "utf-8");
79
+ if (content.trim()) {
80
+ collected.set(key, content);
81
+ }
82
+ } catch {
83
+ // File doesn't exist - skip
84
+ }
85
+ }
86
+ }
87
+
88
+ // Stop at project root
89
+ if (currentDir === projectRoot) break;
90
+
91
+ // Move up
92
+ const parent = dirname(currentDir);
93
+ if (parent === currentDir) break; // Reached filesystem root
94
+ currentDir = parent;
95
+ }
96
+
97
+ // Cache for this directory
98
+ cache.directoryContent.set(cacheKey, collected);
99
+
100
+ // Limit cache size
101
+ if (cache.directoryContent.size > 100) {
102
+ const firstKey = cache.directoryContent.keys().next().value;
103
+ if (firstKey) cache.directoryContent.delete(firstKey);
104
+ }
105
+
106
+ return collected;
107
+ }
108
+
109
+ function formatContextBlock(files: Map<string, string>, label: string): string {
110
+ if (files.size === 0) return "";
111
+
112
+ const blocks: string[] = [];
113
+
114
+ for (const [filename, content] of files) {
115
+ blocks.push(`<context file="${filename}">\n${content}\n</context>`);
116
+ }
117
+
118
+ return `\n<${label}>\n${blocks.join("\n\n")}\n</${label}>\n`;
119
+ }
120
+
121
+ return {
122
+ // Inject project root context into every chat
123
+ "chat.params": async (
124
+ _input: { sessionID: string },
125
+ output: { options?: Record<string, unknown>; system?: string },
126
+ ) => {
127
+ const files = await loadRootContextFiles();
128
+ if (files.size === 0) return;
129
+
130
+ const contextBlock = formatContextBlock(files, "project-context");
131
+
132
+ if (output.system) {
133
+ output.system = output.system + contextBlock;
134
+ } else {
135
+ output.system = contextBlock;
136
+ }
137
+ },
138
+
139
+ // Inject directory-specific context when reading/editing files
140
+ "tool.execute.after": async (
141
+ input: { tool: string; args?: Record<string, unknown> },
142
+ output: { output?: string },
143
+ ) => {
144
+ if (!FILE_ACCESS_TOOLS.includes(input.tool)) return;
145
+
146
+ const filePath = input.args?.file_path as string | undefined;
147
+ if (!filePath) return;
148
+
149
+ try {
150
+ const directoryFiles = await walkUpForContextFiles(filePath);
151
+ if (directoryFiles.size === 0) return;
152
+
153
+ const contextBlock = formatContextBlock(directoryFiles, "directory-context");
154
+
155
+ if (output.output) {
156
+ output.output = output.output + contextBlock;
157
+ }
158
+ } catch {
159
+ // Ignore errors in context injection
160
+ }
161
+ },
162
+ };
163
+ }
@@ -0,0 +1,106 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+ import { getContextLimit } from "../utils/model-limits";
3
+
4
+ // Thresholds for context window warnings
5
+ const WARNING_THRESHOLD = 0.7; // 70% - remind there's still room
6
+ const CRITICAL_THRESHOLD = 0.85; // 85% - getting tight
7
+
8
+ interface MonitorState {
9
+ lastWarningTime: Map<string, number>;
10
+ lastUsageRatio: Map<string, number>;
11
+ }
12
+
13
+ const WARNING_COOLDOWN_MS = 120_000; // 2 minutes between warnings
14
+
15
+ export function createContextWindowMonitorHook(ctx: PluginInput) {
16
+ const state: MonitorState = {
17
+ lastWarningTime: new Map(),
18
+ lastUsageRatio: new Map(),
19
+ };
20
+
21
+ function getEncouragementMessage(usageRatio: number): string {
22
+ const remaining = Math.round((1 - usageRatio) * 100);
23
+
24
+ if (usageRatio < WARNING_THRESHOLD) {
25
+ return ""; // No message needed
26
+ }
27
+
28
+ if (usageRatio < CRITICAL_THRESHOLD) {
29
+ return `Context: ${remaining}% remaining. Plenty of room - don't rush.`;
30
+ }
31
+
32
+ return `Context: ${remaining}% remaining. Consider wrapping up or compacting soon.`;
33
+ }
34
+
35
+ return {
36
+ // Inject context awareness into chat params
37
+ "chat.params": async (
38
+ input: { sessionID: string },
39
+ output: { system?: string; options?: Record<string, unknown> },
40
+ ) => {
41
+ const usageRatio = state.lastUsageRatio.get(input.sessionID);
42
+
43
+ if (usageRatio && usageRatio >= WARNING_THRESHOLD) {
44
+ const message = getEncouragementMessage(usageRatio);
45
+ if (message && output.system) {
46
+ output.system = `${output.system}\n\n<context-status>${message}</context-status>`;
47
+ }
48
+ }
49
+ },
50
+
51
+ event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
52
+ const props = event.properties as Record<string, unknown> | undefined;
53
+
54
+ // Cleanup on session delete
55
+ if (event.type === "session.deleted") {
56
+ const sessionInfo = props?.info as { id?: string } | undefined;
57
+ if (sessionInfo?.id) {
58
+ state.lastWarningTime.delete(sessionInfo.id);
59
+ state.lastUsageRatio.delete(sessionInfo.id);
60
+ }
61
+ return;
62
+ }
63
+
64
+ // Track usage on message updates
65
+ if (event.type === "message.updated") {
66
+ const info = props?.info as Record<string, unknown> | undefined;
67
+ const sessionID = info?.sessionID as string | undefined;
68
+
69
+ if (!sessionID || info?.role !== "assistant") return;
70
+
71
+ const usage = info.usage as Record<string, unknown> | undefined;
72
+ const inputTokens = (usage?.inputTokens as number) || 0;
73
+ const cacheRead = (usage?.cacheReadInputTokens as number) || 0;
74
+ const totalUsed = inputTokens + cacheRead;
75
+
76
+ const modelID = (info.modelID as string) || "";
77
+ const contextLimit = getContextLimit(modelID);
78
+ const usageRatio = totalUsed / contextLimit;
79
+
80
+ state.lastUsageRatio.set(sessionID, usageRatio);
81
+
82
+ // Show toast warning if threshold crossed
83
+ if (usageRatio >= WARNING_THRESHOLD) {
84
+ const lastWarning = state.lastWarningTime.get(sessionID) || 0;
85
+ if (Date.now() - lastWarning > WARNING_COOLDOWN_MS) {
86
+ state.lastWarningTime.set(sessionID, Date.now());
87
+
88
+ const remaining = Math.round((1 - usageRatio) * 100);
89
+ const variant = usageRatio >= CRITICAL_THRESHOLD ? "warning" : "info";
90
+
91
+ await ctx.client.tui
92
+ .showToast({
93
+ body: {
94
+ title: "Context Window",
95
+ message: `${remaining}% remaining (${Math.round(totalUsed / 1000)}K / ${Math.round(contextLimit / 1000)}K tokens)`,
96
+ variant,
97
+ duration: 4000,
98
+ },
99
+ })
100
+ .catch(() => {});
101
+ }
102
+ }
103
+ }
104
+ },
105
+ };
106
+ }
@@ -0,0 +1,96 @@
1
+ // src/hooks/file-ops-tracker.ts
2
+ import type { PluginInput } from "@opencode-ai/plugin";
3
+
4
+ interface FileOps {
5
+ read: Set<string>;
6
+ modified: Set<string>;
7
+ }
8
+
9
+ // Per-session file operation tracking
10
+ const sessionFileOps = new Map<string, FileOps>();
11
+
12
+ function getOrCreateOps(sessionID: string): FileOps {
13
+ let ops = sessionFileOps.get(sessionID);
14
+ if (!ops) {
15
+ ops = { read: new Set(), modified: new Set() };
16
+ sessionFileOps.set(sessionID, ops);
17
+ }
18
+ return ops;
19
+ }
20
+
21
+ export function trackFileOp(sessionID: string, operation: "read" | "write" | "edit", filePath: string): void {
22
+ const ops = getOrCreateOps(sessionID);
23
+ if (operation === "read") {
24
+ ops.read.add(filePath);
25
+ } else {
26
+ // write and edit both modify files
27
+ ops.modified.add(filePath);
28
+ }
29
+ }
30
+
31
+ export function getFileOps(sessionID: string): FileOps {
32
+ const ops = sessionFileOps.get(sessionID);
33
+ if (!ops) {
34
+ return { read: new Set(), modified: new Set() };
35
+ }
36
+ return ops;
37
+ }
38
+
39
+ export function clearFileOps(sessionID: string): void {
40
+ sessionFileOps.delete(sessionID);
41
+ }
42
+
43
+ export function getAndClearFileOps(sessionID: string): FileOps {
44
+ const ops = getFileOps(sessionID);
45
+ // Return copies of the sets before clearing
46
+ const result = {
47
+ read: new Set(ops.read),
48
+ modified: new Set(ops.modified),
49
+ };
50
+ clearFileOps(sessionID);
51
+ return result;
52
+ }
53
+
54
+ export function formatFileOpsForPrompt(ops: FileOps): string {
55
+ const readPaths = Array.from(ops.read).sort();
56
+ const modifiedPaths = Array.from(ops.modified).sort();
57
+
58
+ let result = "<file-operations>\n";
59
+ result += `Read: ${readPaths.length > 0 ? readPaths.join(", ") : "(none)"}\n`;
60
+ result += `Modified: ${modifiedPaths.length > 0 ? modifiedPaths.join(", ") : "(none)"}\n`;
61
+ result += "</file-operations>";
62
+
63
+ return result;
64
+ }
65
+
66
+ export function createFileOpsTrackerHook(_ctx: PluginInput) {
67
+ return {
68
+ "tool.execute.after": async (
69
+ input: { tool: string; sessionID: string; args?: Record<string, unknown> },
70
+ _output: { output?: string },
71
+ ) => {
72
+ const toolName = input.tool.toLowerCase();
73
+
74
+ // Only track read, write, edit tools
75
+ if (!["read", "write", "edit"].includes(toolName)) {
76
+ return;
77
+ }
78
+
79
+ // Extract file path from args
80
+ const filePath = input.args?.filePath as string | undefined;
81
+ if (!filePath) return;
82
+
83
+ trackFileOp(input.sessionID, toolName as "read" | "write" | "edit", filePath);
84
+ },
85
+
86
+ event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
87
+ // Clean up on session delete
88
+ if (event.type === "session.deleted") {
89
+ const props = event.properties as { info?: { id?: string } } | undefined;
90
+ if (props?.info?.id) {
91
+ clearFileOps(props.info.id);
92
+ }
93
+ }
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,78 @@
1
+ // src/hooks/ledger-loader.ts
2
+ import type { PluginInput } from "@opencode-ai/plugin";
3
+ import { readFile, readdir } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ const LEDGER_DIR = "thoughts/ledgers";
7
+ const LEDGER_PREFIX = "CONTINUITY_";
8
+
9
+ export interface LedgerInfo {
10
+ sessionName: string;
11
+ filePath: string;
12
+ content: string;
13
+ }
14
+
15
+ export async function findCurrentLedger(directory: string): Promise<LedgerInfo | null> {
16
+ const ledgerDir = join(directory, LEDGER_DIR);
17
+
18
+ try {
19
+ const files = await readdir(ledgerDir);
20
+ const ledgerFiles = files.filter((f) => f.startsWith(LEDGER_PREFIX) && f.endsWith(".md"));
21
+
22
+ if (ledgerFiles.length === 0) return null;
23
+
24
+ // Get most recently modified ledger
25
+ let latestFile = ledgerFiles[0];
26
+ let latestMtime = 0;
27
+
28
+ for (const file of ledgerFiles) {
29
+ const filePath = join(ledgerDir, file);
30
+ try {
31
+ const stat = await Bun.file(filePath).stat();
32
+ if (stat && stat.mtime.getTime() > latestMtime) {
33
+ latestMtime = stat.mtime.getTime();
34
+ latestFile = file;
35
+ }
36
+ } catch {
37
+ // Skip files we can't stat
38
+ }
39
+ }
40
+
41
+ const filePath = join(ledgerDir, latestFile);
42
+ const content = await readFile(filePath, "utf-8");
43
+ const sessionName = latestFile.replace(LEDGER_PREFIX, "").replace(".md", "");
44
+
45
+ return { sessionName, filePath, content };
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ export function formatLedgerInjection(ledger: LedgerInfo): string {
52
+ return `<continuity-ledger session="${ledger.sessionName}">
53
+ ${ledger.content}
54
+ </continuity-ledger>
55
+
56
+ You are resuming work from a previous context clear. The ledger above contains your session state.
57
+ Review it and continue from where you left off. The "Now" item is your current focus.`;
58
+ }
59
+
60
+ export function createLedgerLoaderHook(ctx: PluginInput) {
61
+ return {
62
+ "chat.params": async (
63
+ _input: { sessionID: string },
64
+ output: { options?: Record<string, unknown>; system?: string },
65
+ ) => {
66
+ const ledger = await findCurrentLedger(ctx.directory);
67
+ if (!ledger) return;
68
+
69
+ const injection = formatLedgerInjection(ledger);
70
+
71
+ if (output.system) {
72
+ output.system = `${injection}\n\n${output.system}`;
73
+ } else {
74
+ output.system = injection;
75
+ }
76
+ },
77
+ };
78
+ }
@@ -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
+ }