687 lines
20 KiB
TypeScript
687 lines
20 KiB
TypeScript
/**
|
|
* Interactive task input mode
|
|
*
|
|
* Allows users to refine task requirements through conversation with AI
|
|
* before executing the task. Uses the same SDK call pattern as piece
|
|
* execution (with onStream) to ensure compatibility.
|
|
*
|
|
* Commands:
|
|
* /go - Confirm and execute the task
|
|
* /cancel - Cancel and exit
|
|
*/
|
|
|
|
import * as readline from 'node:readline';
|
|
import chalk from 'chalk';
|
|
import type { Language } from '../../core/models/index.js';
|
|
import {
|
|
loadGlobalConfig,
|
|
loadPersonaSessions,
|
|
updatePersonaSession,
|
|
loadSessionState,
|
|
clearSessionState,
|
|
type SessionState,
|
|
} from '../../infra/config/index.js';
|
|
import { isQuietMode } from '../../shared/context.js';
|
|
import { getProvider, type ProviderType } from '../../infra/providers/index.js';
|
|
import { selectOption } from '../../shared/prompt/index.js';
|
|
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
|
|
import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js';
|
|
import { loadTemplate } from '../../shared/prompts/index.js';
|
|
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
|
|
const log = createLogger('interactive');
|
|
|
|
/** Shape of interactive UI text */
|
|
interface InteractiveUIText {
|
|
intro: string;
|
|
resume: string;
|
|
noConversation: string;
|
|
summarizeFailed: string;
|
|
continuePrompt: string;
|
|
proposed: string;
|
|
actionPrompt: string;
|
|
actions: {
|
|
execute: string;
|
|
createIssue: string;
|
|
saveTask: string;
|
|
continue: string;
|
|
};
|
|
cancelled: string;
|
|
playNoTask: string;
|
|
}
|
|
|
|
/**
|
|
* Format session state for display
|
|
*/
|
|
function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): string {
|
|
const lines: string[] = [];
|
|
|
|
// Status line
|
|
if (state.status === 'success') {
|
|
lines.push(getLabel('interactive.previousTask.success', lang));
|
|
} else if (state.status === 'error') {
|
|
lines.push(
|
|
getLabel('interactive.previousTask.error', lang, {
|
|
error: state.errorMessage!,
|
|
})
|
|
);
|
|
} else if (state.status === 'user_stopped') {
|
|
lines.push(getLabel('interactive.previousTask.userStopped', lang));
|
|
}
|
|
|
|
// Piece name
|
|
lines.push(
|
|
getLabel('interactive.previousTask.piece', lang, {
|
|
pieceName: state.pieceName,
|
|
})
|
|
);
|
|
|
|
// Timestamp
|
|
const timestamp = new Date(state.timestamp).toLocaleString(lang === 'ja' ? 'ja-JP' : 'en-US');
|
|
lines.push(
|
|
getLabel('interactive.previousTask.timestamp', lang, {
|
|
timestamp,
|
|
})
|
|
);
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function resolveLanguage(lang?: Language): 'en' | 'ja' {
|
|
return lang === 'ja' ? 'ja' : 'en';
|
|
}
|
|
|
|
function getInteractivePrompts(lang: 'en' | 'ja', pieceContext?: PieceContext) {
|
|
const systemPrompt = loadTemplate('score_interactive_system_prompt', lang, {});
|
|
const policyContent = loadTemplate('score_interactive_policy', lang, {});
|
|
|
|
return {
|
|
systemPrompt,
|
|
policyContent,
|
|
lang,
|
|
pieceContext,
|
|
conversationLabel: getLabel('interactive.conversationLabel', lang),
|
|
noTranscript: getLabel('interactive.noTranscript', lang),
|
|
ui: getLabelObject<InteractiveUIText>('interactive.ui', lang),
|
|
};
|
|
}
|
|
|
|
interface ConversationMessage {
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
interface CallAIResult {
|
|
content: string;
|
|
sessionId?: string;
|
|
success: boolean;
|
|
}
|
|
|
|
/**
|
|
* Build the final task description from conversation history for executeTask.
|
|
*/
|
|
function buildTaskFromHistory(history: ConversationMessage[]): string {
|
|
return history
|
|
.map((msg) => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`)
|
|
.join('\n\n');
|
|
}
|
|
|
|
/**
|
|
* Build the summary prompt (used as both system prompt and user message).
|
|
* Renders the complete score_summary_system_prompt template with conversation data.
|
|
* Returns empty string if there is no conversation to summarize.
|
|
*/
|
|
function buildSummaryPrompt(
|
|
history: ConversationMessage[],
|
|
hasSession: boolean,
|
|
lang: 'en' | 'ja',
|
|
noTranscriptNote: string,
|
|
conversationLabel: string,
|
|
pieceContext?: PieceContext,
|
|
): string {
|
|
let conversation = '';
|
|
if (history.length > 0) {
|
|
const historyText = buildTaskFromHistory(history);
|
|
conversation = `${conversationLabel}\n${historyText}`;
|
|
} else if (hasSession) {
|
|
conversation = `${conversationLabel}\n${noTranscriptNote}`;
|
|
} else {
|
|
return '';
|
|
}
|
|
|
|
const hasPiece = !!pieceContext;
|
|
return loadTemplate('score_summary_system_prompt', lang, {
|
|
pieceInfo: hasPiece,
|
|
pieceName: pieceContext?.name ?? '',
|
|
pieceDescription: pieceContext?.description ?? '',
|
|
conversation,
|
|
});
|
|
}
|
|
|
|
type PostSummaryAction = InteractiveModeAction | 'continue';
|
|
|
|
async function selectPostSummaryAction(
|
|
task: string,
|
|
proposedLabel: string,
|
|
ui: InteractiveUIText,
|
|
): Promise<PostSummaryAction | null> {
|
|
blankLine();
|
|
info(proposedLabel);
|
|
console.log(task);
|
|
|
|
return selectOption<PostSummaryAction>(ui.actionPrompt, [
|
|
{ label: ui.actions.execute, value: 'execute' },
|
|
{ label: ui.actions.createIssue, value: 'create_issue' },
|
|
{ label: ui.actions.saveTask, value: 'save_task' },
|
|
{ label: ui.actions.continue, value: 'continue' },
|
|
]);
|
|
}
|
|
|
|
/** Escape sequences for terminal protocol control */
|
|
const PASTE_BRACKET_ENABLE = '\x1B[?2004h';
|
|
const PASTE_BRACKET_DISABLE = '\x1B[?2004l';
|
|
// flag 1: Disambiguate escape codes — modified keys (e.g. Shift+Enter) are reported as CSI sequences while unmodified keys (e.g. Enter) remain as legacy codes (\r)
|
|
const KITTY_KB_ENABLE = '\x1B[>1u';
|
|
const KITTY_KB_DISABLE = '\x1B[<u';
|
|
|
|
/** Known escape sequence prefixes for matching */
|
|
const ESC_PASTE_START = '[200~';
|
|
const ESC_PASTE_END = '[201~';
|
|
const ESC_SHIFT_ENTER = '[13;2u';
|
|
|
|
type InputState = 'normal' | 'paste';
|
|
|
|
/**
|
|
* Decode Kitty CSI-u key sequence into a control character.
|
|
* Example: "[99;5u" (Ctrl+C) -> "\x03"
|
|
*/
|
|
function decodeCtrlKey(rest: string): { ch: string; consumed: number } | null {
|
|
// Kitty CSI-u: [codepoint;modifiersu
|
|
const kittyMatch = rest.match(/^\[(\d+);(\d+)u/);
|
|
if (kittyMatch) {
|
|
const codepoint = Number.parseInt(kittyMatch[1]!, 10);
|
|
const modifiers = Number.parseInt(kittyMatch[2]!, 10);
|
|
// Kitty modifiers are 1-based; Ctrl bit is 4 in 0-based flags.
|
|
const ctrlPressed = (((modifiers - 1) & 4) !== 0);
|
|
if (!ctrlPressed) return null;
|
|
|
|
const key = String.fromCodePoint(codepoint);
|
|
if (!/^[A-Za-z]$/.test(key)) return null;
|
|
|
|
const upper = key.toUpperCase();
|
|
const controlCode = upper.charCodeAt(0) & 0x1f;
|
|
return { ch: String.fromCharCode(controlCode), consumed: kittyMatch[0].length };
|
|
}
|
|
|
|
// xterm modifyOtherKeys: [27;modifiers;codepoint~
|
|
const xtermMatch = rest.match(/^\[27;(\d+);(\d+)~/);
|
|
if (!xtermMatch) return null;
|
|
|
|
const modifiers = Number.parseInt(xtermMatch[1]!, 10);
|
|
const codepoint = Number.parseInt(xtermMatch[2]!, 10);
|
|
const ctrlPressed = (((modifiers - 1) & 4) !== 0);
|
|
if (!ctrlPressed) return null;
|
|
|
|
const key = String.fromCodePoint(codepoint);
|
|
if (!/^[A-Za-z]$/.test(key)) return null;
|
|
|
|
const upper = key.toUpperCase();
|
|
const controlCode = upper.charCodeAt(0) & 0x1f;
|
|
return { ch: String.fromCharCode(controlCode), consumed: xtermMatch[0].length };
|
|
}
|
|
|
|
/**
|
|
* Parse raw stdin data and process each character/sequence.
|
|
*
|
|
* Handles escape sequences for paste bracket mode (start/end),
|
|
* Kitty keyboard protocol (Shift+Enter), and arrow keys (ignored).
|
|
* Regular characters are passed to the onChar callback.
|
|
*/
|
|
function parseInputData(
|
|
data: string,
|
|
callbacks: {
|
|
onPasteStart: () => void;
|
|
onPasteEnd: () => void;
|
|
onShiftEnter: () => void;
|
|
onChar: (ch: string) => void;
|
|
},
|
|
): void {
|
|
let i = 0;
|
|
while (i < data.length) {
|
|
const ch = data[i]!;
|
|
|
|
if (ch === '\x1B') {
|
|
// Try to match known escape sequences
|
|
const rest = data.slice(i + 1);
|
|
|
|
if (rest.startsWith(ESC_PASTE_START)) {
|
|
callbacks.onPasteStart();
|
|
i += 1 + ESC_PASTE_START.length;
|
|
continue;
|
|
}
|
|
if (rest.startsWith(ESC_PASTE_END)) {
|
|
callbacks.onPasteEnd();
|
|
i += 1 + ESC_PASTE_END.length;
|
|
continue;
|
|
}
|
|
if (rest.startsWith(ESC_SHIFT_ENTER)) {
|
|
callbacks.onShiftEnter();
|
|
i += 1 + ESC_SHIFT_ENTER.length;
|
|
continue;
|
|
}
|
|
const ctrlKey = decodeCtrlKey(rest);
|
|
if (ctrlKey) {
|
|
callbacks.onChar(ctrlKey.ch);
|
|
i += 1 + ctrlKey.consumed;
|
|
continue;
|
|
}
|
|
// Arrow keys and other CSI sequences: skip \x1B[ + letter/params
|
|
if (rest.startsWith('[')) {
|
|
const csiMatch = rest.match(/^\[[0-9;]*[A-Za-z~]/);
|
|
if (csiMatch) {
|
|
i += 1 + csiMatch[0].length;
|
|
continue;
|
|
}
|
|
}
|
|
// Unrecognized escape: skip the \x1B
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
callbacks.onChar(ch);
|
|
i++;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read multiline input from the user using raw mode.
|
|
*
|
|
* Supports:
|
|
* - Enter (\r) to confirm and submit input
|
|
* - Shift+Enter (Kitty keyboard protocol) to insert a newline
|
|
* - Paste bracket mode for correctly handling pasted text with newlines
|
|
* - Backspace (\x7F) to delete the last character
|
|
* - Ctrl+C (\x03) and Ctrl+D (\x04) to cancel (returns null)
|
|
*
|
|
* Falls back to readline.question() in non-TTY environments.
|
|
*/
|
|
function readMultilineInput(prompt: string): Promise<string | null> {
|
|
// Non-TTY fallback: use readline for pipe/CI environments
|
|
if (!process.stdin.isTTY) {
|
|
return new Promise((resolve) => {
|
|
if (process.stdin.readable && !process.stdin.destroyed) {
|
|
process.stdin.resume();
|
|
}
|
|
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
});
|
|
|
|
let answered = false;
|
|
|
|
rl.question(prompt, (answer) => {
|
|
answered = true;
|
|
rl.close();
|
|
resolve(answer);
|
|
});
|
|
|
|
rl.on('close', () => {
|
|
if (!answered) {
|
|
resolve(null);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
return new Promise((resolve) => {
|
|
let buffer = '';
|
|
let state: InputState = 'normal';
|
|
|
|
const wasRaw = process.stdin.isRaw;
|
|
process.stdin.setRawMode(true);
|
|
process.stdin.resume();
|
|
|
|
// Enable paste bracket mode and Kitty keyboard protocol
|
|
process.stdout.write(PASTE_BRACKET_ENABLE);
|
|
process.stdout.write(KITTY_KB_ENABLE);
|
|
|
|
// Display the prompt
|
|
process.stdout.write(prompt);
|
|
|
|
function cleanup(): void {
|
|
process.stdin.removeListener('data', onData);
|
|
process.stdout.write(PASTE_BRACKET_DISABLE);
|
|
process.stdout.write(KITTY_KB_DISABLE);
|
|
process.stdin.setRawMode(wasRaw ?? false);
|
|
process.stdin.pause();
|
|
}
|
|
|
|
function onData(data: Buffer): void {
|
|
try {
|
|
const str = data.toString('utf-8');
|
|
|
|
parseInputData(str, {
|
|
onPasteStart() {
|
|
state = 'paste';
|
|
},
|
|
onPasteEnd() {
|
|
state = 'normal';
|
|
},
|
|
onShiftEnter() {
|
|
buffer += '\n';
|
|
process.stdout.write('\n');
|
|
},
|
|
onChar(ch: string) {
|
|
if (state === 'paste') {
|
|
if (ch === '\r' || ch === '\n') {
|
|
buffer += '\n';
|
|
process.stdout.write('\n');
|
|
} else {
|
|
buffer += ch;
|
|
process.stdout.write(ch);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// NORMAL state
|
|
if (ch === '\r') {
|
|
// Enter: confirm input
|
|
process.stdout.write('\n');
|
|
cleanup();
|
|
resolve(buffer);
|
|
return;
|
|
}
|
|
if (ch === '\x03' || ch === '\x04') {
|
|
// Ctrl+C or Ctrl+D: cancel
|
|
process.stdout.write('\n');
|
|
cleanup();
|
|
resolve(null);
|
|
return;
|
|
}
|
|
if (ch === '\x7F') {
|
|
// Backspace: delete last character
|
|
if (buffer.length > 0) {
|
|
buffer = buffer.slice(0, -1);
|
|
process.stdout.write('\b \b');
|
|
}
|
|
return;
|
|
}
|
|
// Regular character
|
|
buffer += ch;
|
|
process.stdout.write(ch);
|
|
},
|
|
});
|
|
} catch {
|
|
cleanup();
|
|
resolve(null);
|
|
}
|
|
}
|
|
|
|
process.stdin.on('data', onData);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Call AI with the same pattern as piece execution.
|
|
* The key requirement is passing onStream — the Agent SDK requires
|
|
* includePartialMessages to be true for the async iterator to yield.
|
|
*/
|
|
async function callAI(
|
|
provider: ReturnType<typeof getProvider>,
|
|
prompt: string,
|
|
cwd: string,
|
|
model: string | undefined,
|
|
sessionId: string | undefined,
|
|
display: StreamDisplay,
|
|
systemPrompt: string,
|
|
): Promise<CallAIResult> {
|
|
const agent = provider.setup({ name: 'interactive', systemPrompt });
|
|
const response = await agent.call(prompt, {
|
|
cwd,
|
|
model,
|
|
sessionId,
|
|
allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
|
|
onStream: display.createHandler(),
|
|
});
|
|
|
|
display.flush();
|
|
const success = response.status !== 'blocked';
|
|
return { content: response.content, sessionId: response.sessionId, success };
|
|
}
|
|
|
|
export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel';
|
|
|
|
export interface InteractiveModeResult {
|
|
/** The action selected by the user */
|
|
action: InteractiveModeAction;
|
|
/** The assembled task text (only meaningful when action is not 'cancel') */
|
|
task: string;
|
|
}
|
|
|
|
export interface PieceContext {
|
|
/** Piece name (e.g. "minimal") */
|
|
name: string;
|
|
/** Piece description */
|
|
description: string;
|
|
/** Piece structure (numbered list of movements) */
|
|
pieceStructure: string;
|
|
}
|
|
|
|
/**
|
|
* Run the interactive task input mode.
|
|
*
|
|
* Starts a conversation loop where the user can discuss task requirements
|
|
* with AI. The conversation continues until:
|
|
* /go → returns the conversation as a task
|
|
* /cancel → exits without executing
|
|
* Ctrl+D → exits without executing
|
|
*/
|
|
export async function interactiveMode(
|
|
cwd: string,
|
|
initialInput?: string,
|
|
pieceContext?: PieceContext,
|
|
): Promise<InteractiveModeResult> {
|
|
const globalConfig = loadGlobalConfig();
|
|
const lang = resolveLanguage(globalConfig.language);
|
|
const prompts = getInteractivePrompts(lang, pieceContext);
|
|
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 history: ConversationMessage[] = [];
|
|
const personaName = 'interactive';
|
|
const savedSessions = loadPersonaSessions(cwd, providerType);
|
|
let sessionId: string | undefined = savedSessions[personaName];
|
|
|
|
// Load and display previous task state
|
|
const sessionState = loadSessionState(cwd);
|
|
if (sessionState) {
|
|
const statusLabel = formatSessionStatus(sessionState, lang);
|
|
info(statusLabel);
|
|
blankLine();
|
|
clearSessionState(cwd);
|
|
}
|
|
|
|
info(prompts.ui.intro);
|
|
if (sessionId) {
|
|
info(prompts.ui.resume);
|
|
}
|
|
blankLine();
|
|
|
|
/** Call AI with automatic retry on session error (stale/invalid session ID). */
|
|
async function callAIWithRetry(prompt: string, systemPrompt: string): Promise<CallAIResult | null> {
|
|
const display = new StreamDisplay('assistant', isQuietMode());
|
|
try {
|
|
const result = await callAI(
|
|
provider,
|
|
prompt,
|
|
cwd,
|
|
model,
|
|
sessionId,
|
|
display,
|
|
systemPrompt,
|
|
);
|
|
// If session failed, clear it and retry without session
|
|
if (!result.success && sessionId) {
|
|
log.info('Session invalid, retrying without session');
|
|
sessionId = undefined;
|
|
const retryDisplay = new StreamDisplay('assistant', isQuietMode());
|
|
const retry = await callAI(
|
|
provider,
|
|
prompt,
|
|
cwd,
|
|
model,
|
|
undefined,
|
|
retryDisplay,
|
|
systemPrompt,
|
|
);
|
|
if (retry.sessionId) {
|
|
sessionId = retry.sessionId;
|
|
updatePersonaSession(cwd, personaName, sessionId, providerType);
|
|
}
|
|
return retry;
|
|
}
|
|
if (result.sessionId) {
|
|
sessionId = result.sessionId;
|
|
updatePersonaSession(cwd, personaName, sessionId, providerType);
|
|
}
|
|
return result;
|
|
} catch (e) {
|
|
const msg = getErrorMessage(e);
|
|
log.error('AI call failed', { error: msg });
|
|
error(msg);
|
|
blankLine();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Inject policy into user message for AI call.
|
|
* Follows the same pattern as piece execution (perform_phase1_message.md).
|
|
*/
|
|
function injectPolicy(userMessage: string): string {
|
|
const policyIntro = lang === 'ja'
|
|
? '以下のポリシーは行動規範です。必ず遵守してください。'
|
|
: 'The following policy defines behavioral guidelines. Please follow them.';
|
|
const reminderLabel = lang === 'ja'
|
|
? '上記の Policy セクションで定義されたポリシー規範を遵守してください。'
|
|
: 'Please follow the policy guidelines defined in the Policy section above.';
|
|
return `## Policy\n${policyIntro}\n\n${prompts.policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`;
|
|
}
|
|
|
|
// Process initial input if provided (e.g. from `takt a`)
|
|
if (initialInput) {
|
|
history.push({ role: 'user', content: initialInput });
|
|
log.debug('Processing initial input', { initialInput, sessionId });
|
|
|
|
const promptWithPolicy = injectPolicy(initialInput);
|
|
const result = await callAIWithRetry(promptWithPolicy, prompts.systemPrompt);
|
|
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('> '));
|
|
|
|
// EOF (Ctrl+D)
|
|
if (input === null) {
|
|
blankLine();
|
|
info('Cancelled');
|
|
return { action: 'cancel', task: '' };
|
|
}
|
|
|
|
const trimmed = input.trim();
|
|
|
|
// Empty input — skip
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
|
|
// Handle slash commands
|
|
if (trimmed.startsWith('/play')) {
|
|
const task = trimmed.slice(5).trim();
|
|
if (!task) {
|
|
info(prompts.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,
|
|
prompts.lang,
|
|
prompts.noTranscript,
|
|
prompts.conversationLabel,
|
|
prompts.pieceContext,
|
|
);
|
|
if (!summaryPrompt) {
|
|
info(prompts.ui.noConversation);
|
|
continue;
|
|
}
|
|
if (userNote) {
|
|
summaryPrompt = `${summaryPrompt}\n\nUser Note:\n${userNote}`;
|
|
}
|
|
const summaryResult = await callAIWithRetry(summaryPrompt, summaryPrompt);
|
|
if (!summaryResult) {
|
|
info(prompts.ui.summarizeFailed);
|
|
continue;
|
|
}
|
|
if (!summaryResult.success) {
|
|
error(summaryResult.content);
|
|
blankLine();
|
|
return { action: 'cancel', task: '' };
|
|
}
|
|
const task = summaryResult.content.trim();
|
|
const selectedAction = await selectPostSummaryAction(task, prompts.ui.proposed, prompts.ui);
|
|
if (selectedAction === 'continue' || selectedAction === null) {
|
|
info(prompts.ui.continuePrompt);
|
|
continue;
|
|
}
|
|
log.info('Interactive mode action selected', { action: selectedAction, messageCount: history.length });
|
|
return { action: selectedAction, task };
|
|
}
|
|
|
|
if (trimmed === '/cancel') {
|
|
info(prompts.ui.cancelled);
|
|
return { action: 'cancel', task: '' };
|
|
}
|
|
|
|
// Regular input — send to AI
|
|
history.push({ role: 'user', content: trimmed });
|
|
|
|
log.debug('Sending to AI', { messageCount: history.length, sessionId });
|
|
process.stdin.pause();
|
|
|
|
const promptWithPolicy = injectPolicy(trimmed);
|
|
const result = await callAIWithRetry(promptWithPolicy, prompts.systemPrompt);
|
|
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();
|
|
}
|
|
}
|
|
}
|