opencode-ralph/plugin/ralph.ts
2026-01-26 02:40:40 +02:00

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