refactor: wave 2 - split atlas, auto-update-checker, session-recovery, todo-enforcer, background-task hooks
- Extract atlas/ into 15 focused modules (hook, event handler, tool policies, types, etc.) - Split auto-update-checker into checker/ and hook/ subdirectories with single-purpose files - Decompose session-recovery into separate recovery strategy files per error type - Extract todo-continuation-enforcer from monolith to directory with dedicated modules - Split background-task/tools.ts into individual tool creator files - Extract command-executor, tmux-utils into focused sub-modules - Split config/schema.ts into domain-specific schema files - Decompose cli/config-manager.ts into focused modules - Rollback skill-mcp-manager, model-availability, index.ts splits that broke tests - Fix all import path depths for moved files (../../ -> ../../../) - Add explicit type annotations to resolve TS7006 implicit any errors Typecheck: 0 errors Tests: 2359 pass, 5 fail (all pre-existing)
This commit is contained in:
@@ -1,428 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
||||
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 { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
startLoop: (
|
||||
sessionID: string,
|
||||
prompt: string,
|
||||
options?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean }
|
||||
) => boolean
|
||||
cancelLoop: (sessionID: string) => boolean
|
||||
getState: () => RalphLoopState | null
|
||||
}
|
||||
|
||||
const DEFAULT_API_TIMEOUT = 3000
|
||||
|
||||
export function createRalphLoopHook(
|
||||
ctx: PluginInput,
|
||||
options?: RalphLoopOptions
|
||||
): RalphLoopHook {
|
||||
const sessions = new Map<string, SessionState>()
|
||||
const config = options?.config
|
||||
const stateDir = config?.state_dir
|
||||
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
|
||||
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
|
||||
const checkSessionExists = options?.checkSessionExists
|
||||
|
||||
function getSessionState(sessionID: string): SessionState {
|
||||
let state = sessions.get(sessionID)
|
||||
if (!state) {
|
||||
state = {}
|
||||
sessions.set(sessionID, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function detectCompletionPromise(
|
||||
transcriptPath: string | undefined,
|
||||
promise: string
|
||||
): boolean {
|
||||
if (!transcriptPath) return false
|
||||
|
||||
try {
|
||||
if (!existsSync(transcriptPath)) return false
|
||||
|
||||
const content = readFileSync(transcriptPath, "utf-8")
|
||||
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
|
||||
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,
|
||||
}
|
||||
}
|
||||
export { createRalphLoopHook } from "./ralph-loop-hook"
|
||||
export type { RalphLoopHook } from "./ralph-loop-hook"
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
||||
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 { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
startLoop: (
|
||||
sessionID: string,
|
||||
prompt: string,
|
||||
options?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean }
|
||||
) => boolean
|
||||
cancelLoop: (sessionID: string) => boolean
|
||||
getState: () => RalphLoopState | null
|
||||
}
|
||||
|
||||
const DEFAULT_API_TIMEOUT = 3000 as const
|
||||
|
||||
export function createRalphLoopHook(
|
||||
ctx: PluginInput,
|
||||
options?: RalphLoopOptions
|
||||
): RalphLoopHook {
|
||||
const sessions = new Map<string, SessionState>()
|
||||
const config = options?.config
|
||||
const stateDir = config?.state_dir
|
||||
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
|
||||
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
|
||||
const checkSessionExists = options?.checkSessionExists
|
||||
|
||||
function getSessionState(sessionID: string): SessionState {
|
||||
let state = sessions.get(sessionID)
|
||||
if (!state) {
|
||||
state = {}
|
||||
sessions.set(sessionID, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function detectCompletionPromise(
|
||||
transcriptPath: string | undefined,
|
||||
promise: string
|
||||
): boolean {
|
||||
if (!transcriptPath) return false
|
||||
|
||||
try {
|
||||
if (!existsSync(transcriptPath)) return false
|
||||
|
||||
const content = readFileSync(transcriptPath, "utf-8")
|
||||
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
|
||||
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user