opencodekit 0.14.0 → 0.14.2
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/dist/index.js +53 -18
- package/dist/template/.opencode/.background-tasks.json +96 -0
- package/dist/template/.opencode/.ralph-state.json +12 -0
- package/dist/template/.opencode/AGENTS.md +112 -6
- package/dist/template/.opencode/agent/build.md +60 -8
- package/dist/template/.opencode/agent/explore.md +1 -0
- package/dist/template/.opencode/agent/looker.md +124 -0
- package/dist/template/.opencode/agent/planner.md +40 -1
- package/dist/template/.opencode/agent/review.md +1 -0
- package/dist/template/.opencode/agent/rush.md +53 -6
- package/dist/template/.opencode/agent/scout.md +1 -1
- package/dist/template/.opencode/agent/vision.md +0 -1
- package/dist/template/.opencode/command/brainstorm.md +58 -3
- package/dist/template/.opencode/command/finish.md +18 -8
- package/dist/template/.opencode/command/fix.md +24 -15
- package/dist/template/.opencode/command/implement.md +95 -29
- package/dist/template/.opencode/command/import-plan.md +30 -8
- package/dist/template/.opencode/command/new-feature.md +105 -14
- package/dist/template/.opencode/command/plan.md +78 -11
- package/dist/template/.opencode/command/pr.md +25 -15
- package/dist/template/.opencode/command/ralph-loop.md +97 -0
- package/dist/template/.opencode/command/revert-feature.md +15 -3
- package/dist/template/.opencode/command/skill-optimize.md +71 -7
- package/dist/template/.opencode/command/start.md +63 -15
- package/dist/template/.opencode/dcp.jsonc +11 -7
- package/dist/template/.opencode/memory/{project/beads-workflow.md → beads-workflow.md} +53 -0
- package/dist/template/.opencode/memory/observations/2026-01-09-pattern-ampcode-mcp-json-includetools-pattern.md +42 -0
- package/dist/template/.opencode/memory/project/conventions.md +53 -3
- package/dist/template/.opencode/memory/project/gotchas.md +52 -5
- package/dist/template/.opencode/memory/vector_db/memories.lance/_transactions/{0-8d00d272-cb80-463b-9774-7120a1c994e7.txn → 0-0d25ba80-ba3b-4209-9046-b45d6093b4da.txn} +0 -0
- package/dist/template/.opencode/memory/vector_db/memories.lance/_versions/1.manifest +0 -0
- package/dist/template/.opencode/memory/vector_db/memories.lance/data/{001010101000000101110001f998d04b63936ff83f9a34152d.lance → 1111100101010101011010004a9ef34df6b29f36a9a53a2892.lance} +0 -0
- package/dist/template/.opencode/opencode.json +529 -587
- package/dist/template/.opencode/package.json +2 -1
- package/dist/template/.opencode/plugin/lsp.ts +299 -0
- package/dist/template/.opencode/plugin/memory.ts +77 -1
- package/dist/template/.opencode/plugin/package.json +1 -1
- package/dist/template/.opencode/plugin/ralph-wiggum.ts +182 -0
- package/dist/template/.opencode/plugin/skill-mcp.ts +155 -36
- package/dist/template/.opencode/skill/chrome-devtools/SKILL.md +43 -65
- package/dist/template/.opencode/skill/chrome-devtools/mcp.json +19 -0
- package/dist/template/.opencode/skill/executing-plans/SKILL.md +32 -2
- package/dist/template/.opencode/skill/finishing-a-development-branch/SKILL.md +42 -17
- package/dist/template/.opencode/skill/playwright/SKILL.md +58 -133
- package/dist/template/.opencode/skill/playwright/mcp.json +16 -0
- package/dist/template/.opencode/tool/background.ts +461 -0
- package/dist/template/.opencode/tool/memory-search.ts +2 -2
- package/dist/template/.opencode/tool/ralph.ts +203 -0
- package/package.json +4 -16
- package/dist/template/.opencode/memory/vector_db/memories.lance/_transactions/1-a3bea825-dad3-47dd-a6d6-ff41b76ff7b0.txn +0 -0
- package/dist/template/.opencode/memory/vector_db/memories.lance/_versions/2.manifest +0 -0
- package/dist/template/.opencode/memory/vector_db/memories.lance/data/010000101010000000010010701b3840d38c2b5f275da99978.lance +0 -0
- /package/dist/template/.opencode/memory/{project/README.md → README.md} +0 -0
- /package/dist/template/.opencode/plugin/{notification.ts → notification.ts.bak} +0 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LSP Plugin - Active LSP Tool Enforcement
|
|
3
|
+
*
|
|
4
|
+
* Forces agents to actively use LSP tools when code files are detected.
|
|
5
|
+
* This is NOT a suggestion - agents MUST execute LSP operations immediately.
|
|
6
|
+
*
|
|
7
|
+
* Mechanism:
|
|
8
|
+
* 1. Hooks into grep/glob/read tool outputs (tool.execute.after)
|
|
9
|
+
* 2. Hooks into user messages mentioning code files (chat.message)
|
|
10
|
+
* 3. Injects MANDATORY LSP execution commands
|
|
11
|
+
* 4. Uses strong language to override agent tendencies to skip
|
|
12
|
+
*
|
|
13
|
+
* Based on oh-my-opencode's LSP forcing pattern.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
17
|
+
|
|
18
|
+
// File extensions that support LSP
|
|
19
|
+
const LSP_SUPPORTED_EXTENSIONS = new Set([
|
|
20
|
+
".ts",
|
|
21
|
+
".tsx",
|
|
22
|
+
".js",
|
|
23
|
+
".jsx",
|
|
24
|
+
".py",
|
|
25
|
+
".go",
|
|
26
|
+
".rs",
|
|
27
|
+
".java",
|
|
28
|
+
".c",
|
|
29
|
+
".cpp",
|
|
30
|
+
".h",
|
|
31
|
+
".hpp",
|
|
32
|
+
".cs",
|
|
33
|
+
".rb",
|
|
34
|
+
".php",
|
|
35
|
+
".swift",
|
|
36
|
+
".kt",
|
|
37
|
+
".scala",
|
|
38
|
+
".lua",
|
|
39
|
+
".zig",
|
|
40
|
+
".vue",
|
|
41
|
+
".svelte",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
// Regex to extract file:line patterns from tool output
|
|
45
|
+
const FILE_LINE_PATTERNS = [
|
|
46
|
+
// Standard grep output: path/file.ts:42: content
|
|
47
|
+
/^([^\s:]+\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h|hpp|cs|rb|php|swift|kt|scala|lua|zig|vue|svelte)):(\d+):/gm,
|
|
48
|
+
// Just path with line: path/file.ts:42
|
|
49
|
+
/([^\s:]+\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h|hpp|cs|rb|php|swift|kt|scala|lua|zig|vue|svelte)):(\d+)/g,
|
|
50
|
+
// Glob/list output with just paths
|
|
51
|
+
/^([^\s:]+\.(ts|tsx|js|jsx|py|go|rs|java|c|cpp|h|hpp|cs|rb|php|swift|kt|scala|lua|zig|vue|svelte))$/gm,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// Patterns that indicate user is asking about code
|
|
55
|
+
const CODE_INTENT_PATTERNS = [
|
|
56
|
+
/\b(edit|modify|change|update|fix|refactor|add|remove|delete)\b.*\.(ts|tsx|js|jsx|py|go|rs)/i,
|
|
57
|
+
/\b(function|class|method|variable|type|interface)\s+\w+/i,
|
|
58
|
+
/\b(implement|create|build)\b.*\b(feature|component|module)/i,
|
|
59
|
+
/@[^\s]+\.(ts|tsx|js|jsx|py|go|rs)/i, // @file.ts mentions
|
|
60
|
+
/\b(src|lib|app|components?)\/[^\s]+\.(ts|tsx|js|jsx)/i, // Path patterns
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
interface FileMatch {
|
|
64
|
+
filePath: string;
|
|
65
|
+
line?: number;
|
|
66
|
+
character?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function extractFileMatches(output: string): FileMatch[] {
|
|
70
|
+
const matches: FileMatch[] = [];
|
|
71
|
+
const seen = new Set<string>();
|
|
72
|
+
|
|
73
|
+
for (const pattern of FILE_LINE_PATTERNS) {
|
|
74
|
+
pattern.lastIndex = 0;
|
|
75
|
+
|
|
76
|
+
let match = pattern.exec(output);
|
|
77
|
+
while (match !== null) {
|
|
78
|
+
const filePath = match[1];
|
|
79
|
+
const line = match[3] ? Number.parseInt(match[3], 10) : undefined;
|
|
80
|
+
|
|
81
|
+
const key = `${filePath}:${line || 0}`;
|
|
82
|
+
if (!seen.has(key)) {
|
|
83
|
+
seen.add(key);
|
|
84
|
+
matches.push({
|
|
85
|
+
filePath,
|
|
86
|
+
line,
|
|
87
|
+
character: 1,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
match = pattern.exec(output);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return matches.slice(0, 5);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function extractFilesFromUserMessage(text: string): string[] {
|
|
99
|
+
const files: string[] = [];
|
|
100
|
+
const seen = new Set<string>();
|
|
101
|
+
|
|
102
|
+
// Match @file.ts patterns
|
|
103
|
+
const atMentions = text.match(/@([^\s]+\.(ts|tsx|js|jsx|py|go|rs))/gi) || [];
|
|
104
|
+
for (const mention of atMentions) {
|
|
105
|
+
const file = mention.replace("@", "");
|
|
106
|
+
if (!seen.has(file)) {
|
|
107
|
+
seen.add(file);
|
|
108
|
+
files.push(file);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Match path patterns like src/foo.ts
|
|
113
|
+
const pathPatterns =
|
|
114
|
+
text.match(
|
|
115
|
+
/\b(src|lib|app|components?)\/[^\s]+\.(ts|tsx|js|jsx|py|go|rs)/gi,
|
|
116
|
+
) || [];
|
|
117
|
+
for (const path of pathPatterns) {
|
|
118
|
+
if (!seen.has(path)) {
|
|
119
|
+
seen.add(path);
|
|
120
|
+
files.push(path);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return files.slice(0, 3);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function generateLspCommands(matches: FileMatch[]): string {
|
|
128
|
+
if (matches.length === 0) return "";
|
|
129
|
+
|
|
130
|
+
const commands: string[] = [];
|
|
131
|
+
|
|
132
|
+
for (const match of matches) {
|
|
133
|
+
if (match.line) {
|
|
134
|
+
// With line number - get definition and references
|
|
135
|
+
commands.push(
|
|
136
|
+
`lsp({ operation: "goToDefinition", filePath: "${match.filePath}", line: ${match.line}, character: ${match.character || 1} })`,
|
|
137
|
+
);
|
|
138
|
+
commands.push(
|
|
139
|
+
`lsp({ operation: "findReferences", filePath: "${match.filePath}", line: ${match.line}, character: ${match.character || 1} })`,
|
|
140
|
+
);
|
|
141
|
+
commands.push(
|
|
142
|
+
`lsp({ operation: "hover", filePath: "${match.filePath}", line: ${match.line}, character: ${match.character || 1} })`,
|
|
143
|
+
);
|
|
144
|
+
} else {
|
|
145
|
+
// Just file path - get document symbols
|
|
146
|
+
commands.push(
|
|
147
|
+
`lsp({ operation: "documentSymbol", filePath: "${match.filePath}", line: 1, character: 1 })`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const uniqueCommands = [...new Set(commands)].slice(0, 6);
|
|
153
|
+
|
|
154
|
+
// AGGRESSIVE enforcement format - designed to force action
|
|
155
|
+
return `
|
|
156
|
+
|
|
157
|
+
╔══════════════════════════════════════════════════════════════════════════════╗
|
|
158
|
+
║ ⚡ LSP EXECUTION REQUIRED - DO NOT SKIP ║
|
|
159
|
+
╠══════════════════════════════════════════════════════════════════════════════╣
|
|
160
|
+
║ ║
|
|
161
|
+
║ Code files detected. You MUST execute these LSP operations NOW. ║
|
|
162
|
+
║ DO NOT proceed with edits until LSP context is retrieved. ║
|
|
163
|
+
║ ║
|
|
164
|
+
║ EXECUTE IMMEDIATELY (copy-paste ready): ║
|
|
165
|
+
║ ║
|
|
166
|
+
${uniqueCommands.map((cmd) => `║ → ${cmd}`).join("\n")}
|
|
167
|
+
║ ║
|
|
168
|
+
╠══════════════════════════════════════════════════════════════════════════════╣
|
|
169
|
+
║ WHY THIS IS MANDATORY: ║
|
|
170
|
+
║ • grep/read show text, LSP shows semantic meaning ║
|
|
171
|
+
║ • LSP provides accurate type info, definitions, call hierarchies ║
|
|
172
|
+
║ • Editing without LSP context leads to broken code ║
|
|
173
|
+
║ • This is a HARD RULE, not a suggestion ║
|
|
174
|
+
╠══════════════════════════════════════════════════════════════════════════════╣
|
|
175
|
+
║ WORKFLOW: grep/read → LSP → understand → THEN edit ║
|
|
176
|
+
╚══════════════════════════════════════════════════════════════════════════════╝
|
|
177
|
+
`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function generateUserMessageNudge(files: string[]): string {
|
|
181
|
+
const lspCommands = files.map(
|
|
182
|
+
(f) =>
|
|
183
|
+
`lsp({ operation: "documentSymbol", filePath: "${f}", line: 1, character: 1 })`,
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
return `
|
|
187
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
188
|
+
⚡ [LSP CONTEXT REQUIRED]
|
|
189
|
+
|
|
190
|
+
Before making ANY changes to these files, you MUST first retrieve LSP context:
|
|
191
|
+
|
|
192
|
+
${lspCommands.map((cmd) => `→ ${cmd}`).join("\n")}
|
|
193
|
+
|
|
194
|
+
This is a HARD RULE. Do NOT guess about code structure.
|
|
195
|
+
Workflow: READ file → LSP documentSymbol/hover → UNDERSTAND → THEN edit
|
|
196
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function shouldInjectNudge(toolName: string): boolean {
|
|
201
|
+
return ["grep", "glob", "read", "batch"].includes(toolName);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function hasCodeFiles(output: string): boolean {
|
|
205
|
+
for (const ext of LSP_SUPPORTED_EXTENSIONS) {
|
|
206
|
+
if (output.includes(ext)) return true;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function userMessageMentionsCode(text: string): boolean {
|
|
212
|
+
return CODE_INTENT_PATTERNS.some((pattern) => pattern.test(text));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export const Lsp: Plugin = async () => {
|
|
216
|
+
return {
|
|
217
|
+
name: "lsp",
|
|
218
|
+
version: "1.1.0",
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Hook: chat.message
|
|
222
|
+
* Injects LSP reminder when user mentions code files in their message
|
|
223
|
+
* This catches intent BEFORE tools are executed
|
|
224
|
+
*/
|
|
225
|
+
"chat.message": async (_input, output) => {
|
|
226
|
+
const { message, parts } = output;
|
|
227
|
+
|
|
228
|
+
// Only process user messages
|
|
229
|
+
if (message.role !== "user") return;
|
|
230
|
+
|
|
231
|
+
// Extract text from all parts
|
|
232
|
+
const fullText = parts
|
|
233
|
+
.filter((p) => p.type === "text")
|
|
234
|
+
.map((p) => ("text" in p ? p.text : ""))
|
|
235
|
+
.join(" ");
|
|
236
|
+
|
|
237
|
+
// Check if user is mentioning code files or asking about code
|
|
238
|
+
if (!userMessageMentionsCode(fullText)) return;
|
|
239
|
+
|
|
240
|
+
// Extract specific files mentioned
|
|
241
|
+
const files = extractFilesFromUserMessage(fullText);
|
|
242
|
+
|
|
243
|
+
// If files found, inject specific LSP commands
|
|
244
|
+
// If no specific files but code intent detected, inject general reminder
|
|
245
|
+
const nudgeText =
|
|
246
|
+
files.length > 0
|
|
247
|
+
? generateUserMessageNudge(files)
|
|
248
|
+
: `
|
|
249
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
250
|
+
⚡ [LSP FIRST - HARD RULE]
|
|
251
|
+
|
|
252
|
+
Code modification detected. Before editing:
|
|
253
|
+
1. Use grep/glob to find relevant files
|
|
254
|
+
2. Use READ to view the file
|
|
255
|
+
3. Use LSP (documentSymbol, goToDefinition, findReferences) to understand structure
|
|
256
|
+
4. ONLY THEN make edits
|
|
257
|
+
|
|
258
|
+
Do NOT skip LSP. Editing without semantic context leads to broken code.
|
|
259
|
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
260
|
+
`;
|
|
261
|
+
|
|
262
|
+
// Inject synthetic message part - cast to any for synthetic property
|
|
263
|
+
(parts as unknown[]).push({
|
|
264
|
+
type: "text",
|
|
265
|
+
text: nudgeText,
|
|
266
|
+
synthetic: true,
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Hook: tool.execute.after
|
|
272
|
+
* Injects LSP commands after grep/glob/read returns code file results
|
|
273
|
+
* This catches AFTER tools show code files
|
|
274
|
+
*/
|
|
275
|
+
"tool.execute.after": async (input, output) => {
|
|
276
|
+
const { tool: toolName } = input;
|
|
277
|
+
const result = output.output;
|
|
278
|
+
|
|
279
|
+
// Skip if not a search/read tool
|
|
280
|
+
if (!shouldInjectNudge(toolName)) return;
|
|
281
|
+
|
|
282
|
+
// Skip if no code files in output
|
|
283
|
+
if (typeof result !== "string" || !hasCodeFiles(result)) return;
|
|
284
|
+
|
|
285
|
+
// Extract file matches
|
|
286
|
+
const matches = extractFileMatches(result);
|
|
287
|
+
if (matches.length === 0) return;
|
|
288
|
+
|
|
289
|
+
// Generate LSP commands (not suggestions - COMMANDS)
|
|
290
|
+
const lspBlock = generateLspCommands(matches);
|
|
291
|
+
if (!lspBlock) return;
|
|
292
|
+
|
|
293
|
+
// Append mandatory LSP block to tool output
|
|
294
|
+
output.output = `${result}${lspBlock}`;
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
export default Lsp;
|
|
@@ -29,7 +29,83 @@ import type { Plugin } from "@opencode-ai/plugin";
|
|
|
29
29
|
const MEMORY_DIR = ".opencode/memory";
|
|
30
30
|
const BEADS_DIR = ".beads/artifacts";
|
|
31
31
|
const SRC_DIR = "src";
|
|
32
|
-
|
|
32
|
+
// All extensions supported by OpenCode LSP (https://opencode.ai/docs/lsp/)
|
|
33
|
+
const CODE_EXTENSIONS = [
|
|
34
|
+
// TypeScript/JavaScript
|
|
35
|
+
".ts",
|
|
36
|
+
".tsx",
|
|
37
|
+
".js",
|
|
38
|
+
".jsx",
|
|
39
|
+
".mjs",
|
|
40
|
+
".cjs",
|
|
41
|
+
".mts",
|
|
42
|
+
".cts",
|
|
43
|
+
// Web frameworks
|
|
44
|
+
".vue",
|
|
45
|
+
".svelte",
|
|
46
|
+
".astro",
|
|
47
|
+
// Python
|
|
48
|
+
".py",
|
|
49
|
+
".pyi",
|
|
50
|
+
// Go
|
|
51
|
+
".go",
|
|
52
|
+
// Rust
|
|
53
|
+
".rs",
|
|
54
|
+
// C/C++
|
|
55
|
+
".c",
|
|
56
|
+
".cpp",
|
|
57
|
+
".cc",
|
|
58
|
+
".cxx",
|
|
59
|
+
".h",
|
|
60
|
+
".hpp",
|
|
61
|
+
".hh",
|
|
62
|
+
".hxx",
|
|
63
|
+
// Java/Kotlin
|
|
64
|
+
".java",
|
|
65
|
+
".kt",
|
|
66
|
+
".kts",
|
|
67
|
+
// C#/F#
|
|
68
|
+
".cs",
|
|
69
|
+
".fs",
|
|
70
|
+
".fsi",
|
|
71
|
+
".fsx",
|
|
72
|
+
// Ruby
|
|
73
|
+
".rb",
|
|
74
|
+
".rake",
|
|
75
|
+
".gemspec",
|
|
76
|
+
// PHP
|
|
77
|
+
".php",
|
|
78
|
+
// Elixir
|
|
79
|
+
".ex",
|
|
80
|
+
".exs",
|
|
81
|
+
// Clojure
|
|
82
|
+
".clj",
|
|
83
|
+
".cljs",
|
|
84
|
+
".cljc",
|
|
85
|
+
// Shell
|
|
86
|
+
".sh",
|
|
87
|
+
".bash",
|
|
88
|
+
".zsh",
|
|
89
|
+
// Swift/Objective-C
|
|
90
|
+
".swift",
|
|
91
|
+
".m",
|
|
92
|
+
".mm",
|
|
93
|
+
// Other
|
|
94
|
+
".lua",
|
|
95
|
+
".dart",
|
|
96
|
+
".gleam",
|
|
97
|
+
".nix",
|
|
98
|
+
".ml",
|
|
99
|
+
".mli",
|
|
100
|
+
".zig",
|
|
101
|
+
".zon",
|
|
102
|
+
".prisma",
|
|
103
|
+
".tf",
|
|
104
|
+
".tfvars",
|
|
105
|
+
".typ",
|
|
106
|
+
".yaml",
|
|
107
|
+
".yml",
|
|
108
|
+
];
|
|
33
109
|
const DEBOUNCE_MS = 30000; // 30 seconds
|
|
34
110
|
const CODE_CHANGE_DEBOUNCE_MS = 10000; // 10 seconds for toast responsiveness
|
|
35
111
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ralph Wiggum Plugin for OpenCode
|
|
3
|
+
*
|
|
4
|
+
* Handles the session.idle event to continue the Ralph loop.
|
|
5
|
+
* Tools are defined separately in .opencode/tool/ralph.ts
|
|
6
|
+
*
|
|
7
|
+
* Based on: https://ghuntley.com/ralph/
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs/promises";
|
|
11
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
12
|
+
|
|
13
|
+
const STATE_FILE = ".opencode/.ralph-state.json";
|
|
14
|
+
const IDLE_DEBOUNCE_MS = 2000;
|
|
15
|
+
let lastIdleTime = 0;
|
|
16
|
+
|
|
17
|
+
interface RalphState {
|
|
18
|
+
active: boolean;
|
|
19
|
+
sessionID: string | null;
|
|
20
|
+
iteration: number;
|
|
21
|
+
maxIterations: number;
|
|
22
|
+
completionPromise: string;
|
|
23
|
+
task: string;
|
|
24
|
+
prdFile: string | null;
|
|
25
|
+
progressFile: string;
|
|
26
|
+
startedAt: number | null;
|
|
27
|
+
mode: "hitl" | "afk";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function loadState(): Promise<RalphState | null> {
|
|
31
|
+
try {
|
|
32
|
+
const content = await fs.readFile(STATE_FILE, "utf-8");
|
|
33
|
+
return JSON.parse(content);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function saveState(state: RalphState): Promise<void> {
|
|
40
|
+
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function resetState(): Promise<void> {
|
|
44
|
+
try {
|
|
45
|
+
await fs.unlink(STATE_FILE);
|
|
46
|
+
} catch {
|
|
47
|
+
// File doesn't exist, that's fine
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const RalphWiggum: Plugin = async ({ client }) => {
|
|
52
|
+
const log = async (
|
|
53
|
+
message: string,
|
|
54
|
+
level: "info" | "warn" | "error" = "info",
|
|
55
|
+
) => {
|
|
56
|
+
await client.app
|
|
57
|
+
.log({
|
|
58
|
+
body: { service: "ralph-wiggum", level, message },
|
|
59
|
+
})
|
|
60
|
+
.catch(() => {});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const showToast = async (
|
|
64
|
+
title: string,
|
|
65
|
+
message: string,
|
|
66
|
+
variant: "info" | "success" | "warning" | "error" = "info",
|
|
67
|
+
) => {
|
|
68
|
+
await client.tui
|
|
69
|
+
.showToast({
|
|
70
|
+
body: {
|
|
71
|
+
title: `Ralph: ${title}`,
|
|
72
|
+
message,
|
|
73
|
+
variant,
|
|
74
|
+
duration: variant === "error" ? 8000 : 5000,
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
.catch(() => {});
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const buildContinuationPrompt = (state: RalphState): string => {
|
|
81
|
+
const prdRef = state.prdFile ? `@${state.prdFile} ` : "";
|
|
82
|
+
const progressRef = `@${state.progressFile}`;
|
|
83
|
+
|
|
84
|
+
return `
|
|
85
|
+
${prdRef}${progressRef}
|
|
86
|
+
|
|
87
|
+
## Ralph Wiggum Loop - Iteration ${state.iteration}/${state.maxIterations}
|
|
88
|
+
|
|
89
|
+
You are in an autonomous loop. Continue working on the task.
|
|
90
|
+
|
|
91
|
+
**Task:** ${state.task}
|
|
92
|
+
|
|
93
|
+
**Instructions:**
|
|
94
|
+
1. Review the PRD/task list and progress file
|
|
95
|
+
2. Choose the highest-priority INCOMPLETE task
|
|
96
|
+
3. Implement ONE feature/change only
|
|
97
|
+
4. Run feedback loops: typecheck, test, lint
|
|
98
|
+
5. Commit if all pass
|
|
99
|
+
6. Update ${state.progressFile}
|
|
100
|
+
7. If ALL tasks complete, output: ${state.completionPromise}
|
|
101
|
+
|
|
102
|
+
**Constraints:** ONE feature per iteration. Quality over speed.
|
|
103
|
+
`.trim();
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const handleSessionIdle = async (sessionID: string): Promise<void> => {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
if (now - lastIdleTime < IDLE_DEBOUNCE_MS) return;
|
|
109
|
+
lastIdleTime = now;
|
|
110
|
+
|
|
111
|
+
const state = await loadState();
|
|
112
|
+
if (!state?.active || state.sessionID !== sessionID) return;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const messagesResponse = await client.session.messages({
|
|
116
|
+
path: { id: sessionID },
|
|
117
|
+
});
|
|
118
|
+
const messages = messagesResponse.data || [];
|
|
119
|
+
const lastMessage = messages[messages.length - 1];
|
|
120
|
+
|
|
121
|
+
const lastText =
|
|
122
|
+
lastMessage?.parts
|
|
123
|
+
?.filter((p) => p.type === "text")
|
|
124
|
+
.map((p) => ("text" in p ? (p.text as string) : ""))
|
|
125
|
+
.join("") || "";
|
|
126
|
+
|
|
127
|
+
if (lastText.includes(state.completionPromise)) {
|
|
128
|
+
const duration = state.startedAt
|
|
129
|
+
? Math.round((Date.now() - state.startedAt) / 1000 / 60)
|
|
130
|
+
: 0;
|
|
131
|
+
await showToast(
|
|
132
|
+
"Complete!",
|
|
133
|
+
`Finished in ${state.iteration} iterations (${duration} min)`,
|
|
134
|
+
"success",
|
|
135
|
+
);
|
|
136
|
+
await log(`Loop completed in ${state.iteration} iterations`);
|
|
137
|
+
await resetState();
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
state.iteration++;
|
|
142
|
+
if (state.iteration >= state.maxIterations) {
|
|
143
|
+
await showToast(
|
|
144
|
+
"Stopped",
|
|
145
|
+
`Max iterations (${state.maxIterations}) reached`,
|
|
146
|
+
"warning",
|
|
147
|
+
);
|
|
148
|
+
await log(`Max iterations reached: ${state.maxIterations}`, "warn");
|
|
149
|
+
await resetState();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
await saveState(state);
|
|
154
|
+
|
|
155
|
+
await client.session.prompt({
|
|
156
|
+
path: { id: sessionID },
|
|
157
|
+
body: {
|
|
158
|
+
parts: [{ type: "text", text: buildContinuationPrompt(state) }],
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await log(`Iteration ${state.iteration}/${state.maxIterations}`);
|
|
163
|
+
} catch (error) {
|
|
164
|
+
await log(`Error in Ralph loop: ${error}`, "error");
|
|
165
|
+
await resetState();
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
event: async ({ event }) => {
|
|
171
|
+
if (event.type === "session.idle") {
|
|
172
|
+
const sessionID = (event as { properties?: { sessionID?: string } })
|
|
173
|
+
.properties?.sessionID;
|
|
174
|
+
if (sessionID) {
|
|
175
|
+
await handleSessionIdle(sessionID);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export default RalphWiggum;
|