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,89 @@
|
|
|
1
|
+
// src/config-loader.ts
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import type { AgentConfig } from "@opencode-ai/sdk";
|
|
6
|
+
|
|
7
|
+
// Safe properties that users can override
|
|
8
|
+
const SAFE_AGENT_PROPERTIES = ["model", "temperature", "maxTokens"] as const;
|
|
9
|
+
|
|
10
|
+
export interface AgentOverride {
|
|
11
|
+
model?: string;
|
|
12
|
+
temperature?: number;
|
|
13
|
+
maxTokens?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface MicodeConfig {
|
|
17
|
+
agents?: Record<string, AgentOverride>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Load micode.json from ~/.config/opencode/micode.json
|
|
22
|
+
* Returns null if file doesn't exist or is invalid JSON
|
|
23
|
+
* @param configDir - Optional override for config directory (for testing)
|
|
24
|
+
*/
|
|
25
|
+
export async function loadMicodeConfig(configDir?: string): Promise<MicodeConfig | null> {
|
|
26
|
+
const baseDir = configDir ?? join(homedir(), ".config", "opencode");
|
|
27
|
+
const configPath = join(baseDir, "micode.json");
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const content = await readFile(configPath, "utf-8");
|
|
31
|
+
const parsed = JSON.parse(content) as Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
// Sanitize the config - only allow safe properties
|
|
34
|
+
if (parsed.agents && typeof parsed.agents === "object") {
|
|
35
|
+
const sanitizedAgents: Record<string, AgentOverride> = {};
|
|
36
|
+
|
|
37
|
+
for (const [agentName, agentConfig] of Object.entries(parsed.agents)) {
|
|
38
|
+
if (agentConfig && typeof agentConfig === "object") {
|
|
39
|
+
const sanitized: AgentOverride = {};
|
|
40
|
+
const config = agentConfig as Record<string, unknown>;
|
|
41
|
+
|
|
42
|
+
for (const prop of SAFE_AGENT_PROPERTIES) {
|
|
43
|
+
if (prop in config) {
|
|
44
|
+
(sanitized as Record<string, unknown>)[prop] = config[prop];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
sanitizedAgents[agentName] = sanitized;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return { agents: sanitizedAgents };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return parsed as MicodeConfig;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Merge user config overrides into plugin agent configs
|
|
63
|
+
* User overrides take precedence for safe properties only
|
|
64
|
+
*/
|
|
65
|
+
export function mergeAgentConfigs(
|
|
66
|
+
pluginAgents: Record<string, AgentConfig>,
|
|
67
|
+
userConfig: MicodeConfig | null,
|
|
68
|
+
): Record<string, AgentConfig> {
|
|
69
|
+
if (!userConfig?.agents) {
|
|
70
|
+
return pluginAgents;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const merged: Record<string, AgentConfig> = {};
|
|
74
|
+
|
|
75
|
+
for (const [name, agentConfig] of Object.entries(pluginAgents)) {
|
|
76
|
+
const userOverride = userConfig.agents[name];
|
|
77
|
+
|
|
78
|
+
if (userOverride) {
|
|
79
|
+
merged[name] = {
|
|
80
|
+
...agentConfig,
|
|
81
|
+
...userOverride,
|
|
82
|
+
};
|
|
83
|
+
} else {
|
|
84
|
+
merged[name] = agentConfig;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return merged;
|
|
89
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/hooks/artifact-auto-index.ts
|
|
2
|
+
// Auto-indexes artifacts when written to thoughts/ directories
|
|
3
|
+
|
|
4
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { getArtifactIndex } from "../tools/artifact-index";
|
|
7
|
+
|
|
8
|
+
const LEDGER_PATH_PATTERN = /thoughts\/ledgers\/CONTINUITY_(.+)\.md$/;
|
|
9
|
+
const PLAN_PATH_PATTERN = /thoughts\/shared\/plans\/(.+)\.md$/;
|
|
10
|
+
|
|
11
|
+
export function parseLedger(content: string, filePath: string, sessionName: string) {
|
|
12
|
+
const goalMatch = content.match(/## Goal\n([^\n]+)/);
|
|
13
|
+
const stateMatch = content.match(/### In Progress\n- \[ \] ([^\n]+)/);
|
|
14
|
+
const decisionsMatch = content.match(/## Key Decisions\n([\s\S]*?)(?=\n## |$)/);
|
|
15
|
+
|
|
16
|
+
// Parse file operations from new ledger format
|
|
17
|
+
const fileOpsSection = content.match(/## File Operations\n([\s\S]*?)(?=\n## |$)/);
|
|
18
|
+
let filesRead = "";
|
|
19
|
+
let filesModified = "";
|
|
20
|
+
|
|
21
|
+
if (fileOpsSection) {
|
|
22
|
+
const readMatch = fileOpsSection[1].match(/### Read\n([\s\S]*?)(?=\n### |$)/);
|
|
23
|
+
const modifiedMatch = fileOpsSection[1].match(/### Modified\n([\s\S]*?)(?=\n### |$)/);
|
|
24
|
+
|
|
25
|
+
if (readMatch) {
|
|
26
|
+
// Extract paths from markdown list items like "- `path`"
|
|
27
|
+
const paths = readMatch[1].match(/`([^`]+)`/g);
|
|
28
|
+
filesRead = paths ? paths.map((p) => p.replace(/`/g, "")).join(",") : "";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (modifiedMatch) {
|
|
32
|
+
const paths = modifiedMatch[1].match(/`([^`]+)`/g);
|
|
33
|
+
filesModified = paths ? paths.map((p) => p.replace(/`/g, "")).join(",") : "";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
id: `ledger-${sessionName}`,
|
|
39
|
+
sessionName,
|
|
40
|
+
filePath,
|
|
41
|
+
goal: goalMatch?.[1] || "",
|
|
42
|
+
stateNow: stateMatch?.[1] || "",
|
|
43
|
+
keyDecisions: decisionsMatch?.[1]?.trim() || "",
|
|
44
|
+
filesRead,
|
|
45
|
+
filesModified,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parsePlan(content: string, filePath: string, fileName: string) {
|
|
50
|
+
// Extract title (first heading)
|
|
51
|
+
const titleMatch = content.match(/^# (.+)$/m);
|
|
52
|
+
const title = titleMatch?.[1] || fileName;
|
|
53
|
+
|
|
54
|
+
// Extract overview
|
|
55
|
+
const overviewMatch = content.match(/## Overview\n\n([\s\S]*?)(?=\n## |$)/);
|
|
56
|
+
const overview = overviewMatch?.[1]?.trim() || "";
|
|
57
|
+
|
|
58
|
+
// Extract approach
|
|
59
|
+
const approachMatch = content.match(/## Approach\n\n([\s\S]*?)(?=\n## |$)/);
|
|
60
|
+
const approach = approachMatch?.[1]?.trim() || "";
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
id: `plan-${fileName}`,
|
|
64
|
+
title,
|
|
65
|
+
filePath,
|
|
66
|
+
overview,
|
|
67
|
+
approach,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function createArtifactAutoIndexHook(_ctx: PluginInput) {
|
|
72
|
+
return {
|
|
73
|
+
"tool.execute.after": async (
|
|
74
|
+
input: { tool: string; args?: Record<string, unknown> },
|
|
75
|
+
_output: { output?: string },
|
|
76
|
+
) => {
|
|
77
|
+
// Only process Write tool
|
|
78
|
+
if (input.tool !== "write") return;
|
|
79
|
+
|
|
80
|
+
const filePath = input.args?.filePath as string | undefined;
|
|
81
|
+
if (!filePath) return;
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
// Check if it's a ledger
|
|
85
|
+
const ledgerMatch = filePath.match(LEDGER_PATH_PATTERN);
|
|
86
|
+
if (ledgerMatch) {
|
|
87
|
+
const content = readFileSync(filePath, "utf-8");
|
|
88
|
+
const index = await getArtifactIndex();
|
|
89
|
+
const record = parseLedger(content, filePath, ledgerMatch[1]);
|
|
90
|
+
await index.indexLedger(record);
|
|
91
|
+
console.log(`[artifact-auto-index] Indexed ledger: ${filePath}`);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check if it's a plan
|
|
96
|
+
const planMatch = filePath.match(PLAN_PATH_PATTERN);
|
|
97
|
+
if (planMatch) {
|
|
98
|
+
const content = readFileSync(filePath, "utf-8");
|
|
99
|
+
const index = await getArtifactIndex();
|
|
100
|
+
const record = parsePlan(content, filePath, planMatch[1]);
|
|
101
|
+
await index.indexPlan(record);
|
|
102
|
+
console.log(`[artifact-auto-index] Indexed plan: ${filePath}`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
} catch (e) {
|
|
106
|
+
// Silent failure - don't interrupt user flow
|
|
107
|
+
console.error(`[artifact-auto-index] Error indexing ${filePath}:`, e);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// src/hooks/auto-clear-ledger.ts
|
|
2
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
3
|
+
import { findCurrentLedger, formatLedgerInjection } from "./ledger-loader";
|
|
4
|
+
import { getFileOps, clearFileOps, formatFileOpsForPrompt } from "./file-ops-tracker";
|
|
5
|
+
import { getContextLimit } from "../utils/model-limits";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_THRESHOLD = 0.6; // 60% of context window
|
|
8
|
+
const MIN_TOKENS_FOR_CLEAR = 50_000;
|
|
9
|
+
export const CLEAR_COOLDOWN_MS = 60_000;
|
|
10
|
+
|
|
11
|
+
interface ClearState {
|
|
12
|
+
lastClearTime: Map<string, number>;
|
|
13
|
+
clearInProgress: Set<string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createAutoClearLedgerHook(ctx: PluginInput) {
|
|
17
|
+
const state: ClearState = {
|
|
18
|
+
lastClearTime: new Map(),
|
|
19
|
+
clearInProgress: new Set(),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
async function checkAndClear(sessionID: string, _providerID?: string, modelID?: string): Promise<void> {
|
|
23
|
+
// Skip if clear in progress
|
|
24
|
+
if (state.clearInProgress.has(sessionID)) return;
|
|
25
|
+
|
|
26
|
+
// Respect cooldown
|
|
27
|
+
const lastTime = state.lastClearTime.get(sessionID) || 0;
|
|
28
|
+
if (Date.now() - lastTime < CLEAR_COOLDOWN_MS) return;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// Get session messages to calculate token usage
|
|
32
|
+
const resp = await ctx.client.session.messages({
|
|
33
|
+
path: { id: sessionID },
|
|
34
|
+
query: { directory: ctx.directory },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const messages = (resp as { data?: unknown[] }).data;
|
|
38
|
+
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
39
|
+
|
|
40
|
+
// Find last assistant message with token info
|
|
41
|
+
const lastAssistant = [...messages].reverse().find((m) => {
|
|
42
|
+
const msg = m as Record<string, unknown>;
|
|
43
|
+
const info = msg.info as Record<string, unknown> | undefined;
|
|
44
|
+
return info?.role === "assistant";
|
|
45
|
+
}) as Record<string, unknown> | undefined;
|
|
46
|
+
|
|
47
|
+
if (!lastAssistant) return;
|
|
48
|
+
|
|
49
|
+
const info = lastAssistant.info as Record<string, unknown> | undefined;
|
|
50
|
+
const usage = info?.usage as Record<string, unknown> | undefined;
|
|
51
|
+
|
|
52
|
+
// Calculate token usage
|
|
53
|
+
const inputTokens = (usage?.inputTokens as number) || 0;
|
|
54
|
+
const cacheRead = (usage?.cacheReadInputTokens as number) || 0;
|
|
55
|
+
const totalUsed = inputTokens + cacheRead;
|
|
56
|
+
|
|
57
|
+
if (totalUsed < MIN_TOKENS_FOR_CLEAR) return;
|
|
58
|
+
|
|
59
|
+
// Get model context limit
|
|
60
|
+
const model = modelID || (info?.modelID as string) || "";
|
|
61
|
+
const contextLimit = getContextLimit(model);
|
|
62
|
+
const usageRatio = totalUsed / contextLimit;
|
|
63
|
+
|
|
64
|
+
if (usageRatio < DEFAULT_THRESHOLD) return;
|
|
65
|
+
|
|
66
|
+
// Start clear process
|
|
67
|
+
state.clearInProgress.add(sessionID);
|
|
68
|
+
state.lastClearTime.set(sessionID, Date.now());
|
|
69
|
+
|
|
70
|
+
await ctx.client.tui
|
|
71
|
+
.showToast({
|
|
72
|
+
body: {
|
|
73
|
+
title: "Context Window",
|
|
74
|
+
message: `${Math.round(usageRatio * 100)}% used - saving ledger and clearing...`,
|
|
75
|
+
variant: "warning",
|
|
76
|
+
duration: 3000,
|
|
77
|
+
},
|
|
78
|
+
})
|
|
79
|
+
.catch(() => {});
|
|
80
|
+
|
|
81
|
+
// Step 1: Get file operations and existing ledger (don't clear yet)
|
|
82
|
+
const fileOps = getFileOps(sessionID);
|
|
83
|
+
const existingLedger = await findCurrentLedger(ctx.directory);
|
|
84
|
+
|
|
85
|
+
// Step 2: Spawn ledger-creator agent to update ledger
|
|
86
|
+
const ledgerSessionResp = await ctx.client.session.create({
|
|
87
|
+
body: {},
|
|
88
|
+
query: { directory: ctx.directory },
|
|
89
|
+
});
|
|
90
|
+
const ledgerSessionID = (ledgerSessionResp as { data?: { id?: string } }).data?.id;
|
|
91
|
+
|
|
92
|
+
if (ledgerSessionID) {
|
|
93
|
+
// Build prompt with previous ledger and file ops
|
|
94
|
+
let promptText = "";
|
|
95
|
+
|
|
96
|
+
if (existingLedger) {
|
|
97
|
+
promptText += `<previous-ledger>\n${existingLedger.content}\n</previous-ledger>\n\n`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
promptText += formatFileOpsForPrompt(fileOps);
|
|
101
|
+
promptText += "\n\n<instruction>\n";
|
|
102
|
+
promptText += existingLedger
|
|
103
|
+
? "Update the ledger with the current session state. Merge the file operations above with any existing ones in the previous ledger."
|
|
104
|
+
: "Create a new continuity ledger for this session.";
|
|
105
|
+
promptText += "\n</instruction>";
|
|
106
|
+
|
|
107
|
+
await ctx.client.session.prompt({
|
|
108
|
+
path: { id: ledgerSessionID },
|
|
109
|
+
body: {
|
|
110
|
+
parts: [{ type: "text", text: promptText }],
|
|
111
|
+
agent: "ledger-creator",
|
|
112
|
+
},
|
|
113
|
+
query: { directory: ctx.directory },
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Wait for ledger completion (poll for idle)
|
|
117
|
+
let attempts = 0;
|
|
118
|
+
let ledgerCompleted = false;
|
|
119
|
+
while (attempts < 30) {
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
121
|
+
const statusResp = await ctx.client.session.get({
|
|
122
|
+
path: { id: ledgerSessionID },
|
|
123
|
+
query: { directory: ctx.directory },
|
|
124
|
+
});
|
|
125
|
+
if ((statusResp as { data?: { status?: string } }).data?.status === "idle") {
|
|
126
|
+
ledgerCompleted = true;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
attempts++;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Only clear file ops after ledger-creator successfully completed
|
|
133
|
+
if (ledgerCompleted) {
|
|
134
|
+
clearFileOps(sessionID);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Step 3: Get first message ID for revert
|
|
139
|
+
const firstMessage = messages[0] as Record<string, unknown> | undefined;
|
|
140
|
+
const firstMessageID = (firstMessage?.info as Record<string, unknown> | undefined)?.id as string | undefined;
|
|
141
|
+
|
|
142
|
+
if (!firstMessageID) {
|
|
143
|
+
throw new Error("Could not find first message ID for revert");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Step 4: Revert session to first message
|
|
147
|
+
await ctx.client.session.revert({
|
|
148
|
+
path: { id: sessionID },
|
|
149
|
+
body: { messageID: firstMessageID },
|
|
150
|
+
query: { directory: ctx.directory },
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Step 5: Inject ledger context
|
|
154
|
+
const ledger = await findCurrentLedger(ctx.directory);
|
|
155
|
+
if (ledger) {
|
|
156
|
+
const injection = formatLedgerInjection(ledger);
|
|
157
|
+
await ctx.client.session.prompt({
|
|
158
|
+
path: { id: sessionID },
|
|
159
|
+
body: {
|
|
160
|
+
parts: [{ type: "text", text: injection }],
|
|
161
|
+
noReply: true,
|
|
162
|
+
},
|
|
163
|
+
query: { directory: ctx.directory },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
await ctx.client.tui
|
|
168
|
+
.showToast({
|
|
169
|
+
body: {
|
|
170
|
+
title: "Context Cleared",
|
|
171
|
+
message: "Ledger saved. Session ready to continue.",
|
|
172
|
+
variant: "success",
|
|
173
|
+
duration: 5000,
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
.catch(() => {});
|
|
177
|
+
} catch (e) {
|
|
178
|
+
// Log error but don't interrupt user flow
|
|
179
|
+
console.error("[auto-clear-ledger] Error:", e);
|
|
180
|
+
await ctx.client.tui
|
|
181
|
+
.showToast({
|
|
182
|
+
body: {
|
|
183
|
+
title: "Clear Failed",
|
|
184
|
+
message: "Could not complete context clear. Continuing normally.",
|
|
185
|
+
variant: "error",
|
|
186
|
+
duration: 5000,
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
.catch(() => {});
|
|
190
|
+
} finally {
|
|
191
|
+
state.clearInProgress.delete(sessionID);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
|
197
|
+
const props = event.properties as Record<string, unknown> | undefined;
|
|
198
|
+
|
|
199
|
+
// Cleanup on session delete
|
|
200
|
+
if (event.type === "session.deleted") {
|
|
201
|
+
const sessionInfo = props?.info as { id?: string } | undefined;
|
|
202
|
+
if (sessionInfo?.id) {
|
|
203
|
+
state.lastClearTime.delete(sessionInfo.id);
|
|
204
|
+
state.clearInProgress.delete(sessionInfo.id);
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Check on message update (assistant finished)
|
|
210
|
+
if (event.type === "message.updated") {
|
|
211
|
+
const info = props?.info as Record<string, unknown> | undefined;
|
|
212
|
+
const sessionID = info?.sessionID as string | undefined;
|
|
213
|
+
|
|
214
|
+
if (sessionID && info?.role === "assistant") {
|
|
215
|
+
const providerID = info.providerID as string | undefined;
|
|
216
|
+
const modelID = info.modelID as string | undefined;
|
|
217
|
+
await checkAndClear(sessionID, providerID, modelID);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Check when session goes idle
|
|
222
|
+
if (event.type === "session.idle") {
|
|
223
|
+
const sessionID = props?.sessionID as string | undefined;
|
|
224
|
+
if (sessionID) {
|
|
225
|
+
await checkAndClear(sessionID);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
|
|
3
|
+
interface TokenLimitError {
|
|
4
|
+
currentTokens?: number;
|
|
5
|
+
maxTokens?: number;
|
|
6
|
+
providerID?: string;
|
|
7
|
+
modelID?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Parse Anthropic token limit errors
|
|
11
|
+
function parseTokenLimitError(error: unknown): TokenLimitError | null {
|
|
12
|
+
if (!error) return null;
|
|
13
|
+
|
|
14
|
+
const errorStr = typeof error === "string" ? error : JSON.stringify(error);
|
|
15
|
+
|
|
16
|
+
// Check for Anthropic-specific token limit messages
|
|
17
|
+
const patterns = [
|
|
18
|
+
/prompt is too long.*?(\d+)\s*tokens.*?maximum.*?(\d+)/i,
|
|
19
|
+
/context.*?(\d+).*?exceeds.*?(\d+)/i,
|
|
20
|
+
/token limit.*?(\d+).*?max.*?(\d+)/i,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
for (const pattern of patterns) {
|
|
24
|
+
const match = errorStr.match(pattern);
|
|
25
|
+
if (match) {
|
|
26
|
+
return {
|
|
27
|
+
currentTokens: parseInt(match[1], 10),
|
|
28
|
+
maxTokens: parseInt(match[2], 10),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Check for generic rate limit / context errors
|
|
34
|
+
if (
|
|
35
|
+
errorStr.includes("context_length_exceeded") ||
|
|
36
|
+
errorStr.includes("token") ||
|
|
37
|
+
errorStr.includes("prompt is too long")
|
|
38
|
+
) {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface AutoCompactState {
|
|
46
|
+
pendingCompact: Set<string>;
|
|
47
|
+
errorData: Map<string, TokenLimitError>;
|
|
48
|
+
retryCount: Map<string, number>;
|
|
49
|
+
inProgress: Set<string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createAutoCompactHook(ctx: PluginInput) {
|
|
53
|
+
const state: AutoCompactState = {
|
|
54
|
+
pendingCompact: new Set(),
|
|
55
|
+
errorData: new Map(),
|
|
56
|
+
retryCount: new Map(),
|
|
57
|
+
inProgress: new Set(),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const MAX_RETRIES = 3;
|
|
61
|
+
|
|
62
|
+
async function attemptRecovery(sessionID: string, providerID?: string, modelID?: string): Promise<void> {
|
|
63
|
+
if (state.inProgress.has(sessionID)) return;
|
|
64
|
+
state.inProgress.add(sessionID);
|
|
65
|
+
|
|
66
|
+
const retries = state.retryCount.get(sessionID) || 0;
|
|
67
|
+
|
|
68
|
+
if (retries >= MAX_RETRIES) {
|
|
69
|
+
await ctx.client.tui
|
|
70
|
+
.showToast({
|
|
71
|
+
body: {
|
|
72
|
+
title: "Auto Compact Failed",
|
|
73
|
+
message: "Max retries reached. Please start a new session or manually compact.",
|
|
74
|
+
variant: "error",
|
|
75
|
+
duration: 5000,
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
.catch(() => {});
|
|
79
|
+
state.inProgress.delete(sessionID);
|
|
80
|
+
state.pendingCompact.delete(sessionID);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
await ctx.client.tui
|
|
86
|
+
.showToast({
|
|
87
|
+
body: {
|
|
88
|
+
title: "Context Limit Hit",
|
|
89
|
+
message: `Attempting to summarize session (attempt ${retries + 1}/${MAX_RETRIES})...`,
|
|
90
|
+
variant: "warning",
|
|
91
|
+
duration: 3000,
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
.catch(() => {});
|
|
95
|
+
|
|
96
|
+
// Try to summarize the session
|
|
97
|
+
if (providerID && modelID) {
|
|
98
|
+
await ctx.client.session.summarize({
|
|
99
|
+
path: { id: sessionID },
|
|
100
|
+
body: { providerID, modelID },
|
|
101
|
+
query: { directory: ctx.directory },
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
await ctx.client.tui
|
|
105
|
+
.showToast({
|
|
106
|
+
body: {
|
|
107
|
+
title: "Session Compacted",
|
|
108
|
+
message: "Context has been summarized. Continuing...",
|
|
109
|
+
variant: "success",
|
|
110
|
+
duration: 3000,
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
.catch(() => {});
|
|
114
|
+
|
|
115
|
+
// Clear state on success
|
|
116
|
+
state.pendingCompact.delete(sessionID);
|
|
117
|
+
state.errorData.delete(sessionID);
|
|
118
|
+
state.retryCount.delete(sessionID);
|
|
119
|
+
|
|
120
|
+
// Send continue prompt
|
|
121
|
+
setTimeout(async () => {
|
|
122
|
+
try {
|
|
123
|
+
await ctx.client.session.prompt({
|
|
124
|
+
path: { id: sessionID },
|
|
125
|
+
body: { parts: [{ type: "text", text: "Continue" }] },
|
|
126
|
+
query: { directory: ctx.directory },
|
|
127
|
+
});
|
|
128
|
+
} catch {}
|
|
129
|
+
}, 500);
|
|
130
|
+
} else {
|
|
131
|
+
await ctx.client.tui
|
|
132
|
+
.showToast({
|
|
133
|
+
body: {
|
|
134
|
+
title: "Cannot Auto-Compact",
|
|
135
|
+
message: "Missing model info. Please compact manually with /compact.",
|
|
136
|
+
variant: "error",
|
|
137
|
+
duration: 5000,
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
.catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
} catch (_e) {
|
|
143
|
+
state.retryCount.set(sessionID, retries + 1);
|
|
144
|
+
|
|
145
|
+
// Exponential backoff
|
|
146
|
+
const delay = Math.min(1000 * 2 ** retries, 10000);
|
|
147
|
+
setTimeout(() => {
|
|
148
|
+
state.inProgress.delete(sessionID);
|
|
149
|
+
attemptRecovery(sessionID, providerID, modelID);
|
|
150
|
+
}, delay);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
state.inProgress.delete(sessionID);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
|
159
|
+
const props = event.properties as Record<string, unknown> | undefined;
|
|
160
|
+
|
|
161
|
+
// Clean up on session delete
|
|
162
|
+
if (event.type === "session.deleted") {
|
|
163
|
+
const sessionInfo = props?.info as { id?: string } | undefined;
|
|
164
|
+
if (sessionInfo?.id) {
|
|
165
|
+
state.pendingCompact.delete(sessionInfo.id);
|
|
166
|
+
state.errorData.delete(sessionInfo.id);
|
|
167
|
+
state.retryCount.delete(sessionInfo.id);
|
|
168
|
+
state.inProgress.delete(sessionInfo.id);
|
|
169
|
+
}
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Detect token limit errors
|
|
174
|
+
if (event.type === "session.error") {
|
|
175
|
+
const sessionID = props?.sessionID as string | undefined;
|
|
176
|
+
const error = props?.error;
|
|
177
|
+
|
|
178
|
+
if (!sessionID) return;
|
|
179
|
+
|
|
180
|
+
const parsed = parseTokenLimitError(error);
|
|
181
|
+
if (parsed) {
|
|
182
|
+
state.pendingCompact.add(sessionID);
|
|
183
|
+
state.errorData.set(sessionID, parsed);
|
|
184
|
+
|
|
185
|
+
// Get last assistant message for provider/model info
|
|
186
|
+
const lastAssistant = await getLastAssistantInfo(sessionID);
|
|
187
|
+
const providerID = parsed.providerID || lastAssistant?.providerID;
|
|
188
|
+
const modelID = parsed.modelID || lastAssistant?.modelID;
|
|
189
|
+
|
|
190
|
+
attemptRecovery(sessionID, providerID, modelID);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Also check message.updated for errors
|
|
195
|
+
if (event.type === "message.updated") {
|
|
196
|
+
const info = props?.info as Record<string, unknown> | undefined;
|
|
197
|
+
const sessionID = info?.sessionID as string | undefined;
|
|
198
|
+
|
|
199
|
+
if (sessionID && info?.role === "assistant" && info.error) {
|
|
200
|
+
const parsed = parseTokenLimitError(info.error);
|
|
201
|
+
if (parsed) {
|
|
202
|
+
parsed.providerID = info.providerID as string | undefined;
|
|
203
|
+
parsed.modelID = info.modelID as string | undefined;
|
|
204
|
+
|
|
205
|
+
state.pendingCompact.add(sessionID);
|
|
206
|
+
state.errorData.set(sessionID, parsed);
|
|
207
|
+
|
|
208
|
+
attemptRecovery(sessionID, parsed.providerID, parsed.modelID);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
async function getLastAssistantInfo(sessionID: string): Promise<{ providerID?: string; modelID?: string } | null> {
|
|
216
|
+
try {
|
|
217
|
+
const resp = await ctx.client.session.messages({
|
|
218
|
+
path: { id: sessionID },
|
|
219
|
+
query: { directory: ctx.directory },
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const data = (resp as { data?: unknown[] }).data;
|
|
223
|
+
if (!Array.isArray(data)) return null;
|
|
224
|
+
|
|
225
|
+
const lastAssistant = [...data].reverse().find((m) => {
|
|
226
|
+
const msg = m as Record<string, unknown>;
|
|
227
|
+
const info = msg.info as Record<string, unknown> | undefined;
|
|
228
|
+
return info?.role === "assistant";
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
if (!lastAssistant) return null;
|
|
232
|
+
const info = (lastAssistant as { info?: Record<string, unknown> }).info;
|
|
233
|
+
return {
|
|
234
|
+
providerID: info?.providerID as string | undefined,
|
|
235
|
+
modelID: info?.modelID as string | undefined,
|
|
236
|
+
};
|
|
237
|
+
} catch {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|