/** * Shared conversation loop for interactive modes (assistant & persona). * * Extracts the common patterns: * - Provider/session initialization * - AI call with retry on stale session * - Session state display/clear * - Conversation loop (slash commands, AI messaging, /go summary) */ import chalk from 'chalk'; import { loadConfig, loadPersonaSessions, updatePersonaSession, loadSessionState, clearSessionState, } from '../../infra/config/index.js'; import { isQuietMode } from '../../shared/context.js'; import { getProvider, type ProviderType } from '../../infra/providers/index.js'; import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js'; import { getLabel, getLabelObject } from '../../shared/i18n/index.js'; import { readMultilineInput } from './lineEditor.js'; import { EXIT_SIGINT } from '../../shared/exitCodes.js'; import { type PieceContext, type InteractiveModeResult, type InteractiveUIText, type ConversationMessage, type PostSummaryAction, resolveLanguage, buildSummaryPrompt, selectPostSummaryAction, formatSessionStatus, } from './interactive.js'; const log = createLogger('conversation-loop'); /** Result from a single AI call */ export interface CallAIResult { content: string; sessionId?: string; success: boolean; } /** Initialized session context for conversation loops */ export interface SessionContext { provider: ReturnType; providerType: ProviderType; model: string | undefined; lang: 'en' | 'ja'; personaName: string; sessionId: string | undefined; } /** * Initialize provider, session, and language for interactive conversation. */ export function initializeSession(cwd: string, personaName: string): SessionContext { const { global: globalConfig } = loadConfig(cwd); const lang = resolveLanguage(globalConfig.language); if (!globalConfig.provider) { throw new Error('Provider is not configured.'); } const providerType = globalConfig.provider as ProviderType; const provider = getProvider(providerType); const model = globalConfig.model as string | undefined; const savedSessions = loadPersonaSessions(cwd, providerType); const sessionId: string | undefined = savedSessions[personaName]; return { provider, providerType, model, lang, personaName, sessionId }; } /** * Display and clear previous session state if present. */ export function displayAndClearSessionState(cwd: string, lang: 'en' | 'ja'): void { const sessionState = loadSessionState(cwd); if (sessionState) { const statusLabel = formatSessionStatus(sessionState, lang); info(statusLabel); blankLine(); clearSessionState(cwd); } } /** * Call AI with automatic retry on stale/invalid session. * * On session failure, clears sessionId and retries once without session. * Updates sessionId and persists it on success. */ export async function callAIWithRetry( prompt: string, systemPrompt: string, allowedTools: string[], cwd: string, ctx: SessionContext, ): Promise<{ result: CallAIResult | null; sessionId: string | undefined }> { const display = new StreamDisplay('assistant', isQuietMode()); const abortController = new AbortController(); let sigintCount = 0; const onSigInt = (): void => { sigintCount += 1; if (sigintCount === 1) { blankLine(); info(getLabel('piece.sigintGraceful', ctx.lang)); abortController.abort(); return; } blankLine(); error(getLabel('piece.sigintForce', ctx.lang)); process.exit(EXIT_SIGINT); }; process.on('SIGINT', onSigInt); let { sessionId } = ctx; try { const agent = ctx.provider.setup({ name: ctx.personaName, systemPrompt }); const response = await agent.call(prompt, { cwd, model: ctx.model, sessionId, allowedTools, abortSignal: abortController.signal, onStream: display.createHandler(), }); display.flush(); const success = response.status !== 'blocked' && response.status !== 'error'; if (!success && sessionId) { log.info('Session invalid, retrying without session'); sessionId = undefined; const retryDisplay = new StreamDisplay('assistant', isQuietMode()); const retryAgent = ctx.provider.setup({ name: ctx.personaName, systemPrompt }); const retry = await retryAgent.call(prompt, { cwd, model: ctx.model, sessionId: undefined, allowedTools, abortSignal: abortController.signal, onStream: retryDisplay.createHandler(), }); retryDisplay.flush(); if (retry.sessionId) { sessionId = retry.sessionId; updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType); } return { result: { content: retry.content, sessionId: retry.sessionId, success: retry.status !== 'blocked' && retry.status !== 'error' }, sessionId, }; } if (response.sessionId) { sessionId = response.sessionId; updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType); } return { result: { content: response.content, sessionId: response.sessionId, success }, sessionId, }; } catch (e) { const msg = getErrorMessage(e); log.error('AI call failed', { error: msg }); error(msg); blankLine(); return { result: null, sessionId }; } finally { process.removeListener('SIGINT', onSigInt); } } export type { PostSummaryAction } from './interactive.js'; /** Strategy for customizing conversation loop behavior */ export interface ConversationStrategy { /** System prompt for AI calls */ systemPrompt: string; /** Allowed tools for AI calls */ allowedTools: string[]; /** Transform user message before sending to AI (e.g., policy injection) */ transformPrompt: (userMessage: string) => string; /** Intro message displayed at start */ introMessage: string; /** Custom action selector (optional). If not provided, uses default selectPostSummaryAction. */ selectAction?: (task: string, lang: 'en' | 'ja') => Promise; } /** * Run the shared conversation loop. * * Handles: EOF, /play, /go (summary), /cancel, regular AI messaging. * The Strategy object controls system prompt, tool access, and prompt transformation. */ export async function runConversationLoop( cwd: string, ctx: SessionContext, strategy: ConversationStrategy, pieceContext: PieceContext | undefined, initialInput: string | undefined, ): Promise { const history: ConversationMessage[] = []; let sessionId = ctx.sessionId; const ui = getLabelObject('interactive.ui', ctx.lang); const conversationLabel = getLabel('interactive.conversationLabel', ctx.lang); const noTranscript = getLabel('interactive.noTranscript', ctx.lang); info(strategy.introMessage); if (sessionId) { info(ui.resume); } blankLine(); /** Helper: call AI with current session and update session state */ async function doCallAI(prompt: string, sysPrompt: string, tools: string[]): Promise { const { result, sessionId: newSessionId } = await callAIWithRetry( prompt, sysPrompt, tools, cwd, { ...ctx, sessionId }, ); sessionId = newSessionId; return result; } if (initialInput) { history.push({ role: 'user', content: initialInput }); log.debug('Processing initial input', { initialInput, sessionId }); const promptWithTransform = strategy.transformPrompt(initialInput); const result = await doCallAI(promptWithTransform, strategy.systemPrompt, strategy.allowedTools); if (result) { if (!result.success) { error(result.content); blankLine(); return { action: 'cancel', task: '' }; } history.push({ role: 'assistant', content: result.content }); blankLine(); } else { history.pop(); } } while (true) { const input = await readMultilineInput(chalk.green('> ')); if (input === null) { blankLine(); info(ui.cancelled); return { action: 'cancel', task: '' }; } const trimmed = input.trim(); if (!trimmed) { continue; } if (trimmed.startsWith('/play')) { const task = trimmed.slice(5).trim(); if (!task) { info(ui.playNoTask); continue; } log.info('Play command', { task }); return { action: 'execute', task }; } if (trimmed.startsWith('/go')) { const userNote = trimmed.slice(3).trim(); let summaryPrompt = buildSummaryPrompt( history, !!sessionId, ctx.lang, noTranscript, conversationLabel, pieceContext, ); if (!summaryPrompt) { info(ui.noConversation); continue; } if (userNote) { summaryPrompt = `${summaryPrompt}\n\nUser Note:\n${userNote}`; } const summaryResult = await doCallAI(summaryPrompt, summaryPrompt, strategy.allowedTools); if (!summaryResult) { info(ui.summarizeFailed); continue; } if (!summaryResult.success) { error(summaryResult.content); blankLine(); return { action: 'cancel', task: '' }; } const task = summaryResult.content.trim(); const selectedAction = strategy.selectAction ? await strategy.selectAction(task, ctx.lang) : await selectPostSummaryAction(task, ui.proposed, ui); if (selectedAction === 'continue' || selectedAction === null) { info(ui.continuePrompt); continue; } log.info('Conversation action selected', { action: selectedAction, messageCount: history.length }); return { action: selectedAction, task }; } if (trimmed === '/cancel') { info(ui.cancelled); return { action: 'cancel', task: '' }; } history.push({ role: 'user', content: trimmed }); log.debug('Sending to AI', { messageCount: history.length, sessionId }); process.stdin.pause(); const promptWithTransform = strategy.transformPrompt(trimmed); const result = await doCallAI(promptWithTransform, strategy.systemPrompt, strategy.allowedTools); if (result) { if (!result.success) { error(result.content); blankLine(); history.pop(); return { action: 'cancel', task: '' }; } history.push({ role: 'assistant', content: result.content }); blankLine(); } else { history.pop(); } } }