From 5771574888c99a2e38b20bca9944ed90839f70d0 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sat, 14 Mar 2026 20:08:47 -0400 Subject: [PATCH 01/29] harden memory package: fix audit parsing, git safety, v2 client migration --- packages/memory/src/hooks/ralph.ts | 343 +++++++++++ packages/memory/src/index.ts | 613 ++++++++++++++++++- packages/memory/src/services/ralph.ts | 152 +++++ packages/memory/test/ralph.test.ts | 646 +++++++++++++++++++++ packages/memory/test/tool-blocking.test.ts | 161 +++++ 5 files changed, 1894 insertions(+), 21 deletions(-) create mode 100644 packages/memory/src/hooks/ralph.ts create mode 100644 packages/memory/src/services/ralph.ts create mode 100644 packages/memory/test/ralph.test.ts create mode 100644 packages/memory/test/tool-blocking.test.ts diff --git a/packages/memory/src/hooks/ralph.ts b/packages/memory/src/hooks/ralph.ts new file mode 100644 index 00000000..a1f24d79 --- /dev/null +++ b/packages/memory/src/hooks/ralph.ts @@ -0,0 +1,343 @@ +import type { PluginInput } from '@opencode-ai/plugin' +import type { OpencodeClient } from '@opencode-ai/sdk/v2' +import type { RalphService, RalphState } from '../services/ralph' +import { MAX_RETRIES } from '../services/ralph' +import type { Logger } from '../types' +import { execSync, spawnSync } from 'child_process' +import { resolve } from 'path' + +export interface RalphEventHandler { + onEvent(input: { event: { type: string; properties?: Record } }): Promise +} + + +export function hasAuditIssues(auditText: string): boolean { + const lower = auditText.toLowerCase() + + if (lower.includes('no issues found') || lower.includes('0 issues found')) return false + + if (/\*\*severity\*\*[:\s]*bug/i.test(auditText)) return true + if (/\*\*severity\*\*[:\s]*warning/i.test(auditText)) return true + if (/severity[:\s]*bug/i.test(auditText)) return true + if (/severity[:\s]*warning/i.test(auditText)) return true + + if (/\b[1-9]\d*\s+(issue|bug|warning)s?\s+found\b/i.test(auditText)) return true + + const issuesSection = auditText.match(/###?\s*Issues\s*\n([\s\S]*?)(?=\n###?\s|\n##\s|$)/i) + if (issuesSection) { + const content = issuesSection[1].trim() + if (content.length > 0 && !/^(none|no issues|n\/a)\.?$/i.test(content)) return true + } + + return false +} + +export function createRalphEventHandler( + ralphService: RalphService, + client: PluginInput['client'], + v2Client: OpencodeClient, + logger: Logger, +): RalphEventHandler { + const minCleanAudits = ralphService.getMinCleanAudits() + async function commitAndCleanupWorktree(state: RalphState): Promise<{ committed: boolean; cleaned: boolean }> { + if (state.inPlace) { + logger.log(`Ralph: in-place mode, skipping commit and cleanup`) + return { committed: false, cleaned: false } + } + + let committed = false + let cleaned = false + + try { + const addResult = spawnSync('git', ['add', '-A'], { cwd: state.worktreeDir, encoding: 'utf-8' }) + if (addResult.status !== 0) { + throw new Error(addResult.stderr || 'git add failed') + } + + const statusResult = spawnSync('git', ['status', '--porcelain'], { cwd: state.worktreeDir, encoding: 'utf-8' }) + if (statusResult.status !== 0) { + throw new Error(statusResult.stderr || 'git status failed') + } + const status = statusResult.stdout.trim() + + if (status) { + const message = `ralph: ${state.worktreeName} completed after ${state.iteration} iterations` + const commitResult = spawnSync('git', ['commit', '-m', message], { cwd: state.worktreeDir, encoding: 'utf-8' }) + if (commitResult.status !== 0) { + throw new Error(commitResult.stderr || 'git commit failed') + } + committed = true + logger.log(`Ralph: committed changes on branch ${state.worktreeBranch}`) + } else { + logger.log(`Ralph: no uncommitted changes to commit on branch ${state.worktreeBranch}`) + } + } catch (err) { + logger.error(`Ralph: failed to commit changes in worktree ${state.worktreeDir}`, err) + } + + try { + const gitCommonDir = execSync('git rev-parse --git-common-dir', { cwd: state.worktreeDir, encoding: 'utf-8' }).trim() + const gitRoot = resolve(state.worktreeDir, gitCommonDir, '..') + const removeResult = spawnSync('git', ['worktree', 'remove', '-f', state.worktreeDir], { cwd: gitRoot, encoding: 'utf-8' }) + if (removeResult.status !== 0) { + throw new Error(removeResult.stderr || 'git worktree remove failed') + } + cleaned = true + logger.log(`Ralph: removed worktree ${state.worktreeDir}, branch ${state.worktreeBranch} preserved`) + } catch (err) { + logger.error(`Ralph: failed to remove worktree ${state.worktreeDir}`, err) + } + + return { committed, cleaned } + } + + async function terminateLoop(sessionId: string, state: RalphState, reason: string): Promise { + ralphService.setState(sessionId, { + ...state, + active: false, + completedAt: new Date().toISOString(), + terminationReason: reason, + }) + logger.log(`Ralph loop terminated: reason="${reason}", worktree="${state.worktreeName}", iteration=${state.iteration}`) + + let commitResult: { committed: boolean; cleaned: boolean } | undefined + if (reason === 'completed') { + commitResult = await commitAndCleanupWorktree(state) + } + + if (state.parentSessionId) { + try { + let notificationText: string + if (state.inPlace) { + if (reason === 'completed') { + notificationText = [ + `Ralph loop "${state.worktreeName}" completed (in-place).`, + '', + `Iteration: ${state.iteration}`, + `Changes are in the current directory on branch: ${state.worktreeBranch}`, + ].join('\n') + } else { + notificationText = `Ralph loop "${state.worktreeName}" terminated (in-place).\n\nReason: ${reason}\nIteration: ${state.iteration}\nDirectory: ${state.worktreeDir}\nBranch: ${state.worktreeBranch}` + } + } else if (reason === 'completed' && commitResult) { + const parts = [`Ralph loop "${state.worktreeName}" completed.`, '', `Iteration: ${state.iteration}`] + if (commitResult.committed) { + parts.push(`Changes committed on branch: ${state.worktreeBranch}`) + } + if (commitResult.cleaned) { + parts.push(`Worktree removed. Use \`git merge ${state.worktreeBranch}\` or \`git checkout ${state.worktreeBranch}\` to access the changes.`) + } else { + parts.push(`Worktree: ${state.worktreeDir}`) + } + notificationText = parts.join('\n') + } else { + notificationText = `Ralph loop "${state.worktreeName}" terminated.\n\nReason: ${reason}\nIteration: ${state.iteration}\nWorktree: ${state.worktreeDir}\nBranch: ${state.worktreeBranch}` + } + + await client.session.promptAsync({ + path: { id: state.parentSessionId }, + body: { + parts: [{ + type: 'text' as const, + text: notificationText, + }], + }, + }) + } catch (err) { + logger.error(`Ralph: failed to notify parent session`, err) + } + } + } + + async function handlePromptError(sessionId: string, state: RalphState, context: string, err: unknown): Promise { + const nextErrorCount = (state.errorCount ?? 0) + 1 + + if (nextErrorCount < MAX_RETRIES) { + logger.error(`Ralph: ${context} (attempt ${nextErrorCount}/${MAX_RETRIES}), will retry`, err) + ralphService.setState(sessionId, { ...state, errorCount: nextErrorCount }) + } else { + logger.error(`Ralph: ${context} (attempt ${nextErrorCount}/${MAX_RETRIES}), giving up`, err) + await terminateLoop(sessionId, state, `error_max_retries: ${context}`) + } + } + + async function getLastAssistantText(sessionId: string, worktreeDir: string): Promise { + try { + const messagesResult = await v2Client.session.messages({ + sessionID: sessionId, + directory: worktreeDir, + limit: 4, + }) + + const messages = (messagesResult.data ?? []) as Array<{ + info: { role: string } + parts: Array<{ type: string; text?: string }> + }> + + const lastAssistant = [...messages].reverse().find((m) => m.info.role === 'assistant') + + if (!lastAssistant) return null + + return lastAssistant.parts + .filter((p) => p.type === 'text' && typeof p.text === 'string') + .map((p) => p.text as string) + .join('\n') + } catch (err) { + logger.error(`Ralph: could not read session messages`, err) + return null + } + } + + async function handleCodingPhase(sessionId: string, state: RalphState): Promise { + if (state.completionPromise) { + const textContent = await getLastAssistantText(sessionId, state.worktreeDir) + if (textContent && ralphService.checkCompletionPromise(textContent, state.completionPromise)) { + if (!state.audit || (state.cleanAuditCount ?? 0) >= minCleanAudits) { + await terminateLoop(sessionId, state, 'completed') + logger.log(`Ralph loop completed: detected ${state.completionPromise} at iteration ${state.iteration}`) + return + } + logger.log(`Ralph: completion promise detected but only ${state.cleanAuditCount ?? 0}/${minCleanAudits} clean audits, continuing`) + } + } + + if (state.maxIterations > 0 && state.iteration >= state.maxIterations) { + await terminateLoop(sessionId, state, 'max_iterations') + return + } + + if (state.audit) { + ralphService.setState(sessionId, { ...state, phase: 'auditing', errorCount: 0 }) + logger.log(`Ralph iteration ${state.iteration} complete, running auditor for session ${sessionId}`) + + try { + await v2Client.session.promptAsync({ + sessionID: sessionId, + directory: state.worktreeDir, + parts: [{ + type: 'subtask' as const, + agent: 'auditor', + description: `Post-iteration ${state.iteration} code review`, + prompt: ralphService.buildAuditPrompt(state), + }], + }) + } catch (err) { + await handlePromptError(sessionId, { ...state, phase: 'coding' }, 'failed to send audit prompt', err) + } + return + } + + const nextIteration = state.iteration + 1 + ralphService.setState(sessionId, { ...state, iteration: nextIteration, errorCount: 0 }) + + const continuationPrompt = ralphService.buildContinuationPrompt({ ...state, iteration: nextIteration }) + logger.log(`Ralph iteration ${nextIteration} for session ${sessionId}`) + + try { + await v2Client.session.promptAsync({ + sessionID: sessionId, + directory: state.worktreeDir, + parts: [{ type: 'text' as const, text: continuationPrompt }], + }) + } catch (err) { + await handlePromptError(sessionId, state, 'failed to send continuation prompt', err) + } + } + + async function handleAuditingPhase(sessionId: string, state: RalphState): Promise { + const auditText = await getLastAssistantText(sessionId, state.worktreeDir) + + const nextIteration = state.iteration + 1 + const auditFindings = auditText && hasAuditIssues(auditText) ? auditText : undefined + const currentCleanCount = state.cleanAuditCount ?? 0 + + let newCleanAuditCount: number + if (auditFindings) { + logger.log(`Ralph audit found issues at iteration ${state.iteration}, resetting clean audit count`) + newCleanAuditCount = 0 + } else { + newCleanAuditCount = currentCleanCount + 1 + logger.log(`Ralph audit clean at iteration ${state.iteration} (${newCleanAuditCount}/${minCleanAudits} clean audits)`) + } + + if (!auditFindings && state.completionPromise) { + if (newCleanAuditCount >= minCleanAudits) { + await terminateLoop(sessionId, state, 'completed') + logger.log(`Ralph loop completed after ${newCleanAuditCount} clean audits at iteration ${state.iteration}`) + return + } + logger.log(`Ralph: clean audit but only ${newCleanAuditCount}/${minCleanAudits} needed, continuing`) + } + + if (state.maxIterations > 0 && nextIteration > state.maxIterations) { + await terminateLoop(sessionId, state, 'max_iterations') + return + } + + ralphService.setState(sessionId, { + ...state, + iteration: nextIteration, + phase: 'coding', + lastAuditResult: auditFindings, + cleanAuditCount: newCleanAuditCount, + errorCount: 0, + }) + + const continuationPrompt = ralphService.buildContinuationPrompt( + { ...state, iteration: nextIteration }, + auditFindings, + ) + logger.log(`Ralph iteration ${nextIteration} for session ${sessionId}`) + + try { + await v2Client.session.promptAsync({ + sessionID: sessionId, + directory: state.worktreeDir, + parts: [{ type: 'text' as const, text: continuationPrompt }], + }) + } catch (err) { + await handlePromptError(sessionId, state, 'failed to send continuation prompt after audit', err) + } + } + + async function onEvent(input: { event: { type: string; properties?: Record } }): Promise { + const { event } = input + + if (event.type === 'worktree.failed') { + const message = event.properties?.message as string + const directory = event.properties?.directory as string + logger.error(`Ralph: worktree failed: ${message}`) + + if (directory) { + const activeLoops = ralphService.listActive() + const affectedLoop = activeLoops.find((s) => s.worktreeDir === directory) + if (affectedLoop) { + await terminateLoop(affectedLoop.sessionId, affectedLoop, `worktree_failed: ${message}`) + } + } + return + } + + if (event.type !== 'session.idle') return + + const sessionId = event.properties?.sessionID as string + if (!sessionId) return + + const state = ralphService.getActiveState(sessionId) + if (!state || !state.active) return + + try { + if (state.phase === 'auditing') { + await handleAuditingPhase(sessionId, state) + } else { + await handleCodingPhase(sessionId, state) + } + } catch (err) { + await handlePromptError(sessionId, state, `unhandled error in ${state.phase} phase`, err) + } + } + + return { + onEvent, + } +} diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index b9701f96..90a0e71d 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -1,10 +1,11 @@ import type { Plugin, PluginInput, Hooks } from '@opencode-ai/plugin' import { tool } from '@opencode-ai/plugin' +import { createOpencodeClient as createV2Client } from '@opencode-ai/sdk/v2' import { agents } from './agents' import { createConfigHandler } from './config' import { VERSION } from './version' -import { createSessionHooks, createMemoryInjectionHook } from './hooks' -import { join } from 'path' +import { createSessionHooks, createMemoryInjectionHook, createRalphEventHandler } from './hooks' +import { join, resolve } from 'path' import { initializeDatabase, resolveDataDir, closeDatabase, createMetadataQuery } from './storage' import type { MemoryService } from './services/memory' import { createVecService } from './storage/vec' @@ -12,19 +13,23 @@ import { createEmbeddingProvider, checkServerHealth, isServerRunning, killEmbedd import { createMemoryService } from './services/memory' import { createEmbeddingSyncService } from './services/embedding-sync' import { createKvService } from './services/kv' +import { createRalphService, type RalphState } from './services/ralph' import { loadPluginConfig } from './setup' import { resolveLogPath } from './storage' -import { createLogger } from './utils/logger' +import { createLogger, slugify } from './utils/logger' import type { Database } from 'bun:sqlite' import type { PluginConfig, CompactionConfig, HealthStatus, Logger } from './types' import type { EmbeddingProvider } from './embedding' import type { VecService } from './storage/vec-types' import { createNoopVecService } from './storage/vec' import { checkForUpdate, formatUpgradeCheck, performUpgrade } from './utils/upgrade' - +import { MAX_RETRIES } from './services/ralph' +import { execSync, spawnSync } from 'child_process' const z = tool.schema +const DEFAULT_PLAN_COMPLETION_PROMISE = 'All phases of the plan have been completed successfully' + async function getHealthStatus( projectId: string, db: Database, @@ -258,11 +263,38 @@ function parseModelString(modelStr?: string): { providerID: string; modelID: str } } +async function retryWithModelFallback( + callWithModel: () => Promise<{ data?: T; error?: unknown }>, + callWithoutModel: () => Promise<{ data?: T; error?: unknown }>, + model: { providerID: string; modelID: string } | undefined, + logger: { error: (msg: string, err?: unknown) => void; log: (msg: string) => void }, + maxRetries: number = 2 +): Promise<{ result: { data?: T; error?: unknown }; usedModel: { providerID: string; modelID: string } | undefined }> { + if (!model) { + return { result: await callWithoutModel(), usedModel: undefined } + } + + let lastError: unknown + for (let attempt = 1; attempt <= maxRetries; attempt++) { + const result = await callWithModel() + if (!result.error) { + return { result, usedModel: model } + } + lastError = result.error + logger.log(`model attempt ${attempt}/${maxRetries} failed, retrying`) + } + + logger.error(`configured model unavailable after ${maxRetries} attempts, falling back to default`, lastError) + return { result: await callWithoutModel(), usedModel: undefined } +} + export function createMemoryPlugin(config: PluginConfig): Plugin { return async (input: PluginInput): Promise => { const { directory, project, client } = input const projectId = project.id + const v2 = createV2Client({ baseUrl: input.serverUrl.toString(), directory }) + const loggingConfig = config.logging const logger = createLogger({ enabled: loggingConfig?.enabled ?? false, @@ -297,6 +329,9 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { const kvService = createKvService(db, logger) + const ralphService = createRalphService(kvService, projectId, logger, config.ralph) + const ralphHandler = createRalphEventHandler(ralphService, client, v2, logger) + const mismatchState: DimensionMismatchState = { detected: false, expected: null, @@ -387,6 +422,198 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { const getCleanup = cleanup + interface RalphSetupOptions { + prompt: string + sessionTitle: string + worktreeName?: string + completionPromise: string | null + maxIterations: number + audit: boolean + agent?: string + model?: { providerID: string; modelID: string } + parentSessionId?: string + inPlace?: boolean + } + + async function setupRalphLoop(options: RalphSetupOptions): Promise { + const autoWorktreeName = options.worktreeName ?? `ralph-${slugify(options.sessionTitle.replace(/^Ralph:\s*/i, ''))}` + const projectDir = directory + const maxIter = options.maxIterations ?? config.ralph?.defaultMaxIterations ?? 0 + + interface LoopContext { + sessionId: string + directory: string + branch: string + workspaceId?: string + inPlace: boolean + } + + let loopContext: LoopContext + + if (options.inPlace) { + let currentBranch: string + try { + currentBranch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: projectDir, encoding: 'utf-8' }).trim() + } catch (err) { + logger.error(`ralph: failed to get current branch`, err) + return 'Failed to determine current git branch.' + } + + const createResult = await v2.session.create({ + title: options.sessionTitle, + directory: projectDir, + }) + + if (createResult.error || !createResult.data) { + logger.error(`ralph: failed to create session`, createResult.error) + return 'Failed to create Ralph session.' + } + + loopContext = { + sessionId: createResult.data.id, + directory: projectDir, + branch: currentBranch, + inPlace: true, + } + } else { + const worktreeResult = await v2.worktree.create({ + worktreeCreateInput: { name: autoWorktreeName }, + }) + + if (worktreeResult.error || !worktreeResult.data) { + logger.error(`ralph: failed to create worktree`, worktreeResult.error) + return 'Failed to create worktree.' + } + + const worktreeInfo = worktreeResult.data + logger.log(`ralph: worktree created at ${worktreeInfo.directory} (branch: ${worktreeInfo.branch})`) + + const createResult = await v2.session.create({ + title: options.sessionTitle, + directory: worktreeInfo.directory, + }) + + if (createResult.error || !createResult.data) { + logger.error(`ralph: failed to create session`, createResult.error) + try { + await v2.worktree.remove({ worktreeRemoveInput: { directory: worktreeInfo.directory } }) + } catch (cleanupErr) { + logger.error(`ralph: failed to cleanup worktree`, cleanupErr) + } + return 'Failed to create Ralph session.' + } + + loopContext = { + sessionId: createResult.data.id, + directory: worktreeInfo.directory, + branch: worktreeInfo.branch, + workspaceId: `wrk-${autoWorktreeName}`, + inPlace: false, + } + } + + const state: RalphState = { + active: true, + sessionId: loopContext.sessionId, + worktreeName: autoWorktreeName, + worktreeDir: loopContext.directory, + worktreeBranch: loopContext.branch, + workspaceId: loopContext.workspaceId ?? '', + iteration: 1, + maxIterations: maxIter, + completionPromise: options.completionPromise, + startedAt: new Date().toISOString(), + prompt: options.prompt, + phase: 'coding', + audit: options.audit, + errorCount: 0, + cleanAuditCount: 0, + parentSessionId: options.parentSessionId, + inPlace: options.inPlace, + } + + ralphService.setState(loopContext.sessionId, state) + logger.log(`ralph: state stored for session=${loopContext.sessionId}`) + + let promptText = options.prompt + if (options.completionPromise) { + promptText += `\n\n---\n\n**IMPORTANT - Completion Signal:** When you have completed ALL phases of this plan successfully, you MUST output the following tag exactly: ${options.completionPromise}\n\nDo NOT output this tag until every phase is truly complete. The loop will continue until this signal is detected.` + } + + const { result: promptResult, usedModel: actualModel } = await retryWithModelFallback( + () => v2.session.promptAsync({ + sessionID: loopContext.sessionId, + directory: loopContext.directory, + parts: [{ type: 'text' as const, text: promptText }], + ...(options.agent && { agent: options.agent }), + model: options.model!, + }), + () => v2.session.promptAsync({ + sessionID: loopContext.sessionId, + directory: loopContext.directory, + parts: [{ type: 'text' as const, text: promptText }], + ...(options.agent && { agent: options.agent }), + }), + options.model, + logger, + ) + + if (promptResult.error) { + logger.error(`ralph: failed to send prompt`, promptResult.error) + ralphService.deleteState(loopContext.sessionId) + if (!options.inPlace && loopContext.workspaceId) { + try { + await v2.worktree.remove({ worktreeRemoveInput: { directory: loopContext.directory } }) + } catch (cleanupErr) { + logger.error(`ralph: failed to cleanup worktree`, cleanupErr) + } + } + return options.inPlace + ? 'Ralph session created but failed to send prompt.' + : 'Ralph session created but failed to send prompt. Cleaned up.' + } + + const maxInfo = maxIter > 0 ? maxIter.toString() : 'unlimited' + const auditInfo = options.audit ? 'enabled' : 'disabled' + const modelInfo = actualModel ? `${actualModel.providerID}/${actualModel.modelID}` : 'default' + + const lines: string[] = [ + options.inPlace ? 'Ralph loop activated! (in-place mode)' : 'Ralph loop activated!', + '', + `Session: ${loopContext.sessionId}`, + `Title: ${options.sessionTitle}`, + ] + + if (options.inPlace) { + lines.push(`Directory: ${loopContext.directory}`) + lines.push(`Branch: ${loopContext.branch} (in-place)`) + } else { + lines.push(`Workspace: ${loopContext.workspaceId}`) + lines.push(`Worktree name: ${autoWorktreeName}`) + lines.push(`Worktree: ${loopContext.directory}`) + lines.push(`Branch: ${loopContext.branch}`) + } + + lines.push( + `Model: ${modelInfo}`, + `Max iterations: ${maxInfo}`, + `Completion promise: ${options.completionPromise ?? 'none'}`, + `Audit: ${auditInfo}`, + '', + 'The loop will automatically continue when the session goes idle.', + 'Use ralph-cancel to stop, or ralph-status to check progress.', + ) + + return lines.join('\n') + } + + const RALPH_BLOCKED_TOOLS: Record = { + question: 'The question tool is not available during a Ralph loop. Do not ask questions — continue working on the task autonomously.', + 'memory-plan-execute': 'The memory-plan-execute tool is not available during a Ralph loop. Focus on executing the current plan.', + 'memory-plan-ralph': 'The memory-plan-ralph tool is not available during a Ralph loop. Focus on executing the current plan.', + 'ralph-loop': 'The ralph-loop tool is not available during a Ralph loop. Focus on executing the current plan.', + } + return { getCleanup, tool: { @@ -528,9 +755,11 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { logger.log(`memory-plan-execute: creating session titled "${args.title}"`) const sessionTitle = args.title.length > 60 ? `${args.title.substring(0, 57)}...` : args.title + const executionModel = parseModelString(config.executionModel) - const createResult = await client.session.create({ - body: { title: sessionTitle }, + const createResult = await v2.session.create({ + title: sessionTitle, + directory, }) if (createResult.error || !createResult.data) { @@ -541,16 +770,23 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { const newSessionId = createResult.data.id logger.log(`memory-plan-execute: created session=${newSessionId}`) - const executionModel = parseModelString(config.executionModel) - - const promptResult = await client.session.promptAsync({ - path: { id: newSessionId }, - body: { + const { result: promptResult, usedModel: actualModel } = await retryWithModelFallback( + () => v2.session.promptAsync({ + sessionID: newSessionId, + directory, parts: [{ type: 'text' as const, text: args.plan }], agent: 'code', - ...(executionModel && { model: executionModel }), - }, - }) + model: executionModel!, + }), + () => v2.session.promptAsync({ + sessionID: newSessionId, + directory, + parts: [{ type: 'text' as const, text: args.plan }], + agent: 'code', + }), + executionModel, + logger, + ) if (promptResult.error) { logger.error(`memory-plan-execute: failed to prompt session`, promptResult.error) @@ -559,10 +795,41 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { logger.log(`memory-plan-execute: prompted session=${newSessionId}`) - const modelInfo = executionModel ? `${executionModel.providerID}/${executionModel.modelID}` : 'default' + const modelInfo = actualModel ? `${actualModel.providerID}/${actualModel.modelID}` : 'default' return `Implementation session created and plan sent.\n\nSession: ${newSessionId}\nTitle: ${sessionTitle}\nModel: ${modelInfo}\n\nSwitch to this session to begin. You can change the model from the session dropdown.` }, }), + 'memory-plan-ralph': tool({ + description: 'Execute a plan using a Ralph iterative development loop. By default runs in an isolated git worktree. Set inPlace to true to run in the current directory instead.', + args: { + plan: z.string().describe('The full implementation plan to send to the Code agent'), + title: z.string().describe('Short title for the session (shown in session list)'), + maxIterations: z.number().optional().default(0).describe('Max iterations before auto-stop (0 = unlimited)'), + audit: z.boolean().optional().default(true).describe('Run auditor after each iteration'), + inPlace: z.boolean().optional().default(false).describe('Run in current directory instead of creating a worktree'), + }, + execute: async (args) => { + if (config.ralph?.enabled === false) { + return 'Ralph loops are disabled in plugin config. Use memory-plan-execute instead.' + } + + logger.log(`memory-plan-ralph: creating worktree for plan="${args.title}"`) + + const sessionTitle = args.title.length > 60 ? `${args.title.substring(0, 57)}...` : args.title + const ralphModel = parseModelString(config.ralph?.model) ?? parseModelString(config.executionModel) + + return setupRalphLoop({ + prompt: args.plan, + sessionTitle: `Ralph: ${sessionTitle}`, + completionPromise: DEFAULT_PLAN_COMPLETION_PROMISE, + maxIterations: args.maxIterations ?? 0, + audit: args.audit ?? config.ralph?.defaultAudit ?? true, + agent: 'code', + model: ralphModel, + inPlace: args.inPlace, + }) + }, + }), 'memory-kv-set': tool({ description: 'Store a key-value pair for the current project. Values expire after 24 hours by default. Use for ephemeral project state like planning progress, code review patterns, or session context.', args: { @@ -612,24 +879,328 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { } const formatted = entries.map((e) => { const expiresIn = Math.round((e.expiresAt - Date.now()) / 60000) - const dataPreview = typeof e.data === 'string' ? e.data.substring(0, 100) : JSON.stringify(e.data).substring(0, 100) - return `- **${e.key}** (expires in ${expiresIn}m)\n ${dataPreview}${dataPreview.length >= 100 ? '...' : ''}` + const dataStr = typeof e.data === 'string' ? e.data : JSON.stringify(e.data) + const preview = dataStr.substring(0, 50).replace(/\n/g, ' ') + return `- **${e.key}** (expires in ${expiresIn}m): ${preview}${dataStr.length > 50 ? '...' : ''}` }) logger.log(`memory-kv-list: ${entries.length} entries`) return `${entries.length} active KV entries:\n\n${formatted.join('\n')}` }, }), + 'ralph-loop': tool({ + description: 'Start a Ralph Wiggum iterative development loop. By default runs in an isolated git worktree. Set inPlace to true to run in the current directory instead.', + args: { + prompt: z.string().describe('The task prompt to iterate on'), + maxIterations: z.number().optional().default(0).describe('Max iterations before auto-stop (0 = unlimited)'), + completionPromise: z.string().optional().describe('Phrase that signals completion when wrapped in tags'), + name: z.string().optional().describe('Optional name for the worktree branch'), + audit: z.boolean().optional().describe('Run auditor after each iteration'), + inPlace: z.boolean().optional().describe('Run in current directory instead of creating a worktree'), + }, + execute: async (args) => { + if (config.ralph?.enabled === false) { + return 'Ralph loops are disabled in plugin config.' + } + + logger.log(`ralph-loop: creating worktree for prompt="${args.prompt.substring(0, 80)}"`) + + const titlePreview = args.prompt.length > 40 ? `${args.prompt.substring(0, 37)}...` : args.prompt + const ralphModel = parseModelString(config.ralph?.model) ?? parseModelString(config.executionModel) + + return setupRalphLoop({ + prompt: args.prompt, + sessionTitle: `Ralph: ${titlePreview}`, + worktreeName: args.name, + completionPromise: args.completionPromise ?? null, + maxIterations: args.maxIterations ?? 0, + audit: args.audit ?? config.ralph?.defaultAudit ?? true, + model: ralphModel, + inPlace: args.inPlace, + }) + }, + }), + 'ralph-cancel': tool({ + description: 'Cancel an active Ralph loop and optionally clean up the worktree.', + args: { + name: z.string().optional().describe('Worktree name of the Ralph loop to cancel'), + }, + execute: async (args) => { + let state: RalphState | null = null + + if (args.name) { + state = ralphService.findByWorktreeName(args.name) + if (!state) { + return `No active Ralph loop found for worktree "${args.name}".` + } + } else { + const active = ralphService.listActive() + if (active.length === 0) return 'No active Ralph loops.' + if (active.length === 1) { + state = active[0] + } else { + return `Multiple active Ralph loops. Specify a name:\n${active.map((s) => `- ${s.worktreeName} (iteration ${s.iteration})`).join('\n')}` + } + } + + ralphService.setState(state.sessionId, { + ...state, + active: false, + completedAt: new Date().toISOString(), + terminationReason: 'cancelled', + }) + logger.log(`ralph-cancel: cancelled loop for session=${state.sessionId} at iteration ${state.iteration}`) + + if (config.ralph?.cleanupWorktree && !state.inPlace) { + try { + const gitCommonDir = execSync('git rev-parse --git-common-dir', { cwd: state.worktreeDir, encoding: 'utf-8' }).trim() + const gitRoot = resolve(state.worktreeDir, gitCommonDir, '..') + const removeResult = spawnSync('git', ['worktree', 'remove', '-f', state.worktreeDir], { cwd: gitRoot, encoding: 'utf-8' }) + if (removeResult.status !== 0) { + throw new Error(removeResult.stderr || 'git worktree remove failed') + } + logger.log(`ralph-cancel: removed worktree ${state.worktreeDir}`) + } catch (err) { + logger.error(`ralph-cancel: failed to remove worktree`, err) + } + } + + const modeInfo = state.inPlace ? ' (in-place)' : '' + return `Cancelled Ralph loop "${state.worktreeName}"${modeInfo} (was at iteration ${state.iteration}).\nDirectory: ${state.worktreeDir}\nBranch: ${state.worktreeBranch}` + }, + }), + 'ralph-status': tool({ + description: 'Check the status of Ralph loops. With no arguments, lists all active loops for the current project. Pass a worktree name for detailed status of a specific loop.', + args: { + name: z.string().optional().describe('Worktree name to check for detailed status'), + }, + execute: async (args) => { + const active = ralphService.listActive() + + if (!args.name) { + const recent = ralphService.listRecent() + + if (active.length === 0) { + if (recent.length === 0) return 'No Ralph loops found.' + + const lines: string[] = ['Recently Completed Ralph Loops', ''] + recent.forEach((s, i) => { + const duration = s.completedAt + ? Math.round((new Date(s.completedAt).getTime() - new Date(s.startedAt).getTime()) / 1000) + : 0 + const minutes = Math.floor(duration / 60) + const seconds = duration % 60 + const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + lines.push(`${i + 1}. ${s.worktreeName}`) + lines.push(` Reason: ${s.terminationReason ?? 'unknown'} | Iterations: ${s.iteration} | Duration: ${durationStr} | Completed: ${s.completedAt}`) + lines.push('') + }) + lines.push('Use ralph-status for detailed info.') + return lines.join('\n') + } + + let statuses: Record = {} + try { + const statusResult = await v2.session.status() + statuses = (statusResult.data ?? {}) as typeof statuses + } catch { + } + + const lines: string[] = [`Active Ralph Loops (${active.length})`, ''] + active.forEach((s, i) => { + const elapsed = Math.round((Date.now() - new Date(s.startedAt).getTime()) / 1000) + const minutes = Math.floor(elapsed / 60) + const seconds = elapsed % 60 + const duration = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + const iterInfo = s.maxIterations > 0 ? `${s.iteration} / ${s.maxIterations}` : `${s.iteration} (unlimited)` + const sessionStatus = statuses[s.sessionId]?.type ?? 'unknown' + const modeIndicator = s.inPlace ? ' (in-place)' : '' + lines.push(`${i + 1}. ${s.worktreeName}${modeIndicator}`) + lines.push(` Phase: ${s.phase} | Iteration: ${iterInfo} | Duration: ${duration} | Status: ${sessionStatus}`) + lines.push('') + }) + + if (recent.length > 0) { + lines.push('Recently Completed:') + lines.push('') + recent.forEach((s, i) => { + const duration = s.completedAt + ? Math.round((new Date(s.completedAt).getTime() - new Date(s.startedAt).getTime()) / 1000) + : 0 + const minutes = Math.floor(duration / 60) + const seconds = duration % 60 + const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + lines.push(`${i + 1}. ${s.worktreeName}`) + lines.push(` Reason: ${s.terminationReason ?? 'unknown'} | Iterations: ${s.iteration} | Duration: ${durationStr} | Completed: ${s.completedAt}`) + lines.push('') + }) + } + + lines.push('Use ralph-status for detailed info, or ralph-cancel to stop a loop.') + return lines.join('\n') + } + + const state = ralphService.findByWorktreeName(args.name) + if (!state) { + const recent = ralphService.listRecent() + const foundRecent = recent.find((s) => s.worktreeName === args.name) + if (foundRecent) { + const maxInfo = foundRecent.maxIterations > 0 ? `${foundRecent.iteration} / ${foundRecent.maxIterations}` : `${foundRecent.iteration} (unlimited)` + const duration = foundRecent.completedAt + ? Math.round((new Date(foundRecent.completedAt).getTime() - new Date(foundRecent.startedAt).getTime()) / 1000) + : 0 + const minutes = Math.floor(duration / 60) + const seconds = duration % 60 + const durationStr = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + + const completedLines: string[] = [ + 'Ralph Loop Status (Completed)', + '', + `Name: ${foundRecent.worktreeName}`, + `Session: ${foundRecent.sessionId}`, + ] + if (foundRecent.inPlace) { + completedLines.push(`Mode: in-place (completed) | Directory: ${foundRecent.worktreeDir}`) + } else { + completedLines.push(`Workspace: ${foundRecent.workspaceId}`) + completedLines.push(`Worktree: ${foundRecent.worktreeDir}`) + } + completedLines.push( + `Iteration: ${maxInfo}`, + `Duration: ${durationStr}`, + `Reason: ${foundRecent.terminationReason ?? 'unknown'}`, + `Branch: ${foundRecent.worktreeBranch}`, + `Started: ${foundRecent.startedAt}`, + `Completed: ${foundRecent.completedAt}`, + ) + return completedLines.join('\n') + } + return `No Ralph loop found for worktree "${args.name}".` + } + + const maxInfo = state.maxIterations > 0 ? `${state.iteration} / ${state.maxIterations}` : `${state.iteration} (unlimited)` + const promptPreview = state.prompt.length > 100 ? `${state.prompt.substring(0, 97)}...` : state.prompt + + let sessionStatus = 'unknown' + try { + const statusResult = await v2.session.status() + const statuses = statusResult.data as Record | undefined + const status = statuses?.[state.sessionId] + if (status) { + sessionStatus = status.type === 'retry' + ? `retry (attempt ${status.attempt}, next in ${Math.round(((status.next ?? 0) - Date.now()) / 1000)}s)` + : status.type + } + } catch { + sessionStatus = 'unavailable' + } + + const elapsed = Math.round((Date.now() - new Date(state.startedAt).getTime()) / 1000) + const minutes = Math.floor(elapsed / 60) + const seconds = elapsed % 60 + const duration = minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s` + + const statusLines: string[] = [ + 'Ralph Loop Status', + '', + `Name: ${state.worktreeName}`, + `Session: ${state.sessionId}`, + ] + if (state.inPlace) { + statusLines.push(`Mode: in-place | Directory: ${state.worktreeDir}`) + } else { + statusLines.push(`Workspace: ${state.workspaceId}`) + statusLines.push(`Worktree: ${state.worktreeDir}`) + } + statusLines.push( + `Status: ${sessionStatus}`, + `Phase: ${state.phase}`, + `Iteration: ${maxInfo}`, + `Duration: ${duration}`, + `Audit: ${state.audit ? 'enabled' : 'disabled'}`, + `Branch: ${state.worktreeBranch}`, + `Completion promise: ${state.completionPromise ?? 'none'}`, + `Started: ${state.startedAt}`, + ...(state.errorCount > 0 ? [`Error count: ${state.errorCount} (retries before termination: ${MAX_RETRIES})`] : []), + `Clean audit passes: ${state.cleanAuditCount ?? 0} / ${config.ralph?.minCleanAudits ?? 2}`, + `Model: ${config.ralph?.model || config.executionModel || 'default'}`, + `Auditor model: ${config.auditorModel || 'default'}`, + '', + `Prompt: ${promptPreview}`, + ) + return statusLines.join('\n') + }, + }), + }, + config: createConfigHandler( + config.auditorModel + ? { ...agents, auditor: { ...agents.auditor, defaultModel: config.auditorModel } } + : agents + ), + 'chat.message': async (input, output) => { + await sessionHooks.onMessage(input, output) }, - config: createConfigHandler(agents), - 'chat.message': sessionHooks.onMessage, event: async (input) => { const eventInput = input as { event: { type: string; properties?: Record } } if (eventInput.event?.type === 'server.instance.disposed') { cleanup() return } + await ralphHandler.onEvent(eventInput) await sessionHooks.onEvent(eventInput) }, + 'tool.execute.before': async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: unknown } + ) => { + const state = ralphService.getActiveState(input.sessionID) + if (!state?.active) return + + if (!(input.tool in RALPH_BLOCKED_TOOLS)) return + + logger.log(`Ralph: blocking ${input.tool} tool before execution in ${state.phase} phase for session ${input.sessionID}`) + + throw new Error(RALPH_BLOCKED_TOOLS[input.tool]!) + }, + 'tool.execute.after': async ( + input: { tool: string; sessionID: string; callID: string; args: unknown }, + output: { title: string; output: string; metadata: unknown } + ) => { + const state = ralphService.getActiveState(input.sessionID) + if (!state?.active) return + + if (!(input.tool in RALPH_BLOCKED_TOOLS)) return + + logger.log(`Ralph: blocked ${input.tool} tool in ${state.phase} phase for session ${input.sessionID}`) + + output.title = 'Tool blocked' + output.output = RALPH_BLOCKED_TOOLS[input.tool]! + }, + 'permission.ask': async (input, output) => { + const req = input as unknown as { sessionID: string; patterns: string[] } + const state = ralphService.getActiveState(req.sessionID) + if (!state?.active) return + + if (req.patterns.some((p) => p.startsWith('git push'))) { + logger.log(`Ralph: denied git push for session ${req.sessionID}`) + output.status = 'deny' + return + } + + if (state.inPlace) return + + const allWithinWorktree = req.patterns.every((p) => { + const resolved = p.startsWith('/') ? p : resolve(state.worktreeDir, p) + return resolved.startsWith(state.worktreeDir) + }) + + if (allWithinWorktree) { + output.status = 'allow' + return + } + + logger.log(`Ralph: denied permission outside worktree for session ${req.sessionID}`) + output.status = 'deny' + }, 'experimental.session.compacting': async (input, output) => { logger.log(`Compacting triggered`) await sessionHooks.onCompacting( @@ -695,9 +1266,9 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { text: ` Plan mode is active. You MUST NOT make any file edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supersedes any other instructions you have received. -You may ONLY: observe, analyze, plan, and use memory tools (memory-read, memory-write, memory-edit, memory-delete, memory-kv-set, memory-kv-get, memory-kv-list), and mcp_question. +You may ONLY: observe, analyze, plan, and use memory tools (memory-read, memory-write, memory-edit, memory-delete, memory-kv-set, memory-kv-get, memory-kv-list), mcp_question, memory-plan-execute, and memory-plan-ralph. -You MUST get explicit approval via mcp_question before calling memory-plan-execute. Never execute a plan without approval. +You MUST get explicit approval via mcp_question before calling memory-plan-execute or memory-plan-ralph. Never execute a plan without approval. `, synthetic: true, }) diff --git a/packages/memory/src/services/ralph.ts b/packages/memory/src/services/ralph.ts new file mode 100644 index 00000000..5056c346 --- /dev/null +++ b/packages/memory/src/services/ralph.ts @@ -0,0 +1,152 @@ +import type { KvService } from './kv' +import type { Logger, RalphConfig } from '../types' + +export const MAX_RETRIES = 3 +export const DEFAULT_MIN_CLEAN_AUDITS = 2 + +export interface RalphState { + active: boolean + sessionId: string + worktreeName: string + worktreeDir: string + worktreeBranch: string + workspaceId: string + iteration: number + maxIterations: number + completionPromise: string | null + startedAt: string + prompt: string + phase: 'coding' | 'auditing' + audit: boolean + lastAuditResult?: string + errorCount: number + cleanAuditCount: number + terminationReason?: string + completedAt?: string + parentSessionId?: string + inPlace?: boolean +} + +export interface RalphService { + getActiveState(sessionId: string): RalphState | null + getAnyState(sessionId: string): RalphState | null + setState(sessionId: string, state: RalphState): void + deleteState(sessionId: string): void + checkCompletionPromise(text: string, promise: string): boolean + buildContinuationPrompt(state: RalphState, auditFindings?: string): string + buildAuditPrompt(state: RalphState): string + listActive(): RalphState[] + listRecent(): RalphState[] + findByWorktreeName(name: string): RalphState | null + getMinCleanAudits(): number +} + +export function createRalphService( + kvService: KvService, + projectId: string, + logger: Logger, + ralphConfig?: RalphConfig, +): RalphService { + const stateKey = (sessionId: string) => `ralph:${sessionId}` + + function getAnyState(sessionId: string): RalphState | null { + return kvService.get(projectId, stateKey(sessionId)) + } + + function getActiveState(sessionId: string): RalphState | null { + const state = kvService.get(projectId, stateKey(sessionId)) + if (!state || !state.active) { + return null + } + return state + } + + function setState(sessionId: string, state: RalphState): void { + kvService.set(projectId, stateKey(sessionId), state) + } + + function deleteState(sessionId: string): void { + kvService.delete(projectId, stateKey(sessionId)) + } + + function checkCompletionPromise(text: string, promise: string): boolean { + const match = text.match(/([\s\S]*?)<\/promise>/) + if (!match) { + return false + } + const extracted = match[1].trim().replace(/\s+/g, ' ') + return extracted === promise + } + + function buildContinuationPrompt(state: RalphState, auditFindings?: string): string { + let systemLine = `Ralph iteration ${state.iteration}` + + if (state.completionPromise) { + systemLine += ` | To stop: output ${state.completionPromise} (ONLY when all requirements are met)` + } else if (state.maxIterations > 0) { + systemLine += ` / ${state.maxIterations}` + } else { + systemLine += ` | No completion promise set - loop runs until cancelled` + } + + let prompt = `[${systemLine}]\n\n${state.prompt}` + + if (auditFindings) { + prompt += `\n\n---\nThe following issues were found by the code auditor. Fix them:\n${auditFindings}` + } + + return prompt + } + + function buildAuditPrompt(state: RalphState): string { + const taskSummary = state.prompt.length > 200 + ? `${state.prompt.substring(0, 197)}...` + : state.prompt + + return [ + `Post-iteration ${state.iteration} code review (branch: ${state.worktreeBranch}).`, + '', + `Task context: ${taskSummary}`, + '', + 'Review the code changes in this worktree. Focus on bugs, logic errors, missing error handling, and convention violations.', + 'If everything looks good, state "No issues found." clearly.', + ].join('\n') + } + + function listActive(): RalphState[] { + const entries = kvService.listByPrefix(projectId, 'ralph:') + return entries + .map((entry) => entry.data as RalphState) + .filter((state): state is RalphState => state !== null && state.active) + } + + function listRecent(): RalphState[] { + const entries = kvService.listByPrefix(projectId, 'ralph:') + return entries + .map((entry) => entry.data as RalphState) + .filter((state): state is RalphState => state !== null && !state.active) + } + + function findByWorktreeName(name: string): RalphState | null { + const active = listActive() + return active.find((s) => s.worktreeName === name) ?? null + } + + function getMinCleanAudits(): number { + return ralphConfig?.minCleanAudits ?? DEFAULT_MIN_CLEAN_AUDITS + } + + return { + getActiveState, + getAnyState, + setState, + deleteState, + checkCompletionPromise, + buildContinuationPrompt, + buildAuditPrompt, + listActive, + listRecent, + findByWorktreeName, + getMinCleanAudits, + } +} diff --git a/packages/memory/test/ralph.test.ts b/packages/memory/test/ralph.test.ts new file mode 100644 index 00000000..784ba0cf --- /dev/null +++ b/packages/memory/test/ralph.test.ts @@ -0,0 +1,646 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { Database } from 'bun:sqlite' +import { createKvQuery } from '../src/storage/kv-queries' +import { createKvService } from '../src/services/kv' +import { createRalphService } from '../src/services/ralph' +import { hasAuditIssues } from '../src/hooks/ralph' + +const TEST_DIR = '/tmp/opencode-manager-ralph-test-' + Date.now() + +function createTestDb(): Database { + const db = new Database(`${TEST_DIR}-${Math.random().toString(36).slice(2)}.db`) + db.run(` + CREATE TABLE IF NOT EXISTS project_kv ( + project_id TEXT NOT NULL, + key TEXT NOT NULL, + data TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (project_id, key) + ) + `) + db.run(`CREATE INDEX IF NOT EXISTS idx_project_kv_expires_at ON project_kv(expires_at)`) + return db +} + +function createMockLogger() { + return { + log: () => {}, + error: () => {}, + debug: () => {}, + } +} + +describe('RalphService', () => { + let db: Database + let kvService: ReturnType + let ralphService: ReturnType + const projectId = 'test-project' + + beforeEach(() => { + db = createTestDb() + kvService = createKvService(db) + ralphService = createRalphService(kvService, projectId, createMockLogger()) + }) + + afterEach(() => { + db.close() + }) + + test('state CRUD operations', () => { + const state = { + active: true, + sessionId: 'session-123', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/ralph-test', + workspaceId: 'wrk-test-worktree', + iteration: 1, + maxIterations: 5, + completionPromise: 'DONE', + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + } + + ralphService.setState('session-123', state) + const retrieved = ralphService.getActiveState('session-123') + expect(retrieved).toEqual(state) + + ralphService.setState('session-123', { ...state, iteration: 2 }) + const updated = ralphService.getActiveState('session-123') + expect(updated?.iteration).toBe(2) + + ralphService.deleteState('session-123') + const deleted = ralphService.getActiveState('session-123') + expect(deleted).toBeNull() + }) + + test('getState returns null for inactive state', () => { + const inactiveState = { + active: false, + sessionId: 'session-456', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/ralph-test', + workspaceId: 'wrk-test-worktree', + iteration: 1, + maxIterations: 0, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + } + + ralphService.setState('session-456', inactiveState) + const retrieved = ralphService.getActiveState('session-456') + expect(retrieved).toBeNull() + }) + + test('getActiveState returns null for non-existent session', () => { + const retrieved = ralphService.getActiveState('non-existent') + expect(retrieved).toBeNull() + }) + + test('checkCompletionPromise matches exact promise', () => { + const text = 'Some response text DONE more text' + expect(ralphService.checkCompletionPromise(text, 'DONE')).toBe(true) + }) + + test('checkCompletionPromise returns false when no promise tags', () => { + const text = 'Some response text without promise tags' + expect(ralphService.checkCompletionPromise(text, 'DONE')).toBe(false) + }) + + test('checkCompletionPromise returns false when promise does not match', () => { + const text = 'Some response NOT_DONE text' + expect(ralphService.checkCompletionPromise(text, 'DONE')).toBe(false) + }) + + test('checkCompletionPromise handles whitespace normalization', () => { + const text = 'Response DONE WITH SPACES text' + expect(ralphService.checkCompletionPromise(text, 'DONE WITH SPACES')).toBe(true) + }) + + test('checkCompletionPromise matches first promise tag when multiple present', () => { + const text = 'First FIRST second SECOND' + expect(ralphService.checkCompletionPromise(text, 'FIRST')).toBe(true) + expect(ralphService.checkCompletionPromise(text, 'SECOND')).toBe(false) + }) + + test('checkCompletionPromise handles multiline promise', () => { + const text = 'Response \n MULTI\n LINE\n text' + expect(ralphService.checkCompletionPromise(text, 'MULTI LINE')).toBe(true) + }) + + test('buildContinuationPrompt includes iteration number', () => { + const state = { + active: true, + sessionId: 'session-789', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/ralph-test', + workspaceId: 'wrk-test-worktree', + iteration: 3, + maxIterations: 0, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'My test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + } + + const prompt = ralphService.buildContinuationPrompt(state) + expect(prompt).toContain('Ralph iteration 3') + expect(prompt).toContain('My test prompt') + }) + + test('buildContinuationPrompt includes completion promise instruction', () => { + const state = { + active: true, + sessionId: 'session-789', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/ralph-test', + workspaceId: 'wrk-test-worktree', + iteration: 1, + maxIterations: 0, + completionPromise: 'COMPLETE_TASK', + startedAt: new Date().toISOString(), + prompt: 'My test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + } + + const prompt = ralphService.buildContinuationPrompt(state) + expect(prompt).toContain('[Ralph iteration 1 | To stop: output COMPLETE_TASK (ONLY when all requirements are met)]') + }) + + test('buildContinuationPrompt includes max iterations when no promise', () => { + const state = { + active: true, + sessionId: 'session-789', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/ralph-test', + workspaceId: 'wrk-test-worktree', + iteration: 2, + maxIterations: 10, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'My test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + } + + const prompt = ralphService.buildContinuationPrompt(state) + expect(prompt).toContain('[Ralph iteration 2 / 10]') + }) + + test('buildContinuationPrompt shows unlimited message when no promise and no max', () => { + const state = { + active: true, + sessionId: 'session-789', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/ralph-test', + workspaceId: 'wrk-test-worktree', + iteration: 1, + maxIterations: 0, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'My test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + } + + const prompt = ralphService.buildContinuationPrompt(state) + expect(prompt).toContain('[Ralph iteration 1 | No completion promise set - loop runs until cancelled]') + }) + + test('state persists across service recreation', () => { + const state = { + active: true, + sessionId: 'session-persist', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/ralph-test', + workspaceId: 'wrk-test-worktree', + iteration: 5, + maxIterations: 10, + completionPromise: 'PERSIST_TEST', + startedAt: new Date().toISOString(), + prompt: 'Persistence test', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + } + + ralphService.setState('session-persist', state) + + const newKvService = createKvService(db) + const newRalphService = createRalphService(newKvService, projectId, createMockLogger()) + + const retrieved = newRalphService.getActiveState('session-persist') + expect(retrieved).toEqual(state) + }) + + test('buildAuditPrompt returns audit instruction', () => { + const state = { + active: true, + sessionId: 'session-audit', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/ralph-test', + workspaceId: 'wrk-test-worktree', + iteration: 1, + maxIterations: 0, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: true, + errorCount: 0, + cleanAuditCount: 0, + } + + const prompt = ralphService.buildAuditPrompt(state) + expect(prompt).toContain('Review the code changes') + expect(prompt).toContain('bugs, logic errors, missing error handling') + expect(prompt).toContain('No issues found') + }) + + test('buildContinuationPrompt appends audit findings when provided', () => { + const state = { + active: true, + sessionId: 'session-audit', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/ralph-test', + workspaceId: 'wrk-test-worktree', + iteration: 2, + maxIterations: 0, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: true, + errorCount: 0, + cleanAuditCount: 0, + } + + const auditFindings = 'Found a bug in line 10' + const prompt = ralphService.buildContinuationPrompt(state, auditFindings) + expect(prompt).toContain('Ralph iteration 2') + expect(prompt).toContain('Test prompt') + expect(prompt).toContain('The following issues were found by the code auditor') + expect(prompt).toContain('Found a bug in line 10') + }) + + test('buildContinuationPrompt without audit findings does not append section', () => { + const state = { + active: true, + sessionId: 'session-audit', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/ralph-test', + workspaceId: 'wrk-test-worktree', + iteration: 2, + maxIterations: 0, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: true, + errorCount: 0, + cleanAuditCount: 0, + } + + const prompt = ralphService.buildContinuationPrompt(state) + expect(prompt).toContain('Ralph iteration 2') + expect(prompt).toContain('Test prompt') + expect(prompt).not.toContain('The following issues were found') + }) + + test('listActive returns only active states', () => { + const activeState1 = { + active: true, + sessionId: 'active-1', + worktreeName: 'worktree-1', + worktreeDir: '/path/to/worktree1', + worktreeBranch: 'opencode/ralph-worktree-1', + workspaceId: 'wrk-worktree-1', + iteration: 1, + maxIterations: 0, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'Active prompt 1', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + } + + const activeState2 = { + active: true, + sessionId: 'active-2', + worktreeName: 'worktree-2', + worktreeDir: '/path/to/worktree2', + worktreeBranch: 'opencode/ralph-worktree-2', + workspaceId: 'ralph-worktree-2', + iteration: 2, + maxIterations: 0, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'Active prompt 2', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + } + + const inactiveState = { + active: false, + sessionId: 'inactive-1', + worktreeName: 'worktree-3', + worktreeDir: '/path/to/worktree3', + worktreeBranch: 'opencode/ralph-worktree-3', + workspaceId: 'ralph-worktree-3', + iteration: 1, + maxIterations: 0, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'Inactive prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + } + + ralphService.setState('active-1', activeState1) + ralphService.setState('active-2', activeState2) + ralphService.setState('inactive-1', inactiveState) + + const active = ralphService.listActive() + expect(active.length).toBe(2) + expect(active.map((s) => s.sessionId)).toContain('active-1') + expect(active.map((s) => s.sessionId)).toContain('active-2') + expect(active.map((s) => s.sessionId)).not.toContain('inactive-1') + }) + + test('findByWorktreeName returns state by worktree name', () => { + const state1 = { + active: true, + sessionId: 'session-1', + worktreeName: 'unique-worktree-name', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/ralph-unique-worktree-name', + workspaceId: 'wrk-unique-worktree-name', + iteration: 1, + maxIterations: 0, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + } + + ralphService.setState('session-1', state1) + + const found = ralphService.findByWorktreeName('unique-worktree-name') + expect(found).toEqual(state1) + + const notFound = ralphService.findByWorktreeName('non-existent') + expect(notFound).toBeNull() + }) + + test('state with errorCount and cleanAuditCount persists correctly', () => { + const state = { + active: true, + sessionId: 'session-err', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/ralph-test', + workspaceId: 'wrk-test-worktree', + iteration: 1, + maxIterations: 5, + completionPromise: 'DONE', + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 2, + cleanAuditCount: 1, + terminationReason: undefined, + parentSessionId: 'parent-session-123', + } + ralphService.setState('session-err', state) + const retrieved = ralphService.getActiveState('session-err') + expect(retrieved?.errorCount).toBe(2) + expect(retrieved?.cleanAuditCount).toBe(1) + expect(retrieved?.parentSessionId).toBe('parent-session-123') + }) + + test('state defaults errorCount to 0', () => { + const state = { + active: true, + sessionId: 'session-default', + worktreeName: 'test-worktree', + worktreeDir: '/path/to/worktree', + worktreeBranch: 'opencode/ralph-test', + workspaceId: 'wrk-test-worktree', + iteration: 1, + maxIterations: 0, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + } + ralphService.setState('session-default', state) + const retrieved = ralphService.getActiveState('session-default') + expect(retrieved?.errorCount).toBe(0) + expect(retrieved?.cleanAuditCount).toBe(0) + }) + + test('state with inPlace flag persists correctly', () => { + const inPlaceState = { + active: true, + sessionId: 'session-inplace', + worktreeName: 'inplace-worktree', + worktreeDir: '/path/to/project', + worktreeBranch: 'main', + workspaceId: '', + iteration: 1, + maxIterations: 5, + completionPromise: 'DONE', + startedAt: new Date().toISOString(), + prompt: 'In-place test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + inPlace: true, + } + ralphService.setState('session-inplace', inPlaceState) + const retrieved = ralphService.getActiveState('session-inplace') + expect(retrieved?.inPlace).toBe(true) + expect(retrieved?.workspaceId).toBe('') + expect(retrieved?.worktreeDir).toBe('/path/to/project') + }) + + test('findByWorktreeName works with inPlace state', () => { + const inPlaceState = { + active: true, + sessionId: 'session-inplace-2', + worktreeName: 'unique-inplace-name', + worktreeDir: '/path/to/project', + worktreeBranch: 'develop', + workspaceId: '', + iteration: 2, + maxIterations: 0, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: true, + errorCount: 0, + cleanAuditCount: 1, + inPlace: true, + } + ralphService.setState('session-inplace-2', inPlaceState) + const found = ralphService.findByWorktreeName('unique-inplace-name') + expect(found).toEqual(inPlaceState) + expect(found?.inPlace).toBe(true) + }) + + test('buildContinuationPrompt works with inPlace state', () => { + const inPlaceState = { + active: true, + sessionId: 'session-inplace-3', + worktreeName: 'inplace-prompt-test', + worktreeDir: '/path/to/project', + worktreeBranch: 'main', + workspaceId: '', + iteration: 3, + maxIterations: 0, + completionPromise: 'COMPLETE', + startedAt: new Date().toISOString(), + prompt: 'In-place prompt test', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + inPlace: true, + } + const prompt = ralphService.buildContinuationPrompt(inPlaceState) + expect(prompt).toContain('Ralph iteration 3') + expect(prompt).toContain('In-place prompt test') + expect(prompt).toContain('COMPLETE') + }) + + test('buildContinuationPrompt with audit findings works with inPlace state', () => { + const inPlaceState = { + active: true, + sessionId: 'session-inplace-4', + worktreeName: 'inplace-audit-test', + worktreeDir: '/path/to/project', + worktreeBranch: 'main', + workspaceId: '', + iteration: 2, + maxIterations: 0, + completionPromise: null, + startedAt: new Date().toISOString(), + prompt: 'In-place audit test', + phase: 'coding' as const, + audit: true, + errorCount: 0, + cleanAuditCount: 0, + inPlace: true, + } + const auditFindings = 'Bug found in component' + const prompt = ralphService.buildContinuationPrompt(inPlaceState, auditFindings) + expect(prompt).toContain('Ralph iteration 2') + expect(prompt).toContain('In-place audit test') + expect(prompt).toContain('The following issues were found by the code auditor') + expect(prompt).toContain('Bug found in component') + }) +}) + +describe('hasAuditIssues', () => { + test('returns false for "No issues found"', () => { + expect(hasAuditIssues('No issues found')).toBe(false) + }) + + test('returns false for "0 issues found"', () => { + expect(hasAuditIssues('0 issues found')).toBe(false) + }) + + test('returns true for severity: bug', () => { + expect(hasAuditIssues('**Severity**: bug\nFound a bug')).toBe(true) + }) + + test('returns true for severity: warning', () => { + expect(hasAuditIssues('severity: warning\nSome warning')).toBe(true) + }) + + test('returns true for "3 issues found"', () => { + expect(hasAuditIssues('3 issues found in the code')).toBe(true) + }) + + test('returns true for "1 bug found"', () => { + expect(hasAuditIssues('1 bug found on line 10')).toBe(true) + }) + + test('returns true for ### Issues section with content', () => { + const text = `### Issues + +- Missing error handling +- Type safety issue` + expect(hasAuditIssues(text)).toBe(true) + }) + + test('returns false for ### Issues section with "None"', () => { + expect(hasAuditIssues('### Issues\n\nNone')).toBe(false) + }) + + test('returns false for ### Issues section with "N/A"', () => { + expect(hasAuditIssues('### Issues\n\nN/A')).toBe(false) + }) + + test('returns false for empty text', () => { + expect(hasAuditIssues('')).toBe(false) + }) + + test('returns false for text without any issue signals', () => { + expect(hasAuditIssues('Code looks good')).toBe(false) + }) + + test('returns true for severity:bug without space', () => { + expect(hasAuditIssues('severity:bug in function')).toBe(true) + }) + + test('returns true for **severity**: bug', () => { + expect(hasAuditIssues('**severity**: bug')).toBe(true) + }) +}) diff --git a/packages/memory/test/tool-blocking.test.ts b/packages/memory/test/tool-blocking.test.ts new file mode 100644 index 00000000..5ef1a2ab --- /dev/null +++ b/packages/memory/test/tool-blocking.test.ts @@ -0,0 +1,161 @@ +import { describe, test, expect, beforeEach, afterEach } from 'bun:test' +import { Database } from 'bun:sqlite' +import { createKvService } from '../src/services/kv' +import { createRalphService } from '../src/services/ralph' +import type { Logger } from '../src/types' + +const TEST_DIR = '/tmp/opencode-manager-tool-blocking-test-' + Date.now() + +function createTestDb(): Database { + const db = new Database(`${TEST_DIR}-${Math.random().toString(36).slice(2)}.db`) + db.run(` + CREATE TABLE IF NOT EXISTS project_kv ( + project_id TEXT NOT NULL, + key TEXT NOT NULL, + data TEXT NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (project_id, key) + ) + `) + db.run(`CREATE INDEX IF NOT EXISTS idx_project_kv_expires_at ON project_kv(expires_at)`) + return db +} + +function createMockLogger(): Logger { + return { + log: () => {}, + error: () => {}, + debug: () => {}, + } +} + +describe('Tool Blocking Logic', () => { + let db: Database + let ralphService: ReturnType + const projectId = 'test-project' + const sessionID = 'test-session-123' + + beforeEach(() => { + db = createTestDb() + const kvService = createKvService(db) + ralphService = createRalphService(kvService, projectId, createMockLogger()) + }) + + afterEach(() => { + db.close() + }) + + describe('Ralph state lookup', () => { + test('getActiveState returns active state when Ralph loop is active', () => { + const state = { + active: true, + sessionId: sessionID, + worktreeName: 'test-worktree', + worktreeDir: '/test/worktree', + worktreeBranch: 'opencode/ralph-test', + workspaceId: 'wrk-test-worktree', + iteration: 1, + maxIterations: 5, + completionPromise: 'DONE', + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + inPlace: false, + } + ralphService.setState(sessionID, state) + + const retrieved = ralphService.getActiveState(sessionID) + expect(retrieved).toEqual(state) + expect(retrieved?.active).toBe(true) + }) + + test('getActiveState returns null when no Ralph loop exists', () => { + const retrieved = ralphService.getActiveState('non-existent-session') + expect(retrieved).toBeNull() + }) + + test('getActiveState returns null when Ralph loop is inactive', () => { + const inactiveState = { + active: false, + sessionId: sessionID, + worktreeName: 'test-worktree', + worktreeDir: '/test/worktree', + worktreeBranch: 'opencode/ralph-test', + workspaceId: 'wrk-test-worktree', + iteration: 1, + maxIterations: 5, + completionPromise: 'DONE', + startedAt: new Date().toISOString(), + prompt: 'Test prompt', + phase: 'coding' as const, + audit: false, + errorCount: 0, + cleanAuditCount: 0, + inPlace: false, + } + ralphService.setState(sessionID, inactiveState) + + const retrieved = ralphService.getActiveState(sessionID) + expect(retrieved).toBeNull() + }) + }) + + describe('Blocked tools list', () => { + test('includes question tool', () => { + const blockedTools = ['question', 'memory-plan-execute', 'memory-plan-ralph', 'ralph-loop'] + expect(blockedTools).toContain('question') + }) + + test('includes memory-plan-execute tool', () => { + const blockedTools = ['question', 'memory-plan-execute', 'memory-plan-ralph', 'ralph-loop'] + expect(blockedTools).toContain('memory-plan-execute') + }) + + test('includes memory-plan-ralph tool', () => { + const blockedTools = ['question', 'memory-plan-execute', 'memory-plan-ralph', 'ralph-loop'] + expect(blockedTools).toContain('memory-plan-ralph') + }) + + test('includes ralph-loop tool', () => { + const blockedTools = ['question', 'memory-plan-execute', 'memory-plan-ralph', 'ralph-loop'] + expect(blockedTools).toContain('ralph-loop') + }) + + test('does not include memory-read tool', () => { + const blockedTools = ['question', 'memory-plan-execute', 'memory-plan-ralph', 'ralph-loop'] + expect(blockedTools).not.toContain('memory-read') + }) + + test('does not include memory-write tool', () => { + const blockedTools = ['question', 'memory-plan-execute', 'memory-plan-ralph', 'ralph-loop'] + expect(blockedTools).not.toContain('memory-write') + }) + }) + + describe('Error messages', () => { + test('question tool has appropriate error message', () => { + const messages: Record = { + 'question': 'The question tool is not available during a Ralph loop. Do not ask questions — continue working on the task autonomously.', + 'memory-plan-execute': 'The memory-plan-execute tool is not available during a Ralph loop. Focus on executing the current plan.', + 'memory-plan-ralph': 'The memory-plan-ralph tool is not available during a Ralph loop. Focus on executing the current plan.', + 'ralph-loop': 'The ralph-loop tool is not available during a Ralph loop. Focus on executing the current plan.', + } + expect(messages['question']).toContain('question tool is not available') + }) + + test('memory-plan-execute tool has appropriate error message', () => { + const messages: Record = { + 'question': 'The question tool is not available during a Ralph loop. Do not ask questions — continue working on the task autonomously.', + 'memory-plan-execute': 'The memory-plan-execute tool is not available during a Ralph loop. Focus on executing the current plan.', + 'memory-plan-ralph': 'The memory-plan-ralph tool is not available during a Ralph loop. Focus on executing the current plan.', + 'ralph-loop': 'The ralph-loop tool is not available during a Ralph loop. Focus on executing the current plan.', + } + expect(messages['memory-plan-execute']).toContain('memory-plan-execute tool is not available') + }) + }) +}) From 043487017c33c98130bcaaaf0c5d25d9f24d3e10 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:45:10 -0400 Subject: [PATCH 02/29] Add plugin memory service and routes --- backend/src/routes/memory.ts | 186 ++++++++++++++++++-------- backend/src/services/plugin-memory.ts | 92 +++++++++++++ 2 files changed, 225 insertions(+), 53 deletions(-) diff --git a/backend/src/routes/memory.ts b/backend/src/routes/memory.ts index 927d3904..b6b25918 100644 --- a/backend/src/routes/memory.ts +++ b/backend/src/routes/memory.ts @@ -11,7 +11,10 @@ import { CreateMemoryRequestSchema, UpdateMemoryRequestSchema, MemoryListQuerySchema, + KvListQuerySchema, PluginConfigSchema, + CreateKvEntryRequestSchema, + UpdateKvEntryRequestSchema, type PluginConfig, } from '@opencode-manager/shared/schemas' @@ -153,8 +156,9 @@ export function createMemoryRoutes(db: Database): Hono { } const stats = pluginMemory.getStats(projectId) + const kvCount = pluginMemory.getKvCount(projectId) - return c.json({ projectId, stats }) + return c.json({ projectId, stats, kvCount }) } catch (error) { logger.error('Failed to get project summary:', error) return c.json({ projectId: null, stats: { total: 0, byScope: {} }, error: 'Failed to get project summary' }, 500) @@ -334,6 +338,134 @@ export function createMemoryRoutes(db: Database): Hono { } }) + app.post('/reindex', async (c) => { + try { + const db = pluginMemory.getDb() + + if (!db) { + return c.json({ + error: 'Memory database not found. Make sure the memory plugin has been initialized.', + total: 0, + embedded: 0, + failed: 0 + }, 404) + } + + const memories = pluginMemory.listAll() + + if (memories.length === 0) { + return c.json({ + success: true, + message: 'No memories to reindex', + total: 0, + embedded: 0, + failed: 0 + }) + } + + try { + db.exec('DELETE FROM memory_embeddings') + } catch { + return c.json({ + success: true, + message: 'Cleared embeddings. Server restart required to regenerate embeddings with new model.', + total: memories.length, + embedded: 0, + failed: 0, + requiresRestart: true + }) + } + + return c.json({ + success: true, + message: `Cleared ${memories.length} embeddings. Server restart required to regenerate embeddings.`, + total: memories.length, + embedded: 0, + failed: 0, + requiresRestart: true + }) + } catch (error) { + logger.error('Failed to reindex memories:', error) + return c.json({ error: 'Failed to reindex memories', details: error instanceof Error ? error.message : 'Unknown error' }, 500) + } + }) + + app.get('/kv', async (c) => { + const query = c.req.query() + const parsed = KvListQuerySchema.safeParse({ + projectId: query.projectId, + prefix: query.prefix, + }) + + if (!parsed.success) { + return c.json({ error: 'Invalid query parameters', details: parsed.error }, 400) + } + + const { projectId, prefix } = parsed.data + const entries = pluginMemory.listKv(projectId, prefix) + return c.json({ entries }) + }) + + app.post('/kv', async (c) => { + const body = await c.req.json() + const parsed = CreateKvEntryRequestSchema.safeParse(body) + + if (!parsed.success) { + return c.json({ error: 'Invalid request', details: parsed.error }, 400) + } + + try { + pluginMemory.setKv(parsed.data.projectId, parsed.data.key, parsed.data.data, parsed.data.ttlMs) + const entry = pluginMemory.getKv(parsed.data.projectId, parsed.data.key) + return c.json({ entry }, 201) + } catch (error) { + logger.error('Failed to create KV entry:', error) + return c.json({ error: 'Failed to create KV entry' }, 500) + } + }) + + app.put('/kv/:key', async (c) => { + const key = decodeURIComponent(c.req.param('key')) + const projectId = c.req.query('projectId') + + if (!projectId) { + return c.json({ error: 'Missing projectId parameter' }, 400) + } + + const body = await c.req.json() + const parsed = UpdateKvEntryRequestSchema.safeParse(body) + + if (!parsed.success) { + return c.json({ error: 'Invalid request', details: parsed.error }, 400) + } + + try { + pluginMemory.setKv(projectId, key, parsed.data.data, parsed.data.ttlMs) + const entry = pluginMemory.getKv(projectId, key) + return c.json({ entry }) + } catch (error) { + logger.error('Failed to update KV entry:', error) + return c.json({ error: 'Failed to update KV entry' }, 500) + } + }) + + app.delete('/kv/:key', async (c) => { + const key = decodeURIComponent(c.req.param('key')) + const projectId = c.req.query('projectId') + + if (!projectId) { + return c.json({ error: 'Missing projectId parameter' }, 400) + } + + try { + pluginMemory.deleteKv(projectId, key) + return c.json({ success: true }) + } catch (error) { + logger.error('Failed to delete KV entry:', error) + return c.json({ error: 'Failed to delete KV entry' }, 500) + } + }) + app.get('/:id', async (c) => { const id = parseInt(c.req.param('id'), 10) @@ -390,57 +522,5 @@ export function createMemoryRoutes(db: Database): Hono { } }) - app.post('/reindex', async (c) => { - try { - const db = pluginMemory.getDb() - - if (!db) { - return c.json({ - error: 'Memory database not found. Make sure the memory plugin has been initialized.', - total: 0, - embedded: 0, - failed: 0 - }, 404) - } - - const memories = pluginMemory.listAll() - - if (memories.length === 0) { - return c.json({ - success: true, - message: 'No memories to reindex', - total: 0, - embedded: 0, - failed: 0 - }) - } - - try { - db.exec('DELETE FROM memory_embeddings') - } catch { - return c.json({ - success: true, - message: 'Cleared embeddings. Server restart required to regenerate embeddings with new model.', - total: memories.length, - embedded: 0, - failed: 0, - requiresRestart: true - }) - } - - return c.json({ - success: true, - message: `Cleared ${memories.length} embeddings. Server restart required to regenerate embeddings.`, - total: memories.length, - embedded: 0, - failed: 0, - requiresRestart: true - }) - } catch (error) { - logger.error('Failed to reindex memories:', error) - return c.json({ error: 'Failed to reindex memories', details: error instanceof Error ? error.message : 'Unknown error' }, 500) - } - }) - return app } diff --git a/backend/src/services/plugin-memory.ts b/backend/src/services/plugin-memory.ts index f3227d5f..84db9f6a 100644 --- a/backend/src/services/plugin-memory.ts +++ b/backend/src/services/plugin-memory.ts @@ -27,6 +27,15 @@ interface DbMemoryRow { updated_at: number } +interface DbKvRow { + project_id: string + key: string + data: string + expires_at: number + created_at: number + updated_at: number +} + interface MemoryFilters { scope?: 'convention' | 'decision' | 'context' content?: string @@ -52,6 +61,22 @@ function mapRowToMemory(row: DbMemoryRow): PluginMemory { } } +function mapRowToKvEntry(row: DbKvRow): { key: string; data: unknown; createdAt: number; updatedAt: number; expiresAt: number } { + let data: unknown = null + try { + data = JSON.parse(row.data) + } catch { + data = row.data + } + return { + key: row.key, + data, + createdAt: row.created_at, + updatedAt: row.updated_at, + expiresAt: row.expires_at, + } +} + export class PluginMemoryService { private db: Database | null = null @@ -233,6 +258,73 @@ export class PluginMemoryService { return { projectId, total, byScope } } + listKv(projectId: string, prefix?: string): { key: string; data: unknown; createdAt: number; updatedAt: number; expiresAt: number }[] { + const db = this.getDb() + if (!db) return [] + + const now = Date.now() + let sql = 'SELECT project_id, key, data, expires_at, created_at, updated_at FROM project_kv WHERE project_id = ? AND expires_at > ?' + const params: (string | number)[] = [projectId, now] + + if (prefix) { + sql += ' AND key LIKE ?' + params.push(`${prefix}%`) + } + + sql += ' ORDER BY updated_at DESC' + + const stmt = db.prepare(sql) + const rows = stmt.all(...params) as DbKvRow[] + return rows.map(mapRowToKvEntry) + } + + getKv(projectId: string, key: string): { key: string; data: unknown; createdAt: number; updatedAt: number; expiresAt: number } | undefined { + const db = this.getDb() + if (!db) return undefined + + const stmt = db.prepare( + 'SELECT project_id, key, data, expires_at, created_at, updated_at FROM project_kv WHERE project_id = ? AND key = ? AND expires_at > ?' + ) + const row = stmt.get(projectId, key, Date.now()) as DbKvRow | undefined + return row ? mapRowToKvEntry(row) : undefined + } + + setKv(projectId: string, key: string, data: unknown, ttlMs?: number): void { + const db = this.getDb() + if (!db) throw new Error('Plugin database not available') + + const now = Date.now() + const expiresAt = ttlMs ? now + ttlMs : Number.MAX_SAFE_INTEGER + const serializedData = JSON.stringify(data) + + const stmt = db.prepare(` + INSERT INTO project_kv (project_id, key, data, expires_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(project_id, key) DO UPDATE SET + data = excluded.data, + expires_at = excluded.expires_at, + updated_at = excluded.updated_at + `) + stmt.run(projectId, key, serializedData, expiresAt, now, now) + } + + deleteKv(projectId: string, key: string): void { + const db = this.getDb() + if (!db) throw new Error('Plugin database not available') + + const stmt = db.prepare('DELETE FROM project_kv WHERE project_id = ? AND key = ?') + stmt.run(projectId, key) + } + + getKvCount(projectId: string): number { + const db = this.getDb() + if (!db) return 0 + + const stmt = db.prepare('SELECT COUNT(*) as count FROM project_kv WHERE project_id = ? AND expires_at > ?') + const result = stmt.get(projectId, Date.now()) as { count: number } + return result.count + } + close(): void { if (this.db) { this.db.close() From 5a2f65fe1fb0c015d6c23e6d6afeec1738ce2bec Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:45:22 -0400 Subject: [PATCH 03/29] Add Ralph loop management CLI commands --- packages/memory/src/cli/commands/cancel.ts | 222 ++++++++++++++++++ packages/memory/src/cli/commands/status.ts | 259 +++++++++++++++++++++ packages/memory/src/cli/index.ts | 22 ++ 3 files changed, 503 insertions(+) create mode 100644 packages/memory/src/cli/commands/cancel.ts create mode 100644 packages/memory/src/cli/commands/status.ts diff --git a/packages/memory/src/cli/commands/cancel.ts b/packages/memory/src/cli/commands/cancel.ts new file mode 100644 index 00000000..95f4bb62 --- /dev/null +++ b/packages/memory/src/cli/commands/cancel.ts @@ -0,0 +1,222 @@ +import type { RalphState } from '../../services/ralph' +import { openDatabase, confirm } from '../utils' +import { execSync } from 'child_process' +import { existsSync } from 'fs' + +interface CancelOptions { + projectId?: string + dbPath?: string + cleanup?: boolean + force?: boolean + help?: boolean +} + +function parseArgs(args: string[]): CancelOptions & { worktreeName?: string } { + const options: CancelOptions & { worktreeName?: string } = {} + let i = 0 + + while (i < args.length) { + const arg = args[i] + + if (arg === '--project' || arg === '-p') { + options.projectId = args[++i] + } else if (arg === '--db-path') { + options.dbPath = args[++i] + } else if (arg === '--cleanup') { + options.cleanup = true + } else if (arg === '--force') { + options.force = true + } else if (arg === '--help' || arg === '-h') { + options.help = true + } else if (!arg.startsWith('-')) { + options.worktreeName = arg + } else { + console.error(`Unknown option: ${arg}`) + help() + process.exit(1) + } + + i++ + } + + return options +} + +export function help(): void { + console.log(` +Cancel a Ralph loop + +Usage: + ocm-mem cancel [name] [options] + +Arguments: + name Worktree name to cancel (optional if only one active) + +Options: + --cleanup Remove worktree directory after cancellation + --force Skip confirmation prompt + --project, -p Project ID (auto-detected from git if not provided) + --db-path Path to memory database + --help, -h Show this help message + `.trim()) +} + +export async function run(args: string[], globalOpts: { dbPath?: string; projectId?: string }): Promise { + const options = parseArgs(args) + options.projectId = options.projectId || globalOpts.projectId + options.dbPath = options.dbPath || globalOpts.dbPath + + if (options.help) { + help() + process.exit(0) + } + + const db = openDatabase(options.dbPath) + + try { + const projectId = options.projectId + + const now = Date.now() + let query: string + let params: (string | number)[] + + if (projectId) { + query = 'SELECT project_id, key, data FROM project_kv WHERE project_id = ? AND key LIKE ? AND expires_at > ?' + params = [projectId, 'ralph:%', now] + } else { + query = 'SELECT project_id, key, data FROM project_kv WHERE key LIKE ? AND expires_at > ?' + params = ['ralph:%', now] + } + + let rows: Array<{ project_id: string; key: string; data: string }> + try { + rows = db.prepare(query).all(...params) as Array<{ project_id: string; key: string; data: string }> + } catch { + rows = [] + } + + if (rows.length === 0) { + console.log('') + console.log('No active Ralph loops.') + console.log('') + return + } + + const loops: Array<{ state: RalphState; row: { project_id: string; key: string; data: string } }> = [] + + for (const row of rows) { + try { + const state = JSON.parse(row.data) as RalphState + if (state.active) { + loops.push({ state, row }) + } + } catch {} + } + + if (loops.length === 0) { + console.log('') + console.log('No active Ralph loops.') + console.log('') + return + } + + let loopToCancel: { state: RalphState; row: { project_id: string; key: string; data: string } } | undefined + + if (options.worktreeName) { + loopToCancel = loops.find((l) => l.state.worktreeName === options.worktreeName) + + if (!loopToCancel) { + console.error(`Ralph loop not found: ${options.worktreeName}`) + console.error('') + console.error('Active loops:') + for (const l of loops) { + console.error(` - ${l.state.worktreeName}`) + } + console.error('') + process.exit(1) + } + } else { + if (loops.length === 1) { + loopToCancel = loops[0] + } else { + console.log('') + console.log('Multiple active Ralph loops. Please specify which one to cancel:') + console.log('') + for (const l of loops) { + console.log(` - ${l.state.worktreeName}`) + } + console.log('') + console.log("Run 'ocm-mem cancel ' to cancel a specific loop.") + console.log('') + process.exit(1) + } + } + + if (!loopToCancel) { + console.error('Internal error: loop not found') + process.exit(1) + } + + const { state } = loopToCancel + + console.log('') + console.log(`Ralph Loop to Cancel:`) + console.log(` Worktree: ${state.worktreeName}`) + console.log(` Session: ${state.sessionId}`) + console.log(` Iteration: ${state.iteration}/${state.maxIterations}`) + console.log(` Phase: ${state.phase}`) + if (options.cleanup) { + console.log(` Worktree: ${state.worktreeDir} (will be removed)`) + } + console.log('') + + await runCancel(db, loopToCancel, options) + } finally { + db.close() + } +} + +async function runCancel( + db: ReturnType, + loopToCancel: { state: RalphState; row: { project_id: string; key: string; data: string } }, + options: CancelOptions & { worktreeName?: string }, +): Promise { + const { state } = loopToCancel + + const shouldProceed = options.force || await confirm(`Cancel Ralph loop '${state.worktreeName}'`) + + if (!shouldProceed) { + console.log('Cancelled.') + return + } + + const updatedState = { + ...state, + active: false, + completedAt: new Date().toISOString(), + terminationReason: 'cancelled', + } + db.prepare('UPDATE project_kv SET data = ?, updated_at = ? WHERE project_id = ? AND key = ?').run( + JSON.stringify(updatedState), + Date.now(), + loopToCancel.row.project_id, + loopToCancel.row.key, + ) + + console.log(`Cancelled Ralph loop: ${state.worktreeName}`) + + if (options.cleanup && state.worktreeDir && !state.inPlace) { + if (existsSync(state.worktreeDir)) { + try { + const gitRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf-8' }).trim() + execSync(`git worktree remove -f "${state.worktreeDir}"`, { encoding: 'utf-8', cwd: gitRoot }) + console.log(`Removed worktree: ${state.worktreeDir}`) + } catch { + console.error(`Failed to remove worktree: ${state.worktreeDir}`) + console.error('You may need to remove it manually.') + } + } + } + + console.log('') +} diff --git a/packages/memory/src/cli/commands/status.ts b/packages/memory/src/cli/commands/status.ts new file mode 100644 index 00000000..afb6ece5 --- /dev/null +++ b/packages/memory/src/cli/commands/status.ts @@ -0,0 +1,259 @@ +import type { RalphState } from '../../services/ralph' +import { openDatabase, truncate } from '../utils' + +interface RalphLoopInfo { + sessionId: string + worktreeName: string + worktreeBranch: string + iteration: number + maxIterations: number + phase: 'coding' | 'auditing' + startedAt: string + audit: boolean +} + +function parseArgs(args: string[]): { projectId?: string; dbPath?: string; help?: boolean; worktreeName?: string } { + const options: { projectId?: string; dbPath?: string; help?: boolean; worktreeName?: string } = {} + let i = 0 + + while (i < args.length) { + const arg = args[i] + + if (arg === '--project' || arg === '-p') { + options.projectId = args[++i] + } else if (arg === '--db-path') { + options.dbPath = args[++i] + } else if (arg === '--help' || arg === '-h') { + options.help = true + } else if (!arg.startsWith('-')) { + options.worktreeName = arg + } else { + console.error(`Unknown option: ${arg}`) + help() + process.exit(1) + } + + i++ + } + + return options +} + +export function help(): void { + console.log(` +Show Ralph loop status + +Usage: + ocm-mem status [options] + ocm-mem status [options] + +Arguments: + name Worktree name for detailed status (optional) + +Options: + --project, -p Project ID (auto-detected from git if not provided) + --db-path Path to memory database + --help, -h Show this help message + `.trim()) +} + +export function run(args: string[], globalOpts: { dbPath?: string; projectId?: string }): void { + const options = parseArgs(args) + options.projectId = options.projectId || globalOpts.projectId + options.dbPath = options.dbPath || globalOpts.dbPath + + if (options.help) { + help() + process.exit(0) + } + + const db = openDatabase(options.dbPath) + + try { + const projectId = options.projectId + + const now = Date.now() + let query: string + let params: (string | number)[] + + if (projectId) { + query = 'SELECT key, data FROM project_kv WHERE project_id = ? AND key LIKE ? AND expires_at > ?' + params = [projectId, 'ralph:%', now] + } else { + query = 'SELECT key, data FROM project_kv WHERE key LIKE ? AND expires_at > ?' + params = ['ralph:%', now] + } + + let rows: Array<{ key: string; data: string }> + try { + rows = db.prepare(query).all(...params) as Array<{ key: string; data: string }> + } catch { + rows = [] + } + + const activeLoops: RalphLoopInfo[] = [] + const recentLoops: Array<{ state: RalphState; row: { key: string; data: string } }> = [] + + for (const row of rows) { + try { + const state = JSON.parse(row.data) as RalphState + if (state.active) { + activeLoops.push({ + sessionId: state.sessionId, + worktreeName: state.worktreeName, + worktreeBranch: state.worktreeBranch, + iteration: state.iteration, + maxIterations: state.maxIterations, + phase: state.phase, + startedAt: state.startedAt, + audit: state.audit, + }) + } else if (state.completedAt) { + recentLoops.push({ state, row }) + } + } catch {} + } + + const worktreeName = options.worktreeName + + if (worktreeName) { + let activeLoop = activeLoops.find((l) => l.worktreeName === worktreeName) + let recentLoop = recentLoops.find((l) => l.state.worktreeName === worktreeName) + + if (!activeLoop && !recentLoop) { + console.error(`Ralph loop not found: ${worktreeName}`) + console.error('') + if (activeLoops.length > 0) { + console.error('Active loops:') + for (const l of activeLoops) { + console.error(` - ${l.worktreeName}`) + } + } + if (recentLoops.length > 0) { + console.error('Recently completed:') + for (const l of recentLoops) { + console.error(` - ${l.state.worktreeName}`) + } + } + console.error('') + process.exit(1) + } + + if (activeLoop) { + const row = rows.find((r) => { + try { + const state = JSON.parse(r.data) as RalphState + return state.worktreeName === worktreeName + } catch { + return false + } + }) + + if (!row) { + console.error(`Failed to retrieve state for: ${worktreeName}`) + process.exit(1) + } + + const state = JSON.parse(row.data) as RalphState + const duration = Date.now() - new Date(state.startedAt).getTime() + const hours = Math.floor(duration / (1000 * 60 * 60)) + const minutes = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60)) + const seconds = Math.floor((duration % (1000 * 60)) / 1000) + + console.log('') + console.log(`Ralph Loop: ${state.worktreeName}`) + console.log(` Session ID: ${state.sessionId}`) + console.log(` Worktree: ${state.worktreeName}`) + console.log(` Branch: ${state.worktreeBranch}`) + console.log(` Worktree Dir: ${state.worktreeDir}`) + if (state.inPlace) { + console.log(` Mode: in-place`) + } + console.log(` Phase: ${state.phase}`) + console.log(` Iteration: ${state.iteration}/${state.maxIterations}`) + console.log(` Duration: ${hours}h ${minutes}m ${seconds}s`) + console.log(` Audit: ${state.audit ? 'Yes' : 'No'}`) + console.log(` Error Count: ${state.errorCount}`) + console.log(` Clean Audits: ${state.cleanAuditCount}`) + console.log(` Started: ${new Date(state.startedAt).toISOString()}`) + if (state.completionPromise) { + console.log(` Completion: ${state.completionPromise}`) + } + console.log('') + } else if (recentLoop) { + const state = recentLoop.state + const completedAt = state.completedAt! + const duration = new Date(completedAt).getTime() - new Date(state.startedAt).getTime() + const hours = Math.floor(duration / (1000 * 60 * 60)) + const minutes = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60)) + const seconds = Math.floor((duration % (1000 * 60)) / 1000) + + console.log('') + console.log(`Ralph Loop (Completed): ${state.worktreeName}`) + console.log(` Session ID: ${state.sessionId}`) + console.log(` Worktree: ${state.worktreeName}`) + console.log(` Branch: ${state.worktreeBranch}`) + console.log(` Worktree Dir: ${state.worktreeDir}`) + if (state.inPlace) { + console.log(` Mode: in-place (completed)`) + } + console.log(` Iteration: ${state.iteration}/${state.maxIterations}`) + console.log(` Duration: ${hours}h ${minutes}m ${seconds}s`) + console.log(` Reason: ${state.terminationReason ?? 'unknown'}`) + console.log(` Started: ${new Date(state.startedAt).toISOString()}`) + console.log(` Completed: ${new Date(completedAt).toISOString()}`) + console.log('') + } + } else { + if (activeLoops.length > 0) { + console.log('') + console.log('Active Ralph Loops:') + console.log(' WORKTREE ITERATION PHASE DURATION AUDIT') + + for (const loop of activeLoops) { + const name = truncate(loop.worktreeName, 19).padEnd(19) + const iteration = `${loop.iteration}/${loop.maxIterations}`.padEnd(11) + const phase = loop.phase.padEnd(11) + + const duration = Date.now() - new Date(loop.startedAt).getTime() + const hours = Math.floor(duration / (1000 * 60 * 60)) + const minutes = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60)) + const durationStr = `${hours}h ${minutes}m`.padEnd(11) + + const audit = loop.audit ? 'Yes' : 'No' + console.log(` ${name} ${iteration} ${phase} ${durationStr} ${audit}`) + } + + console.log('') + console.log(`Total: ${activeLoops.length} active loop(s)`) + console.log('') + } + + if (recentLoops.length > 0) { + console.log('Recently Completed:') + console.log(' WORKTREE ITERATIONS REASON COMPLETED') + + for (const loop of recentLoops) { + const name = truncate(loop.state.worktreeName, 19).padEnd(19) + const iterations = `${loop.state.iteration}`.padEnd(11) + const reason = truncate(loop.state.terminationReason ?? 'unknown', 15).padEnd(15) + const completed = new Date(loop.state.completedAt!).toLocaleString() + console.log(` ${name} ${iterations} ${reason} ${completed}`) + } + + console.log('') + } + + if (activeLoops.length === 0 && recentLoops.length === 0) { + console.log('') + console.log('No Ralph loops found.') + console.log('') + } else { + console.log("Run 'ocm-mem status ' for detailed information.") + console.log('') + } + } + } finally { + db.close() + } +} diff --git a/packages/memory/src/cli/index.ts b/packages/memory/src/cli/index.ts index a2cc47a2..6b8acd90 100644 --- a/packages/memory/src/cli/index.ts +++ b/packages/memory/src/cli/index.ts @@ -67,6 +67,26 @@ const commands: Record = { help() }, }, + status: { + run: async (args, globalOpts) => { + const { run } = await import('./commands/status') + await run(args, globalOpts) + }, + help: async () => { + const { help } = await import('./commands/status') + help() + }, + }, + cancel: { + run: async (args, globalOpts) => { + const { run } = await import('./commands/cancel') + await run(args, globalOpts) + }, + help: async () => { + const { help } = await import('./commands/cancel') + help() + }, + }, } function printMainHelp(): void { @@ -83,6 +103,8 @@ Commands: stats Show memory statistics cleanup Delete memories by criteria upgrade Check for and install plugin updates + status Show Ralph loop status + cancel Cancel a Ralph loop Global Options: --db-path Path to memory database From b8c5ca7ffbed64441e5b7400617ac11f50950e55 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:45:32 -0400 Subject: [PATCH 04/29] Refactor memory package service layer --- packages/memory/src/config.ts | 12 ++++++ packages/memory/src/index.ts | 56 ++++++++++++++++++++++++--- packages/memory/src/services/kv.ts | 18 +++++++++ packages/memory/src/services/ralph.ts | 16 ++++++++ packages/memory/src/setup.ts | 2 + packages/memory/src/types.ts | 11 ++++++ packages/memory/src/version.ts | 2 +- 7 files changed, 110 insertions(+), 7 deletions(-) diff --git a/packages/memory/src/config.ts b/packages/memory/src/config.ts index 628756b9..a712dc7b 100644 --- a/packages/memory/src/config.ts +++ b/packages/memory/src/config.ts @@ -17,6 +17,18 @@ const PLUGIN_COMMANDS: Record) { diff --git a/packages/memory/src/index.ts b/packages/memory/src/index.ts index 90a0e71d..79676109 100644 --- a/packages/memory/src/index.ts +++ b/packages/memory/src/index.ts @@ -17,6 +17,7 @@ import { createRalphService, type RalphState } from './services/ralph' import { loadPluginConfig } from './setup' import { resolveLogPath } from './storage' import { createLogger, slugify } from './utils/logger' +import { stripPromiseTags } from './utils/strip-promise-tags' import type { Database } from 'bun:sqlite' import type { PluginConfig, CompactionConfig, HealthStatus, Logger } from './types' import type { EmbeddingProvider } from './embedding' @@ -410,6 +411,15 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { if (cleaned) return cleaned = true logger.log('Cleaning up plugin resources...') + + // First, stop all active Ralph loops + ralphHandler.terminateAll() + logger.log('Ralph: all active loops terminated') + + // Clear all retry timeouts to prevent callbacks after cleanup + ralphHandler.clearAllRetryTimeouts() + + // Then proceed with remaining cleanup memoryInjection.destroy() await memoryService.destroy() closeDatabase(db) @@ -746,17 +756,51 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { }, }), 'memory-plan-execute': tool({ - description: 'Create a new Code session and send the plan as the first prompt. Call this after the user approves the plan.', + description: 'Send the plan to the Code agent for execution. By default creates a new session. Set inPlace to true to switch to the code agent in the current session (plan is already in context).', args: { plan: z.string().describe('The full implementation plan to send to the Code agent'), title: z.string().describe('Short title for the session (shown in session list)'), + inPlace: z.boolean().optional().default(false).describe('Execute in the current session as a subtask instead of creating a new session'), }, - execute: async (args) => { - logger.log(`memory-plan-execute: creating session titled "${args.title}"`) + execute: async (args, context) => { + logger.log(`memory-plan-execute: ${args.inPlace ? 'switching to code agent' : 'creating session'} titled "${args.title}"`) const sessionTitle = args.title.length > 60 ? `${args.title.substring(0, 57)}...` : args.title const executionModel = parseModelString(config.executionModel) + if (args.inPlace) { + const { result: promptResult, usedModel: actualModel } = await retryWithModelFallback( + () => v2.session.promptAsync({ + sessionID: context.sessionID, + directory, + agent: 'code', + parts: [{ type: 'text' as const, text: args.plan }], + ...(executionModel ? { model: executionModel } : {}), + }), + () => v2.session.promptAsync({ + sessionID: context.sessionID, + directory, + agent: 'code', + parts: [{ type: 'text' as const, text: args.plan }], + }), + executionModel, + logger, + ) + + if (promptResult.error) { + logger.error(`memory-plan-execute: in-place agent switch failed`, promptResult.error) + return `Failed to switch to code agent. Error: ${JSON.stringify(promptResult.error)}` + } + + const modelInfo = actualModel ? `${actualModel.providerID}/${actualModel.modelID}` : 'default' + return `Switching to code agent for execution.\n\nTitle: ${sessionTitle}\nModel: ${modelInfo}\nAgent: code` + } + + const { cleaned: planText, stripped } = stripPromiseTags(args.plan) + if (stripped) { + logger.log(`memory-plan-execute: stripped tags from plan text`) + } + const createResult = await v2.session.create({ title: sessionTitle, directory, @@ -774,14 +818,14 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { () => v2.session.promptAsync({ sessionID: newSessionId, directory, - parts: [{ type: 'text' as const, text: args.plan }], + parts: [{ type: 'text' as const, text: planText }], agent: 'code', model: executionModel!, }), () => v2.session.promptAsync({ sessionID: newSessionId, directory, - parts: [{ type: 'text' as const, text: args.plan }], + parts: [{ type: 'text' as const, text: planText }], agent: 'code', }), executionModel, @@ -1190,7 +1234,7 @@ export function createMemoryPlugin(config: PluginConfig): Plugin { const allWithinWorktree = req.patterns.every((p) => { const resolved = p.startsWith('/') ? p : resolve(state.worktreeDir, p) - return resolved.startsWith(state.worktreeDir) + return resolved === state.worktreeDir || resolved.startsWith(state.worktreeDir + '/') }) if (allWithinWorktree) { diff --git a/packages/memory/src/services/kv.ts b/packages/memory/src/services/kv.ts index 5c2a5397..8e1dc4f1 100644 --- a/packages/memory/src/services/kv.ts +++ b/packages/memory/src/services/kv.ts @@ -16,6 +16,7 @@ export interface KvService { set(projectId: string, key: string, data: T, ttlMs?: number): void delete(projectId: string, key: string): void list(projectId: string): KvEntry[] + listByPrefix(projectId: string, prefix: string): KvEntry[] } export function createKvService(db: Database, logger?: Logger): KvService { @@ -59,5 +60,22 @@ export function createKvService(db: Database, logger?: Logger): KvService { } }) }, + + listByPrefix(projectId: string, prefix: string): KvEntry[] { + const rows = queries.listByPrefix(projectId, prefix) + return rows.map((row) => { + let data: unknown = null + try { + data = JSON.parse(row.data) + } catch { + } + return { + key: row.key, + data, + updatedAt: row.updatedAt, + expiresAt: row.expiresAt, + } + }) + }, } } diff --git a/packages/memory/src/services/ralph.ts b/packages/memory/src/services/ralph.ts index 5056c346..4b22e481 100644 --- a/packages/memory/src/services/ralph.ts +++ b/packages/memory/src/services/ralph.ts @@ -39,6 +39,7 @@ export interface RalphService { listRecent(): RalphState[] findByWorktreeName(name: string): RalphState | null getMinCleanAudits(): number + terminateAll(): void } export function createRalphService( @@ -136,6 +137,20 @@ export function createRalphService( return ralphConfig?.minCleanAudits ?? DEFAULT_MIN_CLEAN_AUDITS } + function terminateAll(): void { + const active = listActive() + for (const state of active) { + const updated: RalphState = { + ...state, + active: false, + completedAt: new Date().toISOString(), + terminationReason: 'shutdown', + } + setState(state.sessionId, updated) + } + logger.log(`Ralph: terminated ${active.length} active loop(s)`) + } + return { getActiveState, getAnyState, @@ -148,5 +163,6 @@ export function createRalphService( listRecent, findByWorktreeName, getMinCleanAudits, + terminateAll, } } diff --git a/packages/memory/src/setup.ts b/packages/memory/src/setup.ts index 8b0d7d2e..1a0985a3 100644 --- a/packages/memory/src/setup.ts +++ b/packages/memory/src/setup.ts @@ -113,6 +113,8 @@ function normalizeConfig(config: PluginConfig): PluginConfig { memoryInjection: config.memoryInjection, messagesTransform: config.messagesTransform, executionModel: config.executionModel, + auditorModel: config.auditorModel, + ralph: config.ralph, } if (normalized.embedding) { diff --git a/packages/memory/src/types.ts b/packages/memory/src/types.ts index 61e8e2c9..152b44b8 100644 --- a/packages/memory/src/types.ts +++ b/packages/memory/src/types.ts @@ -59,6 +59,15 @@ export interface Logger { debug: (message: string, ...args: unknown[]) => void } +export interface RalphConfig { + enabled?: boolean + defaultMaxIterations?: number + cleanupWorktree?: boolean + defaultAudit?: boolean + model?: string + minCleanAudits?: number +} + export interface PluginConfig { dataDir?: string embedding: EmbeddingConfig @@ -68,6 +77,8 @@ export interface PluginConfig { memoryInjection?: MemoryInjectionConfig messagesTransform?: MessagesTransformConfig executionModel?: string + auditorModel?: string + ralph?: RalphConfig } export interface ListMemoriesFilter { diff --git a/packages/memory/src/version.ts b/packages/memory/src/version.ts index 9e553095..5543797f 100644 --- a/packages/memory/src/version.ts +++ b/packages/memory/src/version.ts @@ -1 +1 @@ -export const VERSION = '0.0.18' +export const VERSION = '0.0.19' From a7ff69aaf3dfd65f476703406897ea833615ce92 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:45:37 -0400 Subject: [PATCH 05/29] Update agent implementations --- packages/memory/src/agents/architect.ts | 50 +++++++++++++++++-------- packages/memory/src/agents/auditor.ts | 19 ++++------ packages/memory/src/agents/code.ts | 13 ++----- packages/memory/src/agents/librarian.ts | 23 ++++++------ packages/memory/src/agents/prompts.ts | 32 ++++++++++++++++ 5 files changed, 89 insertions(+), 48 deletions(-) create mode 100644 packages/memory/src/agents/prompts.ts diff --git a/packages/memory/src/agents/architect.ts b/packages/memory/src/agents/architect.ts index 9b5ee2c9..2ba0fa5f 100644 --- a/packages/memory/src/agents/architect.ts +++ b/packages/memory/src/agents/architect.ts @@ -1,3 +1,4 @@ +import { getInjectedMemory } from './prompts' import type { AgentDefinition } from './types' export const architectAgent: AgentDefinition = { @@ -44,7 +45,7 @@ When referencing code, use the pattern \`file_path:line_number\` for easy naviga ## Constraints -You are in READ-ONLY mode. You must NOT edit files, run destructive commands, or make any changes. You may only read, search, and analyze. Formalize the plan and present it for the user for approval before proceeding. You MUST use the question tool (mcp_question) to collect plan approval — never ask for approval via plain text output. Do NOT call memory-plan-execute until the user explicitly approves via the question tool. +You are in READ-ONLY mode. You must NOT edit files, run destructive commands, or make any changes. You may only read, search, and analyze. Formalize the plan and present it for the user for approval before proceeding. You MUST use the question tool (mcp_question) to collect plan approval — never ask for approval via plain text output. Do NOT call memory-plan-execute or memory-plan-ralph until the user explicitly approves via the question tool. ## Memory Integration @@ -54,15 +55,7 @@ For the Research phase, prefer delegating to @Librarian with a clear prompt desc Use memory-read directly only for quick, single-query checks (e.g., confirming a specific convention exists). -## Injected Memory - -Your messages may include \`\` blocks containing memories automatically retrieved based on semantic similarity to the current message. Each entry has the format \`# [] \`. - -- **[convention]**: Rules to follow when planning -- **[decision]**: Architectural constraints with rationale -- **[context]**: Reference information — file locations, domain knowledge - -These memories may be stale or irrelevant. Use your judgement — if a memory seems outdated, note it in your plan and recommend updating or deleting it via memory-edit or memory-delete. +${getInjectedMemory('architect')} ## Project KV Store @@ -78,7 +71,12 @@ KV entries are scoped to the current project and expire after 24 hours. Use this 1. **Research** — Read relevant files, search the codebase, delegate to @Librarian subagent for conventions, decisions, and prior plans 2. **Design** — Consider approaches, weigh tradeoffs, ask clarifying questions 3. **Plan** — Present a clear, detailed plan to the user for review -4. **Approve** — After presenting the plan, you MUST call the question tool (mcp_question) to get explicit approval. Do NOT ask for approval via plain text — always use the question tool with options like "Approve plan" and "Reject plan". Only proceed to call memory-plan-execute after the user selects approval via the question tool +4. **Approve** — After presenting the plan, you MUST call the question tool (mcp_question) to get explicit approval. Do NOT ask for approval via plain text — always use the question tool with these options: + - "New session" — Create a new session and send the plan to the code agent + - "Execute here" — Execute the plan in the current session using the code agent (same session, no context switch) + - "Ralph (worktree)" — Execute using Ralph's iterative development loop in an isolated git worktree + - "Ralph (in place)" — Execute using Ralph's iterative development loop in the current directory + Only proceed to call memory-plan-execute or memory-plan-ralph after the user selects an option via the question tool. ## Plan Format @@ -88,12 +86,34 @@ Present plans with: - **Decisions**: Architectural choices made during planning with rationale - **Conventions**: Existing project conventions that must be followed - **Key Context**: Relevant code patterns, file locations, integration points, and dependencies discovered during research -- **Memory Curation**: After completing all implementation phases, invoke the @Librarian subagent (via Task tool) to update project memories with any new conventions, decisions, or context discovered during implementation. Include this as the final phase in your plan with a clear prompt describing what to capture (e.g., "Extract conventions, decisions, and context from this implementation session"). +- **Memory Curation**: After completing all implementation phases, invoke the @Librarian subagent (via Task tool) to update project memories with any new conventions, decisions, or context discovered during implementation. Include the current branch name for traceability. Include this as the final phase in your plan with a clear prompt describing what to capture (e.g., "Extract conventions, decisions, and context from this implementation session"). ## After Approval -When the user approves the plan, call memory-plan-execute with: +**If "New session" was selected**, call memory-plan-execute with: + +- **plan**: The full implementation plan — must be **fully self-contained** since the code agent has no access to this conversation. Include every file path, implementation details, code patterns to match, phase dependencies, verification steps, and gotchas. Do NOT summarize or abbreviate. Do NOT include any \`\` tags. +- **title**: Short descriptive label for the session list. + +**If "Execute here" was selected**, call memory-plan-execute with: + +- **plan**: A brief reference (e.g., "See plan above") — the plan is already in the session context and will not be re-sent. The code agent continues with the existing conversation. +- **title**: Short descriptive label for the subtask. +- **inPlace**: true — switches to the code agent in the current session. + +**If "Ralph (worktree)" was selected**, call memory-plan-ralph with: + +- **plan**: The full implementation plan — must be **fully self-contained**. +- **title**: Short descriptive label for the session list. +- **maxIterations**: Optional, default 0 (unlimited). Set a limit if the plan has a clear scope. +- **audit**: Optional, default true. Runs the auditor agent between iterations to catch issues. +- **inPlace**: false + +**If "Ralph (in place)" was selected**, call memory-plan-ralph with: -- **plan**: The full implementation plan — must be **fully self-contained** since the code agent has no access to this conversation. Include every file path, implementation details, code patterns to match, phase dependencies, verification steps, and gotchas. Do NOT summarize or abbreviate. -- **title**: Short descriptive label for the session list.`, +- **plan**: The full implementation plan — must be **fully self-contained**. +- **title**: Short descriptive label for the session list. +- **maxIterations**: Optional, default 0 (unlimited). Set a limit if the plan has a clear scope. +- **audit**: Optional, default true. Runs the auditor agent between iterations to catch issues. +- **inPlace**: true`, } diff --git a/packages/memory/src/agents/auditor.ts b/packages/memory/src/agents/auditor.ts index e19e9bd7..02f13b8f 100644 --- a/packages/memory/src/agents/auditor.ts +++ b/packages/memory/src/agents/auditor.ts @@ -1,3 +1,4 @@ +import { getInjectedMemory } from './prompts' import type { AgentDefinition } from './types' export const auditorAgent: AgentDefinition = { @@ -8,7 +9,7 @@ export const auditorAgent: AgentDefinition = { mode: 'subagent', temperature: 0.0, tools: { - exclude: ['memory-plan-execute', 'memory-health', 'memory-delete', 'memory-write', 'memory-edit'], + exclude: ['memory-plan-execute', 'memory-plan-ralph', 'memory-health', 'memory-delete', 'memory-write', 'memory-edit'], }, systemPrompt: `You are a code auditor with access to project memory. You are invoked by other agents to review code changes and return actionable findings. @@ -120,7 +121,7 @@ After completing a review, store each **bug** and **warning** finding in the pro Use \`memory-kv-set\` with a structured key and JSON value: **Key pattern**: \`review-finding::\` -**Value**: JSON object with the finding details +**Value**: JSON object with the finding details. Include the current branch name (via \`git branch --show-current\`) in the \`branch\` field. Example: \`\`\`json @@ -131,7 +132,8 @@ Example: "description": "Missing null check on user.session before accessing .token — throws TypeError when session expires mid-request.", "scenario": "User's session expires between the auth check and token access on line 45.", "status": "open", - "date": "2026-03-07" + "date": "2026-03-07", + "branch": "feature/auth-refactor" } \`\`\` @@ -161,13 +163,6 @@ You have access to these tools: Review findings are stored in the project KV store with 24-hour TTL. Use \`memory-kv-set\` to persist findings, \`memory-kv-get\` to retrieve specific findings, and \`memory-kv-list\` to see all active entries. Entries expire automatically after 24 hours. -## Injected Memory - -Your messages may include \`\` blocks containing memories automatically retrieved based on semantic similarity to the current message. Each entry has the format \`# [] \`. - -- **[convention]**: Rules to check code against -- **[decision]**: Architectural constraints that may apply -- **[context]**: Reference information and persisted review findings - -These memories may be stale or irrelevant. If a memory seems outdated, note it in your review observations.`, +${getInjectedMemory('auditor')} +`, } diff --git a/packages/memory/src/agents/code.ts b/packages/memory/src/agents/code.ts index af187223..532e5963 100644 --- a/packages/memory/src/agents/code.ts +++ b/packages/memory/src/agents/code.ts @@ -1,3 +1,4 @@ +import { getInjectedMemory } from './prompts' import type { AgentDefinition } from './types' export const codeAgent: AgentDefinition = { @@ -61,17 +62,9 @@ You have memory tools (memory-read, memory-write, memory-edit, memory-delete) an - Check for duplicates with memory-read before writing - Update stale memories with memory-edit rather than creating duplicates - Reference file paths when storing structural context +- Note the current git branch (via \`git branch --show-current\`) when storing memories — append "(branch: )" to the content so future sessions know the context in which the knowledge was captured -## Injected Memory - -Your messages may include \`\` blocks containing memories automatically retrieved based on semantic similarity to the current message. Each entry has the format \`# [] \`. - -- **[convention]**: Rules to follow — coding style, naming patterns, workflow preferences -- **[decision]**: Architectural choices with rationale — treat as constraints -- **[context]**: Reference information — file locations, domain knowledge, known issues - -These memories may be stale or irrelevant to the current task. Use your judgement. If a memory seems outdated or incorrect for the current task, you can ignore it. -If you notice patterns of outdated or incorrect memories, consider asking the user to curate them. Use the @Librarian subagent to perform memory research and contradiction resolution. +${getInjectedMemory('code')} ## Project KV Store diff --git a/packages/memory/src/agents/librarian.ts b/packages/memory/src/agents/librarian.ts index eac08fef..5ae759a5 100644 --- a/packages/memory/src/agents/librarian.ts +++ b/packages/memory/src/agents/librarian.ts @@ -1,3 +1,4 @@ +import { INJECTED_MEMORY_HEADER } from './prompts' import type { AgentDefinition } from './types' export const librarianAgent: AgentDefinition = { @@ -8,7 +9,7 @@ export const librarianAgent: AgentDefinition = { mode: 'subagent', temperature: 0.0, tools: { - exclude: ['memory-plan-execute', 'memory-health', 'memory-kv-set', 'memory-kv-get', 'memory-kv-list'], + exclude: ['memory-plan-execute', 'memory-plan-ralph', 'memory-health', 'memory-kv-set', 'memory-kv-get', 'memory-kv-list'], }, systemPrompt: `You are the project's librarian — the keeper of institutional memory. Your purpose is to capture, organize, and retrieve knowledge that persists across sessions. @@ -104,15 +105,17 @@ When creating memories: - Bad: "We use Bun" 4. **Reference files when applicable**: - - This helps agents locate relevant code + - This helps agents locate relevant code -5. **Check for duplicates first**: - - Before creating, use memory-read to see if similar memory exists - - If similar memory exists, update it instead of creating duplicates +5. **Note the branch**: Run \`git branch --show-current\` and append "(branch: )" to the memory content. This helps future curation — knowing which branch a decision was made on indicates whether it's merged/active or experimental. -6. **Avoid ephemeral information**: - - Don't store: task details, temporary workarounds, session-specific context - - Do store: patterns that apply across sessions, lessons learned +6. **Check for duplicates first**: + - Before creating, use memory-read to see if similar memory exists + - If similar memory exists, update it instead of creating duplicates + +7. **Avoid ephemeral information**: + - Don't store: task details, temporary workarounds, session-specific context + - Do store: patterns that apply across sessions, lessons learned ## Curation Rules @@ -197,9 +200,7 @@ You are NOT needed for: 4. **memory-delete**: Remove memories by ID - id: The memory ID to delete -## Injected Memory - -Your messages may include \`\` blocks containing memories automatically retrieved based on semantic similarity to the current message. Each entry has the format \`# [] \`. +${INJECTED_MEMORY_HEADER} Use these as a starting point for your research — they indicate what the system found relevant. Cross-reference with memory-read for completeness. If any injected memory is stale or contradicts newer information, update or delete it. diff --git a/packages/memory/src/agents/prompts.ts b/packages/memory/src/agents/prompts.ts new file mode 100644 index 00000000..4e78674e --- /dev/null +++ b/packages/memory/src/agents/prompts.ts @@ -0,0 +1,32 @@ +type AgentRole = 'code' | 'auditor' | 'architect' + +export const INJECTED_MEMORY_HEADER = `## Injected Memory + +Your messages may include \`\` blocks containing memories automatically retrieved based on semantic similarity to the current message. Each entry has the format \`# [] \`.` + +const SCOPE_DESCRIPTIONS: Record = { + code: `- **[convention]**: Rules to follow — coding style, naming patterns, workflow preferences +- **[decision]**: Architectural choices with rationale — treat as constraints +- **[context]**: Reference information — file locations, domain knowledge, known issues`, + auditor: `- **[convention]**: Rules to check code against +- **[decision]**: Architectural constraints that may apply +- **[context]**: Reference information and persisted review findings`, + architect: `- **[convention]**: Rules to follow when planning +- **[decision]**: Architectural constraints with rationale +- **[context]**: Reference information — file locations, domain knowledge`, +} + +const SCOPE_GUIDANCE: Record = { + code: `These memories may be stale or irrelevant to the current task. Use your judgement. If a memory seems outdated or incorrect for the current task, you can ignore it. +If you notice patterns of outdated or incorrect memories, consider asking the user to curate them. Use the @Librarian subagent to perform memory research and contradiction resolution.`, + auditor: `These memories may be stale or irrelevant. If a memory seems outdated, note it in your review observations.`, + architect: `These memories may be stale or irrelevant. Use your judgement — if a memory seems outdated, note it in your plan and recommend updating or deleting it via memory-edit or memory-delete.`, +} + +export function getInjectedMemory(role: AgentRole): string { + return `${INJECTED_MEMORY_HEADER} + +${SCOPE_DESCRIPTIONS[role]} + +${SCOPE_GUIDANCE[role]}` +} From 91b4c35f13cb5ccb8dfc5c85f952c747eb15200e Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:45:41 -0400 Subject: [PATCH 06/29] Add Ralph event handler and session hooks --- packages/memory/src/hooks/index.ts | 1 + packages/memory/src/hooks/ralph.ts | 104 +++++++++++++++++++++------ packages/memory/src/hooks/session.ts | 4 +- 3 files changed, 87 insertions(+), 22 deletions(-) diff --git a/packages/memory/src/hooks/index.ts b/packages/memory/src/hooks/index.ts index a2f8cb65..15ac0041 100644 --- a/packages/memory/src/hooks/index.ts +++ b/packages/memory/src/hooks/index.ts @@ -7,3 +7,4 @@ export { extractCompactionSummary, } from './compaction-utils' export { createMemoryInjectionHook, type MemoryInjectionHook } from './memory-injection' +export { createRalphEventHandler, type RalphEventHandler } from './ralph' diff --git a/packages/memory/src/hooks/ralph.ts b/packages/memory/src/hooks/ralph.ts index a1f24d79..80b5c761 100644 --- a/packages/memory/src/hooks/ralph.ts +++ b/packages/memory/src/hooks/ralph.ts @@ -8,6 +8,8 @@ import { resolve } from 'path' export interface RalphEventHandler { onEvent(input: { event: { type: string; properties?: Record } }): Promise + terminateAll(): void + clearAllRetryTimeouts(): void } @@ -39,6 +41,7 @@ export function createRalphEventHandler( logger: Logger, ): RalphEventHandler { const minCleanAudits = ralphService.getMinCleanAudits() + const retryTimeouts = new Map() async function commitAndCleanupWorktree(state: RalphState): Promise<{ committed: boolean; cleaned: boolean }> { if (state.inPlace) { logger.log(`Ralph: in-place mode, skipping commit and cleanup`) @@ -92,6 +95,12 @@ export function createRalphEventHandler( } async function terminateLoop(sessionId: string, state: RalphState, reason: string): Promise { + const retryTimeout = retryTimeouts.get(sessionId) + if (retryTimeout) { + clearTimeout(retryTimeout) + retryTimeouts.delete(sessionId) + } + ralphService.setState(sessionId, { ...state, active: false, @@ -149,12 +158,28 @@ export function createRalphEventHandler( } } - async function handlePromptError(sessionId: string, state: RalphState, context: string, err: unknown): Promise { + async function handlePromptError(sessionId: string, state: RalphState, context: string, err: unknown, retryFn?: () => Promise): Promise { const nextErrorCount = (state.errorCount ?? 0) + 1 if (nextErrorCount < MAX_RETRIES) { logger.error(`Ralph: ${context} (attempt ${nextErrorCount}/${MAX_RETRIES}), will retry`, err) ralphService.setState(sessionId, { ...state, errorCount: nextErrorCount }) + if (retryFn) { + const retryTimeout = setTimeout(async () => { + const currentState = ralphService.getActiveState(sessionId) + if (!currentState?.active) { + logger.log(`Ralph: loop cancelled, skipping retry`) + retryTimeouts.delete(sessionId) + return + } + try { + await retryFn() + } catch (retryErr) { + await handlePromptError(sessionId, { ...state, errorCount: nextErrorCount }, context, retryErr, retryFn) + } + }, 2000) + retryTimeouts.set(sessionId, retryTimeout) + } } else { logger.error(`Ralph: ${context} (attempt ${nextErrorCount}/${MAX_RETRIES}), giving up`, err) await terminateLoop(sessionId, state, `error_max_retries: ${context}`) @@ -210,19 +235,25 @@ export function createRalphEventHandler( ralphService.setState(sessionId, { ...state, phase: 'auditing', errorCount: 0 }) logger.log(`Ralph iteration ${state.iteration} complete, running auditor for session ${sessionId}`) + const auditPrompt = { + sessionID: sessionId, + directory: state.worktreeDir, + parts: [{ + type: 'subtask' as const, + agent: 'auditor', + description: `Post-iteration ${state.iteration} code review`, + prompt: ralphService.buildAuditPrompt(state), + }], + } + + const sendAuditPrompt = async () => { + await v2Client.session.promptAsync(auditPrompt) + } + try { - await v2Client.session.promptAsync({ - sessionID: sessionId, - directory: state.worktreeDir, - parts: [{ - type: 'subtask' as const, - agent: 'auditor', - description: `Post-iteration ${state.iteration} code review`, - prompt: ralphService.buildAuditPrompt(state), - }], - }) + await sendAuditPrompt() } catch (err) { - await handlePromptError(sessionId, { ...state, phase: 'coding' }, 'failed to send audit prompt', err) + await handlePromptError(sessionId, { ...state, phase: 'coding' }, 'failed to send audit prompt', err, sendAuditPrompt) } return } @@ -233,14 +264,18 @@ export function createRalphEventHandler( const continuationPrompt = ralphService.buildContinuationPrompt({ ...state, iteration: nextIteration }) logger.log(`Ralph iteration ${nextIteration} for session ${sessionId}`) - try { + const sendContinuationPrompt = async () => { await v2Client.session.promptAsync({ sessionID: sessionId, directory: state.worktreeDir, parts: [{ type: 'text' as const, text: continuationPrompt }], }) + } + + try { + await sendContinuationPrompt() } catch (err) { - await handlePromptError(sessionId, state, 'failed to send continuation prompt', err) + await handlePromptError(sessionId, state, 'failed to send continuation prompt', err, sendContinuationPrompt) } } @@ -260,13 +295,22 @@ export function createRalphEventHandler( logger.log(`Ralph audit clean at iteration ${state.iteration} (${newCleanAuditCount}/${minCleanAudits} clean audits)`) } - if (!auditFindings && state.completionPromise) { + if (!auditFindings) { if (newCleanAuditCount >= minCleanAudits) { - await terminateLoop(sessionId, state, 'completed') - logger.log(`Ralph loop completed after ${newCleanAuditCount} clean audits at iteration ${state.iteration}`) - return + if (state.completionPromise) { + await terminateLoop(sessionId, state, 'completed') + logger.log(`Ralph loop completed after ${newCleanAuditCount} clean audits at iteration ${state.iteration}`) + return + } + if (state.maxIterations <= 0) { + await terminateLoop(sessionId, state, 'completed') + logger.log(`Ralph loop completed after ${newCleanAuditCount} clean audits at iteration ${state.iteration} (no completion promise, terminated by clean audits)`) + return + } + } + if (state.completionPromise) { + logger.log(`Ralph: clean audit but only ${newCleanAuditCount}/${minCleanAudits} needed, continuing`) } - logger.log(`Ralph: clean audit but only ${newCleanAuditCount}/${minCleanAudits} needed, continuing`) } if (state.maxIterations > 0 && nextIteration > state.maxIterations) { @@ -289,14 +333,18 @@ export function createRalphEventHandler( ) logger.log(`Ralph iteration ${nextIteration} for session ${sessionId}`) - try { + const sendContinuationPrompt = async () => { await v2Client.session.promptAsync({ sessionID: sessionId, directory: state.worktreeDir, parts: [{ type: 'text' as const, text: continuationPrompt }], }) + } + + try { + await sendContinuationPrompt() } catch (err) { - await handlePromptError(sessionId, state, 'failed to send continuation prompt after audit', err) + await handlePromptError(sessionId, state, 'failed to send continuation prompt after audit', err, sendContinuationPrompt) } } @@ -337,7 +385,21 @@ export function createRalphEventHandler( } } + function terminateAll(): void { + ralphService.terminateAll() + } + + function clearAllRetryTimeouts(): void { + for (const [sessionId, timeout] of retryTimeouts.entries()) { + clearTimeout(timeout) + retryTimeouts.delete(sessionId) + } + logger.log('Ralph: cleared all retry timeouts') + } + return { onEvent, + terminateAll, + clearAllRetryTimeouts, } } diff --git a/packages/memory/src/hooks/session.ts b/packages/memory/src/hooks/session.ts index 362721a4..75ff0118 100644 --- a/packages/memory/src/hooks/session.ts +++ b/packages/memory/src/hooks/session.ts @@ -65,7 +65,9 @@ For each item found, store it with the appropriate scope: - decision: architectural choices with their rationale - context: project structure, key file locations, domain knowledge, known issues -Be selective — only store knowledge useful in future sessions. Check for duplicates before writing (use memory-read to search first). +Be selective — only store knowledge useful in future sessions. Check for duplicates before writing (use memory-read to search first). + +Note the current git branch (via \`git branch --show-current\`) and append "(branch: )" to each memory stored, so future sessions know the context in which the knowledge was captured. End your response with: 1. A brief summary of what was stored From 04ead791ae8c17f3d39d41914f82b765dd141f32 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:45:53 -0400 Subject: [PATCH 07/29] Update memory package storage module --- packages/memory/src/storage/index.ts | 2 +- packages/memory/src/storage/kv-queries.ts | 12 +++++++ packages/memory/src/storage/vec-client.ts | 42 ++++++++++++++++++++--- packages/memory/src/storage/vec.ts | 2 ++ 4 files changed, 53 insertions(+), 5 deletions(-) diff --git a/packages/memory/src/storage/index.ts b/packages/memory/src/storage/index.ts index 95ae7b1f..efe213e8 100644 --- a/packages/memory/src/storage/index.ts +++ b/packages/memory/src/storage/index.ts @@ -1,5 +1,5 @@ export { initializeDatabase, closeDatabase, resolveDataDir, resolveLogPath } from './database' -export { createVecService } from './vec' +export { createVecService, cleanupOrphanedWorkers } from './vec' export type { VecService, VecSearchResult, TableDimensionsResult } from './vec-types' export { createMemoryQuery } from './memory-queries' export { createMetadataQuery } from './metadata-queries' diff --git a/packages/memory/src/storage/kv-queries.ts b/packages/memory/src/storage/kv-queries.ts index efdadc98..2dcd92b1 100644 --- a/packages/memory/src/storage/kv-queries.ts +++ b/packages/memory/src/storage/kv-queries.ts @@ -54,6 +54,13 @@ export function createKvQuery(db: Database) { ORDER BY updated_at DESC` ) + const listByPrefixStmt = db.prepare( + `SELECT project_id, key, data, expires_at, created_at, updated_at + FROM project_kv + WHERE project_id = ? AND key LIKE ? AND expires_at > ? + ORDER BY updated_at DESC` + ) + const deleteExpiredStmt = db.prepare( `DELETE FROM project_kv WHERE expires_at < ?` ) @@ -78,6 +85,11 @@ export function createKvQuery(db: Database) { return rows.map(mapRow) }, + listByPrefix(projectId: string, prefix: string): KvRow[] { + const rows = listByPrefixStmt.all(projectId, `${prefix}%`, Date.now()) as KvRowRaw[] + return rows.map(mapRow) + }, + deleteExpired(): number { const result = deleteExpiredStmt.run(Date.now()) return result.changes diff --git a/packages/memory/src/storage/vec-client.ts b/packages/memory/src/storage/vec-client.ts index ac4de9c5..cd7ba158 100644 --- a/packages/memory/src/storage/vec-client.ts +++ b/packages/memory/src/storage/vec-client.ts @@ -47,12 +47,13 @@ function sendRequest(socketPath: string, request: Record): Prom }) } -function isWorkerRunning(pidPath: string, socketPath: string): boolean { +async function isWorkerRunning(pidPath: string, socketPath: string): Promise { if (!existsSync(pidPath) || !existsSync(socketPath)) return false try { const pid = parseInt(readFileSync(pidPath, 'utf-8'), 10) process.kill(pid, 0) - return true + const response = await sendRequest(socketPath, { action: 'health' }) + return response.status === 'ok' } catch { return false } @@ -70,6 +71,33 @@ function cleanupStale(pidPath: string, socketPath: string): void { } catch {} } +export function cleanupOrphanedWorkers(pidPath: string, socketPath: string): void { + if (existsSync(socketPath) && !existsSync(pidPath)) { + try { + unlinkSync(socketPath) + } catch {} + } + + if (existsSync(pidPath)) { + try { + const pid = parseInt(readFileSync(pidPath, 'utf-8'), 10) + try { + process.kill(pid, 0) + if (!existsSync(socketPath)) { + try { + process.kill(pid, 'SIGTERM') + } catch {} + } + } catch { + unlinkSync(pidPath) + if (existsSync(socketPath)) { + unlinkSync(socketPath) + } + } + } catch {} + } +} + async function startWorker(config: { dbPath: string dataDir: string @@ -78,11 +106,17 @@ async function startWorker(config: { const socketPath = join(config.dataDir, 'vec-worker.sock') const pidPath = join(config.dataDir, 'vec-worker.pid') - if (isWorkerRunning(pidPath, socketPath)) { + cleanupOrphanedWorkers(pidPath, socketPath) + + if (await isWorkerRunning(pidPath, socketPath)) { return socketPath } - cleanupStale(pidPath, socketPath) + if (existsSync(socketPath)) { + try { + unlinkSync(socketPath) + } catch {} + } const workerScriptJs = join(__dirname, 'vec-worker.js') const workerScript = existsSync(workerScriptJs) diff --git a/packages/memory/src/storage/vec.ts b/packages/memory/src/storage/vec.ts index aa302d89..c1184d9b 100644 --- a/packages/memory/src/storage/vec.ts +++ b/packages/memory/src/storage/vec.ts @@ -2,8 +2,10 @@ import type { Database } from 'bun:sqlite' import { join } from 'path' import type { VecService } from './vec-types' import type { Logger } from '../types' +import { cleanupOrphanedWorkers } from './vec-client' export type { VecService, VecSearchResult } from './vec-types' +export { cleanupOrphanedWorkers } export async function createVecService(_db: Database, dataDir: string, dimensions: number, logger?: Logger): Promise { try { From a5442e2ab0efd4b8a85ed302fda1f4cc1b02a203 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:46:05 -0400 Subject: [PATCH 08/29] Add promise tag stripping utility --- packages/memory/src/utils/logger.ts | 10 ++++++++++ packages/memory/src/utils/strip-promise-tags.ts | 6 ++++++ 2 files changed, 16 insertions(+) create mode 100644 packages/memory/src/utils/strip-promise-tags.ts diff --git a/packages/memory/src/utils/logger.ts b/packages/memory/src/utils/logger.ts index 10afac0a..8f597ec3 100644 --- a/packages/memory/src/utils/logger.ts +++ b/packages/memory/src/utils/logger.ts @@ -5,6 +5,16 @@ import type { LoggingConfig } from '../types' const PREFIX = '[OpenCodeManagerMemory]' const MAX_LOG_FILE_SIZE = 10 * 1024 * 1024 +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .trim() + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .substring(0, 50) +} + function ensureLogDir(filePath: string): void { const dir = dirname(filePath) if (!existsSync(dir)) { diff --git a/packages/memory/src/utils/strip-promise-tags.ts b/packages/memory/src/utils/strip-promise-tags.ts new file mode 100644 index 00000000..2cc41b5f --- /dev/null +++ b/packages/memory/src/utils/strip-promise-tags.ts @@ -0,0 +1,6 @@ +export function stripPromiseTags(text: string): { cleaned: string; stripped: boolean } { + let cleaned = text.replace(/\n*---\n\n\*\*IMPORTANT - Completion Signal:\*\*[\s\S]*?[\s\S]*?<\/promise>[\s\S]*?(?:until this signal is detected\.|$)/g, '') + cleaned = cleaned.replace(/[\s\S]*?<\/promise>/g, '') + cleaned = cleaned.trimEnd() + return { cleaned, stripped: cleaned !== text.trimEnd() } +} From 2114671ec5d78e1f07378368af28738e36459feb Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:46:11 -0400 Subject: [PATCH 09/29] Add KV store UI components --- frontend/src/api/memory.ts | 30 ++- .../src/components/memory/KvFormDialog.tsx | 186 ++++++++++++++++++ frontend/src/components/memory/KvList.tsx | 182 +++++++++++++++++ frontend/src/hooks/useMemories.ts | 64 +++++- frontend/src/pages/Memories.tsx | 50 ++++- 5 files changed, 500 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/memory/KvFormDialog.tsx create mode 100644 frontend/src/components/memory/KvList.tsx diff --git a/frontend/src/api/memory.ts b/frontend/src/api/memory.ts index f52979cf..dac61012 100644 --- a/frontend/src/api/memory.ts +++ b/frontend/src/api/memory.ts @@ -1,6 +1,6 @@ import { fetchWrapper, fetchWrapperVoid } from './fetchWrapper' import { API_BASE_URL } from '@/config' -import type { Memory, MemoryStats, CreateMemoryRequest, UpdateMemoryRequest, PluginConfig } from '@opencode-manager/shared/types' +import type { Memory, MemoryStats, CreateMemoryRequest, UpdateMemoryRequest, PluginConfig, KvEntry, CreateKvEntryRequest, UpdateKvEntryRequest } from '@opencode-manager/shared/types' export async function listMemories(filters?: { projectId?: string @@ -94,3 +94,31 @@ export async function testEmbeddingConfig(): Promise { body: JSON.stringify({}), }) } + +export async function listKvEntries(projectId: string, prefix?: string): Promise<{ entries: KvEntry[] }> { + const params = new URLSearchParams({ projectId }) + if (prefix) params.set('prefix', prefix) + return fetchWrapper(`${API_BASE_URL}/api/memory/kv?${params.toString()}`) +} + +export async function deleteKvEntry(projectId: string, key: string): Promise { + return fetchWrapperVoid(`${API_BASE_URL}/api/memory/kv/${encodeURIComponent(key)}?projectId=${encodeURIComponent(projectId)}`, { + method: 'DELETE', + }) +} + +export async function createKvEntry(data: CreateKvEntryRequest): Promise<{ entry: KvEntry }> { + return fetchWrapper(`${API_BASE_URL}/api/memory/kv`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) +} + +export async function updateKvEntry(projectId: string, key: string, data: UpdateKvEntryRequest): Promise<{ entry: KvEntry }> { + return fetchWrapper(`${API_BASE_URL}/api/memory/kv/${encodeURIComponent(key)}?projectId=${encodeURIComponent(projectId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) +} diff --git a/frontend/src/components/memory/KvFormDialog.tsx b/frontend/src/components/memory/KvFormDialog.tsx new file mode 100644 index 00000000..8cfed190 --- /dev/null +++ b/frontend/src/components/memory/KvFormDialog.tsx @@ -0,0 +1,186 @@ +import { useEffect, useState } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { useCreateKvEntry, useUpdateKvEntry } from '@/hooks/useMemories' +import type { KvEntry, CreateKvEntryRequest, UpdateKvEntryRequest } from '@opencode-manager/shared/types' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Label } from '@/components/ui/label' + +const kvSchema = z.object({ + key: z.string().min(1, 'Key is required'), + data: z.string().min(1, 'Data is required'), + ttlHours: z.number().optional(), +}) + +type KvFormData = z.infer + +interface KvFormDialogProps { + entry?: KvEntry + projectId?: string + open: boolean + onOpenChange: (open: boolean) => void +} + +export function KvFormDialog({ entry, projectId, open, onOpenChange }: KvFormDialogProps) { + const createMutation = useCreateKvEntry() + const updateMutation = useUpdateKvEntry() + + const [jsonError, setJsonError] = useState() + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(kvSchema), + defaultValues: { + key: '', + data: '', + ttlHours: undefined, + }, + }) + + useEffect(() => { + if (open) { + if (entry) { + const ttlMs = entry.expiresAt - entry.updatedAt + const ttlHoursValue = ttlMs > 0 && ttlMs < Number.MAX_SAFE_INTEGER + ? Math.round(ttlMs / (1000 * 60 * 60)) + : undefined + + reset({ + key: entry.key, + data: JSON.stringify(entry.data, null, 2), + ttlHours: ttlHoursValue, + }) + } else { + reset({ + key: '', + data: '', + ttlHours: undefined, + }) + } + setJsonError(undefined) + } + }, [open, entry, reset]) + + const validateJson = (value: string): boolean => { + try { + JSON.parse(value) + setJsonError(undefined) + return true + } catch (e) { + setJsonError(e instanceof Error ? e.message : 'Invalid JSON') + return false + } + } + + const onSubmit = async (data: KvFormData) => { + if (!validateJson(data.data)) { + return + } + + const parsedData = JSON.parse(data.data) + const ttlMs = data.ttlHours ? data.ttlHours * 1000 * 60 * 60 : undefined + + if (entry) { + const updateData: UpdateKvEntryRequest = { + data: parsedData, + ttlMs, + } + await updateMutation.mutateAsync({ projectId: projectId!, key: entry.key, data: updateData }) + } else if (projectId) { + const createData: CreateKvEntryRequest = { + projectId, + key: data.key, + data: parsedData, + ttlMs, + } + await createMutation.mutateAsync(createData) + } + onOpenChange(false) + } + + const isLoading = createMutation.isPending || updateMutation.isPending + + return ( + + + + {entry ? 'Edit KV Entry' : 'Create KV Entry'} + + {entry + ? 'Update the KV entry data and TTL.' + : 'Add a new key-value entry to store project data.'} + + + +
+
+ + + {errors.key && ( +

{errors.key.message}

+ )} +
+ +
+ +