takt/src/features/interactive/conversationLoop.ts

328 lines
10 KiB
TypeScript

/**
* 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<typeof getProvider>;
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<PostSummaryAction | null>;
}
/**
* 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<InteractiveModeResult> {
const history: ConversationMessage[] = [];
let sessionId = ctx.sessionId;
const ui = getLabelObject<InteractiveUIText>('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<CallAIResult | null> {
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();
}
}
}