mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
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:
@@ -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);
|
||||
|
||||
@@ -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)'));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user