micode 0.7.6 → 0.8.0
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 +1 -1
- package/src/agents/artifact-searcher.ts +6 -1
- package/src/agents/brainstormer.ts +20 -2
- package/src/agents/codebase-analyzer.ts +6 -1
- package/src/agents/codebase-locator.ts +6 -1
- package/src/agents/commander.ts +14 -2
- package/src/agents/executor.ts +38 -22
- package/src/agents/implementer.ts +6 -1
- package/src/agents/ledger-creator.ts +6 -1
- package/src/agents/pattern-finder.ts +6 -1
- package/src/agents/planner.ts +20 -11
- package/src/agents/project-initializer.ts +27 -21
- package/src/agents/reviewer.ts +6 -1
- package/src/hooks/auto-compact.ts +138 -184
- package/src/hooks/context-injector.ts +4 -4
- package/src/hooks/context-window-monitor.ts +3 -3
- package/src/index.ts +76 -4
- package/src/tools/spawn-agent.ts +93 -0
- package/src/hooks/auto-clear-ledger.ts +0 -230
- package/src/hooks/preemptive-compaction.ts +0 -183
|
@@ -1,241 +1,195 @@
|
|
|
1
1
|
import type { PluginInput } from "@opencode-ai/plugin";
|
|
2
|
+
import { getContextLimit } from "../utils/model-limits";
|
|
3
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
}
|
|
6
|
+
// Compact when this percentage of context is used
|
|
7
|
+
const COMPACT_THRESHOLD = 0.50;
|
|
32
8
|
|
|
33
|
-
|
|
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
|
-
}
|
|
9
|
+
const LEDGER_DIR = "thoughts/ledgers";
|
|
44
10
|
|
|
45
11
|
interface AutoCompactState {
|
|
46
|
-
pendingCompact: Set<string>;
|
|
47
|
-
errorData: Map<string, TokenLimitError>;
|
|
48
|
-
retryCount: Map<string, number>;
|
|
49
12
|
inProgress: Set<string>;
|
|
13
|
+
lastCompactTime: Map<string, number>;
|
|
50
14
|
}
|
|
51
15
|
|
|
16
|
+
// Cooldown between compaction attempts (prevent rapid re-triggering)
|
|
17
|
+
const COMPACT_COOLDOWN_MS = 30_000; // 30 seconds
|
|
18
|
+
|
|
52
19
|
export function createAutoCompactHook(ctx: PluginInput) {
|
|
53
20
|
const state: AutoCompactState = {
|
|
54
|
-
pendingCompact: new Set(),
|
|
55
|
-
errorData: new Map(),
|
|
56
|
-
retryCount: new Map(),
|
|
57
21
|
inProgress: new Set(),
|
|
22
|
+
lastCompactTime: new Map(),
|
|
58
23
|
};
|
|
59
24
|
|
|
60
|
-
|
|
25
|
+
async function writeSummaryToLedger(sessionID: string): Promise<void> {
|
|
26
|
+
try {
|
|
27
|
+
// Fetch session messages to find the summary
|
|
28
|
+
const resp = await ctx.client.session.messages({
|
|
29
|
+
path: { id: sessionID },
|
|
30
|
+
query: { directory: ctx.directory },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const messages = (resp as { data?: unknown[] }).data;
|
|
34
|
+
if (!Array.isArray(messages)) return;
|
|
35
|
+
|
|
36
|
+
// Find the summary message (has summary: true)
|
|
37
|
+
const summaryMsg = [...messages].reverse().find((m) => {
|
|
38
|
+
const msg = m as Record<string, unknown>;
|
|
39
|
+
const info = msg.info as Record<string, unknown> | undefined;
|
|
40
|
+
return info?.role === "assistant" && info?.summary === true;
|
|
41
|
+
}) as Record<string, unknown> | undefined;
|
|
42
|
+
|
|
43
|
+
if (!summaryMsg) return;
|
|
44
|
+
|
|
45
|
+
// Extract text parts from the summary
|
|
46
|
+
const parts = summaryMsg.parts as Array<{ type: string; text?: string }> | undefined;
|
|
47
|
+
if (!parts) return;
|
|
48
|
+
|
|
49
|
+
const summaryText = parts
|
|
50
|
+
.filter((p) => p.type === "text" && p.text)
|
|
51
|
+
.map((p) => p.text)
|
|
52
|
+
.join("\n\n");
|
|
53
|
+
|
|
54
|
+
if (!summaryText.trim()) return;
|
|
55
|
+
|
|
56
|
+
// Create ledger directory if needed
|
|
57
|
+
const ledgerDir = join(ctx.directory, LEDGER_DIR);
|
|
58
|
+
await mkdir(ledgerDir, { recursive: true });
|
|
59
|
+
|
|
60
|
+
// Write ledger file - summary is already structured (Factory.ai/pi-mono format)
|
|
61
|
+
const timestamp = new Date().toISOString();
|
|
62
|
+
const sessionName = sessionID.slice(0, 8); // Use first 8 chars of session ID
|
|
63
|
+
const ledgerPath = join(ledgerDir, `CONTINUITY_${sessionName}.md`);
|
|
64
|
+
|
|
65
|
+
// Add metadata header, then the structured summary as-is
|
|
66
|
+
const ledgerContent = `---
|
|
67
|
+
session: ${sessionName}
|
|
68
|
+
updated: ${timestamp}
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
${summaryText}
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
await writeFile(ledgerPath, ledgerContent, "utf-8");
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// Don't fail the compaction flow if ledger write fails
|
|
77
|
+
console.error("[auto-compact] Failed to write ledger:", e);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function triggerCompaction(
|
|
82
|
+
sessionID: string,
|
|
83
|
+
providerID: string,
|
|
84
|
+
modelID: string,
|
|
85
|
+
usageRatio: number,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
if (state.inProgress.has(sessionID)) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Check cooldown
|
|
92
|
+
const lastCompact = state.lastCompactTime.get(sessionID) || 0;
|
|
93
|
+
if (Date.now() - lastCompact < COMPACT_COOLDOWN_MS) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
61
96
|
|
|
62
|
-
async function attemptRecovery(sessionID: string, providerID?: string, modelID?: string): Promise<void> {
|
|
63
|
-
if (state.inProgress.has(sessionID)) return;
|
|
64
97
|
state.inProgress.add(sessionID);
|
|
65
98
|
|
|
66
|
-
|
|
99
|
+
try {
|
|
100
|
+
const usedPercent = Math.round(usageRatio * 100);
|
|
101
|
+
const thresholdPercent = Math.round(COMPACT_THRESHOLD * 100);
|
|
67
102
|
|
|
68
|
-
if (retries >= MAX_RETRIES) {
|
|
69
103
|
await ctx.client.tui
|
|
70
104
|
.showToast({
|
|
71
105
|
body: {
|
|
72
|
-
title: "Auto
|
|
73
|
-
message:
|
|
74
|
-
variant: "
|
|
75
|
-
duration:
|
|
106
|
+
title: "Auto Compacting",
|
|
107
|
+
message: `Context at ${usedPercent}% (threshold: ${thresholdPercent}%). Summarizing...`,
|
|
108
|
+
variant: "warning",
|
|
109
|
+
duration: 3000,
|
|
76
110
|
},
|
|
77
111
|
})
|
|
78
112
|
.catch(() => {});
|
|
79
|
-
state.inProgress.delete(sessionID);
|
|
80
|
-
state.pendingCompact.delete(sessionID);
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
113
|
|
|
84
|
-
|
|
114
|
+
await ctx.client.session.summarize({
|
|
115
|
+
path: { id: sessionID },
|
|
116
|
+
body: { providerID, modelID },
|
|
117
|
+
query: { directory: ctx.directory },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
state.lastCompactTime.set(sessionID, Date.now());
|
|
121
|
+
|
|
122
|
+
// Write summary to ledger file
|
|
123
|
+
await writeSummaryToLedger(sessionID);
|
|
124
|
+
|
|
85
125
|
await ctx.client.tui
|
|
86
126
|
.showToast({
|
|
87
127
|
body: {
|
|
88
|
-
title: "
|
|
89
|
-
message:
|
|
90
|
-
variant: "
|
|
128
|
+
title: "Compaction Complete",
|
|
129
|
+
message: "Session summarized and ledger updated.",
|
|
130
|
+
variant: "success",
|
|
91
131
|
duration: 3000,
|
|
92
132
|
},
|
|
93
133
|
})
|
|
94
134
|
.catch(() => {});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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;
|
|
135
|
+
} catch (e) {
|
|
136
|
+
const errorMsg = e instanceof Error ? e.message : String(e);
|
|
137
|
+
await ctx.client.tui
|
|
138
|
+
.showToast({
|
|
139
|
+
body: {
|
|
140
|
+
title: "Compaction Failed",
|
|
141
|
+
message: errorMsg.slice(0, 100),
|
|
142
|
+
variant: "error",
|
|
143
|
+
duration: 5000,
|
|
144
|
+
},
|
|
145
|
+
})
|
|
146
|
+
.catch(() => {});
|
|
147
|
+
} finally {
|
|
148
|
+
state.inProgress.delete(sessionID);
|
|
152
149
|
}
|
|
153
|
-
|
|
154
|
-
state.inProgress.delete(sessionID);
|
|
155
150
|
}
|
|
156
151
|
|
|
157
152
|
return {
|
|
158
153
|
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
|
159
154
|
const props = event.properties as Record<string, unknown> | undefined;
|
|
160
155
|
|
|
161
|
-
//
|
|
156
|
+
// Cleanup on session delete
|
|
162
157
|
if (event.type === "session.deleted") {
|
|
163
158
|
const sessionInfo = props?.info as { id?: string } | undefined;
|
|
164
159
|
if (sessionInfo?.id) {
|
|
165
|
-
state.pendingCompact.delete(sessionInfo.id);
|
|
166
|
-
state.errorData.delete(sessionInfo.id);
|
|
167
|
-
state.retryCount.delete(sessionInfo.id);
|
|
168
160
|
state.inProgress.delete(sessionInfo.id);
|
|
161
|
+
state.lastCompactTime.delete(sessionInfo.id);
|
|
169
162
|
}
|
|
170
163
|
return;
|
|
171
164
|
}
|
|
172
165
|
|
|
173
|
-
//
|
|
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
|
|
166
|
+
// Monitor usage on assistant message completion
|
|
195
167
|
if (event.type === "message.updated") {
|
|
196
168
|
const info = props?.info as Record<string, unknown> | undefined;
|
|
197
169
|
const sessionID = info?.sessionID as string | undefined;
|
|
198
170
|
|
|
199
|
-
if (sessionID
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
171
|
+
if (!sessionID || info?.role !== "assistant") return;
|
|
172
|
+
|
|
173
|
+
// Skip if this is already a summary message
|
|
174
|
+
if (info?.summary === true) return;
|
|
175
|
+
|
|
176
|
+
const tokens = info?.tokens as { input?: number; cache?: { read?: number } } | undefined;
|
|
177
|
+
const inputTokens = tokens?.input || 0;
|
|
178
|
+
const cacheRead = tokens?.cache?.read || 0;
|
|
179
|
+
const totalUsed = inputTokens + cacheRead;
|
|
204
180
|
|
|
205
|
-
|
|
206
|
-
state.errorData.set(sessionID, parsed);
|
|
181
|
+
if (totalUsed === 0) return;
|
|
207
182
|
|
|
208
|
-
|
|
209
|
-
|
|
183
|
+
const modelID = (info?.modelID as string) || "";
|
|
184
|
+
const providerID = (info?.providerID as string) || "";
|
|
185
|
+
const contextLimit = getContextLimit(modelID);
|
|
186
|
+
const usageRatio = totalUsed / contextLimit;
|
|
187
|
+
|
|
188
|
+
// Trigger compaction if over threshold
|
|
189
|
+
if (usageRatio >= COMPACT_THRESHOLD) {
|
|
190
|
+
triggerCompaction(sessionID, providerID, modelID, usageRatio);
|
|
210
191
|
}
|
|
211
192
|
}
|
|
212
193
|
},
|
|
213
194
|
};
|
|
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
195
|
}
|
|
@@ -2,11 +2,11 @@ import type { PluginInput } from "@opencode-ai/plugin";
|
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { join, dirname, resolve } from "node:path";
|
|
4
4
|
|
|
5
|
-
// Files to inject at project root level
|
|
6
|
-
const ROOT_CONTEXT_FILES = ["ARCHITECTURE.md", "CODE_STYLE.md", "
|
|
5
|
+
// Files to inject at project root level (AGENTS.md and CLAUDE.md handled by OpenCode natively)
|
|
6
|
+
const ROOT_CONTEXT_FILES = ["ARCHITECTURE.md", "CODE_STYLE.md", "README.md"] as const;
|
|
7
7
|
|
|
8
|
-
// Files to collect when walking up directories
|
|
9
|
-
const DIRECTORY_CONTEXT_FILES = ["
|
|
8
|
+
// Files to collect when walking up directories (AGENTS.md handled by OpenCode natively)
|
|
9
|
+
const DIRECTORY_CONTEXT_FILES = ["README.md"] as const;
|
|
10
10
|
|
|
11
11
|
// Tools that trigger directory-aware context injection
|
|
12
12
|
const FILE_ACCESS_TOOLS = ["Read", "read", "Edit", "edit"];
|
|
@@ -68,9 +68,9 @@ export function createContextWindowMonitorHook(ctx: PluginInput) {
|
|
|
68
68
|
|
|
69
69
|
if (!sessionID || info?.role !== "assistant") return;
|
|
70
70
|
|
|
71
|
-
const
|
|
72
|
-
const inputTokens =
|
|
73
|
-
const cacheRead =
|
|
71
|
+
const tokens = info.tokens as { input?: number; cache?: { read?: number } } | undefined;
|
|
72
|
+
const inputTokens = tokens?.input || 0;
|
|
73
|
+
const cacheRead = tokens?.cache?.read || 0;
|
|
74
74
|
const totalUsed = inputTokens + cacheRead;
|
|
75
75
|
|
|
76
76
|
const modelID = (info.modelID as string) || "";
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { ast_grep_search, ast_grep_replace, checkAstGrepAvailable } from "./tool
|
|
|
9
9
|
import { btca_ask, checkBtcaAvailable } from "./tools/btca";
|
|
10
10
|
import { look_at } from "./tools/look-at";
|
|
11
11
|
import { artifact_search } from "./tools/artifact-search";
|
|
12
|
+
import { createSpawnAgentTool } from "./tools/spawn-agent";
|
|
12
13
|
|
|
13
14
|
// Hooks
|
|
14
15
|
import { createAutoCompactHook } from "./hooks/auto-compact";
|
|
@@ -17,10 +18,9 @@ import { createSessionRecoveryHook } from "./hooks/session-recovery";
|
|
|
17
18
|
import { createTokenAwareTruncationHook } from "./hooks/token-aware-truncation";
|
|
18
19
|
import { createContextWindowMonitorHook } from "./hooks/context-window-monitor";
|
|
19
20
|
import { createCommentCheckerHook } from "./hooks/comment-checker";
|
|
20
|
-
import { createAutoClearLedgerHook } from "./hooks/auto-clear-ledger";
|
|
21
21
|
import { createLedgerLoaderHook } from "./hooks/ledger-loader";
|
|
22
22
|
import { createArtifactAutoIndexHook } from "./hooks/artifact-auto-index";
|
|
23
|
-
import { createFileOpsTrackerHook } from "./hooks/file-ops-tracker";
|
|
23
|
+
import { createFileOpsTrackerHook, getFileOps } from "./hooks/file-ops-tracker";
|
|
24
24
|
|
|
25
25
|
// PTY System
|
|
26
26
|
import { PTYManager, createPtyTools } from "./tools/pty";
|
|
@@ -84,7 +84,6 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
|
|
|
84
84
|
// Hooks
|
|
85
85
|
const autoCompactHook = createAutoCompactHook(ctx);
|
|
86
86
|
const contextInjectorHook = createContextInjectorHook(ctx);
|
|
87
|
-
const autoClearLedgerHook = createAutoClearLedgerHook(ctx);
|
|
88
87
|
const ledgerLoaderHook = createLedgerLoaderHook(ctx);
|
|
89
88
|
const sessionRecoveryHook = createSessionRecoveryHook(ctx);
|
|
90
89
|
const tokenAwareTruncationHook = createTokenAwareTruncationHook(ctx);
|
|
@@ -97,6 +96,9 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
|
|
|
97
96
|
const ptyManager = new PTYManager();
|
|
98
97
|
const ptyTools = createPtyTools(ptyManager);
|
|
99
98
|
|
|
99
|
+
// Spawn agent tool (for subagents to spawn other subagents)
|
|
100
|
+
const spawn_agent = createSpawnAgentTool(ctx);
|
|
101
|
+
|
|
100
102
|
return {
|
|
101
103
|
// Tools
|
|
102
104
|
tool: {
|
|
@@ -105,6 +107,7 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
|
|
|
105
107
|
btca_ask,
|
|
106
108
|
look_at,
|
|
107
109
|
artifact_search,
|
|
110
|
+
spawn_agent,
|
|
108
111
|
...ptyTools,
|
|
109
112
|
},
|
|
110
113
|
|
|
@@ -194,6 +197,62 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
|
|
|
194
197
|
}
|
|
195
198
|
},
|
|
196
199
|
|
|
200
|
+
// Structured compaction prompt (Factory.ai / pi-mono best practices)
|
|
201
|
+
"experimental.session.compacting": async (
|
|
202
|
+
input: { sessionID: string },
|
|
203
|
+
output: { context: string[]; prompt?: string },
|
|
204
|
+
) => {
|
|
205
|
+
// Get file operations for this session
|
|
206
|
+
const fileOps = getFileOps(input.sessionID);
|
|
207
|
+
const readPaths = Array.from(fileOps.read).sort();
|
|
208
|
+
const modifiedPaths = Array.from(fileOps.modified).sort();
|
|
209
|
+
|
|
210
|
+
const fileOpsSection = `
|
|
211
|
+
## File Operations
|
|
212
|
+
### Read
|
|
213
|
+
${readPaths.length > 0 ? readPaths.map((p) => `- \`${p}\``).join("\n") : "- (none)"}
|
|
214
|
+
|
|
215
|
+
### Modified
|
|
216
|
+
${modifiedPaths.length > 0 ? modifiedPaths.map((p) => `- \`${p}\``).join("\n") : "- (none)"}`;
|
|
217
|
+
|
|
218
|
+
output.prompt = `Create a structured summary for continuing this conversation. Use this EXACT format:
|
|
219
|
+
|
|
220
|
+
# Session Summary
|
|
221
|
+
|
|
222
|
+
## Goal
|
|
223
|
+
{The core objective being pursued - one sentence describing success criteria}
|
|
224
|
+
|
|
225
|
+
## Constraints & Preferences
|
|
226
|
+
{Technical requirements, patterns to follow, things to avoid - or "(none)"}
|
|
227
|
+
|
|
228
|
+
## Progress
|
|
229
|
+
### Done
|
|
230
|
+
- [x] {Completed items with specific details}
|
|
231
|
+
|
|
232
|
+
### In Progress
|
|
233
|
+
- [ ] {Current work - what's actively being worked on}
|
|
234
|
+
|
|
235
|
+
### Blocked
|
|
236
|
+
- {Issues preventing progress, if any - or "(none)"}
|
|
237
|
+
|
|
238
|
+
## Key Decisions
|
|
239
|
+
- **{Decision}**: {Rationale - why this choice was made}
|
|
240
|
+
|
|
241
|
+
## Next Steps
|
|
242
|
+
1. {Ordered list of what to do next - be specific}
|
|
243
|
+
|
|
244
|
+
## Critical Context
|
|
245
|
+
- {Data, examples, references, or findings needed to continue work}
|
|
246
|
+
- {Important discoveries or insights from this session}
|
|
247
|
+
${fileOpsSection}
|
|
248
|
+
|
|
249
|
+
IMPORTANT:
|
|
250
|
+
- Preserve EXACT file paths and function names
|
|
251
|
+
- Focus on information needed to continue seamlessly
|
|
252
|
+
- Be specific about what was done, not vague summaries
|
|
253
|
+
- Include any error messages or issues encountered`;
|
|
254
|
+
},
|
|
255
|
+
|
|
197
256
|
// Tool output processing
|
|
198
257
|
"tool.execute.after": async (
|
|
199
258
|
input: { tool: string; sessionID: string; callID: string; args?: Record<string, unknown> },
|
|
@@ -218,6 +277,20 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
|
|
|
218
277
|
);
|
|
219
278
|
},
|
|
220
279
|
|
|
280
|
+
// Filter out CLAUDE.md/AGENTS.md from system prompt for our agents
|
|
281
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
282
|
+
output.system = output.system.filter((s) => {
|
|
283
|
+
// Keep entries that don't come from CLAUDE.md or AGENTS.md
|
|
284
|
+
if (s.startsWith("Instructions from:")) {
|
|
285
|
+
const path = s.split("\n")[0];
|
|
286
|
+
if (path.includes("CLAUDE.md") || path.includes("AGENTS.md")) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return true;
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
|
|
221
294
|
event: async ({ event }) => {
|
|
222
295
|
// Session cleanup (think mode + PTY)
|
|
223
296
|
if (event.type === "session.deleted") {
|
|
@@ -230,7 +303,6 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
|
|
|
230
303
|
|
|
231
304
|
// Run all event hooks
|
|
232
305
|
await autoCompactHook.event({ event });
|
|
233
|
-
await autoClearLedgerHook.event({ event });
|
|
234
306
|
await sessionRecoveryHook.event({ event });
|
|
235
307
|
await tokenAwareTruncationHook.event({ event });
|
|
236
308
|
await contextWindowMonitorHook.event({ event });
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin/tool";
|
|
2
|
+
import type { PluginInput } from "@opencode-ai/plugin";
|
|
3
|
+
|
|
4
|
+
interface SessionCreateResponse {
|
|
5
|
+
data?: { id?: string };
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface MessagePart {
|
|
9
|
+
type: string;
|
|
10
|
+
text?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SessionMessage {
|
|
14
|
+
info?: { role?: "user" | "assistant" };
|
|
15
|
+
parts?: MessagePart[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface SessionMessagesResponse {
|
|
19
|
+
data?: SessionMessage[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createSpawnAgentTool(ctx: PluginInput) {
|
|
23
|
+
return tool({
|
|
24
|
+
description: `FOR SUBAGENTS ONLY - Primary agents (commander, brainstormer) should use the built-in Task tool instead.
|
|
25
|
+
Spawn a subagent to execute a task synchronously. The agent runs to completion and returns its result.
|
|
26
|
+
Use this when you are a SUBAGENT (executor, planner, project-initializer) and need to spawn other subagents.
|
|
27
|
+
For parallel execution, call spawn_agent multiple times in ONE message.`,
|
|
28
|
+
args: {
|
|
29
|
+
agent: tool.schema.string().describe("Agent to spawn (e.g., 'implementer', 'reviewer')"),
|
|
30
|
+
prompt: tool.schema.string().describe("Full prompt/instructions for the agent"),
|
|
31
|
+
description: tool.schema.string().describe("Short description of the task"),
|
|
32
|
+
},
|
|
33
|
+
execute: async (args) => {
|
|
34
|
+
const { agent, prompt, description } = args;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
// Create new session for the subagent
|
|
38
|
+
const sessionResp = (await ctx.client.session.create({
|
|
39
|
+
body: {},
|
|
40
|
+
query: { directory: ctx.directory },
|
|
41
|
+
})) as SessionCreateResponse;
|
|
42
|
+
|
|
43
|
+
const sessionID = sessionResp.data?.id;
|
|
44
|
+
if (!sessionID) {
|
|
45
|
+
return `## spawn_agent Failed\n\nFailed to create session for agent "${agent}"`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Run the prompt synchronously (waits for completion)
|
|
49
|
+
await ctx.client.session.prompt({
|
|
50
|
+
path: { id: sessionID },
|
|
51
|
+
body: {
|
|
52
|
+
parts: [{ type: "text", text: prompt }],
|
|
53
|
+
agent: agent,
|
|
54
|
+
},
|
|
55
|
+
query: { directory: ctx.directory },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Get the result from session messages
|
|
59
|
+
const messagesResp = (await ctx.client.session.messages({
|
|
60
|
+
path: { id: sessionID },
|
|
61
|
+
query: { directory: ctx.directory },
|
|
62
|
+
})) as SessionMessagesResponse;
|
|
63
|
+
|
|
64
|
+
// Find the last assistant message
|
|
65
|
+
const messages = messagesResp.data || [];
|
|
66
|
+
const lastAssistant = messages
|
|
67
|
+
.filter((m) => m.info?.role === "assistant")
|
|
68
|
+
.pop();
|
|
69
|
+
|
|
70
|
+
const result =
|
|
71
|
+
lastAssistant?.parts
|
|
72
|
+
?.filter((p) => p.type === "text" && p.text)
|
|
73
|
+
.map((p) => p.text)
|
|
74
|
+
.join("\n") || "(No response from agent)";
|
|
75
|
+
|
|
76
|
+
// Clean up session
|
|
77
|
+
await ctx.client.session
|
|
78
|
+
.delete({
|
|
79
|
+
path: { id: sessionID },
|
|
80
|
+
query: { directory: ctx.directory },
|
|
81
|
+
})
|
|
82
|
+
.catch(() => {
|
|
83
|
+
// Ignore cleanup errors
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return `## ${description}\n\n**Agent**: ${agent}\n\n### Result\n\n${result}`;
|
|
87
|
+
} catch (error) {
|
|
88
|
+
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
89
|
+
return `## spawn_agent Failed\n\n**Agent**: ${agent}\n**Error**: ${errorMsg}`;
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|