From 07e1c0dd5ea5e169e625c9fa0bd8068361773406 Mon Sep 17 00:00:00 2001 From: Dmytro Stanchiev Date: Sun, 19 Apr 2026 17:50:34 -0400 Subject: [PATCH] 1 Signed-off-by: Dmytro Stanchiev --- .gitignore | 3 + LICENSE.md | 80 +++++++ README.md | 10 + bun.lock | 31 +++ package.json | 39 ++++ src/index.ts | 134 +++++++++++ src/ralph-loop/claude-config-dir.ts | 8 + src/ralph-loop/command-arguments.ts | 38 +++ src/ralph-loop/commands.ts | 56 +++++ src/ralph-loop/completion-promise-detector.ts | 105 +++++++++ src/ralph-loop/config.ts | 106 +++++++++ src/ralph-loop/constants.ts | 4 + src/ralph-loop/continuation-prompt-builder.ts | 24 ++ .../continuation-prompt-injector.ts | 129 ++++++++++ src/ralph-loop/internal-initiator-marker.ts | 8 + src/ralph-loop/iteration-continuation.ts | 60 +++++ src/ralph-loop/logger.ts | 35 +++ src/ralph-loop/loop-session-recovery.ts | 33 +++ src/ralph-loop/loop-state-controller.ts | 90 +++++++ src/ralph-loop/normalize-sdk-response.ts | 22 ++ src/ralph-loop/prompt-tools.ts | 18 ++ src/ralph-loop/ralph-loop-event-handler.ts | 221 ++++++++++++++++++ src/ralph-loop/ralph-loop-hook.ts | 56 +++++ src/ralph-loop/session-reset-strategy.ts | 69 ++++++ src/ralph-loop/simple-frontmatter.ts | 36 +++ src/ralph-loop/storage.ts | 96 ++++++++ src/ralph-loop/system-directive.ts | 1 + src/ralph-loop/transcript.ts | 8 + src/ralph-loop/types.ts | 20 ++ src/ralph-loop/with-timeout.ts | 17 ++ tsconfig.json | 16 ++ 31 files changed, 1573 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 bun.lock create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/ralph-loop/claude-config-dir.ts create mode 100644 src/ralph-loop/command-arguments.ts create mode 100644 src/ralph-loop/commands.ts create mode 100644 src/ralph-loop/completion-promise-detector.ts create mode 100644 src/ralph-loop/config.ts create mode 100644 src/ralph-loop/constants.ts create mode 100644 src/ralph-loop/continuation-prompt-builder.ts create mode 100644 src/ralph-loop/continuation-prompt-injector.ts create mode 100644 src/ralph-loop/internal-initiator-marker.ts create mode 100644 src/ralph-loop/iteration-continuation.ts create mode 100644 src/ralph-loop/logger.ts create mode 100644 src/ralph-loop/loop-session-recovery.ts create mode 100644 src/ralph-loop/loop-state-controller.ts create mode 100644 src/ralph-loop/normalize-sdk-response.ts create mode 100644 src/ralph-loop/prompt-tools.ts create mode 100644 src/ralph-loop/ralph-loop-event-handler.ts create mode 100644 src/ralph-loop/ralph-loop-hook.ts create mode 100644 src/ralph-loop/session-reset-strategy.ts create mode 100644 src/ralph-loop/simple-frontmatter.ts create mode 100644 src/ralph-loop/storage.ts create mode 100644 src/ralph-loop/system-directive.ts create mode 100644 src/ralph-loop/transcript.ts create mode 100644 src/ralph-loop/types.ts create mode 100644 src/ralph-loop/with-timeout.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ba34fb6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +.DS_Store diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..c6053e8 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,80 @@ +# License + +Portions of this software are licensed as follows: + +- All third party components incorporated into the Software are licensed under the original license + provided by the owner of the applicable component. +- Content outside of the above mentioned files or restrictions is available under the "Sustainable Use + License" as defined below. + +## Sustainable Use License + +Version 1.0 + +### Acceptance + +By using the software, you agree to all of the terms and conditions below. + +### Copyright License + +The licensor grants you a non-exclusive, royalty-free, worldwide, non-sublicensable, non-transferable license +to use, copy, distribute, make available, and prepare derivative works of the software, in each case subject +to the limitations below. + +### Limitations + +You may use or modify the software only for your own internal business purposes or for non-commercial or +personal use. You may distribute the software or provide it to others only if you do so free of charge for +non-commercial purposes. You may not alter, remove, or obscure any licensing, copyright, or other notices of +the licensor in the software. Any use of the licensor's trademarks is subject to applicable law. + +### Patents + +The licensor grants you a license, under any patent claims the licensor can license, or becomes able to +license, to make, have made, use, sell, offer for sale, import and have imported the software, in each case +subject to the limitations and conditions in this license. This license does not cover any patent claims that +you cause to be infringed by modifications or additions to the software. If you or your company make any +written claim that the software infringes or contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If your company makes such a claim, your patent +license ends immediately for work on behalf of your company. + +### Notices + +You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these +terms. If you modify the software, you must include in any modified copies of the software a prominent notice +stating that you have modified the software. + +### No Other Rights + +These terms do not imply any licenses other than those expressly granted in these terms. + +### Termination + +If you use the software in violation of these terms, such use is not licensed, and your license will +automatically terminate. If the licensor provides you with a notice of your violation, and you cease all +violation of this license no later than 30 days after you receive that notice, your license will be reinstated +retroactively. However, if you violate these terms after such reinstatement, any additional violation of these +terms will cause your license to terminate automatically and permanently. + +### No Liability + +As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will +not be liable to you for any damages arising out of these terms or the use or nature of the software, under +any kind of legal claim. + +### Definitions + +The "licensor" is the entity offering these terms. + +The "software" is the software the licensor makes available under these terms, including any portion of it. + +"You" refers to the individual or entity agreeing to these terms. + +"Your company" is any legal entity, sole proprietorship, or other kind of organization that you work for, plus +all organizations that have control over, are under the control of, or are under common control with that +organization. Control means ownership of substantially all the assets of an entity, or the power to direct its +management and policies by vote, contract, or otherwise. Control can be direct or indirect. + +"Your license" is the license granted to you for the software under these terms. + +"Use" means anything you do with the software requiring your license. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bfc427 --- /dev/null +++ b/README.md @@ -0,0 +1,10 @@ +# opencode-plugin-ralph-loop + +Standalone implementation of the **Ralph Loop** (self-referential dev loop) extracted from `oh-my-opencode`. + +Provides: +- `/ralph-loop` command +- `/ulw-loop` command (ultrawork mode) +- `/cancel-ralph` command + +State is persisted to `.sisyphus/ralph-loop.local.md` in the current project directory. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..04b3668 --- /dev/null +++ b/bun.lock @@ -0,0 +1,31 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "opencode-plugin-ralph-loop", + "dependencies": { + "@opencode-ai/plugin": "^1.1.19", + }, + "devDependencies": { + "bun-types": "1.3.6", + "typescript": "^5.7.3", + }, + }, + }, + "packages": { + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.2.15", "", { "dependencies": { "@opencode-ai/sdk": "1.2.15", "zod": "4.1.8" } }, "sha512-mh9S05W+CZZmo6q3uIEBubS66QVgiev7fRafX7vemrCfz+3pEIkSwipLjU/sxIewC9yLiDWLqS73DH/iEQzVDw=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.2.15", "", {}, "sha512-NUJNlyBCdZ4R0EBLjJziEQOp2XbRPJosaMcTcWSWO5XJPKGUpz0u8ql+5cR8K+v2RJ+hp2NobtNwpjEYfe6BRQ=="], + + "@types/node": ["@types/node@25.3.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw=="], + + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..6613575 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "opencode-plugin-ralph-loop", + "version": "0.1.0", + "description": "Standalone Ralph Loop plugin for OpenCode (extracted from oh-my-opencode)", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "files": [ + "dist", + "README.md", + "LICENSE.md" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "bun build src/index.ts --outdir dist --target bun --format esm && tsc --emitDeclarationOnly", + "clean": "rm -rf dist", + "prepublishOnly": "bun run clean && bun run build", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "opencode", + "plugin", + "ralph-loop", + "continuation" + ], + "license": "SUL-1.0", + "dependencies": { + "@opencode-ai/plugin": "^1.1.19" + }, + "devDependencies": { + "bun-types": "1.3.6", + "typescript": "^5.7.3" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f56b117 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,134 @@ +import type { Plugin } from "@opencode-ai/plugin" + +import { loadRalphLoopConfig } from "./ralph-loop/config" +import { RALPH_LOOP_COMMANDS } from "./ralph-loop/commands" +import { parseRalphLoopArguments } from "./ralph-loop/command-arguments" +import { createRalphLoopHook } from "./ralph-loop/ralph-loop-hook" + +type ToolExecuteBeforeInput = { tool: string; sessionID: string; callID: string } +type ToolExecuteBeforeOutput = { args: Record } + +type ChatMessagePart = { type: string; text?: string; [key: string]: unknown } +type ChatMessageInput = { sessionID: string; agent?: string; model?: unknown } +type ChatMessageOutput = { message: Record; parts: ChatMessagePart[] } + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +const RalphLoopPlugin: Plugin = async (ctx) => { + const ralphConfig = loadRalphLoopConfig(ctx.directory) + + const ralphLoop = createRalphLoopHook(ctx, { + config: ralphConfig, + checkSessionExists: async (sessionID) => { + try { + const sessionApi = ctx.client.session as unknown as { + get?: (args: { + path: { id: string } + query?: { directory: string } + }) => Promise + } + if (typeof sessionApi.get !== "function") return false + + // Some OpenCode SDK versions require `query.directory`, some ignore it. + const response = await sessionApi.get({ + path: { id: sessionID }, + query: { directory: ctx.directory }, + }) + + const data = + isRecord(response) && "data" in response + ? response.data + : isRecord(response) && "200" in response + ? response["200"] + : response + + const id = isRecord(data) ? data.id : undefined + return typeof id === "string" && id.length > 0 + } catch { + return false + } + }, + }) + + return { + config: async (config: Record) => { + const existing = (config.command as Record) ?? {} + // Builtins first; allow user/system overrides to win. + config.command = { ...RALPH_LOOP_COMMANDS, ...existing } + }, + + "tool.execute.before": async (input: ToolExecuteBeforeInput, output: ToolExecuteBeforeOutput) => { + if (!ralphLoop) return + if (input.tool !== "skill") return + + const rawName = typeof output.args.name === "string" ? output.args.name : undefined + if (!rawName) return + + const command = rawName.replace(/^\//, "").toLowerCase() + const sessionID = input.sessionID + if (!sessionID) return + + if (command.startsWith("ralph-loop")) { + const rawArgs = rawName.replace(/^\/?(ralph-loop)\s*/i, "") + const parsed = parseRalphLoopArguments(rawArgs) + ralphLoop.startLoop(sessionID, parsed.prompt, { + maxIterations: parsed.maxIterations, + completionPromise: parsed.completionPromise, + strategy: parsed.strategy, + }) + } else if (command.startsWith("ulw-loop")) { + const rawArgs = rawName.replace(/^\/?(ulw-loop)\s*/i, "") + const parsed = parseRalphLoopArguments(rawArgs) + ralphLoop.startLoop(sessionID, parsed.prompt, { + ultrawork: true, + maxIterations: parsed.maxIterations, + completionPromise: parsed.completionPromise, + strategy: parsed.strategy, + }) + } else if (command.startsWith("cancel-ralph")) { + ralphLoop.cancelLoop(sessionID) + } + }, + + "chat.message": async (input: ChatMessageInput, output: ChatMessageOutput) => { + if (!ralphLoop) return + + const parts = output.parts + const promptText = + parts + ?.filter((p) => p.type === "text" && p.text) + .map((p) => p.text) + .join("\n") + .trim() || "" + + const isRalphLoopTemplate = + promptText.includes("You are starting a Ralph Loop") && + promptText.includes("") + const isCancelRalphTemplate = promptText.includes( + "Cancel the currently active Ralph Loop", + ) + + if (isRalphLoopTemplate) { + const taskMatch = promptText.match(/\s*([\s\S]*?)\s*<\/user-task>/i) + const rawTask = taskMatch?.[1]?.trim() || "" + const parsed = parseRalphLoopArguments(rawTask) + + ralphLoop.startLoop(input.sessionID, parsed.prompt, { + maxIterations: parsed.maxIterations, + completionPromise: parsed.completionPromise, + strategy: parsed.strategy, + }) + } else if (isCancelRalphTemplate) { + ralphLoop.cancelLoop(input.sessionID) + } + }, + + event: async (input: { event: { type: string; properties?: unknown } }) => { + await ralphLoop.event(input) + }, + } +} + +export default RalphLoopPlugin diff --git a/src/ralph-loop/claude-config-dir.ts b/src/ralph-loop/claude-config-dir.ts new file mode 100644 index 0000000..b1275b0 --- /dev/null +++ b/src/ralph-loop/claude-config-dir.ts @@ -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") +} diff --git a/src/ralph-loop/command-arguments.ts b/src/ralph-loop/command-arguments.ts new file mode 100644 index 0000000..90c4920 --- /dev/null +++ b/src/ralph-loop/command-arguments.ts @@ -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, + } +} diff --git a/src/ralph-loop/commands.ts b/src/ralph-loop/commands.ts new file mode 100644 index 0000000..21cc9d3 --- /dev/null +++ b/src/ralph-loop/commands.ts @@ -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: \`{{COMPLETION_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 = { + "ralph-loop": { + name: "ralph-loop", + description: "(builtin) Start self-referential development loop until completion", + template: `\n${RALPH_LOOP_TEMPLATE}\n\n\n\n$ARGUMENTS\n`, + }, + "ulw-loop": { + name: "ulw-loop", + description: "(builtin) Start ultrawork loop - continues until completion with ultrawork mode", + template: `\n${RALPH_LOOP_TEMPLATE}\n\n\n\n$ARGUMENTS\n`, + }, + "cancel-ralph": { + name: "cancel-ralph", + description: "(builtin) Cancel active Ralph Loop", + template: `\n${CANCEL_RALPH_TEMPLATE}\n`, + }, +} diff --git a/src/ralph-loop/completion-promise-detector.ts b/src/ralph-loop/completion-promise-detector.ts new file mode 100644 index 0000000..c3bcb63 --- /dev/null +++ b/src/ralph-loop/completion-promise-detector.ts @@ -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 { + return typeof value === "object" && value !== null +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +} + +function buildPromisePattern(promise: string): RegExp { + return new RegExp(`\\s*${escapeRegex(promise)}\\s*`, "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 { + try { + const sessionApi = ctx.client.session as unknown as { + messages?: (args: { + path: { id: string } + query?: { directory: string } + }) => Promise + } + 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 + } +} diff --git a/src/ralph-loop/config.ts b/src/ralph-loop/config.ts new file mode 100644 index 0000000..e953e90 --- /dev/null +++ b/src/ralph-loop/config.ts @@ -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 ?? {}), + } +} diff --git a/src/ralph-loop/constants.ts b/src/ralph-loop/constants.ts new file mode 100644 index 0000000..02d7983 --- /dev/null +++ b/src/ralph-loop/constants.ts @@ -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" diff --git a/src/ralph-loop/continuation-prompt-builder.ts b/src/ralph-loop/continuation-prompt-builder.ts new file mode 100644 index 0000000..10c0c91 --- /dev/null +++ b/src/ralph-loop/continuation-prompt-builder.ts @@ -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}} +- 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 +} diff --git a/src/ralph-loop/continuation-prompt-injector.ts b/src/ralph-loop/continuation-prompt-injector.ts new file mode 100644 index 0000000..ba616a0 --- /dev/null +++ b/src/ralph-loop/continuation-prompt-injector.ts @@ -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 +} + +function isRecord(value: unknown): value is Record { + 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 + } + query?: { directory: string } +} + +type PromptApi = (args: PromptArgs) => Promise + +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 + } + + const promptValue = sessionValue.prompt + if (typeof promptValue === "function") { + return (args) => Reflect.apply(promptValue, sessionValue, [args]) as Promise + } + + return null +} + +export async function injectContinuationPrompt( + ctx: PluginInput, + options: { + sessionID: string + prompt: string + directory: string + apiTimeoutMs: number + inheritFromSessionID?: string + }, +): Promise { + let agent: string | undefined + let model: { providerID: string; modelID: string } | undefined + let tools: Record | 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 + } + 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 }) +} diff --git a/src/ralph-loop/internal-initiator-marker.ts b/src/ralph-loop/internal-initiator-marker.ts new file mode 100644 index 0000000..78f81a8 --- /dev/null +++ b/src/ralph-loop/internal-initiator-marker.ts @@ -0,0 +1,8 @@ +export const OMO_INTERNAL_INITIATOR_MARKER = "" + +export function createInternalAgentTextPart(text: string): { type: "text"; text: string } { + return { + type: "text", + text: `${text}\n${OMO_INTERNAL_INITIATOR_MARKER}`, + } +} diff --git a/src/ralph-loop/iteration-continuation.ts b/src/ralph-loop/iteration-continuation.ts new file mode 100644 index 0000000..8b05260 --- /dev/null +++ b/src/ralph-loop/iteration-continuation.ts @@ -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 { + 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, + }) +} diff --git a/src/ralph-loop/logger.ts b/src/ralph-loop/logger.ts new file mode 100644 index 0000000..720abbb --- /dev/null +++ b/src/ralph-loop/logger.ts @@ -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 +} diff --git a/src/ralph-loop/loop-session-recovery.ts b/src/ralph-loop/loop-session-recovery.ts new file mode 100644 index 0000000..f50362e --- /dev/null +++ b/src/ralph-loop/loop-session-recovery.ts @@ -0,0 +1,33 @@ +type SessionState = { + isRecovering?: boolean +} + +export function createLoopSessionRecovery(options?: { recoveryWindowMs?: number }) { + const recoveryWindowMs = options?.recoveryWindowMs ?? 5000 + const sessions = new Map() + + 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) + }, + } +} diff --git a/src/ralph-loop/loop-state-controller.ts b/src/ralph-loop/loop-state-controller.ts new file mode 100644 index 0000000..99cb5d9 --- /dev/null +++ b/src/ralph-loop/loop-state-controller.ts @@ -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 + }, + } +} diff --git a/src/ralph-loop/normalize-sdk-response.ts b/src/ralph-loop/normalize-sdk-response.ts new file mode 100644 index 0000000..d4a863c --- /dev/null +++ b/src/ralph-loop/normalize-sdk-response.ts @@ -0,0 +1,22 @@ +export function normalizeSDKResponse( + response: unknown, + fallback: T, + options?: { preferResponseOnMissingData?: boolean }, +): T { + const prefer = options?.preferResponseOnMissingData ?? false + if (response && typeof response === "object") { + const rec = response as Record + 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 +} diff --git a/src/ralph-loop/prompt-tools.ts b/src/ralph-loop/prompt-tools.ts new file mode 100644 index 0000000..0a99c4f --- /dev/null +++ b/src/ralph-loop/prompt-tools.ts @@ -0,0 +1,18 @@ +export type PromptToolPermission = boolean | "allow" | "deny" | "ask" + +export function normalizePromptTools( + tools: Record | undefined, +): Record | undefined { + if (!tools) return undefined + const normalized: Record = {} + 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 +} diff --git a/src/ralph-loop/ralph-loop-event-handler.ts b/src/ralph-loop/ralph-loop-event-handler.ts new file mode 100644 index 0000000..688c7aa --- /dev/null +++ b/src/ralph-loop/ralph-loop-event-handler.ts @@ -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 { + return typeof value === "object" && value !== null +} + +type ToastBody = { + title: string + message: string + variant: string + duration: number +} + +type ShowToastApi = (args: { body: ToastBody }) => Promise + +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 +} + +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 => { + const props = event.properties as Record | 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) + } + } + } +} diff --git a/src/ralph-loop/ralph-loop-hook.ts b/src/ralph-loop/ralph-loop-hook.ts new file mode 100644 index 0000000..74464df --- /dev/null +++ b/src/ralph-loop/ralph-loop-hook.ts @@ -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 + 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, + } +} diff --git a/src/ralph-loop/session-reset-strategy.ts b/src/ralph-loop/session-reset-strategy.ts new file mode 100644 index 0000000..caa165c --- /dev/null +++ b/src/ralph-loop/session-reset-strategy.ts @@ -0,0 +1,69 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "./logger" + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +export async function createIterationSession( + ctx: PluginInput, + parentSessionID: string, + directory: string, +): Promise { + const sessionApi = ctx.client.session as unknown as { + create?: (args: { + body: { parentID: string; title?: string } + query?: { directory: string } + }) => Promise + } + 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 { + 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 + +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 +} diff --git a/src/ralph-loop/simple-frontmatter.ts b/src/ralph-loop/simple-frontmatter.ts new file mode 100644 index 0000000..ed599da --- /dev/null +++ b/src/ralph-loop/simple-frontmatter.ts @@ -0,0 +1,36 @@ +export function parseFrontmatter(content: string): { + data: Record + 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 = {} + + 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 } +} diff --git a/src/ralph-loop/storage.ts b/src/ralph-loop/storage.ts new file mode 100644 index 0000000..743487b --- /dev/null +++ b/src/ralph-loop/storage.ts @@ -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 +} diff --git a/src/ralph-loop/system-directive.ts b/src/ralph-loop/system-directive.ts new file mode 100644 index 0000000..2df6b4d --- /dev/null +++ b/src/ralph-loop/system-directive.ts @@ -0,0 +1 @@ +export const SYSTEM_DIRECTIVE_PREFIX = "[SYSTEM DIRECTIVE: OH-MY-OPENCODE" diff --git a/src/ralph-loop/transcript.ts b/src/ralph-loop/transcript.ts new file mode 100644 index 0000000..dd76cce --- /dev/null +++ b/src/ralph-loop/transcript.ts @@ -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`) +} diff --git a/src/ralph-loop/types.ts b/src/ralph-loop/types.ts new file mode 100644 index 0000000..283afcd --- /dev/null +++ b/src/ralph-loop/types.ts @@ -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 +} diff --git a/src/ralph-loop/with-timeout.ts b/src/ralph-loop/with-timeout.ts new file mode 100644 index 0000000..66bfcdd --- /dev/null +++ b/src/ralph-loop/with-timeout.ts @@ -0,0 +1,17 @@ +export async function withTimeout(promise: Promise, timeoutMs: number): Promise { + let timeoutId: ReturnType | undefined + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error("API timeout")) + }, timeoutMs) + }) + + try { + return await Promise.race([promise, timeoutPromise]) + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..0952a09 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "dist", + "types": ["bun-types"], + "noUncheckedIndexedAccess": true + }, + "include": ["src/**/*.ts"] +}