feat(cli): add support for custom execution IDs and multi-turn conversations

- Introduced `--id <id>` option in CLI for custom execution IDs.
- Enhanced CLI command handling to support multi-turn conversations.
- Updated execution and conversation detail retrieval to accommodate new structure.
- Implemented merging of multiple conversations with tracking of source IDs.
- Improved history management to save and load conversation records.
- Added styles for displaying multi-turn conversation details in the dashboard.
- Refactored existing execution detail functions for backward compatibility.
This commit is contained in:
catlog22
2025-12-13 14:03:24 +08:00
parent 23e15e479e
commit c780544792
14 changed files with 1483 additions and 640 deletions

View File

@@ -161,6 +161,7 @@ export function run(argv: string[]): void {
.option('--limit <n>', 'History limit')
.option('--status <status>', 'Filter by status')
.option('--resume [id]', 'Resume previous session (empty=last, or execution ID)')
.option('--id <id>', 'Custom execution ID (e.g., IMPL-001-step1)')
.action((subcommand, args, options) => cliCommand(subcommand, args, options));
program.parse(argv);

View File

@@ -8,7 +8,8 @@ import {
cliExecutorTool,
getCliToolsStatus,
getExecutionHistory,
getExecutionDetail
getExecutionDetail,
getConversationDetail
} from '../tools/cli-executor.js';
interface CliExecOptions {
@@ -20,6 +21,7 @@ interface CliExecOptions {
timeout?: string;
noStream?: boolean;
resume?: string | boolean; // true = last, string = execution ID
id?: string; // Custom execution ID (e.g., IMPL-001-step1)
}
interface HistoryOptions {
@@ -61,11 +63,30 @@ async function execAction(prompt: string | undefined, options: CliExecOptions):
process.exit(1);
}
const { tool = 'gemini', mode = 'analysis', model, cd, includeDirs, timeout, noStream, resume } = options;
const { tool = 'gemini', mode = 'analysis', model, cd, includeDirs, timeout, noStream, resume, id } = options;
// Parse resume IDs for merge scenario
const resumeIds = resume && typeof resume === 'string' ? resume.split(',').map(s => s.trim()).filter(Boolean) : [];
const isMerge = resumeIds.length > 1;
// Show execution mode
const resumeInfo = resume ? (typeof resume === 'string' ? ` resuming ${resume}` : ' resuming last') : '';
console.log(chalk.cyan(`\n Executing ${tool} (${mode} mode${resumeInfo})...\n`));
let resumeInfo = '';
if (isMerge) {
resumeInfo = ` merging ${resumeIds.length} conversations`;
} else if (resume) {
resumeInfo = typeof resume === 'string' ? ` resuming ${resume}` : ' resuming last';
}
const idInfo = id ? ` [${id}]` : '';
console.log(chalk.cyan(`\n Executing ${tool} (${mode} mode${resumeInfo})${idInfo}...\n`));
// Show merge details
if (isMerge) {
console.log(chalk.gray(' Merging conversations:'));
for (const rid of resumeIds) {
console.log(chalk.gray(`${rid}`));
}
console.log();
}
// Streaming output handler
const onOutput = noStream ? null : (chunk: any) => {
@@ -81,7 +102,8 @@ async function execAction(prompt: string | undefined, options: CliExecOptions):
cd,
includeDirs,
timeout: timeout ? parseInt(timeout, 10) : 300000,
resume // pass resume parameter
resume,
id // custom execution ID
}, onOutput);
// If not streaming, print output now
@@ -89,12 +111,25 @@ async function execAction(prompt: string | undefined, options: CliExecOptions):
console.log(result.stdout);
}
// Print summary with execution ID for resume
// Print summary with execution ID and turn info
console.log();
if (result.success) {
console.log(chalk.green(` ✓ Completed in ${(result.execution.duration_ms / 1000).toFixed(1)}s`));
const turnInfo = result.conversation.turn_count > 1
? ` (turn ${result.conversation.turn_count})`
: '';
console.log(chalk.green(` ✓ Completed in ${(result.execution.duration_ms / 1000).toFixed(1)}s${turnInfo}`));
console.log(chalk.gray(` ID: ${result.execution.id}`));
console.log(chalk.dim(` Resume: ccw cli exec "..." --resume ${result.execution.id}`));
if (isMerge && !id) {
// Merge without custom ID: updated all source conversations
console.log(chalk.gray(` Updated ${resumeIds.length} conversations: ${resumeIds.join(', ')}`));
} else if (isMerge && id) {
// Merge with custom ID: created new merged conversation
console.log(chalk.gray(` Created merged conversation from ${resumeIds.length} sources`));
}
if (result.conversation.turn_count > 1) {
console.log(chalk.gray(` Total: ${result.conversation.turn_count} turns, ${(result.conversation.total_duration_ms / 1000).toFixed(1)}s`));
}
console.log(chalk.dim(` Continue: ccw cli exec "..." --resume ${result.execution.id}`));
} else {
console.log(chalk.red(` ✗ Failed (${result.execution.status})`));
console.log(chalk.gray(` ID: ${result.execution.id}`));
@@ -135,9 +170,10 @@ async function historyAction(options: HistoryOptions): Promise<void> {
? `${(exec.duration_ms / 1000).toFixed(1)}s`
: `${exec.duration_ms}ms`;
const timeAgo = getTimeAgo(new Date(exec.timestamp));
const timeAgo = getTimeAgo(new Date(exec.updated_at || exec.timestamp));
const turnInfo = exec.turn_count && exec.turn_count > 1 ? chalk.cyan(` [${exec.turn_count} turns]`) : '';
console.log(` ${statusIcon} ${chalk.bold.white(exec.tool.padEnd(8))} ${chalk.gray(timeAgo.padEnd(12))} ${chalk.gray(duration.padEnd(8))}`);
console.log(` ${statusIcon} ${chalk.bold.white(exec.tool.padEnd(8))} ${chalk.gray(timeAgo.padEnd(12))} ${chalk.gray(duration.padEnd(8))}${turnInfo}`);
console.log(chalk.gray(` ${exec.prompt_preview}`));
console.log(chalk.dim(` ID: ${exec.id}`));
console.log();
@@ -145,49 +181,60 @@ async function historyAction(options: HistoryOptions): Promise<void> {
}
/**
* Show execution detail
* @param {string} executionId - Execution ID
* Show conversation detail with all turns
* @param {string} conversationId - Conversation ID
*/
async function detailAction(executionId: string | undefined): Promise<void> {
if (!executionId) {
console.error(chalk.red('Error: Execution ID is required'));
console.error(chalk.gray('Usage: ccw cli detail <execution-id>'));
async function detailAction(conversationId: string | undefined): Promise<void> {
if (!conversationId) {
console.error(chalk.red('Error: Conversation ID is required'));
console.error(chalk.gray('Usage: ccw cli detail <conversation-id>'));
process.exit(1);
}
const detail = getExecutionDetail(process.cwd(), executionId);
const conversation = getConversationDetail(process.cwd(), conversationId);
if (!detail) {
console.error(chalk.red(`Error: Execution not found: ${executionId}`));
if (!conversation) {
console.error(chalk.red(`Error: Conversation not found: ${conversationId}`));
process.exit(1);
}
console.log(chalk.bold.cyan('\n Execution Detail\n'));
console.log(` ${chalk.gray('ID:')} ${detail.id}`);
console.log(` ${chalk.gray('Tool:')} ${detail.tool}`);
console.log(` ${chalk.gray('Model:')} ${detail.model}`);
console.log(` ${chalk.gray('Mode:')} ${detail.mode}`);
console.log(` ${chalk.gray('Status:')} ${detail.status}`);
console.log(` ${chalk.gray('Duration:')} ${detail.duration_ms}ms`);
console.log(` ${chalk.gray('Timestamp:')} ${detail.timestamp}`);
console.log(chalk.bold.cyan('\n Prompt:\n'));
console.log(chalk.gray(' ' + detail.prompt.split('\n').join('\n ')));
if (detail.output.stdout) {
console.log(chalk.bold.cyan('\n Output:\n'));
console.log(detail.output.stdout);
console.log(chalk.bold.cyan('\n Conversation Detail\n'));
console.log(` ${chalk.gray('ID:')} ${conversation.id}`);
console.log(` ${chalk.gray('Tool:')} ${conversation.tool}`);
console.log(` ${chalk.gray('Model:')} ${conversation.model}`);
console.log(` ${chalk.gray('Mode:')} ${conversation.mode}`);
console.log(` ${chalk.gray('Status:')} ${conversation.latest_status}`);
console.log(` ${chalk.gray('Turns:')} ${conversation.turn_count}`);
console.log(` ${chalk.gray('Duration:')} ${(conversation.total_duration_ms / 1000).toFixed(1)}s total`);
console.log(` ${chalk.gray('Created:')} ${conversation.created_at}`);
if (conversation.turn_count > 1) {
console.log(` ${chalk.gray('Updated:')} ${conversation.updated_at}`);
}
if (detail.output.stderr) {
console.log(chalk.bold.red('\n Errors:\n'));
console.log(detail.output.stderr);
}
if (detail.output.truncated) {
console.log(chalk.yellow('\n Note: Output was truncated due to size.'));
// Show all turns
for (const turn of conversation.turns) {
console.log(chalk.bold.cyan(`\n ═══ Turn ${turn.turn} ═══`));
console.log(chalk.gray(` ${turn.timestamp} | ${turn.status} | ${(turn.duration_ms / 1000).toFixed(1)}s`));
console.log(chalk.bold.white('\n Prompt:'));
console.log(chalk.gray(' ' + turn.prompt.split('\n').join('\n ')));
if (turn.output.stdout) {
console.log(chalk.bold.white('\n Output:'));
console.log(turn.output.stdout);
}
if (turn.output.stderr) {
console.log(chalk.bold.red('\n Errors:'));
console.log(turn.output.stderr);
}
if (turn.output.truncated) {
console.log(chalk.yellow('\n Note: Output was truncated due to size.'));
}
}
console.log(chalk.dim(`\n Continue: ccw cli exec "..." --resume ${conversation.id}`));
console.log();
}
@@ -256,6 +303,7 @@ export async function cliCommand(
console.log(chalk.gray(' --timeout <ms> Timeout in milliseconds (default: 300000)'));
console.log(chalk.gray(' --no-stream Disable streaming output'));
console.log(chalk.gray(' --resume [id] Resume previous session (empty=last, or execution ID)'));
console.log(chalk.gray(' --id <id> Custom execution ID (e.g., IMPL-001-step1)'));
console.log();
console.log(' History Options:');
console.log(chalk.gray(' --limit <n> Number of results (default: 20)'));

View File

@@ -8,7 +8,7 @@ import { createHash } from 'crypto';
import { scanSessions } from './session-scanner.js';
import { aggregateData } from './data-aggregator.js';
import { resolvePath, getRecentPaths, trackRecentPath, removeRecentPath, normalizePathForDisplay, getWorkflowDir } from '../utils/path-resolver.js';
import { getCliToolsStatus, getExecutionHistory, getExecutionDetail, deleteExecution, executeCliTool } from '../tools/cli-executor.js';
import { getCliToolsStatus, getExecutionHistory, getExecutionDetail, getConversationDetail, deleteExecution, executeCliTool } from '../tools/cli-executor.js';
import { getAllManifests } from './manifest.js';
import { checkVenvStatus, bootstrapVenv, executeCodexLens, checkSemanticStatus, installSemantic } from '../tools/codex-lens.js';
import { listTools } from '../tools/index.js';
@@ -673,16 +673,16 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
return;
}
// Handle GET request
const detail = getExecutionDetail(projectPath, executionId);
if (!detail) {
// Handle GET request - return full conversation with all turns
const conversation = getConversationDetail(projectPath, executionId);
if (!conversation) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Execution not found' }));
res.end(JSON.stringify({ error: 'Conversation not found' }));
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(detail));
res.end(JSON.stringify(conversation));
return;
}

View File

@@ -2126,3 +2126,87 @@
padding-top: 1rem;
border-top: 1px solid hsl(var(--border));
}
/* ========================================
* Multi-Turn Conversation Styles
* ======================================== */
/* Turn Badge in History List */
.cli-turn-badge {
font-size: 0.5625rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
background: hsl(var(--primary) / 0.12);
color: hsl(var(--primary));
border-radius: 9999px;
text-transform: uppercase;
letter-spacing: 0.02em;
}
/* Turns Container in Detail Modal */
.cli-turns-container {
max-height: 60vh;
overflow-y: auto;
}
/* Turn Section */
.cli-turn-section {
margin-bottom: 1rem;
}
.cli-turn-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
flex-wrap: wrap;
}
.cli-turn-number {
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.625rem;
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
border-radius: 9999px;
}
.cli-turn-status {
font-size: 0.625rem;
font-weight: 600;
padding: 0.1875rem 0.5rem;
border-radius: 9999px;
text-transform: uppercase;
}
.cli-turn-status.status-success {
background: hsl(var(--success) / 0.12);
color: hsl(var(--success));
}
.cli-turn-status.status-error {
background: hsl(var(--destructive) / 0.12);
color: hsl(var(--destructive));
}
.cli-turn-status.status-timeout {
background: hsl(var(--warning) / 0.12);
color: hsl(var(--warning));
}
.cli-turn-duration {
font-size: 0.6875rem;
color: hsl(var(--muted-foreground));
}
/* Turn Divider */
.cli-turn-divider {
border: none;
border-top: 1px dashed hsl(var(--border));
margin: 1.25rem 0;
}
/* Error Section (smaller in multi-turn) */
.cli-detail-error-section .cli-detail-error {
max-height: 100px;
}

View File

@@ -90,13 +90,17 @@ function renderCliHistory() {
const statusClass = exec.status === 'success' ? 'text-success' :
exec.status === 'timeout' ? 'text-warning' : 'text-destructive';
const duration = formatDuration(exec.duration_ms);
const timeAgo = getTimeAgo(new Date(exec.timestamp));
const timeAgo = getTimeAgo(new Date(exec.updated_at || exec.timestamp));
const turnBadge = exec.turn_count && exec.turn_count > 1
? `<span class="cli-turn-badge">${exec.turn_count} turns</span>`
: '';
return `
<div class="cli-history-item">
<div class="cli-history-item-content" onclick="showExecutionDetail('${exec.id}')">
<div class="cli-history-item-header">
<span class="cli-tool-tag cli-tool-${exec.tool}">${exec.tool}</span>
${turnBadge}
<span class="cli-history-time">${timeAgo}</span>
<i data-lucide="${statusIcon}" class="w-3.5 h-3.5 ${statusClass}"></i>
</div>
@@ -163,50 +167,102 @@ function renderToolFilter() {
// ========== Execution Detail Modal ==========
async function showExecutionDetail(executionId, sourceDir) {
const detail = await loadExecutionDetail(executionId, sourceDir);
if (!detail) {
showRefreshToast('Execution not found', 'error');
const conversation = await loadExecutionDetail(executionId, sourceDir);
if (!conversation) {
showRefreshToast('Conversation not found', 'error');
return;
}
// Handle both old (single execution) and new (conversation) formats
const isConversation = conversation.turns && Array.isArray(conversation.turns);
const turnCount = isConversation ? conversation.turn_count : 1;
const totalDuration = isConversation ? conversation.total_duration_ms : conversation.duration_ms;
const latestStatus = isConversation ? conversation.latest_status : conversation.status;
const createdAt = isConversation ? conversation.created_at : conversation.timestamp;
// Build turns HTML
let turnsHtml = '';
if (isConversation && conversation.turns.length > 0) {
turnsHtml = conversation.turns.map((turn, idx) => `
<div class="cli-turn-section">
<div class="cli-turn-header">
<span class="cli-turn-number">Turn ${turn.turn}</span>
<span class="cli-turn-status status-${turn.status}">${turn.status}</span>
<span class="cli-turn-duration">${formatDuration(turn.duration_ms)}</span>
</div>
<div class="cli-detail-section">
<h4><i data-lucide="message-square"></i> Prompt</h4>
<pre class="cli-detail-prompt">${escapeHtml(turn.prompt)}</pre>
</div>
${turn.output.stdout ? `
<div class="cli-detail-section">
<h4><i data-lucide="terminal"></i> Output</h4>
<pre class="cli-detail-output">${escapeHtml(turn.output.stdout)}</pre>
</div>
` : ''}
${turn.output.stderr ? `
<div class="cli-detail-section cli-detail-error-section">
<h4><i data-lucide="alert-triangle"></i> Errors</h4>
<pre class="cli-detail-error">${escapeHtml(turn.output.stderr)}</pre>
</div>
` : ''}
${turn.output.truncated ? `
<p class="text-warning" style="font-size: 0.75rem; margin-top: 0.5rem;">
<i data-lucide="info" class="w-3 h-3" style="display: inline;"></i>
Output was truncated due to size.
</p>
` : ''}
</div>
`).join('<hr class="cli-turn-divider">');
} else {
// Legacy single execution format
const detail = conversation;
turnsHtml = `
<div class="cli-detail-section">
<h4><i data-lucide="message-square"></i> Prompt</h4>
<pre class="cli-detail-prompt">${escapeHtml(detail.prompt)}</pre>
</div>
${detail.output.stdout ? `
<div class="cli-detail-section">
<h4><i data-lucide="terminal"></i> Output</h4>
<pre class="cli-detail-output">${escapeHtml(detail.output.stdout)}</pre>
</div>
` : ''}
${detail.output.stderr ? `
<div class="cli-detail-section">
<h4><i data-lucide="alert-triangle"></i> Errors</h4>
<pre class="cli-detail-error">${escapeHtml(detail.output.stderr)}</pre>
</div>
` : ''}
${detail.output.truncated ? `
<p class="text-warning" style="font-size: 0.75rem; margin-top: 0.5rem;">
<i data-lucide="info" class="w-3 h-3" style="display: inline;"></i>
Output was truncated due to size.
</p>
` : ''}
`;
}
const modalContent = `
<div class="cli-detail-header">
<div class="cli-detail-info">
<span class="cli-tool-tag cli-tool-${detail.tool}">${detail.tool}</span>
<span class="cli-detail-status status-${detail.status}">${detail.status}</span>
<span class="text-muted-foreground">${formatDuration(detail.duration_ms)}</span>
<span class="cli-tool-tag cli-tool-${conversation.tool}">${conversation.tool}</span>
${turnCount > 1 ? `<span class="cli-turn-badge">${turnCount} turns</span>` : ''}
<span class="cli-detail-status status-${latestStatus}">${latestStatus}</span>
<span class="text-muted-foreground">${formatDuration(totalDuration)}</span>
</div>
<div class="cli-detail-meta">
<span><i data-lucide="cpu" class="w-3 h-3"></i> ${detail.model || 'default'}</span>
<span><i data-lucide="toggle-right" class="w-3 h-3"></i> ${detail.mode}</span>
<span><i data-lucide="calendar" class="w-3 h-3"></i> ${new Date(detail.timestamp).toLocaleString()}</span>
<span><i data-lucide="cpu" class="w-3 h-3"></i> ${conversation.model || 'default'}</span>
<span><i data-lucide="toggle-right" class="w-3 h-3"></i> ${conversation.mode}</span>
<span><i data-lucide="calendar" class="w-3 h-3"></i> ${new Date(createdAt).toLocaleString()}</span>
</div>
</div>
<div class="cli-detail-section">
<h4><i data-lucide="message-square"></i> Prompt</h4>
<pre class="cli-detail-prompt">${escapeHtml(detail.prompt)}</pre>
<div class="cli-turns-container">
${turnsHtml}
</div>
${detail.output.stdout ? `
<div class="cli-detail-section">
<h4><i data-lucide="terminal"></i> Output</h4>
<pre class="cli-detail-output">${escapeHtml(detail.output.stdout)}</pre>
</div>
` : ''}
${detail.output.stderr ? `
<div class="cli-detail-section">
<h4><i data-lucide="alert-triangle"></i> Errors</h4>
<pre class="cli-detail-error">${escapeHtml(detail.output.stderr)}</pre>
</div>
` : ''}
${detail.output.truncated ? `
<p class="text-warning" style="font-size: 0.75rem; margin-top: 0.5rem;">
<i data-lucide="info" class="w-3 h-3" style="display: inline;"></i>
Output was truncated due to size.
</p>
` : ''}
<div class="cli-detail-actions">
<button class="btn btn-sm btn-outline" onclick="copyExecutionPrompt('${executionId}')">
<i data-lucide="copy" class="w-3.5 h-3.5"></i> Copy Prompt
<button class="btn btn-sm btn-outline" onclick="copyConversationId('${executionId}')">
<i data-lucide="copy" class="w-3.5 h-3.5"></i> Copy ID
</button>
<button class="btn btn-sm btn-outline btn-danger" onclick="confirmDeleteExecution('${executionId}'); closeModal();">
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i> Delete
@@ -214,7 +270,7 @@ async function showExecutionDetail(executionId, sourceDir) {
</div>
`;
showModal('Execution Detail', modalContent);
showModal('Conversation Detail', modalContent);
}
// ========== Actions ==========
@@ -269,7 +325,7 @@ async function deleteExecution(executionId) {
}
}
// ========== Copy Prompt ==========
// ========== Copy Functions ==========
async function copyExecutionPrompt(executionId) {
const detail = await loadExecutionDetail(executionId);
if (!detail) {
@@ -287,6 +343,17 @@ async function copyExecutionPrompt(executionId) {
}
}
async function copyConversationId(conversationId) {
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(conversationId);
showRefreshToast('ID copied to clipboard', 'success');
} catch (err) {
showRefreshToast('Failed to copy', 'error');
}
}
}
// ========== Helpers ==========
function formatDuration(ms) {
if (ms >= 60000) {

View File

@@ -21,7 +21,8 @@ const ParamsSchema = z.object({
cd: z.string().optional(),
includeDirs: z.string().optional(),
timeout: z.number().default(300000),
resume: z.union([z.boolean(), z.string()]).optional(), // true = last, string = execution ID
resume: z.union([z.boolean(), z.string()]).optional(), // true = last, string = single ID or comma-separated IDs
id: z.string().optional(), // Custom execution ID (e.g., IMPL-001-step1)
});
type Params = z.infer<typeof ParamsSchema>;
@@ -31,6 +32,36 @@ interface ToolAvailability {
path: string | null;
}
// Single turn in a conversation
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;
};
}
// Multi-turn conversation record
interface ConversationRecord {
id: string;
created_at: string;
updated_at: string;
tool: string;
model: string;
mode: string;
total_duration_ms: number;
turn_count: number;
latest_status: 'success' | 'error' | 'timeout';
turns: ConversationTurn[];
}
// Legacy single execution record (for backward compatibility)
interface ExecutionRecord {
id: string;
timestamp: string;
@@ -53,10 +84,12 @@ interface HistoryIndex {
total_executions: number;
executions: {
id: string;
timestamp: 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;
}[];
}
@@ -64,6 +97,7 @@ interface HistoryIndex {
interface ExecutionOutput {
success: boolean;
execution: ExecutionRecord;
conversation: ConversationRecord; // Full conversation record
stdout: string;
stderr: string;
}
@@ -206,33 +240,47 @@ function loadHistoryIndex(historyDir: string): HistoryIndex {
}
/**
* Save execution to history
* Save conversation to history (create new or append turn)
*/
function saveExecution(historyDir: string, execution: ExecutionRecord): void {
// Create date-based subdirectory
const dateStr = new Date().toISOString().split('T')[0];
function saveConversation(historyDir: string, conversation: ConversationRecord): void {
// Create date-based subdirectory using created_at date
const dateStr = conversation.created_at.split('T')[0];
const dateDir = join(historyDir, dateStr);
if (!existsSync(dateDir)) {
mkdirSync(dateDir, { recursive: true });
}
// Save execution record
const filename = `${execution.id}.json`;
writeFileSync(join(dateDir, filename), JSON.stringify(execution, null, 2), 'utf8');
// Save conversation record
const filename = `${conversation.id}.json`;
writeFileSync(join(dateDir, filename), JSON.stringify(conversation, null, 2), 'utf8');
// Update index
const index = loadHistoryIndex(historyDir);
index.total_executions++;
// Add to executions (keep last 100 in index)
index.executions.unshift({
id: execution.id,
timestamp: execution.timestamp,
tool: execution.tool,
status: execution.status,
duration_ms: execution.duration_ms,
prompt_preview: execution.prompt.substring(0, 100) + (execution.prompt.length > 100 ? '...' : '')
});
// Check if this conversation already exists in index
const existingIdx = index.executions.findIndex(e => e.id === conversation.id);
const latestTurn = conversation.turns[conversation.turns.length - 1];
const indexEntry = {
id: conversation.id,
timestamp: conversation.created_at,
updated_at: conversation.updated_at,
tool: conversation.tool,
status: conversation.latest_status,
duration_ms: conversation.total_duration_ms,
turn_count: conversation.turn_count,
prompt_preview: latestTurn.prompt.substring(0, 100) + (latestTurn.prompt.length > 100 ? '...' : '')
};
if (existingIdx >= 0) {
// Update existing entry and move to top
index.executions.splice(existingIdx, 1);
index.executions.unshift(indexEntry);
} else {
// Add new entry
index.total_executions++;
index.executions.unshift(indexEntry);
}
if (index.executions.length > 100) {
index.executions = index.executions.slice(0, 100);
@@ -241,6 +289,137 @@ function saveExecution(historyDir: string, execution: ExecutionRecord): void {
writeFileSync(join(historyDir, 'index.json'), JSON.stringify(index, null, 2), 'utf8');
}
/**
* Load existing conversation by ID
*/
function loadConversation(historyDir: string, conversationId: string): ConversationRecord | null {
// Search in all date directories
if (existsSync(historyDir)) {
const dateDirs = readdirSync(historyDir).filter(d => {
const dirPath = join(historyDir, d);
return statSync(dirPath).isDirectory() && /^\d{4}-\d{2}-\d{2}$/.test(d);
});
// Search newest first
for (const dateDir of dateDirs.sort().reverse()) {
const filePath = join(historyDir, dateDir, `${conversationId}.json`);
if (existsSync(filePath)) {
try {
const data = JSON.parse(readFileSync(filePath, 'utf8'));
// Check if it's a conversation record (has turns array)
if (data.turns && Array.isArray(data.turns)) {
return data as ConversationRecord;
}
// Convert legacy ExecutionRecord to ConversationRecord
return convertToConversation(data);
} catch {
continue;
}
}
}
}
return null;
}
/**
* Convert legacy ExecutionRecord to ConversationRecord
*/
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,
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
}]
};
}
/**
* Merge multiple conversations into a unified context
* Returns merged turns sorted by timestamp with source tracking
*/
interface MergedTurn extends ConversationTurn {
source_id: string; // Original conversation ID
}
interface MergeResult {
mergedTurns: MergedTurn[];
sourceConversations: ConversationRecord[];
totalDuration: number;
}
function mergeConversations(conversations: ConversationRecord[]): MergeResult {
const mergedTurns: MergedTurn[] = [];
// Collect all turns with source tracking
for (const conv of conversations) {
for (const turn of conv.turns) {
mergedTurns.push({
...turn,
source_id: conv.id
});
}
}
// Sort by timestamp
mergedTurns.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
// Re-number turns
mergedTurns.forEach((turn, idx) => {
turn.turn = idx + 1;
});
// Calculate total duration
const totalDuration = mergedTurns.reduce((sum, t) => sum + t.duration_ms, 0);
return {
mergedTurns,
sourceConversations: conversations,
totalDuration
};
}
/**
* Build prompt from merged conversations
*/
function buildMergedPrompt(mergeResult: MergeResult, newPrompt: string): string {
const parts: string[] = [];
parts.push('=== MERGED CONVERSATION HISTORY ===');
parts.push(`(From ${mergeResult.sourceConversations.length} conversations: ${mergeResult.sourceConversations.map(c => c.id).join(', ')})`);
parts.push('');
// Add all merged turns with source tracking
for (const turn of mergeResult.mergedTurns) {
parts.push(`--- Turn ${turn.turn} [${turn.source_id}] ---`);
parts.push('USER:');
parts.push(turn.prompt);
parts.push('');
parts.push('ASSISTANT:');
parts.push(turn.output.stdout || '[No output recorded]');
parts.push('');
}
parts.push('=== NEW REQUEST ===');
parts.push('');
parts.push(newPrompt);
return parts.join('\n');
}
/**
* Execute CLI tool with streaming output
*/
@@ -253,17 +432,95 @@ async function executeCliTool(
throw new Error(`Invalid params: ${parsed.error.message}`);
}
const { tool, prompt, mode, model, cd, includeDirs, timeout, resume } = parsed.data;
const { tool, prompt, mode, model, cd, includeDirs, timeout, resume, id: customId } = parsed.data;
// Determine working directory early (needed for resume lookup)
// Determine working directory early (needed for conversation lookup)
const workingDir = cd || process.cwd();
const historyDir = ensureHistoryDir(workingDir);
// Build final prompt (with resume context if applicable)
// Determine conversation ID and load existing conversation
// Logic:
// - If --resume <id1,id2,...> (multiple IDs): merge conversations
// - With --id: create new merged conversation
// - Without --id: append to ALL source conversations
// - If --resume <id> AND --id <newId>: fork - read context from resume ID, create new conversation with newId
// - If --id provided (no resume): use that ID (create new or append)
// - If --resume <id> without --id: use resume ID (append to existing)
// - No params: create new with auto-generated ID
let conversationId: string;
let existingConversation: ConversationRecord | null = null;
let contextConversation: ConversationRecord | null = null; // For fork scenario
let mergeResult: MergeResult | null = null; // For merge scenario
let sourceConversations: ConversationRecord[] = []; // All source conversations for merge
// Parse resume IDs (can be comma-separated for merge)
const resumeIds: string[] = resume
? (typeof resume === 'string' ? resume.split(',').map(id => id.trim()).filter(Boolean) : [])
: [];
const isMerge = resumeIds.length > 1;
const resumeId = resumeIds.length === 1 ? resumeIds[0] : null;
if (isMerge) {
// Merge scenario: multiple resume IDs
sourceConversations = resumeIds
.map(id => loadConversation(historyDir, id))
.filter((c): c is ConversationRecord => c !== null);
if (sourceConversations.length === 0) {
throw new Error('No valid conversations found for merge');
}
mergeResult = mergeConversations(sourceConversations);
if (customId) {
// Create new merged conversation with custom ID
conversationId = customId;
existingConversation = loadConversation(historyDir, customId);
} else {
// Will append to ALL source conversations (handled in save logic)
// Use first source conversation ID as primary
conversationId = sourceConversations[0].id;
existingConversation = sourceConversations[0];
}
} else if (customId && resumeId) {
// Fork: read context from resume ID, but create new conversation with custom ID
conversationId = customId;
contextConversation = loadConversation(historyDir, resumeId);
existingConversation = loadConversation(historyDir, customId);
} else if (customId) {
// Use custom ID - may be new or existing
conversationId = customId;
existingConversation = loadConversation(historyDir, customId);
} else if (resumeId) {
// Resume single ID without new ID - append to existing conversation
conversationId = resumeId;
existingConversation = loadConversation(historyDir, resumeId);
} else if (resume) {
// resume=true: get last conversation for this tool
const history = getExecutionHistory(workingDir, { limit: 1, tool });
if (history.executions.length > 0) {
conversationId = history.executions[0].id;
existingConversation = loadConversation(historyDir, conversationId);
} else {
// No previous conversation, create new
conversationId = `${Date.now()}-${tool}`;
}
} else {
// New conversation with auto-generated ID
conversationId = `${Date.now()}-${tool}`;
}
// Build final prompt with conversation context
// For merge: use merged context from all source conversations
// For fork: use contextConversation (from resume ID) for prompt context
// For append: use existingConversation (from target ID)
let finalPrompt = prompt;
if (resume) {
const previousExecution = getPreviousExecution(workingDir, tool, resume);
if (previousExecution) {
finalPrompt = buildContinuationPrompt(previousExecution, prompt);
if (mergeResult && mergeResult.mergedTurns.length > 0) {
finalPrompt = buildMergedPrompt(mergeResult, prompt);
} else {
const conversationForContext = contextConversation || existingConversation;
if (conversationForContext && conversationForContext.turns.length > 0) {
finalPrompt = buildMultiTurnPrompt(conversationForContext, prompt);
}
}
@@ -283,8 +540,6 @@ async function executeCliTool(
include: includeDirs
});
// Create execution record
const executionId = `${Date.now()}-${tool}`;
const startTime = Date.now();
return new Promise((resolve, reject) => {
@@ -356,9 +611,135 @@ async function executeCliTool(
}
}
// Create execution record
// Create new turn
const newTurnOutput = {
stdout: stdout.substring(0, 10240), // Truncate to 10KB
stderr: stderr.substring(0, 2048), // Truncate to 2KB
truncated: stdout.length > 10240 || stderr.length > 2048
};
// Determine base turn number for merge scenarios
const baseTurnNumber = isMerge && mergeResult
? mergeResult.mergedTurns.length + 1
: (existingConversation ? existingConversation.turns.length + 1 : 1);
const newTurn: ConversationTurn = {
turn: baseTurnNumber,
timestamp: new Date(startTime).toISOString(),
prompt,
duration_ms: duration,
status,
exit_code: code,
output: newTurnOutput
};
// Create or update conversation record
let conversation: ConversationRecord;
if (isMerge && mergeResult && !customId) {
// Merge without --id: append to ALL source conversations
// Save new turn to each source conversation
const savedConversations: ConversationRecord[] = [];
for (const srcConv of sourceConversations) {
const turnForSrc: ConversationTurn = {
...newTurn,
turn: srcConv.turns.length + 1 // Use each conversation's turn count
};
const updatedConv: ConversationRecord = {
...srcConv,
updated_at: new Date().toISOString(),
total_duration_ms: srcConv.total_duration_ms + duration,
turn_count: srcConv.turns.length + 1,
latest_status: status,
turns: [...srcConv.turns, turnForSrc]
};
savedConversations.push(updatedConv);
}
// Use first conversation as primary
conversation = savedConversations[0];
// Save all source conversations
try {
for (const conv of savedConversations) {
saveConversation(historyDir, conv);
}
} catch (err) {
console.error('[CLI Executor] Failed to save merged histories:', (err as Error).message);
}
} else if (isMerge && mergeResult && customId) {
// Merge with --id: create new conversation with merged turns + new turn
// Convert merged turns to regular turns (without source_id)
const mergedTurns: ConversationTurn[] = mergeResult.mergedTurns.map((mt, idx) => ({
turn: idx + 1,
timestamp: mt.timestamp,
prompt: mt.prompt,
duration_ms: mt.duration_ms,
status: mt.status,
exit_code: mt.exit_code,
output: mt.output
}));
conversation = existingConversation
? {
...existingConversation,
updated_at: new Date().toISOString(),
total_duration_ms: existingConversation.total_duration_ms + duration,
turn_count: existingConversation.turns.length + 1,
latest_status: status,
turns: [...existingConversation.turns, newTurn]
}
: {
id: conversationId,
created_at: new Date(startTime).toISOString(),
updated_at: new Date().toISOString(),
tool,
model: model || 'default',
mode,
total_duration_ms: mergeResult.totalDuration + duration,
turn_count: mergedTurns.length + 1,
latest_status: status,
turns: [...mergedTurns, newTurn]
};
// Save merged conversation
try {
saveConversation(historyDir, conversation);
} catch (err) {
console.error('[CLI Executor] Failed to save merged conversation:', (err as Error).message);
}
} else {
// Normal scenario: single conversation
conversation = existingConversation
? {
...existingConversation,
updated_at: new Date().toISOString(),
total_duration_ms: existingConversation.total_duration_ms + duration,
turn_count: existingConversation.turns.length + 1,
latest_status: status,
turns: [...existingConversation.turns, newTurn]
}
: {
id: conversationId,
created_at: new Date(startTime).toISOString(),
updated_at: new Date().toISOString(),
tool,
model: model || 'default',
mode,
total_duration_ms: duration,
turn_count: 1,
latest_status: status,
turns: [newTurn]
};
// Try to save conversation to history
try {
saveConversation(historyDir, conversation);
} catch (err) {
// Non-fatal: continue even if history save fails
console.error('[CLI Executor] Failed to save history:', (err as Error).message);
}
}
// Create legacy execution record for backward compatibility
const execution: ExecutionRecord = {
id: executionId,
id: conversationId,
timestamp: new Date(startTime).toISOString(),
tool,
model: model || 'default',
@@ -367,25 +748,13 @@ async function executeCliTool(
status,
exit_code: code,
duration_ms: duration,
output: {
stdout: stdout.substring(0, 10240), // Truncate to 10KB
stderr: stderr.substring(0, 2048), // Truncate to 2KB
truncated: stdout.length > 10240 || stderr.length > 2048
}
output: newTurnOutput
};
// Try to save to history
try {
const historyDir = ensureHistoryDir(workingDir);
saveExecution(historyDir, execution);
} catch (err) {
// Non-fatal: continue even if history save fails
console.error('[CLI Executor] Failed to save history:', (err as Error).message);
}
resolve({
success: status === 'success',
execution,
conversation,
stdout,
stderr
});
@@ -576,27 +945,34 @@ export function getExecutionHistory(baseDir: string, options: {
}
/**
* Get execution detail by ID
* Get conversation detail by ID (returns ConversationRecord)
*/
export function getConversationDetail(baseDir: string, conversationId: string): ConversationRecord | null {
const historyDir = join(baseDir, '.workflow', '.cli-history');
return loadConversation(historyDir, conversationId);
}
/**
* Get execution detail by ID (legacy, returns ExecutionRecord for backward compatibility)
*/
export function getExecutionDetail(baseDir: string, executionId: string): ExecutionRecord | null {
const historyDir = join(baseDir, '.workflow', '.cli-history');
const conversation = getConversationDetail(baseDir, executionId);
if (!conversation) return null;
// Parse date from execution ID
const timestamp = parseInt(executionId.split('-')[0], 10);
const date = new Date(timestamp);
const dateStr = date.toISOString().split('T')[0];
const filePath = join(historyDir, dateStr, `${executionId}.json`);
if (existsSync(filePath)) {
try {
return JSON.parse(readFileSync(filePath, 'utf8'));
} catch {
return null;
}
}
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
};
}
/**
@@ -649,7 +1025,34 @@ export async function getCliToolsStatus(): Promise<Record<string, ToolAvailabili
}
/**
* Build continuation prompt with previous conversation context
* Build multi-turn prompt with full conversation history
*/
function buildMultiTurnPrompt(conversation: ConversationRecord, newPrompt: string): string {
const parts: string[] = [];
parts.push('=== CONVERSATION HISTORY ===');
parts.push('');
// Add all previous turns
for (const turn of conversation.turns) {
parts.push(`--- Turn ${turn.turn} ---`);
parts.push('USER:');
parts.push(turn.prompt);
parts.push('');
parts.push('ASSISTANT:');
parts.push(turn.output.stdout || '[No output recorded]');
parts.push('');
}
parts.push('=== NEW REQUEST ===');
parts.push('');
parts.push(newPrompt);
return parts.join('\n');
}
/**
* Build continuation prompt with previous conversation context (legacy)
*/
function buildContinuationPrompt(previous: ExecutionRecord, additionalPrompt?: string): string {
const parts: string[] = [];
@@ -707,6 +1110,9 @@ export function getLatestExecution(baseDir: string, tool?: string): ExecutionRec
return getExecutionDetail(baseDir, history.executions[0].id);
}
// Export types
export type { ConversationRecord, ConversationTurn, ExecutionRecord };
// Export utility functions and tool definition for backward compatibility
export { executeCliTool, checkToolAvailability };