feat: add Ralph Loop self-referential development loop (#337)
* feat(config): add RalphLoopConfigSchema and hook name - Add ralph-loop to HookNameSchema enum - Add RalphLoopConfigSchema with enabled, default_max_iterations, state_dir - Add ralph_loop field to OhMyOpenCodeConfigSchema - Export RalphLoopConfig type * feat(ralph-loop): add hook directory structure with constants and types - Add constants.ts with HOOK_NAME, DEFAULT_STATE_FILE, COMPLETION_TAG_PATTERN - Add types.ts with RalphLoopState and RalphLoopOptions interfaces - Export RalphLoopConfig from config/index.ts * feat(ralph-loop): add storage module for markdown state file management - Implement readState/writeState/clearState/incrementIteration - Use YAML frontmatter format for state persistence - Support custom state file paths via config * feat(ralph-loop): implement main hook with session.idle handler - Add createRalphLoopHook factory with event handler - Implement startLoop, cancelLoop, getState API - Detect completion promise in transcript - Auto-continue with iteration tracking - Handle max iterations limit - Show toast notifications for status updates - Support session recovery and cleanup * test(ralph-loop): add comprehensive BDD-style tests - Add 17 test cases covering storage, hook lifecycle, iteration - Test completion detection, cancellation, recovery, session cleanup - Fix storage.ts to handle YAML value parsing correctly - Use BDD #given/#when/#then comments per project convention * feat(builtin-commands): add ralph-loop and cancel-ralph commands * feat(ralph-loop): register hook in main plugin * docs: add Ralph Loop feature to all README files * chore: regenerate JSON schema with ralph-loop config * feat(ralph-loop): change state file path from .opencode to .sisyphus 🤖 Generated with assistance of https://github.com/code-yeongyu/oh-my-opencode * feat(ralph-loop): integrate ralph-loop and cancel-ralph command handlers into plugin hooks - Add chat.message hook to detect and start ralph-loop or cancel-ralph templates - Add slashcommand hook to handle /ralph-loop and /cancel-ralph commands - Support custom --max-iterations and --completion-promise options 🤖 Generated with assistance of https://github.com/code-yeongyu/oh-my-opencode --------- Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
@@ -22,3 +22,4 @@ export { createNonInteractiveEnvHook } from "./non-interactive-env";
|
||||
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
|
||||
export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
|
||||
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
|
||||
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
||||
|
||||
5
src/hooks/ralph-loop/constants.ts
Normal file
5
src/hooks/ralph-loop/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const HOOK_NAME = "ralph-loop"
|
||||
export const DEFAULT_STATE_FILE = ".sisyphus/ralph-loop.local.md"
|
||||
export const COMPLETION_TAG_PATTERN = /<promise>(.*?)<\/promise>/is
|
||||
export const DEFAULT_MAX_ITERATIONS = 100
|
||||
export const DEFAULT_COMPLETION_PROMISE = "DONE"
|
||||
388
src/hooks/ralph-loop/index.test.ts
Normal file
388
src/hooks/ralph-loop/index.test.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { createRalphLoopHook } from "./index"
|
||||
import { readState, writeState, clearState } from "./storage"
|
||||
import type { RalphLoopState } from "./types"
|
||||
|
||||
describe("ralph-loop", () => {
|
||||
const TEST_DIR = join(tmpdir(), "ralph-loop-test-" + Date.now())
|
||||
let promptCalls: Array<{ sessionID: string; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string; variant: string }>
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
prompt: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
return {}
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async (opts: { body: { title: string; message: string; variant: string } }) => {
|
||||
toastCalls.push({
|
||||
title: opts.body.title,
|
||||
message: opts.body.message,
|
||||
variant: opts.body.variant,
|
||||
})
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
directory: TEST_DIR,
|
||||
} as Parameters<typeof createRalphLoopHook>[0]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
promptCalls = []
|
||||
toastCalls = []
|
||||
|
||||
if (!existsSync(TEST_DIR)) {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
clearState(TEST_DIR)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearState(TEST_DIR)
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("storage", () => {
|
||||
test("should write and read state correctly", () => {
|
||||
// #given - a state object
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
iteration: 1,
|
||||
max_iterations: 50,
|
||||
completion_promise: "DONE",
|
||||
started_at: "2025-12-30T01:00:00Z",
|
||||
prompt: "Build a REST API",
|
||||
session_id: "test-session-123",
|
||||
}
|
||||
|
||||
// #when - write and read state
|
||||
const writeSuccess = writeState(TEST_DIR, state)
|
||||
const readResult = readState(TEST_DIR)
|
||||
|
||||
// #then - state should match
|
||||
expect(writeSuccess).toBe(true)
|
||||
expect(readResult).not.toBeNull()
|
||||
expect(readResult?.active).toBe(true)
|
||||
expect(readResult?.iteration).toBe(1)
|
||||
expect(readResult?.max_iterations).toBe(50)
|
||||
expect(readResult?.completion_promise).toBe("DONE")
|
||||
expect(readResult?.prompt).toBe("Build a REST API")
|
||||
expect(readResult?.session_id).toBe("test-session-123")
|
||||
})
|
||||
|
||||
test("should return null for non-existent state", () => {
|
||||
// #given - no state file exists
|
||||
// #when - read state
|
||||
const result = readState(TEST_DIR)
|
||||
|
||||
// #then - should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("should clear state correctly", () => {
|
||||
// #given - existing state
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
iteration: 1,
|
||||
max_iterations: 50,
|
||||
completion_promise: "DONE",
|
||||
started_at: "2025-12-30T01:00:00Z",
|
||||
prompt: "Test prompt",
|
||||
}
|
||||
writeState(TEST_DIR, state)
|
||||
|
||||
// #when - clear state
|
||||
const clearSuccess = clearState(TEST_DIR)
|
||||
const readResult = readState(TEST_DIR)
|
||||
|
||||
// #then - state should be cleared
|
||||
expect(clearSuccess).toBe(true)
|
||||
expect(readResult).toBeNull()
|
||||
})
|
||||
|
||||
test("should handle multiline prompts", () => {
|
||||
// #given - state with multiline prompt
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
iteration: 1,
|
||||
max_iterations: 10,
|
||||
completion_promise: "FINISHED",
|
||||
started_at: "2025-12-30T02:00:00Z",
|
||||
prompt: "Build a feature\nwith multiple lines\nand requirements",
|
||||
}
|
||||
|
||||
// #when - write and read
|
||||
writeState(TEST_DIR, state)
|
||||
const readResult = readState(TEST_DIR)
|
||||
|
||||
// #then - multiline prompt preserved
|
||||
expect(readResult?.prompt).toBe("Build a feature\nwith multiple lines\nand requirements")
|
||||
})
|
||||
})
|
||||
|
||||
describe("hook", () => {
|
||||
test("should start loop and write state", () => {
|
||||
// #given - hook instance
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
|
||||
// #when - start loop
|
||||
const success = hook.startLoop("session-123", "Build something", {
|
||||
maxIterations: 25,
|
||||
completionPromise: "FINISHED",
|
||||
})
|
||||
|
||||
// #then - state should be written
|
||||
expect(success).toBe(true)
|
||||
const state = hook.getState()
|
||||
expect(state?.active).toBe(true)
|
||||
expect(state?.iteration).toBe(1)
|
||||
expect(state?.max_iterations).toBe(25)
|
||||
expect(state?.completion_promise).toBe("FINISHED")
|
||||
expect(state?.prompt).toBe("Build something")
|
||||
expect(state?.session_id).toBe("session-123")
|
||||
})
|
||||
|
||||
test("should inject continuation when loop active and no completion detected", async () => {
|
||||
// #given - active loop state
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - continuation should be injected
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].sessionID).toBe("session-123")
|
||||
expect(promptCalls[0].text).toContain("RALPH LOOP")
|
||||
expect(promptCalls[0].text).toContain("Build a feature")
|
||||
expect(promptCalls[0].text).toContain("2/10")
|
||||
|
||||
// #then - iteration should be incremented
|
||||
const state = hook.getState()
|
||||
expect(state?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("should stop loop when max iterations reached", async () => {
|
||||
// #given - loop at max iteration
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build something", { maxIterations: 2 })
|
||||
|
||||
const state = hook.getState()!
|
||||
state.iteration = 2
|
||||
writeState(TEST_DIR, state)
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls.length).toBe(0)
|
||||
|
||||
// #then - warning toast shown
|
||||
expect(toastCalls.length).toBe(1)
|
||||
expect(toastCalls[0].title).toBe("Ralph Loop Stopped")
|
||||
expect(toastCalls[0].variant).toBe("warning")
|
||||
|
||||
// #then - state should be cleared
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should cancel loop via cancelLoop", () => {
|
||||
// #given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Test task")
|
||||
|
||||
// #when - cancel loop
|
||||
const success = hook.cancelLoop("session-123")
|
||||
|
||||
// #then - loop cancelled
|
||||
expect(success).toBe(true)
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should not cancel loop for different session", () => {
|
||||
// #given - active loop for session-123
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Test task")
|
||||
|
||||
// #when - try to cancel for different session
|
||||
const success = hook.cancelLoop("session-456")
|
||||
|
||||
// #then - cancel should fail
|
||||
expect(success).toBe(false)
|
||||
expect(hook.getState()).not.toBeNull()
|
||||
})
|
||||
|
||||
test("should skip injection during recovery", async () => {
|
||||
// #given - active loop and session in recovery
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Test task")
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID: "session-123", error: new Error("test") },
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle immediately
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should clear state on session deletion", async () => {
|
||||
// #given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Test task")
|
||||
|
||||
// #when - session deleted
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
properties: { info: { id: "session-123" } },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - state should be cleared
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should not inject for different session than loop owner", async () => {
|
||||
// #given - loop owned by session-123
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Test task")
|
||||
|
||||
// #when - different session goes idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-456" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should use default config values", () => {
|
||||
// #given - hook with config
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
config: {
|
||||
enabled: true,
|
||||
default_max_iterations: 200,
|
||||
},
|
||||
})
|
||||
|
||||
// #when - start loop without options
|
||||
hook.startLoop("session-123", "Test task")
|
||||
|
||||
// #then - should use config defaults
|
||||
const state = hook.getState()
|
||||
expect(state?.max_iterations).toBe(200)
|
||||
})
|
||||
|
||||
test("should not inject when no loop is active", async () => {
|
||||
// #given - no active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should detect completion promise and stop loop", async () => {
|
||||
// #given - active loop with transcript containing completion
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "COMPLETE" })
|
||||
|
||||
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
|
||||
writeFileSync(transcriptPath, JSON.stringify({ content: "Task done <promise>COMPLETE</promise>" }))
|
||||
|
||||
// #when - session goes idle with transcript
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123", transcriptPath },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - loop completed, no continuation
|
||||
expect(promptCalls.length).toBe(0)
|
||||
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should handle multiple iterations correctly", async () => {
|
||||
// #given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build feature", { maxIterations: 5 })
|
||||
|
||||
// #when - multiple idle events
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - iteration incremented correctly
|
||||
expect(hook.getState()?.iteration).toBe(3)
|
||||
expect(promptCalls.length).toBe(2)
|
||||
})
|
||||
|
||||
test("should include prompt and promise in continuation message", async () => {
|
||||
// #given - loop with specific prompt and promise
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Create a calculator app", {
|
||||
completionPromise: "CALCULATOR_DONE",
|
||||
maxIterations: 10,
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - continuation includes original task and promise
|
||||
expect(promptCalls[0].text).toContain("Create a calculator app")
|
||||
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
|
||||
})
|
||||
})
|
||||
})
|
||||
272
src/hooks/ralph-loop/index.ts
Normal file
272
src/hooks/ralph-loop/index.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { readState, writeState, clearState, incrementIteration } from "./storage"
|
||||
import {
|
||||
HOOK_NAME,
|
||||
DEFAULT_MAX_ITERATIONS,
|
||||
DEFAULT_COMPLETION_PROMISE,
|
||||
} from "./constants"
|
||||
import type { RalphLoopState, RalphLoopOptions } from "./types"
|
||||
|
||||
export * from "./types"
|
||||
export * from "./constants"
|
||||
export { readState, writeState, clearState, incrementIteration } from "./storage"
|
||||
|
||||
interface SessionState {
|
||||
isRecovering?: boolean
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `[RALPH LOOP - ITERATION {{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 }
|
||||
) => boolean
|
||||
cancelLoop: (sessionID: string) => boolean
|
||||
getState: () => RalphLoopState | null
|
||||
}
|
||||
|
||||
export function createRalphLoopHook(
|
||||
ctx: PluginInput,
|
||||
options?: RalphLoopOptions
|
||||
): RalphLoopHook {
|
||||
const sessions = new Map<string, SessionState>()
|
||||
const config = options?.config
|
||||
const stateDir = config?.state_dir
|
||||
|
||||
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")
|
||||
return pattern.test(content)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
const startLoop = (
|
||||
sessionID: string,
|
||||
prompt: string,
|
||||
loopOptions?: { maxIterations?: number; completionPromise?: string }
|
||||
): 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,
|
||||
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) {
|
||||
return
|
||||
}
|
||||
|
||||
const transcriptPath = props?.transcriptPath as string | undefined
|
||||
|
||||
if (detectCompletionPromise(transcriptPath, state.completion_promise)) {
|
||||
log(`[${HOOK_NAME}] Completion detected!`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
promise: state.completion_promise,
|
||||
})
|
||||
clearState(ctx.directory, stateDir)
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Ralph Loop Complete!",
|
||||
message: `Task completed after ${state.iteration} iteration(s)`,
|
||||
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)
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Ralph Loop",
|
||||
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
|
||||
variant: "info",
|
||||
duration: 2000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
try {
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
parts: [{ type: "text", text: continuationPrompt }],
|
||||
},
|
||||
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
|
||||
if (sessionID) {
|
||||
const sessionState = getSessionState(sessionID)
|
||||
sessionState.isRecovering = true
|
||||
setTimeout(() => {
|
||||
sessionState.isRecovering = false
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
startLoop,
|
||||
cancelLoop,
|
||||
getState,
|
||||
}
|
||||
}
|
||||
113
src/hooks/ralph-loop/storage.ts
Normal file
113
src/hooks/ralph-loop/storage.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import type { RalphLoopState } from "./types"
|
||||
import { DEFAULT_STATE_FILE, DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS } from "./constants"
|
||||
|
||||
export function getStateFilePath(directory: string, customPath?: string): string {
|
||||
return customPath
|
||||
? join(directory, customPath)
|
||||
: join(directory, DEFAULT_STATE_FILE)
|
||||
}
|
||||
|
||||
export function readState(directory: string, customPath?: string): RalphLoopState | null {
|
||||
const filePath = getStateFilePath(directory, customPath)
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<Record<string, unknown>>(content)
|
||||
|
||||
const active = data.active
|
||||
const iteration = data.iteration
|
||||
|
||||
if (active === undefined || iteration === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isActive = active === true || active === "true"
|
||||
const iterationNum = typeof iteration === "number" ? iteration : Number(iteration)
|
||||
|
||||
if (isNaN(iterationNum)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stripQuotes = (val: unknown): string => {
|
||||
const str = String(val ?? "")
|
||||
return str.replace(/^["']|["']$/g, "")
|
||||
}
|
||||
|
||||
return {
|
||||
active: isActive,
|
||||
iteration: iterationNum,
|
||||
max_iterations: Number(data.max_iterations) || DEFAULT_MAX_ITERATIONS,
|
||||
completion_promise: stripQuotes(data.completion_promise) || DEFAULT_COMPLETION_PROMISE,
|
||||
started_at: stripQuotes(data.started_at) || new Date().toISOString(),
|
||||
prompt: body.trim(),
|
||||
session_id: data.session_id ? stripQuotes(data.session_id) : undefined,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeState(
|
||||
directory: string,
|
||||
state: RalphLoopState,
|
||||
customPath?: string
|
||||
): boolean {
|
||||
const filePath = getStateFilePath(directory, customPath)
|
||||
|
||||
try {
|
||||
const dir = dirname(filePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
const sessionIdLine = state.session_id ? `session_id: "${state.session_id}"\n` : ""
|
||||
const content = `---
|
||||
active: ${state.active}
|
||||
iteration: ${state.iteration}
|
||||
max_iterations: ${state.max_iterations}
|
||||
completion_promise: "${state.completion_promise}"
|
||||
started_at: "${state.started_at}"
|
||||
${sessionIdLine}---
|
||||
${state.prompt}
|
||||
`
|
||||
|
||||
writeFileSync(filePath, content, "utf-8")
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function clearState(directory: string, customPath?: string): boolean {
|
||||
const filePath = getStateFilePath(directory, customPath)
|
||||
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function incrementIteration(
|
||||
directory: string,
|
||||
customPath?: string
|
||||
): RalphLoopState | null {
|
||||
const state = readState(directory, customPath)
|
||||
if (!state) return null
|
||||
|
||||
state.iteration += 1
|
||||
if (writeState(directory, state, customPath)) {
|
||||
return state
|
||||
}
|
||||
return null
|
||||
}
|
||||
15
src/hooks/ralph-loop/types.ts
Normal file
15
src/hooks/ralph-loop/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { RalphLoopConfig } from "../../config"
|
||||
|
||||
export interface RalphLoopState {
|
||||
active: boolean
|
||||
iteration: number
|
||||
max_iterations: number
|
||||
completion_promise: string
|
||||
started_at: string
|
||||
prompt: string
|
||||
session_id?: string
|
||||
}
|
||||
|
||||
export interface RalphLoopOptions {
|
||||
config?: RalphLoopConfig
|
||||
}
|
||||
Reference in New Issue
Block a user