8
src/ralph-loop/claude-config-dir.ts
Normal file
8
src/ralph-loop/claude-config-dir.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
export function getClaudeConfigDir(): string {
|
||||
const envConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
if (envConfigDir) return envConfigDir
|
||||
return join(homedir(), ".claude")
|
||||
}
|
||||
38
src/ralph-loop/command-arguments.ts
Normal file
38
src/ralph-loop/command-arguments.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type RalphLoopStrategy = "reset" | "continue"
|
||||
|
||||
export type ParsedRalphLoopArguments = {
|
||||
prompt: string
|
||||
maxIterations?: number
|
||||
completionPromise?: string
|
||||
strategy?: RalphLoopStrategy
|
||||
}
|
||||
|
||||
const DEFAULT_PROMPT = "Complete the task as instructed"
|
||||
|
||||
export function parseRalphLoopArguments(rawArguments: string): ParsedRalphLoopArguments {
|
||||
const taskMatch = rawArguments.match(/^("|')(.+?)\1/)
|
||||
const promptCandidate =
|
||||
taskMatch?.[2] ??
|
||||
(rawArguments.startsWith("--")
|
||||
? ""
|
||||
: rawArguments.split(/\s+--/)[0]?.trim() ?? "")
|
||||
const prompt = promptCandidate || DEFAULT_PROMPT
|
||||
|
||||
const maxIterationMatch = rawArguments.match(/--max-iterations=(\d+)/i)
|
||||
const maxIterationsRaw = maxIterationMatch?.[1]
|
||||
const completionPromiseQuoted = rawArguments.match(/--completion-promise=("|')(.+?)\1/i)
|
||||
const completionPromiseUnquoted = rawArguments.match(/--completion-promise=([^\s\"']+)/i)
|
||||
const completionPromise = completionPromiseQuoted?.[2] ?? completionPromiseUnquoted?.[1]
|
||||
const strategyMatch = rawArguments.match(/--strategy=(reset|continue)/i)
|
||||
const strategyValue = strategyMatch?.[1]?.toLowerCase()
|
||||
|
||||
return {
|
||||
prompt,
|
||||
maxIterations: maxIterationsRaw ? Number.parseInt(maxIterationsRaw, 10) : undefined,
|
||||
completionPromise,
|
||||
strategy:
|
||||
strategyValue === "reset" || strategyValue === "continue"
|
||||
? (strategyValue as RalphLoopStrategy)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
56
src/ralph-loop/commands.ts
Normal file
56
src/ralph-loop/commands.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export const RALPH_LOOP_TEMPLATE = `You are starting a Ralph Loop - a self-referential development loop that runs until task completion.
|
||||
|
||||
## How Ralph Loop Works
|
||||
|
||||
1. You will work on the task continuously
|
||||
2. When you believe the task is FULLY complete, output: \`<promise>{{COMPLETION_PROMISE}}</promise>\`
|
||||
3. If you don't output the promise, the loop will automatically inject another prompt to continue
|
||||
4. Maximum iterations: Configurable (default 100)
|
||||
|
||||
## Rules
|
||||
|
||||
- Focus on completing the task fully, not partially
|
||||
- Don't output the completion promise until the task is truly done
|
||||
- Each iteration should make meaningful progress toward the goal
|
||||
- If stuck, try different approaches
|
||||
- Use todos to track your progress
|
||||
|
||||
## Exit Conditions
|
||||
|
||||
1. **Completion**: Output your completion promise tag when fully complete
|
||||
2. **Max Iterations**: Loop stops automatically at limit
|
||||
3. **Cancel**: User runs \`/cancel-ralph\` command
|
||||
|
||||
## Your Task
|
||||
|
||||
Parse the arguments below and begin working on the task. The format is:
|
||||
\`"task description" [--completion-promise=TEXT] [--max-iterations=N] [--strategy=reset|continue]\`
|
||||
|
||||
Default completion promise is "DONE" and default max iterations is 100.`
|
||||
|
||||
export const CANCEL_RALPH_TEMPLATE = `Cancel the currently active Ralph Loop.
|
||||
|
||||
This will:
|
||||
1. Stop the loop from continuing
|
||||
2. Clear the loop state file
|
||||
3. Allow the session to end normally
|
||||
|
||||
Check if a loop is active and cancel it. Inform the user of the result.`
|
||||
|
||||
export const RALPH_LOOP_COMMANDS: Record<string, unknown> = {
|
||||
"ralph-loop": {
|
||||
name: "ralph-loop",
|
||||
description: "(builtin) Start self-referential development loop until completion",
|
||||
template: `<command-instruction>\n${RALPH_LOOP_TEMPLATE}\n</command-instruction>\n\n<user-task>\n$ARGUMENTS\n</user-task>`,
|
||||
},
|
||||
"ulw-loop": {
|
||||
name: "ulw-loop",
|
||||
description: "(builtin) Start ultrawork loop - continues until completion with ultrawork mode",
|
||||
template: `<command-instruction>\n${RALPH_LOOP_TEMPLATE}\n</command-instruction>\n\n<user-task>\n$ARGUMENTS\n</user-task>`,
|
||||
},
|
||||
"cancel-ralph": {
|
||||
name: "cancel-ralph",
|
||||
description: "(builtin) Cancel active Ralph Loop",
|
||||
template: `<command-instruction>\n${CANCEL_RALPH_TEMPLATE}\n</command-instruction>`,
|
||||
},
|
||||
}
|
||||
105
src/ralph-loop/completion-promise-detector.ts
Normal file
105
src/ralph-loop/completion-promise-detector.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
|
||||
import { log } from "./logger"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { withTimeout } from "./with-timeout"
|
||||
|
||||
interface OpenCodeSessionMessage {
|
||||
info?: { role?: string }
|
||||
parts?: Array<{ type: string; text?: string }>
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
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" && pattern.test(line)) return true
|
||||
} catch {
|
||||
// ignore malformed lines
|
||||
}
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function detectCompletionInSessionMessages(
|
||||
ctx: PluginInput,
|
||||
options: { sessionID: string; promise: string; apiTimeoutMs: number; directory: string },
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const sessionApi = ctx.client.session as unknown as {
|
||||
messages?: (args: {
|
||||
path: { id: string }
|
||||
query?: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
if (typeof sessionApi.messages !== "function") return false
|
||||
|
||||
const response = await withTimeout(
|
||||
sessionApi.messages({
|
||||
path: { id: options.sessionID },
|
||||
query: { directory: options.directory },
|
||||
}),
|
||||
options.apiTimeoutMs,
|
||||
)
|
||||
|
||||
const messageArray: unknown[] = Array.isArray(response)
|
||||
? response
|
||||
: isRecord(response) && Array.isArray(response.data)
|
||||
? (response.data as unknown[])
|
||||
: isRecord(response) && Array.isArray(response["200"])
|
||||
? (response["200"] as unknown[])
|
||||
: []
|
||||
|
||||
const assistantMessages = (messageArray as OpenCodeSessionMessage[]).filter(
|
||||
(msg) => msg.info?.role === "assistant",
|
||||
)
|
||||
if (assistantMessages.length === 0) return false
|
||||
|
||||
const pattern = buildPromisePattern(options.promise)
|
||||
const recentAssistants = assistantMessages.slice(-3)
|
||||
for (const assistant of recentAssistants) {
|
||||
if (!assistant.parts) continue
|
||||
|
||||
let responseText = ""
|
||||
for (const part of assistant.parts) {
|
||||
if (part.type !== "text") continue
|
||||
responseText += `${responseText ? "\n" : ""}${part.text ?? ""}`
|
||||
}
|
||||
|
||||
if (pattern.test(responseText)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} catch (err) {
|
||||
setTimeout(() => {
|
||||
log(`[${HOOK_NAME}] Session messages check failed`, {
|
||||
sessionID: options.sessionID,
|
||||
error: String(err),
|
||||
})
|
||||
}, 0)
|
||||
return false
|
||||
}
|
||||
}
|
||||
106
src/ralph-loop/config.ts
Normal file
106
src/ralph-loop/config.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
export type RalphLoopStrategy = "reset" | "continue"
|
||||
|
||||
export type RalphLoopConfig = {
|
||||
enabled?: boolean
|
||||
default_max_iterations?: number
|
||||
state_dir?: string
|
||||
default_strategy?: RalphLoopStrategy
|
||||
}
|
||||
|
||||
function resolveOpenCodeConfigDir(): string {
|
||||
const xdg = process.env.XDG_CONFIG_HOME
|
||||
if (xdg && xdg.length > 0) {
|
||||
return join(xdg, "opencode")
|
||||
}
|
||||
return join(homedir(), ".config", "opencode")
|
||||
}
|
||||
|
||||
function stripJsonComments(input: string): string {
|
||||
// Minimal JSONC stripper that preserves strings.
|
||||
let out = ""
|
||||
let i = 0
|
||||
let inString: '"' | "'" | null = null
|
||||
while (i < input.length) {
|
||||
const ch = input[i]
|
||||
const next = i + 1 < input.length ? input[i + 1] : ""
|
||||
|
||||
if (inString) {
|
||||
out += ch
|
||||
if (ch === "\\") {
|
||||
// Skip escaped char.
|
||||
if (i + 1 < input.length) {
|
||||
out += input[i + 1]
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (ch === inString) {
|
||||
inString = null
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === '"' || ch === "'") {
|
||||
inString = ch
|
||||
out += ch
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
if (ch === "/" && next === "/") {
|
||||
// Line comment.
|
||||
i += 2
|
||||
while (i < input.length && input[i] !== "\n") i++
|
||||
continue
|
||||
}
|
||||
if (ch === "/" && next === "*") {
|
||||
// Block comment.
|
||||
i += 2
|
||||
while (i < input.length) {
|
||||
if (input[i] === "*" && i + 1 < input.length && input[i + 1] === "/") {
|
||||
i += 2
|
||||
break
|
||||
}
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
out += ch
|
||||
i++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function tryReadConfig(path: string): RalphLoopConfig | null {
|
||||
if (!existsSync(path)) return null
|
||||
try {
|
||||
const raw = readFileSync(path, "utf-8")
|
||||
const parsed = JSON.parse(stripJsonComments(raw)) as unknown
|
||||
if (!parsed || typeof parsed !== "object") return null
|
||||
return parsed as RalphLoopConfig
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function loadRalphLoopConfig(projectDir: string): RalphLoopConfig {
|
||||
const userDir = resolveOpenCodeConfigDir()
|
||||
const user =
|
||||
tryReadConfig(join(userDir, "ralph-loop.jsonc")) ??
|
||||
tryReadConfig(join(userDir, "ralph-loop.json"))
|
||||
|
||||
const project =
|
||||
tryReadConfig(join(projectDir, ".opencode", "ralph-loop.jsonc")) ??
|
||||
tryReadConfig(join(projectDir, ".opencode", "ralph-loop.json"))
|
||||
|
||||
return {
|
||||
...(user ?? {}),
|
||||
...(project ?? {}),
|
||||
}
|
||||
}
|
||||
4
src/ralph-loop/constants.ts
Normal file
4
src/ralph-loop/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const HOOK_NAME = "ralph-loop"
|
||||
export const DEFAULT_STATE_FILE = ".sisyphus/ralph-loop.local.md"
|
||||
export const DEFAULT_MAX_ITERATIONS = 100
|
||||
export const DEFAULT_COMPLETION_PROMISE = "DONE"
|
||||
24
src/ralph-loop/continuation-prompt-builder.ts
Normal file
24
src/ralph-loop/continuation-prompt-builder.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { RalphLoopState } from "./types"
|
||||
import { SYSTEM_DIRECTIVE_PREFIX } from "./system-directive"
|
||||
|
||||
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
|
||||
}
|
||||
129
src/ralph-loop/continuation-prompt-injector.ts
Normal file
129
src/ralph-loop/continuation-prompt-injector.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { log } from "./logger"
|
||||
import { createInternalAgentTextPart } from "./internal-initiator-marker"
|
||||
import { normalizeSDKResponse } from "./normalize-sdk-response"
|
||||
import { normalizePromptTools, type PromptToolPermission } from "./prompt-tools"
|
||||
import { withTimeout } from "./with-timeout"
|
||||
|
||||
type MessageInfo = {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
modelID?: string
|
||||
providerID?: string
|
||||
tools?: Record<string, PromptToolPermission>
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
type PromptArgs = {
|
||||
path: { id: string }
|
||||
body: {
|
||||
parts: Array<{ type: "text"; text: string }>
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
query?: { directory: string }
|
||||
}
|
||||
|
||||
type PromptApi = (args: PromptArgs) => Promise<unknown>
|
||||
|
||||
function getPromptApi(
|
||||
client: PluginInput["client"],
|
||||
): PromptApi | null {
|
||||
const clientRecord = client as unknown
|
||||
if (!isRecord(clientRecord)) return null
|
||||
const sessionValue = clientRecord.session
|
||||
if (!isRecord(sessionValue)) return null
|
||||
|
||||
const promptAsyncValue = sessionValue.promptAsync
|
||||
if (typeof promptAsyncValue === "function") {
|
||||
return (args) => Reflect.apply(promptAsyncValue, sessionValue, [args]) as Promise<unknown>
|
||||
}
|
||||
|
||||
const promptValue = sessionValue.prompt
|
||||
if (typeof promptValue === "function") {
|
||||
return (args) => Reflect.apply(promptValue, sessionValue, [args]) as Promise<unknown>
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export async function injectContinuationPrompt(
|
||||
ctx: PluginInput,
|
||||
options: {
|
||||
sessionID: string
|
||||
prompt: string
|
||||
directory: string
|
||||
apiTimeoutMs: number
|
||||
inheritFromSessionID?: string
|
||||
},
|
||||
): Promise<void> {
|
||||
let agent: string | undefined
|
||||
let model: { providerID: string; modelID: string } | undefined
|
||||
let tools: Record<string, PromptToolPermission> | undefined
|
||||
const sourceSessionID = options.inheritFromSessionID ?? options.sessionID
|
||||
|
||||
try {
|
||||
const sessionApi = ctx.client.session as unknown as {
|
||||
messages?: (args: {
|
||||
path: { id: string }
|
||||
query?: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
if (typeof sessionApi.messages !== "function") {
|
||||
throw new Error("OpenCode client missing session.messages")
|
||||
}
|
||||
|
||||
const messagesResp = await withTimeout(
|
||||
sessionApi.messages({
|
||||
path: { id: sourceSessionID },
|
||||
query: { directory: options.directory },
|
||||
}),
|
||||
options.apiTimeoutMs,
|
||||
)
|
||||
|
||||
const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: MessageInfo }>, {
|
||||
preferResponseOnMissingData: true,
|
||||
})
|
||||
|
||||
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)
|
||||
tools = info.tools
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best effort. If we cannot inherit message context, still inject prompt.
|
||||
}
|
||||
|
||||
const inheritedTools = normalizePromptTools(tools)
|
||||
|
||||
const promptApi = getPromptApi(ctx.client)
|
||||
if (!promptApi) {
|
||||
throw new Error("OpenCode client missing session.prompt/promptAsync")
|
||||
}
|
||||
|
||||
await promptApi({
|
||||
path: { id: options.sessionID },
|
||||
body: {
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
...(inheritedTools ? { tools: inheritedTools } : {}),
|
||||
parts: [createInternalAgentTextPart(options.prompt)],
|
||||
},
|
||||
query: { directory: options.directory },
|
||||
})
|
||||
|
||||
log("[ralph-loop] continuation injected", { sessionID: options.sessionID })
|
||||
}
|
||||
8
src/ralph-loop/internal-initiator-marker.ts
Normal file
8
src/ralph-loop/internal-initiator-marker.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export const OMO_INTERNAL_INITIATOR_MARKER = "<!-- OMO_INTERNAL_INITIATOR -->"
|
||||
|
||||
export function createInternalAgentTextPart(text: string): { type: "text"; text: string } {
|
||||
return {
|
||||
type: "text",
|
||||
text: `${text}\n${OMO_INTERNAL_INITIATOR_MARKER}`,
|
||||
}
|
||||
}
|
||||
60
src/ralph-loop/iteration-continuation.ts
Normal file
60
src/ralph-loop/iteration-continuation.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import type { RalphLoopState } from "./types"
|
||||
import { log } from "./logger"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import { buildContinuationPrompt } from "./continuation-prompt-builder"
|
||||
import { injectContinuationPrompt } from "./continuation-prompt-injector"
|
||||
import { createIterationSession, selectSessionInTui } from "./session-reset-strategy"
|
||||
|
||||
type ContinuationOptions = {
|
||||
directory: string
|
||||
apiTimeoutMs: number
|
||||
previousSessionID: string
|
||||
loopState: { setSessionID: (sessionID: string) => RalphLoopState | null }
|
||||
}
|
||||
|
||||
export async function continueIteration(
|
||||
ctx: PluginInput,
|
||||
state: RalphLoopState,
|
||||
options: ContinuationOptions,
|
||||
): Promise<void> {
|
||||
const strategy = state.strategy ?? "continue"
|
||||
const continuationPrompt = buildContinuationPrompt(state)
|
||||
|
||||
if (strategy === "reset") {
|
||||
const newSessionID = await createIterationSession(
|
||||
ctx,
|
||||
options.previousSessionID,
|
||||
options.directory,
|
||||
)
|
||||
if (!newSessionID) return
|
||||
|
||||
await injectContinuationPrompt(ctx, {
|
||||
sessionID: newSessionID,
|
||||
inheritFromSessionID: options.previousSessionID,
|
||||
prompt: continuationPrompt,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
})
|
||||
|
||||
await selectSessionInTui(ctx.client, newSessionID)
|
||||
|
||||
const boundState = options.loopState.setSessionID(newSessionID)
|
||||
if (!boundState) {
|
||||
log(`[${HOOK_NAME}] Failed to bind loop state to new session`, {
|
||||
previousSessionID: options.previousSessionID,
|
||||
newSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await injectContinuationPrompt(ctx, {
|
||||
sessionID: options.previousSessionID,
|
||||
prompt: continuationPrompt,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
})
|
||||
}
|
||||
35
src/ralph-loop/logger.ts
Normal file
35
src/ralph-loop/logger.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import * as fs from "node:fs"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
|
||||
const logFile = path.join(os.tmpdir(), "opencode-plugin-ralph-loop.log")
|
||||
|
||||
function shouldLogToConsole(): boolean {
|
||||
const raw = process.env.RALPH_LOOP_DEBUG
|
||||
if (!raw) return false
|
||||
return raw === "1" || raw.toLowerCase() === "true" || raw.toLowerCase() === "yes"
|
||||
}
|
||||
|
||||
export function log(message: string, meta?: unknown): void {
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
const entry = `[${timestamp}] ${message}${meta !== undefined ? ` ${JSON.stringify(meta)}` : ""}\n`
|
||||
fs.appendFileSync(logFile, entry)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (shouldLogToConsole()) {
|
||||
try {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(message, meta)
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getLogFilePath(): string {
|
||||
return logFile
|
||||
}
|
||||
33
src/ralph-loop/loop-session-recovery.ts
Normal file
33
src/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)
|
||||
},
|
||||
}
|
||||
}
|
||||
90
src/ralph-loop/loop-state-controller.ts
Normal file
90
src/ralph-loop/loop-state-controller.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
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 "./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
|
||||
strategy?: "reset" | "continue"
|
||||
},
|
||||
): 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,
|
||||
strategy: loopOptions?.strategy ?? config?.default_strategy ?? "continue",
|
||||
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)
|
||||
},
|
||||
|
||||
setSessionID(sessionID: string): RalphLoopState | null {
|
||||
const state = readState(directory, stateDir)
|
||||
if (!state) return null
|
||||
state.session_id = sessionID
|
||||
if (!writeState(directory, state, stateDir)) return null
|
||||
return state
|
||||
},
|
||||
}
|
||||
}
|
||||
22
src/ralph-loop/normalize-sdk-response.ts
Normal file
22
src/ralph-loop/normalize-sdk-response.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export function normalizeSDKResponse<T>(
|
||||
response: unknown,
|
||||
fallback: T,
|
||||
options?: { preferResponseOnMissingData?: boolean },
|
||||
): T {
|
||||
const prefer = options?.preferResponseOnMissingData ?? false
|
||||
if (response && typeof response === "object") {
|
||||
const rec = response as Record<string, unknown>
|
||||
if ("data" in rec) {
|
||||
const d = rec.data
|
||||
if (d !== undefined) return d as T
|
||||
}
|
||||
if ("200" in rec) {
|
||||
const d = rec["200"]
|
||||
if (d !== undefined) return d as T
|
||||
}
|
||||
}
|
||||
if (prefer && response !== undefined) {
|
||||
return response as T
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
18
src/ralph-loop/prompt-tools.ts
Normal file
18
src/ralph-loop/prompt-tools.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type PromptToolPermission = boolean | "allow" | "deny" | "ask"
|
||||
|
||||
export function normalizePromptTools(
|
||||
tools: Record<string, PromptToolPermission> | undefined,
|
||||
): Record<string, boolean> | undefined {
|
||||
if (!tools) return undefined
|
||||
const normalized: Record<string, boolean> = {}
|
||||
for (const [toolName, permission] of Object.entries(tools)) {
|
||||
if (permission === false || permission === "deny") {
|
||||
normalized[toolName] = false
|
||||
continue
|
||||
}
|
||||
if (permission === true || permission === "allow" || permission === "ask") {
|
||||
normalized[toolName] = true
|
||||
}
|
||||
}
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined
|
||||
}
|
||||
221
src/ralph-loop/ralph-loop-event-handler.ts
Normal file
221
src/ralph-loop/ralph-loop-event-handler.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { log } from "./logger"
|
||||
import type { RalphLoopOptions, RalphLoopState } from "./types"
|
||||
import { HOOK_NAME } from "./constants"
|
||||
import {
|
||||
detectCompletionInSessionMessages,
|
||||
detectCompletionInTranscript,
|
||||
} from "./completion-promise-detector"
|
||||
import { continueIteration } from "./iteration-continuation"
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
type ToastBody = {
|
||||
title: string
|
||||
message: string
|
||||
variant: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
type ShowToastApi = (args: { body: ToastBody }) => Promise<unknown>
|
||||
|
||||
function getShowToastApi(client: unknown): ShowToastApi | null {
|
||||
if (!isRecord(client)) return null
|
||||
const tui = client.tui
|
||||
if (!isRecord(tui)) return null
|
||||
const showToast = tui.showToast
|
||||
if (typeof showToast !== "function") return null
|
||||
return (args) => Reflect.apply(showToast, tui, [args]) as Promise<unknown>
|
||||
}
|
||||
|
||||
type SessionRecovery = {
|
||||
isRecovering: (sessionID: string) => boolean
|
||||
markRecovering: (sessionID: string) => void
|
||||
clear: (sessionID: string) => void
|
||||
}
|
||||
|
||||
type LoopStateController = {
|
||||
getState: () => RalphLoopState | null
|
||||
clear: () => boolean
|
||||
incrementIteration: () => RalphLoopState | null
|
||||
setSessionID: (sessionID: string) => 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
|
||||
const showToast = getShowToastApi(ctx.client)
|
||||
|
||||
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 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 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 showToast
|
||||
?.({
|
||||
body: {
|
||||
title: "Ralph Loop",
|
||||
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
|
||||
variant: "info",
|
||||
duration: 2000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
try {
|
||||
await continueIteration(ctx, newState, {
|
||||
previousSessionID: sessionID,
|
||||
directory: options.directory,
|
||||
apiTimeoutMs: options.apiTimeoutMs,
|
||||
loopState: options.loopState,
|
||||
})
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
56
src/ralph-loop/ralph-loop-hook.ts
Normal file
56
src/ralph-loop/ralph-loop-hook.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import type { RalphLoopOptions, RalphLoopState } from "./types"
|
||||
import { getTranscriptPath as getDefaultTranscriptPath } from "./transcript"
|
||||
import { createLoopSessionRecovery } from "./loop-session-recovery"
|
||||
import { createLoopStateController } from "./loop-state-controller"
|
||||
import { createRalphLoopEventHandler } from "./ralph-loop-event-handler"
|
||||
|
||||
export interface RalphLoopHook {
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
startLoop: (
|
||||
sessionID: string,
|
||||
prompt: string,
|
||||
options?: {
|
||||
maxIterations?: number
|
||||
completionPromise?: string
|
||||
ultrawork?: boolean
|
||||
strategy?: "reset" | "continue"
|
||||
},
|
||||
) => boolean
|
||||
cancelLoop: (sessionID: string) => boolean
|
||||
getState: () => RalphLoopState | null
|
||||
}
|
||||
|
||||
const DEFAULT_API_TIMEOUT = 5000 as const
|
||||
|
||||
export function createRalphLoopHook(ctx: PluginInput, options?: RalphLoopOptions): RalphLoopHook {
|
||||
const config = options?.config
|
||||
const stateDir = config?.state_dir
|
||||
const getTranscriptPath = options?.getTranscriptPath ?? ((id: string) => getDefaultTranscriptPath(id))
|
||||
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
|
||||
const checkSessionExists = options?.checkSessionExists
|
||||
|
||||
const loopState = createLoopStateController({
|
||||
directory: ctx.directory,
|
||||
stateDir,
|
||||
config,
|
||||
})
|
||||
const sessionRecovery = createLoopSessionRecovery()
|
||||
|
||||
const event = createRalphLoopEventHandler(ctx, {
|
||||
directory: ctx.directory,
|
||||
apiTimeoutMs: apiTimeout,
|
||||
getTranscriptPath,
|
||||
checkSessionExists,
|
||||
sessionRecovery,
|
||||
loopState,
|
||||
})
|
||||
|
||||
return {
|
||||
event,
|
||||
startLoop: loopState.startLoop,
|
||||
cancelLoop: loopState.cancelLoop,
|
||||
getState: loopState.getState as () => RalphLoopState | null,
|
||||
}
|
||||
}
|
||||
69
src/ralph-loop/session-reset-strategy.ts
Normal file
69
src/ralph-loop/session-reset-strategy.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "./logger"
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
export async function createIterationSession(
|
||||
ctx: PluginInput,
|
||||
parentSessionID: string,
|
||||
directory: string,
|
||||
): Promise<string | null> {
|
||||
const sessionApi = ctx.client.session as unknown as {
|
||||
create?: (args: {
|
||||
body: { parentID: string; title?: string }
|
||||
query?: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
if (typeof sessionApi.create !== "function") return null
|
||||
|
||||
const createResult = await sessionApi.create({
|
||||
body: { parentID: parentSessionID, title: "Ralph Loop Iteration" },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
const data = isRecord(createResult) ? (createResult.data as unknown) : undefined
|
||||
const id = isRecord(data) ? (data.id as unknown) : undefined
|
||||
const error = isRecord(createResult) ? createResult.error : undefined
|
||||
|
||||
if (error !== undefined || typeof id !== "string" || id.length === 0) {
|
||||
log("[ralph-loop] Failed to create iteration session", {
|
||||
parentSessionID,
|
||||
error: String(error ?? "No session ID returned"),
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
export async function selectSessionInTui(
|
||||
client: PluginInput["client"],
|
||||
sessionID: string,
|
||||
): Promise<boolean> {
|
||||
const selectSession = getSelectSessionApi(client)
|
||||
if (!selectSession) return false
|
||||
|
||||
try {
|
||||
await selectSession({ body: { sessionID } })
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
log("[ralph-loop] Failed to select session in TUI", {
|
||||
sessionID,
|
||||
error: String(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type SelectSessionApi = (args: { body: { sessionID: string } }) => Promise<unknown>
|
||||
|
||||
function getSelectSessionApi(client: unknown): SelectSessionApi | null {
|
||||
if (!isRecord(client)) return null
|
||||
const tuiValue = client.tui
|
||||
if (!isRecord(tuiValue)) return null
|
||||
const selectSessionValue = tuiValue.selectSession
|
||||
if (typeof selectSessionValue !== "function") return null
|
||||
return (args) => Reflect.apply(selectSessionValue, tuiValue, [args]) as Promise<unknown>
|
||||
}
|
||||
36
src/ralph-loop/simple-frontmatter.ts
Normal file
36
src/ralph-loop/simple-frontmatter.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export function parseFrontmatter(content: string): {
|
||||
data: Record<string, unknown>
|
||||
body: string
|
||||
} {
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n?---\r?\n([\s\S]*)$/
|
||||
const match = content.match(frontmatterRegex)
|
||||
if (!match) {
|
||||
return { data: {}, body: content }
|
||||
}
|
||||
|
||||
const yaml = match[1] ?? ""
|
||||
const body = match[2] ?? ""
|
||||
const data: Record<string, unknown> = {}
|
||||
|
||||
for (const rawLine of yaml.split(/\r?\n/)) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) continue
|
||||
const m = line.match(/^([A-Za-z0-9_]+):\s*(.*)$/)
|
||||
if (!m) continue
|
||||
const key = m[1]
|
||||
if (!key) continue
|
||||
let value: unknown = m[2] ?? ""
|
||||
|
||||
if (typeof value === "string") {
|
||||
const v = value.trim()
|
||||
if (v === "true") value = true
|
||||
else if (v === "false") value = false
|
||||
else if (/^-?\d+(\.\d+)?$/.test(v)) value = Number(v)
|
||||
else value = v
|
||||
}
|
||||
|
||||
data[key] = value
|
||||
}
|
||||
|
||||
return { data, body }
|
||||
}
|
||||
96
src/ralph-loop/storage.ts
Normal file
96
src/ralph-loop/storage.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
|
||||
import type { RalphLoopState } from "./types"
|
||||
import {
|
||||
DEFAULT_COMPLETION_PROMISE,
|
||||
DEFAULT_MAX_ITERATIONS,
|
||||
DEFAULT_STATE_FILE,
|
||||
} from "./constants"
|
||||
import { parseFrontmatter } from "./simple-frontmatter"
|
||||
|
||||
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(content)
|
||||
|
||||
const active = (data.active as unknown)
|
||||
const iteration = (data.iteration as unknown)
|
||||
|
||||
if (active === undefined || iteration === undefined) return null
|
||||
|
||||
const isActive = active === true || active === "true"
|
||||
const iterationNum = typeof iteration === "number" ? iteration : Number(iteration)
|
||||
if (Number.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,
|
||||
ultrawork:
|
||||
data.ultrawork === true || data.ultrawork === "true" ? true : undefined,
|
||||
strategy:
|
||||
data.strategy === "reset" || data.strategy === "continue"
|
||||
? (data.strategy as "reset" | "continue")
|
||||
: 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 ultraworkLine = state.ultrawork !== undefined ? `ultrawork: ${state.ultrawork}\n` : ""
|
||||
const strategyLine = state.strategy ? `strategy: "${state.strategy}"\n` : ""
|
||||
const content = `---\nactive: ${state.active}\niteration: ${state.iteration}\nmax_iterations: ${state.max_iterations}\ncompletion_promise: "${state.completion_promise}"\nstarted_at: "${state.started_at}"\n${sessionIdLine}${ultraworkLine}${strategyLine}---\n${state.prompt}\n`
|
||||
|
||||
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
|
||||
return writeState(directory, state, customPath) ? state : null
|
||||
}
|
||||
1
src/ralph-loop/system-directive.ts
Normal file
1
src/ralph-loop/system-directive.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const SYSTEM_DIRECTIVE_PREFIX = "[SYSTEM DIRECTIVE: OH-MY-OPENCODE"
|
||||
8
src/ralph-loop/transcript.ts
Normal file
8
src/ralph-loop/transcript.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { join } from "node:path"
|
||||
import { getClaudeConfigDir } from "./claude-config-dir"
|
||||
|
||||
const TRANSCRIPT_DIR = join(getClaudeConfigDir(), "transcripts")
|
||||
|
||||
export function getTranscriptPath(sessionId: string): string {
|
||||
return join(TRANSCRIPT_DIR, `${sessionId}.jsonl`)
|
||||
}
|
||||
20
src/ralph-loop/types.ts
Normal file
20
src/ralph-loop/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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
|
||||
ultrawork?: boolean
|
||||
strategy?: "reset" | "continue"
|
||||
}
|
||||
|
||||
export interface RalphLoopOptions {
|
||||
config?: RalphLoopConfig
|
||||
getTranscriptPath?: (sessionId: string) => string | undefined
|
||||
apiTimeout?: number
|
||||
checkSessionExists?: (sessionId: string) => Promise<boolean>
|
||||
}
|
||||
17
src/ralph-loop/with-timeout.ts
Normal file
17
src/ralph-loop/with-timeout.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export async function withTimeout<TData>(promise: Promise<TData>, timeoutMs: number): Promise<TData> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error("API timeout"))
|
||||
}, timeoutMs)
|
||||
})
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeoutPromise])
|
||||
} finally {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user