refactor(ralph-loop): split hook into state controller and event handler modules
Extract Ralph loop lifecycle management: - loop-state-controller.ts: start/stop/recovery state machine - ralph-loop-event-handler.ts: event handling logic - continuation-prompt-builder.ts, continuation-prompt-injector.ts - completion-promise-detector.ts, loop-session-recovery.ts - message-storage-directory.ts
This commit is contained in:
parent
161d6e4159
commit
0f145b2e40
90
src/hooks/ralph-loop/completion-promise-detector.ts
Normal file
90
src/hooks/ralph-loop/completion-promise-detector.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { existsSync, readFileSync } from "node:fs"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
import { HOOK_NAME } from "./constants"
|
||||||
|
|
||||||
|
interface OpenCodeSessionMessage {
|
||||||
|
info?: { role?: string }
|
||||||
|
parts?: Array<{ type: string; text?: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegex(str: string): string {
|
||||||
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPromisePattern(promise: string): RegExp {
|
||||||
|
return new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectCompletionInTranscript(
|
||||||
|
transcriptPath: string | undefined,
|
||||||
|
promise: string,
|
||||||
|
): boolean {
|
||||||
|
if (!transcriptPath) return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!existsSync(transcriptPath)) return false
|
||||||
|
|
||||||
|
const content = readFileSync(transcriptPath, "utf-8")
|
||||||
|
const pattern = buildPromisePattern(promise)
|
||||||
|
const lines = content.split("\n").filter((line) => line.trim())
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const entry = JSON.parse(line) as { type?: string }
|
||||||
|
if (entry.type === "user") continue
|
||||||
|
if (pattern.test(line)) return true
|
||||||
|
} catch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectCompletionInSessionMessages(
|
||||||
|
ctx: PluginInput,
|
||||||
|
options: {
|
||||||
|
sessionID: string
|
||||||
|
promise: string
|
||||||
|
apiTimeoutMs: number
|
||||||
|
directory: string
|
||||||
|
},
|
||||||
|
): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await Promise.race([
|
||||||
|
ctx.client.session.messages({
|
||||||
|
path: { id: options.sessionID },
|
||||||
|
query: { directory: options.directory },
|
||||||
|
}),
|
||||||
|
new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error("API timeout")), options.apiTimeoutMs),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
const messages = (response as { data?: unknown[] }).data ?? []
|
||||||
|
if (!Array.isArray(messages)) return false
|
||||||
|
|
||||||
|
const assistantMessages = (messages as OpenCodeSessionMessage[]).filter(
|
||||||
|
(msg) => msg.info?.role === "assistant",
|
||||||
|
)
|
||||||
|
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||||
|
if (!lastAssistant?.parts) return false
|
||||||
|
|
||||||
|
const pattern = buildPromisePattern(options.promise)
|
||||||
|
const responseText = lastAssistant.parts
|
||||||
|
.filter((p) => p.type === "text")
|
||||||
|
.map((p) => p.text ?? "")
|
||||||
|
.join("\n")
|
||||||
|
|
||||||
|
return pattern.test(responseText)
|
||||||
|
} catch (err) {
|
||||||
|
log(`[${HOOK_NAME}] Session messages check failed`, {
|
||||||
|
sessionID: options.sessionID,
|
||||||
|
error: String(err),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/hooks/ralph-loop/continuation-prompt-builder.ts
Normal file
27
src/hooks/ralph-loop/continuation-prompt-builder.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||||
|
import type { RalphLoopState } from "./types"
|
||||||
|
|
||||||
|
const CONTINUATION_PROMPT = `${SYSTEM_DIRECTIVE_PREFIX} - RALPH LOOP {{ITERATION}}/{{MAX}}]
|
||||||
|
|
||||||
|
Your previous attempt did not output the completion promise. Continue working on the task.
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
- Review your progress so far
|
||||||
|
- Continue from where you left off
|
||||||
|
- When FULLY complete, output: <promise>{{PROMISE}}</promise>
|
||||||
|
- Do not stop until the task is truly done
|
||||||
|
|
||||||
|
Original task:
|
||||||
|
{{PROMPT}}`
|
||||||
|
|
||||||
|
export function buildContinuationPrompt(state: RalphLoopState): string {
|
||||||
|
const continuationPrompt = CONTINUATION_PROMPT.replace(
|
||||||
|
"{{ITERATION}}",
|
||||||
|
String(state.iteration),
|
||||||
|
)
|
||||||
|
.replace("{{MAX}}", String(state.max_iterations))
|
||||||
|
.replace("{{PROMISE}}", state.completion_promise)
|
||||||
|
.replace("{{PROMPT}}", state.prompt)
|
||||||
|
|
||||||
|
return state.ultrawork ? `ultrawork ${continuationPrompt}` : continuationPrompt
|
||||||
|
}
|
||||||
61
src/hooks/ralph-loop/continuation-prompt-injector.ts
Normal file
61
src/hooks/ralph-loop/continuation-prompt-injector.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
import { findNearestMessageWithFields } from "../../features/hook-message-injector"
|
||||||
|
import { getMessageDir } from "./message-storage-directory"
|
||||||
|
|
||||||
|
type MessageInfo = {
|
||||||
|
agent?: string
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
modelID?: string
|
||||||
|
providerID?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function injectContinuationPrompt(
|
||||||
|
ctx: PluginInput,
|
||||||
|
options: { sessionID: string; prompt: string; directory: string },
|
||||||
|
): Promise<void> {
|
||||||
|
let agent: string | undefined
|
||||||
|
let model: { providerID: string; modelID: string } | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
const messagesResp = await ctx.client.session.messages({
|
||||||
|
path: { id: options.sessionID },
|
||||||
|
})
|
||||||
|
const messages = (messagesResp.data ?? []) as Array<{ info?: MessageInfo }>
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
const info = messages[i]?.info
|
||||||
|
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
|
||||||
|
agent = info.agent
|
||||||
|
model =
|
||||||
|
info.model ??
|
||||||
|
(info.providerID && info.modelID
|
||||||
|
? { providerID: info.providerID, modelID: info.modelID }
|
||||||
|
: undefined)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
const messageDir = getMessageDir(options.sessionID)
|
||||||
|
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||||
|
agent = currentMessage?.agent
|
||||||
|
model =
|
||||||
|
currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||||
|
? {
|
||||||
|
providerID: currentMessage.model.providerID,
|
||||||
|
modelID: currentMessage.model.modelID,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.client.session.promptAsync({
|
||||||
|
path: { id: options.sessionID },
|
||||||
|
body: {
|
||||||
|
...(agent !== undefined ? { agent } : {}),
|
||||||
|
...(model !== undefined ? { model } : {}),
|
||||||
|
parts: [{ type: "text", text: options.prompt }],
|
||||||
|
},
|
||||||
|
query: { directory: options.directory },
|
||||||
|
})
|
||||||
|
|
||||||
|
log("[ralph-loop] continuation injected", { sessionID: options.sessionID })
|
||||||
|
}
|
||||||
33
src/hooks/ralph-loop/loop-session-recovery.ts
Normal file
33
src/hooks/ralph-loop/loop-session-recovery.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
type SessionState = {
|
||||||
|
isRecovering?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createLoopSessionRecovery(options?: { recoveryWindowMs?: number }) {
|
||||||
|
const recoveryWindowMs = options?.recoveryWindowMs ?? 5000
|
||||||
|
const sessions = new Map<string, SessionState>()
|
||||||
|
|
||||||
|
function getSessionState(sessionID: string): SessionState {
|
||||||
|
let state = sessions.get(sessionID)
|
||||||
|
if (!state) {
|
||||||
|
state = {}
|
||||||
|
sessions.set(sessionID, state)
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isRecovering(sessionID: string): boolean {
|
||||||
|
return getSessionState(sessionID).isRecovering === true
|
||||||
|
},
|
||||||
|
markRecovering(sessionID: string): void {
|
||||||
|
const state = getSessionState(sessionID)
|
||||||
|
state.isRecovering = true
|
||||||
|
setTimeout(() => {
|
||||||
|
state.isRecovering = false
|
||||||
|
}, recoveryWindowMs)
|
||||||
|
},
|
||||||
|
clear(sessionID: string): void {
|
||||||
|
sessions.delete(sessionID)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/hooks/ralph-loop/loop-state-controller.ts
Normal file
81
src/hooks/ralph-loop/loop-state-controller.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import type { RalphLoopOptions, RalphLoopState } from "./types"
|
||||||
|
import {
|
||||||
|
DEFAULT_COMPLETION_PROMISE,
|
||||||
|
DEFAULT_MAX_ITERATIONS,
|
||||||
|
HOOK_NAME,
|
||||||
|
} from "./constants"
|
||||||
|
import { clearState, incrementIteration, readState, writeState } from "./storage"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
|
export function createLoopStateController(options: {
|
||||||
|
directory: string
|
||||||
|
stateDir: string | undefined
|
||||||
|
config: RalphLoopOptions["config"] | undefined
|
||||||
|
}) {
|
||||||
|
const directory = options.directory
|
||||||
|
const stateDir = options.stateDir
|
||||||
|
const config = options.config
|
||||||
|
|
||||||
|
return {
|
||||||
|
startLoop(
|
||||||
|
sessionID: string,
|
||||||
|
prompt: string,
|
||||||
|
loopOptions?: {
|
||||||
|
maxIterations?: number
|
||||||
|
completionPromise?: string
|
||||||
|
ultrawork?: boolean
|
||||||
|
},
|
||||||
|
): boolean {
|
||||||
|
const state: RalphLoopState = {
|
||||||
|
active: true,
|
||||||
|
iteration: 1,
|
||||||
|
max_iterations:
|
||||||
|
loopOptions?.maxIterations ??
|
||||||
|
config?.default_max_iterations ??
|
||||||
|
DEFAULT_MAX_ITERATIONS,
|
||||||
|
completion_promise:
|
||||||
|
loopOptions?.completionPromise ??
|
||||||
|
DEFAULT_COMPLETION_PROMISE,
|
||||||
|
ultrawork: loopOptions?.ultrawork,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
prompt,
|
||||||
|
session_id: sessionID,
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = writeState(directory, state, stateDir)
|
||||||
|
if (success) {
|
||||||
|
log(`[${HOOK_NAME}] Loop started`, {
|
||||||
|
sessionID,
|
||||||
|
maxIterations: state.max_iterations,
|
||||||
|
completionPromise: state.completion_promise,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
},
|
||||||
|
|
||||||
|
cancelLoop(sessionID: string): boolean {
|
||||||
|
const state = readState(directory, stateDir)
|
||||||
|
if (!state || state.session_id !== sessionID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = clearState(directory, stateDir)
|
||||||
|
if (success) {
|
||||||
|
log(`[${HOOK_NAME}] Loop cancelled`, { sessionID, iteration: state.iteration })
|
||||||
|
}
|
||||||
|
return success
|
||||||
|
},
|
||||||
|
|
||||||
|
getState(): RalphLoopState | null {
|
||||||
|
return readState(directory, stateDir)
|
||||||
|
},
|
||||||
|
|
||||||
|
clear(): boolean {
|
||||||
|
return clearState(directory, stateDir)
|
||||||
|
},
|
||||||
|
|
||||||
|
incrementIteration(): RalphLoopState | null {
|
||||||
|
return incrementIteration(directory, stateDir)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/hooks/ralph-loop/message-storage-directory.ts
Normal file
16
src/hooks/ralph-loop/message-storage-directory.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { existsSync, readdirSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||||
|
|
||||||
|
export function getMessageDir(sessionID: string): string | null {
|
||||||
|
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||||
|
|
||||||
|
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||||
|
if (existsSync(directPath)) return directPath
|
||||||
|
|
||||||
|
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||||
|
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||||
|
if (existsSync(sessionPath)) return sessionPath
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
178
src/hooks/ralph-loop/ralph-loop-event-handler.ts
Normal file
178
src/hooks/ralph-loop/ralph-loop-event-handler.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
import type { RalphLoopOptions, RalphLoopState } from "./types"
|
||||||
|
import { HOOK_NAME } from "./constants"
|
||||||
|
import {
|
||||||
|
detectCompletionInSessionMessages,
|
||||||
|
detectCompletionInTranscript,
|
||||||
|
} from "./completion-promise-detector"
|
||||||
|
import { buildContinuationPrompt } from "./continuation-prompt-builder"
|
||||||
|
import { injectContinuationPrompt } from "./continuation-prompt-injector"
|
||||||
|
|
||||||
|
type SessionRecovery = {
|
||||||
|
isRecovering: (sessionID: string) => boolean
|
||||||
|
markRecovering: (sessionID: string) => void
|
||||||
|
clear: (sessionID: string) => void
|
||||||
|
}
|
||||||
|
type LoopStateController = { getState: () => RalphLoopState | null; clear: () => boolean; incrementIteration: () => RalphLoopState | null }
|
||||||
|
type RalphLoopEventHandlerOptions = { directory: string; apiTimeoutMs: number; getTranscriptPath: (sessionID: string) => string | undefined; checkSessionExists?: RalphLoopOptions["checkSessionExists"]; sessionRecovery: SessionRecovery; loopState: LoopStateController }
|
||||||
|
|
||||||
|
export function createRalphLoopEventHandler(
|
||||||
|
ctx: PluginInput,
|
||||||
|
options: RalphLoopEventHandlerOptions,
|
||||||
|
) {
|
||||||
|
return async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||||
|
const props = event.properties as Record<string, unknown> | undefined
|
||||||
|
|
||||||
|
if (event.type === "session.idle") {
|
||||||
|
const sessionID = props?.sessionID as string | undefined
|
||||||
|
if (!sessionID) return
|
||||||
|
|
||||||
|
if (options.sessionRecovery.isRecovering(sessionID)) {
|
||||||
|
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = options.loopState.getState()
|
||||||
|
if (!state || !state.active) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.session_id && state.session_id !== sessionID) {
|
||||||
|
if (options.checkSessionExists) {
|
||||||
|
try {
|
||||||
|
const exists = await options.checkSessionExists(state.session_id)
|
||||||
|
if (!exists) {
|
||||||
|
options.loopState.clear()
|
||||||
|
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
|
||||||
|
orphanedSessionId: state.session_id,
|
||||||
|
currentSessionId: sessionID,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
log(`[${HOOK_NAME}] Failed to check session existence`, {
|
||||||
|
sessionId: state.session_id,
|
||||||
|
error: String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcriptPath = options.getTranscriptPath(sessionID)
|
||||||
|
const completionViaTranscript = detectCompletionInTranscript(transcriptPath, state.completion_promise)
|
||||||
|
const completionViaApi = completionViaTranscript
|
||||||
|
? false
|
||||||
|
: await detectCompletionInSessionMessages(ctx, {
|
||||||
|
sessionID,
|
||||||
|
promise: state.completion_promise,
|
||||||
|
apiTimeoutMs: options.apiTimeoutMs,
|
||||||
|
directory: options.directory,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (completionViaTranscript || completionViaApi) {
|
||||||
|
log(`[${HOOK_NAME}] Completion detected!`, {
|
||||||
|
sessionID,
|
||||||
|
iteration: state.iteration,
|
||||||
|
promise: state.completion_promise,
|
||||||
|
detectedVia: completionViaTranscript
|
||||||
|
? "transcript_file"
|
||||||
|
: "session_messages_api",
|
||||||
|
})
|
||||||
|
options.loopState.clear()
|
||||||
|
|
||||||
|
const title = state.ultrawork ? "ULTRAWORK LOOP COMPLETE!" : "Ralph Loop Complete!"
|
||||||
|
const message = state.ultrawork ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` : `Task completed after ${state.iteration} iteration(s)`
|
||||||
|
await ctx.client.tui.showToast({ body: { title, message, variant: "success", duration: 5000 } }).catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.iteration >= state.max_iterations) {
|
||||||
|
log(`[${HOOK_NAME}] Max iterations reached`, {
|
||||||
|
sessionID,
|
||||||
|
iteration: state.iteration,
|
||||||
|
max: state.max_iterations,
|
||||||
|
})
|
||||||
|
options.loopState.clear()
|
||||||
|
|
||||||
|
await ctx.client.tui
|
||||||
|
.showToast({
|
||||||
|
body: { title: "Ralph Loop Stopped", message: `Max iterations (${state.max_iterations}) reached without completion`, variant: "warning", duration: 5000 },
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = options.loopState.incrementIteration()
|
||||||
|
if (!newState) {
|
||||||
|
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[${HOOK_NAME}] Continuing loop`, {
|
||||||
|
sessionID,
|
||||||
|
iteration: newState.iteration,
|
||||||
|
max: newState.max_iterations,
|
||||||
|
})
|
||||||
|
|
||||||
|
await ctx.client.tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Ralph Loop",
|
||||||
|
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
|
||||||
|
variant: "info",
|
||||||
|
duration: 2000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await injectContinuationPrompt(ctx, {
|
||||||
|
sessionID,
|
||||||
|
prompt: buildContinuationPrompt(newState),
|
||||||
|
directory: options.directory,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log(`[${HOOK_NAME}] Failed to inject continuation`, {
|
||||||
|
sessionID,
|
||||||
|
error: String(err),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session.deleted") {
|
||||||
|
const sessionInfo = props?.info as { id?: string } | undefined
|
||||||
|
if (!sessionInfo?.id) return
|
||||||
|
const state = options.loopState.getState()
|
||||||
|
if (state?.session_id === sessionInfo.id) {
|
||||||
|
options.loopState.clear()
|
||||||
|
log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id })
|
||||||
|
}
|
||||||
|
options.sessionRecovery.clear(sessionInfo.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type === "session.error") {
|
||||||
|
const sessionID = props?.sessionID as string | undefined
|
||||||
|
const error = props?.error as { name?: string } | undefined
|
||||||
|
|
||||||
|
if (error?.name === "MessageAbortedError") {
|
||||||
|
if (sessionID) {
|
||||||
|
const state = options.loopState.getState()
|
||||||
|
if (state?.session_id === sessionID) {
|
||||||
|
options.loopState.clear()
|
||||||
|
log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID })
|
||||||
|
}
|
||||||
|
options.sessionRecovery.clear(sessionID)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionID) {
|
||||||
|
options.sessionRecovery.markRecovering(sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,60 +1,9 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
import type { RalphLoopOptions, RalphLoopState } from "./types"
|
||||||
import { join } from "node:path"
|
|
||||||
import { log } from "../../shared/logger"
|
|
||||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
|
||||||
import { readState, writeState, clearState, incrementIteration } from "./storage"
|
|
||||||
import {
|
|
||||||
HOOK_NAME,
|
|
||||||
DEFAULT_MAX_ITERATIONS,
|
|
||||||
DEFAULT_COMPLETION_PROMISE,
|
|
||||||
} from "./constants"
|
|
||||||
import type { RalphLoopState, RalphLoopOptions } from "./types"
|
|
||||||
import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript"
|
import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript"
|
||||||
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
import { createLoopSessionRecovery } from "./loop-session-recovery"
|
||||||
|
import { createLoopStateController } from "./loop-state-controller"
|
||||||
function getMessageDir(sessionID: string): string | null {
|
import { createRalphLoopEventHandler } from "./ralph-loop-event-handler"
|
||||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
|
||||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
|
||||||
if (existsSync(directPath)) return directPath
|
|
||||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
|
||||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
|
||||||
if (existsSync(sessionPath)) return sessionPath
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export * from "./types"
|
|
||||||
export * from "./constants"
|
|
||||||
export { readState, writeState, clearState, incrementIteration } from "./storage"
|
|
||||||
|
|
||||||
interface SessionState {
|
|
||||||
isRecovering?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OpenCodeSessionMessage {
|
|
||||||
info?: {
|
|
||||||
role?: string
|
|
||||||
}
|
|
||||||
parts?: Array<{
|
|
||||||
type: string
|
|
||||||
text?: string
|
|
||||||
[key: string]: unknown
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONTINUATION_PROMPT = `${SYSTEM_DIRECTIVE_PREFIX} - RALPH LOOP {{ITERATION}}/{{MAX}}]
|
|
||||||
|
|
||||||
Your previous attempt did not output the completion promise. Continue working on the task.
|
|
||||||
|
|
||||||
IMPORTANT:
|
|
||||||
- Review your progress so far
|
|
||||||
- Continue from where you left off
|
|
||||||
- When FULLY complete, output: <promise>{{PROMISE}}</promise>
|
|
||||||
- Do not stop until the task is truly done
|
|
||||||
|
|
||||||
Original task:
|
|
||||||
{{PROMPT}}`
|
|
||||||
|
|
||||||
export interface RalphLoopHook {
|
export interface RalphLoopHook {
|
||||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||||
@ -73,356 +22,32 @@ export function createRalphLoopHook(
|
|||||||
ctx: PluginInput,
|
ctx: PluginInput,
|
||||||
options?: RalphLoopOptions
|
options?: RalphLoopOptions
|
||||||
): RalphLoopHook {
|
): RalphLoopHook {
|
||||||
const sessions = new Map<string, SessionState>()
|
|
||||||
const config = options?.config
|
const config = options?.config
|
||||||
const stateDir = config?.state_dir
|
const stateDir = config?.state_dir
|
||||||
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
|
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
|
||||||
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
|
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
|
||||||
const checkSessionExists = options?.checkSessionExists
|
const checkSessionExists = options?.checkSessionExists
|
||||||
|
|
||||||
function getSessionState(sessionID: string): SessionState {
|
const loopState = createLoopStateController({
|
||||||
let state = sessions.get(sessionID)
|
directory: ctx.directory,
|
||||||
if (!state) {
|
stateDir,
|
||||||
state = {}
|
config,
|
||||||
sessions.set(sessionID, state)
|
})
|
||||||
}
|
const sessionRecovery = createLoopSessionRecovery()
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectCompletionPromise(
|
const event = createRalphLoopEventHandler(ctx, {
|
||||||
transcriptPath: string | undefined,
|
directory: ctx.directory,
|
||||||
promise: string
|
apiTimeoutMs: apiTimeout,
|
||||||
): boolean {
|
getTranscriptPath,
|
||||||
if (!transcriptPath) return false
|
checkSessionExists,
|
||||||
|
sessionRecovery,
|
||||||
|
loopState,
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
return {
|
||||||
if (!existsSync(transcriptPath)) return false
|
event,
|
||||||
|
startLoop: loopState.startLoop,
|
||||||
const content = readFileSync(transcriptPath, "utf-8")
|
cancelLoop: loopState.cancelLoop,
|
||||||
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
|
getState: loopState.getState as () => RalphLoopState | null,
|
||||||
const lines = content.split("\n").filter(l => l.trim())
|
}
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
try {
|
|
||||||
const entry = JSON.parse(line)
|
|
||||||
if (entry.type === "user") continue
|
|
||||||
if (pattern.test(line)) return true
|
|
||||||
} catch {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeRegex(str: string): string {
|
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function detectCompletionInSessionMessages(
|
|
||||||
sessionID: string,
|
|
||||||
promise: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const response = await Promise.race([
|
|
||||||
ctx.client.session.messages({
|
|
||||||
path: { id: sessionID },
|
|
||||||
query: { directory: ctx.directory },
|
|
||||||
}),
|
|
||||||
new Promise<never>((_, reject) =>
|
|
||||||
setTimeout(() => reject(new Error("API timeout")), apiTimeout)
|
|
||||||
),
|
|
||||||
])
|
|
||||||
|
|
||||||
const messages = (response as { data?: unknown[] }).data ?? []
|
|
||||||
if (!Array.isArray(messages)) return false
|
|
||||||
|
|
||||||
const assistantMessages = (messages as OpenCodeSessionMessage[]).filter(
|
|
||||||
(msg) => msg.info?.role === "assistant"
|
|
||||||
)
|
|
||||||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
|
||||||
if (!lastAssistant?.parts) return false
|
|
||||||
|
|
||||||
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
|
|
||||||
const responseText = lastAssistant.parts
|
|
||||||
.filter((p) => p.type === "text")
|
|
||||||
.map((p) => p.text ?? "")
|
|
||||||
.join("\n")
|
|
||||||
|
|
||||||
return pattern.test(responseText)
|
|
||||||
} catch (err) {
|
|
||||||
log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const startLoop = (
|
|
||||||
sessionID: string,
|
|
||||||
prompt: string,
|
|
||||||
loopOptions?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean }
|
|
||||||
): boolean => {
|
|
||||||
const state: RalphLoopState = {
|
|
||||||
active: true,
|
|
||||||
iteration: 1,
|
|
||||||
max_iterations:
|
|
||||||
loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS,
|
|
||||||
completion_promise: loopOptions?.completionPromise ?? DEFAULT_COMPLETION_PROMISE,
|
|
||||||
ultrawork: loopOptions?.ultrawork,
|
|
||||||
started_at: new Date().toISOString(),
|
|
||||||
prompt,
|
|
||||||
session_id: sessionID,
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = writeState(ctx.directory, state, stateDir)
|
|
||||||
if (success) {
|
|
||||||
log(`[${HOOK_NAME}] Loop started`, {
|
|
||||||
sessionID,
|
|
||||||
maxIterations: state.max_iterations,
|
|
||||||
completionPromise: state.completion_promise,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return success
|
|
||||||
}
|
|
||||||
|
|
||||||
const cancelLoop = (sessionID: string): boolean => {
|
|
||||||
const state = readState(ctx.directory, stateDir)
|
|
||||||
if (!state || state.session_id !== sessionID) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = clearState(ctx.directory, stateDir)
|
|
||||||
if (success) {
|
|
||||||
log(`[${HOOK_NAME}] Loop cancelled`, { sessionID, iteration: state.iteration })
|
|
||||||
}
|
|
||||||
return success
|
|
||||||
}
|
|
||||||
|
|
||||||
const getState = (): RalphLoopState | null => {
|
|
||||||
return readState(ctx.directory, stateDir)
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = async ({
|
|
||||||
event,
|
|
||||||
}: {
|
|
||||||
event: { type: string; properties?: unknown }
|
|
||||||
}): Promise<void> => {
|
|
||||||
const props = event.properties as Record<string, unknown> | undefined
|
|
||||||
|
|
||||||
if (event.type === "session.idle") {
|
|
||||||
const sessionID = props?.sessionID as string | undefined
|
|
||||||
if (!sessionID) return
|
|
||||||
|
|
||||||
const sessionState = getSessionState(sessionID)
|
|
||||||
if (sessionState.isRecovering) {
|
|
||||||
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = readState(ctx.directory, stateDir)
|
|
||||||
if (!state || !state.active) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.session_id && state.session_id !== sessionID) {
|
|
||||||
if (checkSessionExists) {
|
|
||||||
try {
|
|
||||||
const originalSessionExists = await checkSessionExists(state.session_id)
|
|
||||||
if (!originalSessionExists) {
|
|
||||||
clearState(ctx.directory, stateDir)
|
|
||||||
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
|
|
||||||
orphanedSessionId: state.session_id,
|
|
||||||
currentSessionId: sessionID,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
log(`[${HOOK_NAME}] Failed to check session existence`, {
|
|
||||||
sessionId: state.session_id,
|
|
||||||
error: String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const transcriptPath = getTranscriptPath(sessionID)
|
|
||||||
const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise)
|
|
||||||
|
|
||||||
const completionDetectedViaApi = completionDetectedViaTranscript
|
|
||||||
? false
|
|
||||||
: await detectCompletionInSessionMessages(sessionID, state.completion_promise)
|
|
||||||
|
|
||||||
if (completionDetectedViaTranscript || completionDetectedViaApi) {
|
|
||||||
log(`[${HOOK_NAME}] Completion detected!`, {
|
|
||||||
sessionID,
|
|
||||||
iteration: state.iteration,
|
|
||||||
promise: state.completion_promise,
|
|
||||||
detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api",
|
|
||||||
})
|
|
||||||
clearState(ctx.directory, stateDir)
|
|
||||||
|
|
||||||
const title = state.ultrawork
|
|
||||||
? "ULTRAWORK LOOP COMPLETE!"
|
|
||||||
: "Ralph Loop Complete!"
|
|
||||||
const message = state.ultrawork
|
|
||||||
? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)`
|
|
||||||
: `Task completed after ${state.iteration} iteration(s)`
|
|
||||||
|
|
||||||
await ctx.client.tui
|
|
||||||
.showToast({
|
|
||||||
body: {
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
variant: "success",
|
|
||||||
duration: 5000,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.iteration >= state.max_iterations) {
|
|
||||||
log(`[${HOOK_NAME}] Max iterations reached`, {
|
|
||||||
sessionID,
|
|
||||||
iteration: state.iteration,
|
|
||||||
max: state.max_iterations,
|
|
||||||
})
|
|
||||||
clearState(ctx.directory, stateDir)
|
|
||||||
|
|
||||||
await ctx.client.tui
|
|
||||||
.showToast({
|
|
||||||
body: {
|
|
||||||
title: "Ralph Loop Stopped",
|
|
||||||
message: `Max iterations (${state.max_iterations}) reached without completion`,
|
|
||||||
variant: "warning",
|
|
||||||
duration: 5000,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const newState = incrementIteration(ctx.directory, stateDir)
|
|
||||||
if (!newState) {
|
|
||||||
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`[${HOOK_NAME}] Continuing loop`, {
|
|
||||||
sessionID,
|
|
||||||
iteration: newState.iteration,
|
|
||||||
max: newState.max_iterations,
|
|
||||||
})
|
|
||||||
|
|
||||||
const continuationPrompt = CONTINUATION_PROMPT.replace("{{ITERATION}}", String(newState.iteration))
|
|
||||||
.replace("{{MAX}}", String(newState.max_iterations))
|
|
||||||
.replace("{{PROMISE}}", newState.completion_promise)
|
|
||||||
.replace("{{PROMPT}}", newState.prompt)
|
|
||||||
|
|
||||||
const finalPrompt = newState.ultrawork
|
|
||||||
? `ultrawork ${continuationPrompt}`
|
|
||||||
: continuationPrompt
|
|
||||||
|
|
||||||
await ctx.client.tui
|
|
||||||
.showToast({
|
|
||||||
body: {
|
|
||||||
title: "Ralph Loop",
|
|
||||||
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
|
|
||||||
variant: "info",
|
|
||||||
duration: 2000,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
|
|
||||||
try {
|
|
||||||
let agent: string | undefined
|
|
||||||
let model: { providerID: string; modelID: string } | undefined
|
|
||||||
|
|
||||||
try {
|
|
||||||
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
|
|
||||||
const messages = (messagesResp.data ?? []) as Array<{
|
|
||||||
info?: { agent?: string; model?: { providerID: string; modelID: string }; modelID?: string; providerID?: string }
|
|
||||||
}>
|
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
|
||||||
const info = messages[i].info
|
|
||||||
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
|
|
||||||
agent = info.agent
|
|
||||||
model = info.model ?? (info.providerID && info.modelID ? { providerID: info.providerID, modelID: info.modelID } : undefined)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
const messageDir = getMessageDir(sessionID)
|
|
||||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
|
||||||
agent = currentMessage?.agent
|
|
||||||
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
|
||||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.client.session.promptAsync({
|
|
||||||
path: { id: sessionID },
|
|
||||||
body: {
|
|
||||||
...(agent !== undefined ? { agent } : {}),
|
|
||||||
...(model !== undefined ? { model } : {}),
|
|
||||||
parts: [{ type: "text", text: finalPrompt }],
|
|
||||||
},
|
|
||||||
query: { directory: ctx.directory },
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
log(`[${HOOK_NAME}] Failed to inject continuation`, {
|
|
||||||
sessionID,
|
|
||||||
error: String(err),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "session.deleted") {
|
|
||||||
const sessionInfo = props?.info as { id?: string } | undefined
|
|
||||||
if (sessionInfo?.id) {
|
|
||||||
const state = readState(ctx.directory, stateDir)
|
|
||||||
if (state?.session_id === sessionInfo.id) {
|
|
||||||
clearState(ctx.directory, stateDir)
|
|
||||||
log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id })
|
|
||||||
}
|
|
||||||
sessions.delete(sessionInfo.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.type === "session.error") {
|
|
||||||
const sessionID = props?.sessionID as string | undefined
|
|
||||||
const error = props?.error as { name?: string } | undefined
|
|
||||||
|
|
||||||
if (error?.name === "MessageAbortedError") {
|
|
||||||
if (sessionID) {
|
|
||||||
const state = readState(ctx.directory, stateDir)
|
|
||||||
if (state?.session_id === sessionID) {
|
|
||||||
clearState(ctx.directory, stateDir)
|
|
||||||
log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID })
|
|
||||||
}
|
|
||||||
sessions.delete(sessionID)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sessionID) {
|
|
||||||
const sessionState = getSessionState(sessionID)
|
|
||||||
sessionState.isRecovering = true
|
|
||||||
setTimeout(() => {
|
|
||||||
sessionState.isRecovering = false
|
|
||||||
}, 5000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
event,
|
|
||||||
startLoop,
|
|
||||||
cancelLoop,
|
|
||||||
getState,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user