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,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
|
+
}
|