1037 lines
33 KiB
JavaScript
1037 lines
33 KiB
JavaScript
// @bun
|
|
// src/ralph-loop/config.ts
|
|
import { existsSync, readFileSync } from "fs";
|
|
import { homedir } from "os";
|
|
import { join } from "path";
|
|
function resolveOpenCodeConfigDir() {
|
|
const xdg = process.env.XDG_CONFIG_HOME;
|
|
if (xdg && xdg.length > 0) {
|
|
return join(xdg, "opencode");
|
|
}
|
|
return join(homedir(), ".config", "opencode");
|
|
}
|
|
function stripJsonComments(input) {
|
|
let out = "";
|
|
let i = 0;
|
|
let inString = null;
|
|
while (i < input.length) {
|
|
const ch = input[i];
|
|
const next = i + 1 < input.length ? input[i + 1] : "";
|
|
if (inString) {
|
|
out += ch;
|
|
if (ch === "\\") {
|
|
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 === "/") {
|
|
i += 2;
|
|
while (i < input.length && input[i] !== `
|
|
`)
|
|
i++;
|
|
continue;
|
|
}
|
|
if (ch === "/" && next === "*") {
|
|
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) {
|
|
if (!existsSync(path))
|
|
return null;
|
|
try {
|
|
const raw = readFileSync(path, "utf-8");
|
|
const parsed = JSON.parse(stripJsonComments(raw));
|
|
if (!parsed || typeof parsed !== "object")
|
|
return null;
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
function loadRalphLoopConfig(projectDir) {
|
|
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 ?? {}
|
|
};
|
|
}
|
|
|
|
// src/ralph-loop/commands.ts
|
|
var 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.`;
|
|
var 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.`;
|
|
var RALPH_LOOP_COMMANDS = {
|
|
"ralph-loop": {
|
|
name: "ralph-loop",
|
|
description: "(builtin) Start self-referential development loop until completion",
|
|
template: `<command-instruction>
|
|
${RALPH_LOOP_TEMPLATE}
|
|
</command-instruction>
|
|
|
|
<user-task>
|
|
$ARGUMENTS
|
|
</user-task>`
|
|
},
|
|
"ulw-loop": {
|
|
name: "ulw-loop",
|
|
description: "(builtin) Start ultrawork loop - continues until completion with ultrawork mode",
|
|
template: `<command-instruction>
|
|
${RALPH_LOOP_TEMPLATE}
|
|
</command-instruction>
|
|
|
|
<user-task>
|
|
$ARGUMENTS
|
|
</user-task>`
|
|
},
|
|
"cancel-ralph": {
|
|
name: "cancel-ralph",
|
|
description: "(builtin) Cancel active Ralph Loop",
|
|
template: `<command-instruction>
|
|
${CANCEL_RALPH_TEMPLATE}
|
|
</command-instruction>`
|
|
}
|
|
};
|
|
|
|
// src/ralph-loop/command-arguments.ts
|
|
var DEFAULT_PROMPT = "Complete the task as instructed";
|
|
function parseRalphLoopArguments(rawArguments) {
|
|
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 : undefined
|
|
};
|
|
}
|
|
|
|
// src/ralph-loop/transcript.ts
|
|
import { join as join3 } from "path";
|
|
|
|
// src/ralph-loop/claude-config-dir.ts
|
|
import { homedir as homedir2 } from "os";
|
|
import { join as join2 } from "path";
|
|
function getClaudeConfigDir() {
|
|
const envConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
if (envConfigDir)
|
|
return envConfigDir;
|
|
return join2(homedir2(), ".claude");
|
|
}
|
|
|
|
// src/ralph-loop/transcript.ts
|
|
var TRANSCRIPT_DIR = join3(getClaudeConfigDir(), "transcripts");
|
|
function getTranscriptPath(sessionId) {
|
|
return join3(TRANSCRIPT_DIR, `${sessionId}.jsonl`);
|
|
}
|
|
|
|
// src/ralph-loop/loop-session-recovery.ts
|
|
function createLoopSessionRecovery(options) {
|
|
const recoveryWindowMs = options?.recoveryWindowMs ?? 5000;
|
|
const sessions = new Map;
|
|
function getSessionState(sessionID) {
|
|
let state = sessions.get(sessionID);
|
|
if (!state) {
|
|
state = {};
|
|
sessions.set(sessionID, state);
|
|
}
|
|
return state;
|
|
}
|
|
return {
|
|
isRecovering(sessionID) {
|
|
return getSessionState(sessionID).isRecovering === true;
|
|
},
|
|
markRecovering(sessionID) {
|
|
const state = getSessionState(sessionID);
|
|
state.isRecovering = true;
|
|
setTimeout(() => {
|
|
state.isRecovering = false;
|
|
}, recoveryWindowMs);
|
|
},
|
|
clear(sessionID) {
|
|
sessions.delete(sessionID);
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/ralph-loop/constants.ts
|
|
var HOOK_NAME = "ralph-loop";
|
|
var DEFAULT_STATE_FILE = ".sisyphus/ralph-loop.local.md";
|
|
var DEFAULT_MAX_ITERATIONS = 100;
|
|
var DEFAULT_COMPLETION_PROMISE = "DONE";
|
|
|
|
// src/ralph-loop/storage.ts
|
|
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
|
|
import { dirname, join as join4 } from "path";
|
|
|
|
// src/ralph-loop/simple-frontmatter.ts
|
|
function parseFrontmatter(content) {
|
|
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 = {};
|
|
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 = 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 };
|
|
}
|
|
|
|
// src/ralph-loop/storage.ts
|
|
function getStateFilePath(directory, customPath) {
|
|
return customPath ? join4(directory, customPath) : join4(directory, DEFAULT_STATE_FILE);
|
|
}
|
|
function readState(directory, customPath) {
|
|
const filePath = getStateFilePath(directory, customPath);
|
|
if (!existsSync2(filePath))
|
|
return null;
|
|
try {
|
|
const content = readFileSync2(filePath, "utf-8");
|
|
const { data, body } = parseFrontmatter(content);
|
|
const active = data.active;
|
|
const iteration = data.iteration;
|
|
if (active === undefined || iteration === undefined)
|
|
return null;
|
|
const isActive = active === true || active === "true";
|
|
const iterationNum = typeof iteration === "number" ? iteration : Number(iteration);
|
|
if (Number.isNaN(iterationNum))
|
|
return null;
|
|
const stripQuotes = (val) => {
|
|
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 : undefined
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
function writeState(directory, state, customPath) {
|
|
const filePath = getStateFilePath(directory, customPath);
|
|
try {
|
|
const dir = dirname(filePath);
|
|
if (!existsSync2(dir)) {
|
|
mkdirSync(dir, { recursive: true });
|
|
}
|
|
const sessionIdLine = state.session_id ? `session_id: "${state.session_id}"
|
|
` : "";
|
|
const ultraworkLine = state.ultrawork !== undefined ? `ultrawork: ${state.ultrawork}
|
|
` : "";
|
|
const strategyLine = state.strategy ? `strategy: "${state.strategy}"
|
|
` : "";
|
|
const content = `---
|
|
active: ${state.active}
|
|
iteration: ${state.iteration}
|
|
max_iterations: ${state.max_iterations}
|
|
completion_promise: "${state.completion_promise}"
|
|
started_at: "${state.started_at}"
|
|
${sessionIdLine}${ultraworkLine}${strategyLine}---
|
|
${state.prompt}
|
|
`;
|
|
writeFileSync(filePath, content, "utf-8");
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
function clearState(directory, customPath) {
|
|
const filePath = getStateFilePath(directory, customPath);
|
|
try {
|
|
if (existsSync2(filePath)) {
|
|
unlinkSync(filePath);
|
|
}
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
function incrementIteration(directory, customPath) {
|
|
const state = readState(directory, customPath);
|
|
if (!state)
|
|
return null;
|
|
state.iteration += 1;
|
|
return writeState(directory, state, customPath) ? state : null;
|
|
}
|
|
|
|
// src/ralph-loop/logger.ts
|
|
import * as fs from "fs";
|
|
import * as os from "os";
|
|
import * as path from "path";
|
|
var logFile = path.join(os.tmpdir(), "opencode-plugin-ralph-loop.log");
|
|
function shouldLogToConsole() {
|
|
const raw = process.env.RALPH_LOOP_DEBUG;
|
|
if (!raw)
|
|
return false;
|
|
return raw === "1" || raw.toLowerCase() === "true" || raw.toLowerCase() === "yes";
|
|
}
|
|
function log(message, meta) {
|
|
try {
|
|
const timestamp = new Date().toISOString();
|
|
const entry = `[${timestamp}] ${message}${meta !== undefined ? ` ${JSON.stringify(meta)}` : ""}
|
|
`;
|
|
fs.appendFileSync(logFile, entry);
|
|
} catch {}
|
|
if (shouldLogToConsole()) {
|
|
try {
|
|
console.log(message, meta);
|
|
} catch {
|
|
console.log(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
// src/ralph-loop/loop-state-controller.ts
|
|
function createLoopStateController(options) {
|
|
const directory = options.directory;
|
|
const stateDir = options.stateDir;
|
|
const config = options.config;
|
|
return {
|
|
startLoop(sessionID, prompt, loopOptions) {
|
|
const state = {
|
|
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) {
|
|
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() {
|
|
return readState(directory, stateDir);
|
|
},
|
|
clear() {
|
|
return clearState(directory, stateDir);
|
|
},
|
|
incrementIteration() {
|
|
return incrementIteration(directory, stateDir);
|
|
},
|
|
setSessionID(sessionID) {
|
|
const state = readState(directory, stateDir);
|
|
if (!state)
|
|
return null;
|
|
state.session_id = sessionID;
|
|
if (!writeState(directory, state, stateDir))
|
|
return null;
|
|
return state;
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/ralph-loop/completion-promise-detector.ts
|
|
import { existsSync as existsSync3, readFileSync as readFileSync3 } from "fs";
|
|
|
|
// src/ralph-loop/with-timeout.ts
|
|
async function withTimeout(promise, timeoutMs) {
|
|
let timeoutId;
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// src/ralph-loop/completion-promise-detector.ts
|
|
function isRecord(value) {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
function escapeRegex(str) {
|
|
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
function buildPromisePattern(promise) {
|
|
return new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is");
|
|
}
|
|
function detectCompletionInTranscript(transcriptPath, promise) {
|
|
if (!transcriptPath)
|
|
return false;
|
|
try {
|
|
if (!existsSync3(transcriptPath))
|
|
return false;
|
|
const content = readFileSync3(transcriptPath, "utf-8");
|
|
const pattern = buildPromisePattern(promise);
|
|
const lines = content.split(`
|
|
`).filter((line) => line.trim());
|
|
for (const line of lines) {
|
|
try {
|
|
const entry = JSON.parse(line);
|
|
if (entry.type !== "user" && pattern.test(line))
|
|
return true;
|
|
} catch {}
|
|
}
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
async function detectCompletionInSessionMessages(ctx, options) {
|
|
try {
|
|
const sessionApi = ctx.client.session;
|
|
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 = Array.isArray(response) ? response : isRecord(response) && Array.isArray(response.data) ? response.data : isRecord(response) && Array.isArray(response["200"]) ? response["200"] : [];
|
|
const assistantMessages = messageArray.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 ? `
|
|
` : ""}${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;
|
|
}
|
|
}
|
|
|
|
// src/ralph-loop/system-directive.ts
|
|
var SYSTEM_DIRECTIVE_PREFIX = "[SYSTEM DIRECTIVE: OH-MY-OPENCODE";
|
|
|
|
// src/ralph-loop/continuation-prompt-builder.ts
|
|
var 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}}`;
|
|
function buildContinuationPrompt(state) {
|
|
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;
|
|
}
|
|
|
|
// src/ralph-loop/internal-initiator-marker.ts
|
|
var OMO_INTERNAL_INITIATOR_MARKER = "<!-- OMO_INTERNAL_INITIATOR -->";
|
|
function createInternalAgentTextPart(text) {
|
|
return {
|
|
type: "text",
|
|
text: `${text}
|
|
${OMO_INTERNAL_INITIATOR_MARKER}`
|
|
};
|
|
}
|
|
|
|
// src/ralph-loop/normalize-sdk-response.ts
|
|
function normalizeSDKResponse(response, fallback, options) {
|
|
const prefer = options?.preferResponseOnMissingData ?? false;
|
|
if (response && typeof response === "object") {
|
|
const rec = response;
|
|
if ("data" in rec) {
|
|
const d = rec.data;
|
|
if (d !== undefined)
|
|
return d;
|
|
}
|
|
if ("200" in rec) {
|
|
const d = rec["200"];
|
|
if (d !== undefined)
|
|
return d;
|
|
}
|
|
}
|
|
if (prefer && response !== undefined) {
|
|
return response;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
// src/ralph-loop/prompt-tools.ts
|
|
function normalizePromptTools(tools) {
|
|
if (!tools)
|
|
return;
|
|
const normalized = {};
|
|
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;
|
|
}
|
|
|
|
// src/ralph-loop/continuation-prompt-injector.ts
|
|
function isRecord2(value) {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
function getPromptApi(client) {
|
|
const clientRecord = client;
|
|
if (!isRecord2(clientRecord))
|
|
return null;
|
|
const sessionValue = clientRecord.session;
|
|
if (!isRecord2(sessionValue))
|
|
return null;
|
|
const promptAsyncValue = sessionValue.promptAsync;
|
|
if (typeof promptAsyncValue === "function") {
|
|
return (args) => Reflect.apply(promptAsyncValue, sessionValue, [args]);
|
|
}
|
|
const promptValue = sessionValue.prompt;
|
|
if (typeof promptValue === "function") {
|
|
return (args) => Reflect.apply(promptValue, sessionValue, [args]);
|
|
}
|
|
return null;
|
|
}
|
|
async function injectContinuationPrompt(ctx, options) {
|
|
let agent;
|
|
let model;
|
|
let tools;
|
|
const sourceSessionID = options.inheritFromSessionID ?? options.sessionID;
|
|
try {
|
|
const sessionApi = ctx.client.session;
|
|
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, [], {
|
|
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 {}
|
|
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 });
|
|
}
|
|
|
|
// src/ralph-loop/session-reset-strategy.ts
|
|
function isRecord3(value) {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
async function createIterationSession(ctx, parentSessionID, directory) {
|
|
const sessionApi = ctx.client.session;
|
|
if (typeof sessionApi.create !== "function")
|
|
return null;
|
|
const createResult = await sessionApi.create({
|
|
body: { parentID: parentSessionID, title: "Ralph Loop Iteration" },
|
|
query: { directory }
|
|
});
|
|
const data = isRecord3(createResult) ? createResult.data : undefined;
|
|
const id = isRecord3(data) ? data.id : undefined;
|
|
const error = isRecord3(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;
|
|
}
|
|
async function selectSessionInTui(client, sessionID) {
|
|
const selectSession = getSelectSessionApi(client);
|
|
if (!selectSession)
|
|
return false;
|
|
try {
|
|
await selectSession({ body: { sessionID } });
|
|
return true;
|
|
} catch (error) {
|
|
log("[ralph-loop] Failed to select session in TUI", {
|
|
sessionID,
|
|
error: String(error)
|
|
});
|
|
return false;
|
|
}
|
|
}
|
|
function getSelectSessionApi(client) {
|
|
if (!isRecord3(client))
|
|
return null;
|
|
const tuiValue = client.tui;
|
|
if (!isRecord3(tuiValue))
|
|
return null;
|
|
const selectSessionValue = tuiValue.selectSession;
|
|
if (typeof selectSessionValue !== "function")
|
|
return null;
|
|
return (args) => Reflect.apply(selectSessionValue, tuiValue, [args]);
|
|
}
|
|
|
|
// src/ralph-loop/iteration-continuation.ts
|
|
async function continueIteration(ctx, state, options) {
|
|
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
|
|
});
|
|
}
|
|
|
|
// src/ralph-loop/ralph-loop-event-handler.ts
|
|
function isRecord4(value) {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
function getShowToastApi(client) {
|
|
if (!isRecord4(client))
|
|
return null;
|
|
const tui = client.tui;
|
|
if (!isRecord4(tui))
|
|
return null;
|
|
const showToast = tui.showToast;
|
|
if (typeof showToast !== "function")
|
|
return null;
|
|
return (args) => Reflect.apply(showToast, tui, [args]);
|
|
}
|
|
function createRalphLoopEventHandler(ctx, options) {
|
|
return async ({ event }) => {
|
|
const props = event.properties;
|
|
const showToast = getShowToastApi(ctx.client);
|
|
if (event.type === "session.idle") {
|
|
const sessionID = props?.sessionID;
|
|
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;
|
|
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;
|
|
const error = props?.error;
|
|
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);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// src/ralph-loop/ralph-loop-hook.ts
|
|
var DEFAULT_API_TIMEOUT = 5000;
|
|
function createRalphLoopHook(ctx, options) {
|
|
const config = options?.config;
|
|
const stateDir = config?.state_dir;
|
|
const getTranscriptPath2 = options?.getTranscriptPath ?? ((id) => getTranscriptPath(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: getTranscriptPath2,
|
|
checkSessionExists,
|
|
sessionRecovery,
|
|
loopState
|
|
});
|
|
return {
|
|
event,
|
|
startLoop: loopState.startLoop,
|
|
cancelLoop: loopState.cancelLoop,
|
|
getState: loopState.getState
|
|
};
|
|
}
|
|
|
|
// src/index.ts
|
|
function isRecord5(value) {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
var RalphLoopPlugin = async (ctx) => {
|
|
const ralphConfig = loadRalphLoopConfig(ctx.directory);
|
|
const ralphLoop = createRalphLoopHook(ctx, {
|
|
config: ralphConfig,
|
|
checkSessionExists: async (sessionID) => {
|
|
try {
|
|
const sessionApi = ctx.client.session;
|
|
if (typeof sessionApi.get !== "function")
|
|
return false;
|
|
const response = await sessionApi.get({
|
|
path: { id: sessionID },
|
|
query: { directory: ctx.directory }
|
|
});
|
|
const data = isRecord5(response) && "data" in response ? response.data : isRecord5(response) && ("200" in response) ? response["200"] : response;
|
|
const id = isRecord5(data) ? data.id : undefined;
|
|
return typeof id === "string" && id.length > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
});
|
|
return {
|
|
config: async (config) => {
|
|
const existing = config.command ?? {};
|
|
config.command = { ...RALPH_LOOP_COMMANDS, ...existing };
|
|
},
|
|
"tool.execute.before": async (input, output) => {
|
|
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, output) => {
|
|
if (!ralphLoop)
|
|
return;
|
|
const parts = output.parts;
|
|
const promptText = parts?.filter((p) => p.type === "text" && p.text).map((p) => p.text).join(`
|
|
`).trim() || "";
|
|
const isRalphLoopTemplate = promptText.includes("You are starting a Ralph Loop") && promptText.includes("<user-task>");
|
|
const isCancelRalphTemplate = promptText.includes("Cancel the currently active Ralph Loop");
|
|
if (isRalphLoopTemplate) {
|
|
const taskMatch = promptText.match(/<user-task>\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) => {
|
|
await ralphLoop.event(input);
|
|
}
|
|
};
|
|
};
|
|
var src_default = RalphLoopPlugin;
|
|
export {
|
|
src_default as default
|
|
};
|