/** * CLI Executor State * Conversation history + execution record storage (SQLite-backed) */ import type { HistoryIndexEntry } from './cli-history-store.js'; import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js'; import type { CliOutputUnit } from './cli-output-converter.js'; // Lazy-loaded SQLite store module let sqliteStoreModule: typeof import('./cli-history-store.js') | null = null; /** * Get or initialize SQLite store (async) */ export async function getSqliteStore(baseDir: string) { if (!sqliteStoreModule) { sqliteStoreModule = await import('./cli-history-store.js'); } return sqliteStoreModule.getHistoryStore(baseDir); } /** * Get SQLite store (sync - uses cached module) */ function getSqliteStoreSync(baseDir: string) { if (!sqliteStoreModule) { throw new Error('SQLite store not initialized. Call an async function first.'); } return sqliteStoreModule.getHistoryStore(baseDir); } // Execution category types export type ExecutionCategory = 'user' | 'internal' | 'insight'; // Single turn in a conversation export interface ConversationTurn { turn: number; timestamp: string; prompt: string; duration_ms: number; status: 'success' | 'error' | 'timeout'; exit_code: number | null; output: { stdout: string; stderr: string; truncated: boolean; cached?: boolean; stdout_full?: string; stderr_full?: string; structured?: CliOutputUnit[]; // Structured IR sequence for advanced parsing }; } // Multi-turn conversation record export interface ConversationRecord { id: string; created_at: string; updated_at: string; tool: string; model: string; mode: string; category: ExecutionCategory; // user | internal | insight total_duration_ms: number; turn_count: number; latest_status: 'success' | 'error' | 'timeout'; turns: ConversationTurn[]; parent_execution_id?: string; // For fork/retry scenarios } // Legacy single execution record (for backward compatibility) export interface ExecutionRecord { id: string; timestamp: string; tool: string; model: string; mode: string; prompt: string; status: 'success' | 'error' | 'timeout'; exit_code: number | null; duration_ms: number; output: { stdout: string; stderr: string; truncated: boolean; }; parsedOutput?: string; // Extracted clean text from structured output units finalOutput?: string; // Agent message only (for --final flag) } interface HistoryIndex { version: number; total_executions: number; executions: { id: string; timestamp: string; // created_at for conversations updated_at?: string; // last update time tool: string; status: string; duration_ms: number; turn_count?: number; // number of turns in conversation prompt_preview: string; }[]; } export interface ExecutionOutput { success: boolean; execution: ExecutionRecord; conversation: ConversationRecord; // Full conversation record stdout: string; stderr: string; parsedOutput?: string; // Extracted text from stream JSON response finalOutput?: string; // Agent message only (for --final flag) } /** * Ensure history directory exists (uses centralized storage) */ export function ensureHistoryDir(baseDir: string): string { const paths = StoragePaths.project(baseDir); ensureStorageDir(paths.cliHistory); return paths.cliHistory; } /** * Save conversation to SQLite * @param baseDir - Project base directory (NOT historyDir) */ async function saveConversationAsync(baseDir: string, conversation: ConversationRecord): Promise { const store = await getSqliteStore(baseDir); store.saveConversation(conversation); } /** * Sync wrapper for saveConversation (uses cached SQLite module) * @param baseDir - Project base directory (NOT historyDir) */ export function saveConversation(baseDir: string, conversation: ConversationRecord): void { try { const store = getSqliteStoreSync(baseDir); store.saveConversation(conversation); } catch { // If sync not available, queue for async save saveConversationAsync(baseDir, conversation).catch(err => { console.error('[CLI Executor] Failed to save conversation:', err.message); }); } } /** * Load existing conversation by ID from SQLite * @param baseDir - Project base directory (NOT historyDir) */ async function loadConversationAsync(baseDir: string, conversationId: string): Promise { const store = await getSqliteStore(baseDir); return store.getConversation(conversationId); } /** * Sync wrapper for loadConversation (uses cached SQLite module) * @param baseDir - Project base directory (NOT historyDir) */ export function loadConversation(baseDir: string, conversationId: string): ConversationRecord | null { try { const store = getSqliteStoreSync(baseDir); return store.getConversation(conversationId); } catch { // SQLite not initialized yet, return null return null; } } /** * Convert legacy ExecutionRecord to ConversationRecord */ export function convertToConversation(record: ExecutionRecord): ConversationRecord { return { id: record.id, created_at: record.timestamp, updated_at: record.timestamp, tool: record.tool, model: record.model, mode: record.mode, category: 'user', // Legacy records default to user category total_duration_ms: record.duration_ms, turn_count: 1, latest_status: record.status, turns: [{ turn: 1, timestamp: record.timestamp, prompt: record.prompt, duration_ms: record.duration_ms, status: record.status, exit_code: record.exit_code, output: record.output }] }; } /** * Get execution history from SQLite (centralized storage) */ export async function getExecutionHistoryAsync(baseDir: string, options: { limit?: number; tool?: string | null; status?: string | null; category?: ExecutionCategory | null; search?: string | null; recursive?: boolean; } = {}): Promise<{ total: number; count: number; executions: (HistoryIndex['executions'][0] & { sourceDir?: string })[]; }> { const { limit = 50, tool = null, status = null, category = null, search = null, recursive = false } = options; // Recursive mode: aggregate data from parent and all child projects if (recursive) { const { scanChildProjectsAsync } = await import('../config/storage-paths.js'); const childProjects = await scanChildProjectsAsync(baseDir); let allExecutions: (HistoryIndex['executions'][0] & { sourceDir?: string })[] = []; let totalCount = 0; // Query parent project - apply limit at source to reduce memory footprint try { const parentStore = await getSqliteStore(baseDir); const parentResult = parentStore.getHistory({ limit, tool, status, category, search }); totalCount += parentResult.total; for (const exec of parentResult.executions) { allExecutions.push({ ...exec, sourceDir: baseDir }); } } catch (error) { if (process.env.DEBUG) { console.error(`[CLI History] Failed to query parent project ${baseDir}:`, error); } } // Query all child projects - apply limit to each child for (const child of childProjects) { try { const childStore = await getSqliteStore(child.projectPath); const childResult = childStore.getHistory({ limit, tool, status, category, search }); totalCount += childResult.total; for (const exec of childResult.executions) { allExecutions.push({ ...exec, sourceDir: child.relativePath // Show relative path for clarity }); } } catch (error) { if (process.env.DEBUG) { console.error(`[CLI History] Failed to query child project ${child.projectPath}:`, error); } } } // Sort by timestamp (newest first) and apply limit allExecutions.sort((a, b) => Number(b.timestamp) - Number(a.timestamp)); const limitedExecutions = allExecutions.slice(0, limit); return { total: totalCount, count: limitedExecutions.length, executions: limitedExecutions }; } // Non-recursive mode: only query current project const store = await getSqliteStore(baseDir); return store.getHistory({ limit, tool, status, category, search }); } /** * Get execution history (sync version - uses cached SQLite module) */ export function getExecutionHistory(baseDir: string, options: { limit?: number; tool?: string | null; status?: string | null; recursive?: boolean; } = {}): { total: number; count: number; executions: (HistoryIndex['executions'][0] & { sourceDir?: string })[]; } { const { limit = 50, tool = null, status = null, recursive = false } = options; try { if (recursive) { const { scanChildProjects } = require('../config/storage-paths.js'); const childProjects = scanChildProjects(baseDir); let allExecutions: (HistoryIndex['executions'][0] & { sourceDir?: string })[] = []; let totalCount = 0; // Query parent project - apply limit at source try { const parentStore = getSqliteStoreSync(baseDir); const parentResult = parentStore.getHistory({ limit, tool, status }); totalCount += parentResult.total; for (const exec of parentResult.executions) { allExecutions.push({ ...exec, sourceDir: baseDir }); } } catch (error) { if (process.env.DEBUG) { console.error(`[CLI History] Failed to query parent project ${baseDir}:`, error); } } // Query all child projects - apply limit to each child for (const child of childProjects) { try { const childStore = getSqliteStoreSync(child.projectPath); const childResult = childStore.getHistory({ limit, tool, status }); totalCount += childResult.total; for (const exec of childResult.executions) { allExecutions.push({ ...exec, sourceDir: child.relativePath // Show relative path for clarity }); } } catch (error) { if (process.env.DEBUG) { console.error(`[CLI History] Failed to query child project ${child.projectPath}:`, error); } } } // Sort by timestamp (newest first) and apply limit allExecutions.sort((a, b) => Number(b.timestamp) - Number(a.timestamp)); const limitedExecutions = allExecutions.slice(0, limit); return { total: totalCount, count: limitedExecutions.length, executions: limitedExecutions }; } const store = getSqliteStoreSync(baseDir); return store.getHistory({ limit, tool, status }); } catch { // SQLite not initialized yet, return empty return { total: 0, count: 0, executions: [] }; } } /** * Get conversation detail by ID */ export function getConversationDetail(baseDir: string, conversationId: string): ConversationRecord | null { // Pass baseDir directly - loadConversation will resolve the correct storage path return loadConversation(baseDir, conversationId); } /** * Get conversation detail with native session mapping info */ export function getConversationDetailWithNativeInfo(baseDir: string, conversationId: string) { try { const store = getSqliteStoreSync(baseDir); return store.getConversationWithNativeInfo(conversationId); } catch { // SQLite not initialized, return null return null; } } /** * Get execution detail by ID (legacy, returns ExecutionRecord for backward compatibility) */ export function getExecutionDetail(baseDir: string, executionId: string): ExecutionRecord | null { const conversation = getConversationDetail(baseDir, executionId); if (!conversation) return null; // Convert to legacy ExecutionRecord format (using latest turn) const latestTurn = conversation.turns[conversation.turns.length - 1]; return { id: conversation.id, timestamp: conversation.created_at, tool: conversation.tool, model: conversation.model, mode: conversation.mode, prompt: latestTurn.prompt, status: conversation.latest_status, exit_code: latestTurn.exit_code, duration_ms: conversation.total_duration_ms, output: latestTurn.output }; } /** * Delete execution by ID (async version) */ export async function deleteExecutionAsync(baseDir: string, executionId: string): Promise<{ success: boolean; error?: string }> { const store = await getSqliteStore(baseDir); return store.deleteConversation(executionId); } /** * Delete execution by ID (sync version - uses cached SQLite module) */ export function deleteExecution(baseDir: string, executionId: string): { success: boolean; error?: string } { try { const store = getSqliteStoreSync(baseDir); return store.deleteConversation(executionId); } catch { return { success: false, error: 'SQLite store not initialized' }; } } /** * Batch delete executions (async) */ export async function batchDeleteExecutionsAsync(baseDir: string, ids: string[]): Promise<{ success: boolean; deleted: number; total: number; errors?: string[]; }> { const store = await getSqliteStore(baseDir); const result = store.batchDelete(ids); return { ...result, total: ids.length }; } /** * Get latest execution for a specific tool */ export function getLatestExecution(baseDir: string, tool?: string): ExecutionRecord | null { const history = getExecutionHistory(baseDir, { limit: 1, tool: tool || null }); if (history.executions.length === 0) { return null; } return getExecutionDetail(baseDir, history.executions[0].id); } // ========== Native Session Content Functions ========== /** * Get native session content by CCW ID * Parses the native session file and returns full conversation data */ export async function getNativeSessionContent(baseDir: string, ccwId: string) { const store = await getSqliteStore(baseDir); return await store.getNativeSessionContent(ccwId); } /** * Get formatted native conversation text */ export async function getFormattedNativeConversation(baseDir: string, ccwId: string, options?: { includeThoughts?: boolean; includeToolCalls?: boolean; includeTokens?: boolean; maxContentLength?: number; }) { const store = await getSqliteStore(baseDir); return await store.getFormattedNativeConversation(ccwId, options); } /** * Get conversation pairs from native session */ export async function getNativeConversationPairs(baseDir: string, ccwId: string) { const store = await getSqliteStore(baseDir); return await store.getNativeConversationPairs(ccwId); } /** * Get enriched conversation (CCW + native session merged) */ export async function getEnrichedConversation(baseDir: string, ccwId: string) { const store = await getSqliteStore(baseDir); return await store.getEnrichedConversation(ccwId); } /** * Get history with native session info * Supports recursive querying of child projects */ export async function getHistoryWithNativeInfo(baseDir: string, options?: { limit?: number; offset?: number; tool?: string | null; status?: string | null; category?: ExecutionCategory | null; search?: string | null; recursive?: boolean; }) { const { limit = 50, recursive = false, ...queryOptions } = options || {}; // Non-recursive mode: query single project if (!recursive) { const store = await getSqliteStore(baseDir); return store.getHistoryWithNativeInfo({ limit, ...queryOptions }); } // Recursive mode: aggregate data from parent and all child projects const { scanChildProjectsAsync } = await import('../config/storage-paths.js'); const childProjects = await scanChildProjectsAsync(baseDir); // Use the same type as store.getHistoryWithNativeInfo returns type ExecutionWithNativeAndSource = HistoryIndexEntry & { hasNativeSession: boolean; nativeSessionId?: string; nativeSessionPath?: string; }; const allExecutions: ExecutionWithNativeAndSource[] = []; let totalCount = 0; // Query parent project try { const parentStore = await getSqliteStore(baseDir); const parentResult = parentStore.getHistoryWithNativeInfo({ limit, ...queryOptions }); totalCount += parentResult.total; for (const exec of parentResult.executions) { allExecutions.push({ ...exec, sourceDir: baseDir }); } } catch (error) { if (process.env.DEBUG) { console.error(`[CLI History] Failed to query parent project ${baseDir}:`, error); } } // Query all child projects for (const child of childProjects) { try { const childStore = await getSqliteStore(child.projectPath); const childResult = childStore.getHistoryWithNativeInfo({ limit, ...queryOptions }); totalCount += childResult.total; for (const exec of childResult.executions) { allExecutions.push({ ...exec, sourceDir: child.projectPath }); } } catch (error) { if (process.env.DEBUG) { console.error(`[CLI History] Failed to query child project ${child.projectPath}:`, error); } } } // Sort by updated_at descending and apply limit allExecutions.sort((a, b) => { const timeA = a.updated_at ? new Date(a.updated_at).getTime() : new Date(a.timestamp).getTime(); const timeB = b.updated_at ? new Date(b.updated_at).getTime() : new Date(b.timestamp).getTime(); return timeB - timeA; }); const limitedExecutions = allExecutions.slice(0, limit); return { total: totalCount, count: limitedExecutions.length, executions: limitedExecutions }; }