mirror of
https://github.com/rot13maxi/opencode-ralph.git
synced 2026-05-27 14:27:40 +00:00
442 lines
13 KiB
TypeScript
442 lines
13 KiB
TypeScript
import type { Plugin } from "@opencode-ai/plugin"
|
|
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs"
|
|
import { join } from "path"
|
|
|
|
/**
|
|
* Ralph Wiggum Plugin for OpenCode (Enhanced Fork)
|
|
*
|
|
* Implementation of the Ralph Wiggum technique - continuous self-referential AI loops
|
|
* for iterative development. Named after Ralph Wiggum from The Simpsons, embodying
|
|
* the philosophy of persistent iteration despite setbacks.
|
|
*
|
|
* ENHANCEMENTS over original:
|
|
* - History tracking with iteration metrics
|
|
* - Mid-loop context injection
|
|
* - Struggle detection and warnings
|
|
* - Tool usage tracking per iteration
|
|
*
|
|
* Core concept: Feed the same prompt repeatedly, letting the AI see its previous work
|
|
* in files and git history, creating a self-referential feedback loop.
|
|
*
|
|
* Based on: https://ghuntley.com/ralph/
|
|
* Fork: https://github.com/chindris-mihai-alexandru/opencode-ralph-fork
|
|
*/
|
|
|
|
interface RalphState {
|
|
active: boolean
|
|
iteration: number
|
|
maxIterations: number
|
|
completionPromise: string | null
|
|
startedAt: string
|
|
prompt: string
|
|
}
|
|
|
|
interface IterationRecord {
|
|
iteration: number
|
|
startedAt: string
|
|
endedAt: string
|
|
durationMs: number
|
|
toolsUsed: Record<string, number>
|
|
filesModified: string[]
|
|
errorsEncountered: number
|
|
}
|
|
|
|
interface RalphHistory {
|
|
loopId: string
|
|
startedAt: string
|
|
iterations: IterationRecord[]
|
|
totalToolCalls: number
|
|
struggleIndicators: string[]
|
|
}
|
|
|
|
const STATE_FILE = "ralph-loop.local.md"
|
|
const HISTORY_FILE = "ralph-history.local.json"
|
|
const CONTEXT_FILE = "ralph-context.local.md"
|
|
|
|
// Track current iteration state for tool monitoring
|
|
let currentIterationStart: string | null = null
|
|
let currentIterationTools: Record<string, number> = {}
|
|
let currentIterationFiles: string[] = []
|
|
let currentIterationErrors: number = 0
|
|
|
|
function parseRalphState(directory: string): RalphState | null {
|
|
const statePath = join(directory, STATE_FILE)
|
|
|
|
if (!existsSync(statePath)) {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(statePath, "utf-8")
|
|
|
|
// Parse YAML frontmatter
|
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
|
|
if (!frontmatterMatch) {
|
|
return null
|
|
}
|
|
|
|
const [, frontmatter, prompt] = frontmatterMatch
|
|
|
|
// Parse frontmatter values
|
|
const getValue = (key: string): string | null => {
|
|
const match = frontmatter.match(new RegExp(`^${key}:\\s*(.*)$`, "m"))
|
|
if (!match) return null
|
|
// Remove surrounding quotes if present
|
|
return match[1].replace(/^["'](.*)["']$/, "$1")
|
|
}
|
|
|
|
const active = getValue("active") === "true"
|
|
const iteration = parseInt(getValue("iteration") || "1", 10)
|
|
const maxIterations = parseInt(getValue("max_iterations") || "0", 10)
|
|
const completionPromise = getValue("completion_promise")
|
|
const startedAt = getValue("started_at") || new Date().toISOString()
|
|
|
|
return {
|
|
active,
|
|
iteration,
|
|
maxIterations,
|
|
completionPromise: completionPromise === "null" ? null : completionPromise,
|
|
startedAt,
|
|
prompt: prompt.trim(),
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function writeRalphState(directory: string, state: RalphState): void {
|
|
const statePath = join(directory, STATE_FILE)
|
|
|
|
const completionPromiseYaml =
|
|
state.completionPromise === null ? "null" : `"${state.completionPromise}"`
|
|
|
|
const content = `---
|
|
active: ${state.active}
|
|
iteration: ${state.iteration}
|
|
max_iterations: ${state.maxIterations}
|
|
completion_promise: ${completionPromiseYaml}
|
|
started_at: "${state.startedAt}"
|
|
---
|
|
|
|
${state.prompt}
|
|
`
|
|
|
|
writeFileSync(statePath, content, "utf-8")
|
|
}
|
|
|
|
function deleteRalphState(directory: string): boolean {
|
|
const statePath = join(directory, STATE_FILE)
|
|
|
|
if (existsSync(statePath)) {
|
|
unlinkSync(statePath)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function loadHistory(directory: string): RalphHistory | null {
|
|
const historyPath = join(directory, HISTORY_FILE)
|
|
|
|
if (!existsSync(historyPath)) {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(historyPath, "utf-8")
|
|
return JSON.parse(content)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function saveHistory(directory: string, history: RalphHistory): void {
|
|
const historyPath = join(directory, HISTORY_FILE)
|
|
writeFileSync(historyPath, JSON.stringify(history, null, 2), "utf-8")
|
|
}
|
|
|
|
function initializeHistory(directory: string, state: RalphState): RalphHistory {
|
|
const history: RalphHistory = {
|
|
loopId: `ralph-${Date.now()}`,
|
|
startedAt: state.startedAt,
|
|
iterations: [],
|
|
totalToolCalls: 0,
|
|
struggleIndicators: [],
|
|
}
|
|
saveHistory(directory, history)
|
|
return history
|
|
}
|
|
|
|
function recordIteration(directory: string, iteration: number): void {
|
|
let history = loadHistory(directory)
|
|
if (!history) {
|
|
const state = parseRalphState(directory)
|
|
if (!state) return
|
|
history = initializeHistory(directory, state)
|
|
}
|
|
|
|
const now = new Date().toISOString()
|
|
const startTime = currentIterationStart || now
|
|
const durationMs = new Date(now).getTime() - new Date(startTime).getTime()
|
|
|
|
const record: IterationRecord = {
|
|
iteration,
|
|
startedAt: startTime,
|
|
endedAt: now,
|
|
durationMs,
|
|
toolsUsed: { ...currentIterationTools },
|
|
filesModified: [...currentIterationFiles],
|
|
errorsEncountered: currentIterationErrors,
|
|
}
|
|
|
|
history.iterations.push(record)
|
|
history.totalToolCalls += Object.values(currentIterationTools).reduce((a, b) => a + b, 0)
|
|
|
|
// Detect struggle indicators
|
|
if (history.iterations.length >= 3) {
|
|
const recentIterations = history.iterations.slice(-3)
|
|
|
|
// Check for no file modifications in last 3 iterations
|
|
const noFileChanges = recentIterations.every(it => it.filesModified.length === 0)
|
|
if (noFileChanges && !history.struggleIndicators.includes("no_file_changes")) {
|
|
history.struggleIndicators.push("no_file_changes")
|
|
}
|
|
|
|
// Check for high error rate
|
|
const totalErrors = recentIterations.reduce((sum, it) => sum + it.errorsEncountered, 0)
|
|
if (totalErrors >= 5 && !history.struggleIndicators.includes("high_error_rate")) {
|
|
history.struggleIndicators.push("high_error_rate")
|
|
}
|
|
|
|
// Check for repeated short iterations (might be stuck in a loop)
|
|
const avgDuration = recentIterations.reduce((sum, it) => sum + it.durationMs, 0) / 3
|
|
if (avgDuration < 5000 && !history.struggleIndicators.includes("rapid_iterations")) {
|
|
history.struggleIndicators.push("rapid_iterations")
|
|
}
|
|
}
|
|
|
|
saveHistory(directory, history)
|
|
|
|
// Reset iteration tracking
|
|
currentIterationStart = null
|
|
currentIterationTools = {}
|
|
currentIterationFiles = []
|
|
currentIterationErrors = 0
|
|
}
|
|
|
|
function loadPendingContext(directory: string): string | null {
|
|
const contextPath = join(directory, CONTEXT_FILE)
|
|
|
|
if (!existsSync(contextPath)) {
|
|
return null
|
|
}
|
|
|
|
try {
|
|
const content = readFileSync(contextPath, "utf-8")
|
|
// Parse context from after frontmatter
|
|
const match = content.match(/^---\n[\s\S]*?\n---\n([\s\S]*)$/)
|
|
if (match) {
|
|
return match[1].trim()
|
|
}
|
|
return content.trim()
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function clearPendingContext(directory: string): void {
|
|
const contextPath = join(directory, CONTEXT_FILE)
|
|
if (existsSync(contextPath)) {
|
|
unlinkSync(contextPath)
|
|
}
|
|
}
|
|
|
|
function checkCompletionPromise(text: string, promise: string): boolean {
|
|
// Extract text from <promise> tags
|
|
const promiseMatch = text.match(/<promise>([\s\S]*?)<\/promise>/)
|
|
if (!promiseMatch) return false
|
|
|
|
// Normalize whitespace and compare
|
|
const promiseText = promiseMatch[1].trim().replace(/\s+/g, " ")
|
|
return promiseText === promise
|
|
}
|
|
|
|
function formatStruggleWarning(history: RalphHistory): string {
|
|
if (history.struggleIndicators.length === 0) {
|
|
return ""
|
|
}
|
|
|
|
const warnings: string[] = []
|
|
|
|
if (history.struggleIndicators.includes("no_file_changes")) {
|
|
warnings.push("⚠️ No file changes in 3+ iterations")
|
|
}
|
|
if (history.struggleIndicators.includes("high_error_rate")) {
|
|
warnings.push("⚠️ High error rate detected")
|
|
}
|
|
if (history.struggleIndicators.includes("rapid_iterations")) {
|
|
warnings.push("⚠️ Unusually rapid iterations (possible stuck loop)")
|
|
}
|
|
|
|
if (warnings.length > 0) {
|
|
warnings.push("💡 Consider using: /ralph-context \"your hint here\"")
|
|
}
|
|
|
|
return warnings.join("\n")
|
|
}
|
|
|
|
export const RalphPlugin: Plugin = async ({ directory, client, $ }) => {
|
|
return {
|
|
/**
|
|
* Track tool executions for history
|
|
*/
|
|
"tool.execute.after": async (input, output) => {
|
|
const state = parseRalphState(directory)
|
|
if (!state || !state.active) return
|
|
|
|
// Track tool usage
|
|
const toolName = input.tool || "unknown"
|
|
currentIterationTools[toolName] = (currentIterationTools[toolName] || 0) + 1
|
|
|
|
// Track file modifications
|
|
if (toolName === "write" || toolName === "edit") {
|
|
const filePath = output.args?.filePath || output.args?.path
|
|
if (filePath && !currentIterationFiles.includes(filePath)) {
|
|
currentIterationFiles.push(filePath)
|
|
}
|
|
}
|
|
|
|
// Track errors
|
|
if (output.error) {
|
|
currentIterationErrors++
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Handle session idle event - this is when the AI has finished responding
|
|
* and would normally wait for user input. In Ralph mode, we intercept this
|
|
* to continue the loop.
|
|
*/
|
|
event: async ({ event }) => {
|
|
if (event.type !== "session.idle") return
|
|
|
|
const state = parseRalphState(directory)
|
|
if (!state || !state.active) return
|
|
|
|
// Mark iteration start if not set
|
|
if (!currentIterationStart) {
|
|
currentIterationStart = new Date().toISOString()
|
|
}
|
|
|
|
// Get the last assistant message to check for completion
|
|
// We need to check if the completion promise was output
|
|
if (state.completionPromise) {
|
|
try {
|
|
// Use the SDK to get session messages
|
|
const session = await client.session.get({ id: event.properties.sessionID })
|
|
if (session.messages && session.messages.length > 0) {
|
|
// Find the last assistant message
|
|
const lastAssistantMsg = [...session.messages]
|
|
.reverse()
|
|
.find((m) => m.role === "assistant")
|
|
|
|
if (lastAssistantMsg) {
|
|
// Extract text content from message parts
|
|
const textContent = lastAssistantMsg.parts
|
|
?.filter((p: any) => p.type === "text")
|
|
.map((p: any) => p.text)
|
|
.join("\n")
|
|
|
|
if (textContent && checkCompletionPromise(textContent, state.completionPromise)) {
|
|
// Record final iteration and completion
|
|
recordIteration(directory, state.iteration)
|
|
deleteRalphState(directory)
|
|
await client.app.log({
|
|
service: "ralph-plugin",
|
|
level: "info",
|
|
message: `Ralph loop completed: detected <promise>${state.completionPromise}</promise>`,
|
|
})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
// If we can't check messages, continue the loop
|
|
await client.app.log({
|
|
service: "ralph-plugin",
|
|
level: "warn",
|
|
message: `Could not check for completion promise: ${err}`,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Record the current iteration
|
|
recordIteration(directory, state.iteration)
|
|
|
|
// Check max iterations
|
|
if (state.maxIterations > 0 && state.iteration >= state.maxIterations) {
|
|
deleteRalphState(directory)
|
|
await client.app.log({
|
|
service: "ralph-plugin",
|
|
level: "info",
|
|
message: `Ralph loop stopped: max iterations (${state.maxIterations}) reached`,
|
|
})
|
|
return
|
|
}
|
|
|
|
// Continue the loop - increment iteration and feed prompt back
|
|
const nextIteration = state.iteration + 1
|
|
writeRalphState(directory, {
|
|
...state,
|
|
iteration: nextIteration,
|
|
})
|
|
|
|
// Start tracking next iteration
|
|
currentIterationStart = new Date().toISOString()
|
|
|
|
// Build the continuation message
|
|
let systemMsg = `Ralph iteration ${nextIteration}`
|
|
if (state.completionPromise) {
|
|
systemMsg += ` | To stop: output <promise>${state.completionPromise}</promise> (ONLY when TRUE)`
|
|
} else if (state.maxIterations > 0) {
|
|
systemMsg += ` / ${state.maxIterations}`
|
|
} else {
|
|
systemMsg += ` | No completion promise set - loop runs until cancelled`
|
|
}
|
|
|
|
// Check for pending context injection
|
|
const pendingContext = loadPendingContext(directory)
|
|
let contextMsg = ""
|
|
if (pendingContext) {
|
|
contextMsg = `\n\n📝 OPERATOR GUIDANCE:\n${pendingContext}`
|
|
clearPendingContext(directory)
|
|
}
|
|
|
|
// Check for struggle indicators
|
|
const history = loadHistory(directory)
|
|
let struggleMsg = ""
|
|
if (history) {
|
|
struggleMsg = formatStruggleWarning(history)
|
|
if (struggleMsg) {
|
|
struggleMsg = `\n\n${struggleMsg}`
|
|
}
|
|
}
|
|
|
|
// Log the iteration
|
|
await client.app.log({
|
|
service: "ralph-plugin",
|
|
level: "info",
|
|
message: systemMsg + (pendingContext ? " [+context]" : ""),
|
|
})
|
|
|
|
// Append the prompt back to continue the session
|
|
// The prompt includes a marker showing the iteration
|
|
const continuationPrompt = `[${systemMsg}]${contextMsg}${struggleMsg}\n\n${state.prompt}`
|
|
|
|
// Use session.send to continue the conversation
|
|
await client.session.send({
|
|
id: event.properties.sessionID,
|
|
text: continuationPrompt,
|
|
})
|
|
},
|
|
}
|
|
}
|