diff --git a/.gitignore b/.gitignore index 92b4d30d..4e00aaf4 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ COMMAND_FLOW_STANDARD.md COMMAND_TEMPLATE_EXECUTOR.md COMMAND_TEMPLATE_ORCHESTRATOR.md *.pyc -.codexlens/ \ No newline at end of file +.codexlens/ +settings.json \ No newline at end of file diff --git a/ccw/src/cli.ts b/ccw/src/cli.ts index 0b425363..1ec4a468 100644 --- a/ccw/src/cli.ts +++ b/ccw/src/cli.ts @@ -9,6 +9,7 @@ import { listCommand } from './commands/list.js'; import { toolCommand } from './commands/tool.js'; import { sessionCommand } from './commands/session.js'; import { cliCommand } from './commands/cli.js'; +import { memoryCommand } from './commands/memory.js'; import { readFileSync, existsSync } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; @@ -160,9 +161,30 @@ export function run(argv: string[]): void { .option('--no-stream', 'Disable streaming output') .option('--limit ', 'History limit') .option('--status ', 'Filter by status') - .option('--resume [id]', 'Resume previous session (empty=last, or execution ID)') + .option('--category ', 'Execution category: user, internal, insight', 'user') + .option('--resume [id]', 'Resume previous session (empty=last, or execution ID, or comma-separated IDs for merge)') .option('--id ', 'Custom execution ID (e.g., IMPL-001-step1)') + .option('--no-native', 'Force prompt concatenation instead of native resume') .action((subcommand, args, options) => cliCommand(subcommand, args, options)); + // Memory command + program + .command('memory [subcommand] [args...]') + .description('Memory module for context tracking and prompt optimization') + .option('--type ', 'Entity type: file, module, topic') + .option('--action ', 'Action: read, write, mention') + .option('--value ', 'Entity value (file path, etc.)') + .option('--session ', 'Session ID') + .option('--stdin', 'Read input from stdin (for Claude Code hooks)') + .option('--source ', 'Import source: history, sessions, all', 'all') + .option('--project ', 'Project name filter') + .option('--limit ', 'Number of results', '20') + .option('--sort ', 'Sort by: heat, reads, writes', 'heat') + .option('--json', 'Output as JSON') + .option('--context ', 'Current task context') + .option('--older-than ', 'Age threshold for pruning', '30d') + .option('--dry-run', 'Preview without deleting') + .action((subcommand, args, options) => memoryCommand(subcommand, args, options)); + program.parse(argv); } diff --git a/ccw/src/commands/cli.ts b/ccw/src/commands/cli.ts index ffb1aa5c..4c741c54 100644 --- a/ccw/src/commands/cli.ts +++ b/ccw/src/commands/cli.ts @@ -53,8 +53,9 @@ interface CliExecOptions { includeDirs?: string; timeout?: string; noStream?: boolean; - resume?: string | boolean; // true = last, string = execution ID + resume?: string | boolean; // true = last, string = execution ID, comma-separated for merge id?: string; // Custom execution ID (e.g., IMPL-001-step1) + noNative?: boolean; // Force prompt concatenation instead of native resume } interface HistoryOptions { @@ -96,7 +97,7 @@ async function execAction(prompt: string | undefined, options: CliExecOptions): process.exit(1); } - const { tool = 'gemini', mode = 'analysis', model, cd, includeDirs, timeout, noStream, resume, id } = options; + const { tool = 'gemini', mode = 'analysis', model, cd, includeDirs, timeout, noStream, resume, id, noNative } = options; // Parse resume IDs for merge scenario const resumeIds = resume && typeof resume === 'string' ? resume.split(',').map(s => s.trim()).filter(Boolean) : []; @@ -109,8 +110,9 @@ async function execAction(prompt: string | undefined, options: CliExecOptions): } else if (resume) { resumeInfo = typeof resume === 'string' ? ` resuming ${resume}` : ' resuming last'; } + const nativeMode = noNative ? ' (prompt-concat)' : ''; const idInfo = id ? ` [${id}]` : ''; - console.log(chalk.cyan(`\n Executing ${tool} (${mode} mode${resumeInfo})${idInfo}...\n`)); + console.log(chalk.cyan(`\n Executing ${tool} (${mode} mode${resumeInfo}${nativeMode})${idInfo}...\n`)); // Show merge details if (isMerge) { @@ -145,7 +147,8 @@ async function execAction(prompt: string | undefined, options: CliExecOptions): includeDirs, timeout: timeout ? parseInt(timeout, 10) : 300000, resume, - id // custom execution ID + id, // custom execution ID + noNative }, onOutput); // If not streaming, print output now diff --git a/ccw/src/commands/memory.ts b/ccw/src/commands/memory.ts new file mode 100644 index 00000000..a11b3e61 --- /dev/null +++ b/ccw/src/commands/memory.ts @@ -0,0 +1,705 @@ +/** + * Memory Command - Context tracking and prompt optimization + * Provides CLI interface for Memory module operations + */ + +import chalk from 'chalk'; +import { getMemoryStore, type Entity, type HotEntity, type PromptHistory } from '../core/memory-store.js'; +import { HistoryImporter } from '../core/history-importer.js'; +import { join } from 'path'; +import { existsSync, readdirSync } from 'fs'; + +interface TrackOptions { + type?: string; + action?: string; + value?: string; + session?: string; + stdin?: boolean; +} + +interface ImportOptions { + source?: string; + project?: string; +} + +interface StatsOptions { + type?: string; + limit?: string; + sort?: string; + json?: boolean; +} + +interface SearchOptions { + limit?: string; + json?: boolean; +} + +interface SuggestOptions { + context?: string; + limit?: string; + json?: boolean; +} + +interface PruneOptions { + olderThan?: string; + dryRun?: boolean; +} + +/** + * Read JSON data from stdin (for Claude Code hooks) + */ +async function readStdin(): Promise { + return new Promise((resolve) => { + let data = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('readable', () => { + let chunk; + while ((chunk = process.stdin.read()) !== null) { + data += chunk; + } + }); + process.stdin.on('end', () => { + resolve(data); + }); + // Handle case where stdin is empty or not piped + if (process.stdin.isTTY) { + resolve(''); + } + }); +} + +/** + * Normalize file path for consistent storage + */ +function normalizePath(filePath: string): string { + // Convert Windows paths to forward slashes and remove drive letter variations + return filePath + .replace(/\\/g, '/') + .replace(/^[A-Za-z]:/, (match) => match.toLowerCase()); +} + +/** + * Get project path from current working directory + */ +function getProjectPath(): string { + return process.cwd(); +} + +/** + * Track entity access (used by hooks) + */ +async function trackAction(options: TrackOptions): Promise { + let { type, action, value, session, stdin } = options; + + // If --stdin flag is set, read from stdin (Claude Code hook format) + if (stdin) { + try { + const stdinData = await readStdin(); + if (stdinData) { + const hookData = JSON.parse(stdinData); + session = hookData.session_id || session; + + // Extract value based on hook event + if (hookData.tool_input) { + // PostToolUse event + value = hookData.tool_input.file_path || + hookData.tool_input.paths || + hookData.tool_input.path || + JSON.stringify(hookData.tool_input); + } else if (hookData.prompt) { + // UserPromptSubmit event + value = hookData.prompt; + } + } + } catch { + // Silently continue if stdin parsing fails + } + } + + if (!type || !action) { + console.error(chalk.red('Error: --type and --action are required')); + console.error(chalk.gray('Usage: ccw memory track --type file --action read --value "path" --session "id"')); + console.error(chalk.gray(' ccw memory track --type file --action read --stdin')); + process.exit(1); + } + + // Validate type and action + const validTypes = ['file', 'module', 'topic', 'url']; + const validActions = ['read', 'write', 'mention']; + + if (!validTypes.includes(type)) { + if (!stdin) { + console.error(chalk.red(`Error: Invalid type "${type}". Must be one of: ${validTypes.join(', ')}`)); + } + process.exit(stdin ? 0 : 1); + } + + if (!validActions.includes(action)) { + if (!stdin) { + console.error(chalk.red(`Error: Invalid action "${action}". Must be one of: ${validActions.join(', ')}`)); + } + process.exit(stdin ? 0 : 1); + } + + // Skip if no value provided + if (!value) { + if (stdin) { + process.exit(0); + } + console.error(chalk.red('Error: --value is required')); + process.exit(1); + } + + try { + const projectPath = getProjectPath(); + const store = getMemoryStore(projectPath); + const now = new Date().toISOString(); + + // Normalize value for file types + const normalizedValue = type === 'file' ? normalizePath(value) : value.toLowerCase(); + + // Upsert entity + const entityId = store.upsertEntity({ + type: type as Entity['type'], + value: value, + normalized_value: normalizedValue, + first_seen_at: now, + last_seen_at: now + }); + + // Log access + store.logAccess({ + entity_id: entityId, + action: action as 'read' | 'write' | 'mention', + session_id: session, + timestamp: now + }); + + // Update statistics + store.updateStats(entityId, action as 'read' | 'write' | 'mention'); + + // Calculate heat score periodically (every 10th access) + const stats = store.getStats(entityId); + if (stats) { + const totalAccess = stats.read_count + stats.write_count + stats.mention_count; + if (totalAccess % 10 === 0) { + store.calculateHeatScore(entityId); + } + } + + if (stdin) { + // Silent mode for hooks - just exit successfully + process.exit(0); + } + + console.log(chalk.green('✓ Tracked:'), chalk.cyan(`${type}:${action}`), chalk.gray(value)); + } catch (error) { + if (stdin) { + // Silent failure for hooks + process.exit(0); + } + console.error(chalk.red(`Error tracking: ${(error as Error).message}`)); + process.exit(1); + } +} + +/** + * Import Claude Code history + */ +async function importAction(options: ImportOptions): Promise { + const { source = 'all', project } = options; + + console.log(chalk.bold.cyan('\n Importing Claude Code History\n')); + console.log(chalk.gray(` Source: ${source}`)); + if (project) { + console.log(chalk.gray(` Project: ${project}`)); + } + + try { + const projectPath = getProjectPath(); + const memoryDir = join(projectPath, '.workflow', '.memory'); + const dbPath = join(memoryDir, 'history.db'); + + // Ensure memory directory exists + const { mkdirSync } = await import('fs'); + if (!existsSync(memoryDir)) { + mkdirSync(memoryDir, { recursive: true }); + } + + const importer = new HistoryImporter(dbPath); + let totalImported = 0; + let totalSkipped = 0; + let totalErrors = 0; + + // Import global history + if (source === 'all' || source === 'history') { + console.log(chalk.gray('\n Importing global history...')); + const globalResult = await importer.importGlobalHistory(); + totalImported += globalResult.imported; + totalSkipped += globalResult.skipped; + totalErrors += globalResult.errors; + console.log(chalk.gray(` Imported: ${globalResult.imported}, Skipped: ${globalResult.skipped}, Errors: ${globalResult.errors}`)); + } + + // Import project sessions + if (source === 'all' || source === 'sessions') { + const claudeHome = process.env.USERPROFILE || process.env.HOME || ''; + const projectsDir = join(claudeHome, '.claude', 'projects'); + + if (existsSync(projectsDir)) { + const projects = project + ? [project] + : readdirSync(projectsDir).filter(f => { + const fullPath = join(projectsDir, f); + return existsSync(fullPath) && require('fs').statSync(fullPath).isDirectory(); + }); + + for (const proj of projects) { + console.log(chalk.gray(`\n Importing sessions for: ${proj}...`)); + const sessionResult = await importer.importProjectSessions(proj); + totalImported += sessionResult.imported; + totalSkipped += sessionResult.skipped; + totalErrors += sessionResult.errors; + console.log(chalk.gray(` Imported: ${sessionResult.imported}, Skipped: ${sessionResult.skipped}, Errors: ${sessionResult.errors}`)); + } + } + } + + importer.close(); + + console.log(chalk.bold.green('\n Import Complete\n')); + console.log(chalk.gray(` Total Imported: ${totalImported}`)); + console.log(chalk.gray(` Total Skipped: ${totalSkipped}`)); + console.log(chalk.gray(` Total Errors: ${totalErrors}`)); + console.log(chalk.gray(` Database: ${dbPath}\n`)); + } catch (error) { + console.error(chalk.red(`\n Error importing: ${(error as Error).message}\n`)); + process.exit(1); + } +} + +/** + * Show hotspot statistics + */ +async function statsAction(options: StatsOptions): Promise { + const { type, limit = '20', sort = 'heat', json } = options; + const limitNum = parseInt(limit, 10); + + try { + const projectPath = getProjectPath(); + const store = getMemoryStore(projectPath); + + // Get hot entities + const hotEntities = store.getHotEntities(limitNum * 2); // Get more to filter + + // Filter by type if specified + let filtered: HotEntity[] = type + ? hotEntities.filter((e: HotEntity) => e.type === type) + : hotEntities; + + // Sort by specified field + if (sort === 'reads') { + filtered.sort((a: HotEntity, b: HotEntity) => b.stats.read_count - a.stats.read_count); + } else if (sort === 'writes') { + filtered.sort((a: HotEntity, b: HotEntity) => b.stats.write_count - a.stats.write_count); + } + // Default is already sorted by heat_score + + // Limit results + filtered = filtered.slice(0, limitNum); + + if (json) { + const output = filtered.map((e: HotEntity) => ({ + type: e.type, + value: e.value, + reads: e.stats.read_count, + writes: e.stats.write_count, + mentions: e.stats.mention_count, + heat: Math.round(e.stats.heat_score * 100) / 100, + lastSeen: e.last_seen_at + })); + console.log(JSON.stringify(output, null, 2)); + return; + } + + console.log(chalk.bold.cyan('\n Memory Hotspot Statistics\n')); + + if (type) { + console.log(chalk.gray(` Type: ${type}`)); + } + console.log(chalk.gray(` Sort: ${sort} | Limit: ${limit}\n`)); + + if (filtered.length === 0) { + console.log(chalk.yellow(' No data yet. Use hooks to track file access or run:')); + console.log(chalk.gray(' ccw memory track --type file --action read --value "path/to/file"')); + console.log(chalk.gray(' ccw memory import --source all\n')); + return; + } + + // Display table header + console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────')); + console.log( + chalk.bold(' Type ') + + chalk.bold('Heat ') + + chalk.bold('R ') + + chalk.bold('W ') + + chalk.bold('M ') + + chalk.bold('Value') + ); + console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────')); + + for (const entity of filtered) { + const typeStr = entity.type.padEnd(8); + const heatStr = entity.stats.heat_score.toFixed(1).padStart(6); + const readStr = String(entity.stats.read_count).padStart(3); + const writeStr = String(entity.stats.write_count).padStart(3); + const mentionStr = String(entity.stats.mention_count).padStart(3); + + // Truncate value if too long + const maxValueLen = 40; + let valueStr = entity.value; + if (valueStr.length > maxValueLen) { + valueStr = '...' + valueStr.slice(-maxValueLen + 3); + } + + // Color based on type + const typeColor = entity.type === 'file' ? chalk.blue : + entity.type === 'module' ? chalk.magenta : + entity.type === 'topic' ? chalk.yellow : chalk.gray; + + console.log( + ' ' + + typeColor(typeStr) + + chalk.cyan(heatStr) + ' ' + + chalk.green(readStr) + ' ' + + chalk.red(writeStr) + ' ' + + chalk.yellow(mentionStr) + ' ' + + chalk.gray(valueStr) + ); + } + + console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────')); + console.log(chalk.gray(`\n R=Reads, W=Writes, M=Mentions, Heat=Composite score\n`)); + + } catch (error) { + if (json) { + console.log(JSON.stringify({ error: (error as Error).message }, null, 2)); + } else { + console.error(chalk.red(`\n Error: ${(error as Error).message}\n`)); + } + process.exit(1); + } +} + +/** + * Search through prompt history + */ +async function searchAction(query: string | undefined, options: SearchOptions): Promise { + if (!query) { + console.error(chalk.red('Error: Search query is required')); + console.error(chalk.gray('Usage: ccw memory search ""')); + process.exit(1); + } + + const { limit = '20', json } = options; + const limitNum = parseInt(limit, 10); + + try { + const projectPath = getProjectPath(); + const store = getMemoryStore(projectPath); + + // Search prompts using FTS + const results = store.searchPrompts(query, limitNum); + + if (json) { + const output = results.map((p: PromptHistory) => ({ + id: p.id, + sessionId: p.session_id, + prompt: p.prompt_text?.substring(0, 200) + (p.prompt_text && p.prompt_text.length > 200 ? '...' : ''), + timestamp: p.timestamp, + intentLabel: p.intent_label + })); + console.log(JSON.stringify(output, null, 2)); + return; + } + + console.log(chalk.bold.cyan('\n Searching Prompt History\n')); + console.log(chalk.gray(` Query: ${query}`)); + console.log(chalk.gray(` Limit: ${limit}\n`)); + + if (results.length === 0) { + console.log(chalk.yellow(' No results found.')); + console.log(chalk.gray(' Try importing history first: ccw memory import --source all\n')); + return; + } + + console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────')); + + for (const prompt of results) { + const timestamp = new Date(prompt.timestamp).toLocaleString(); + const preview = prompt.prompt_text?.substring(0, 80).replace(/\n/g, ' ') || '(no content)'; + + console.log(chalk.gray(` ${timestamp}`)); + console.log(chalk.white(` ${preview}${preview.length >= 80 ? '...' : ''}`)); + if (prompt.intent_label) { + console.log(chalk.cyan(` Intent: ${prompt.intent_label}`)); + } + console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────')); + } + + console.log(chalk.gray(`\n Found ${results.length} result(s)\n`)); + + } catch (error) { + if (json) { + console.log(JSON.stringify({ error: (error as Error).message }, null, 2)); + } else { + console.error(chalk.red(`\n Error: ${(error as Error).message}\n`)); + } + process.exit(1); + } +} + +/** + * Get optimization suggestions based on similar successful prompts + */ +async function suggestAction(options: SuggestOptions): Promise { + const { context, limit = '5', json } = options; + const limitNum = parseInt(limit, 10); + + try { + const projectPath = getProjectPath(); + const store = getMemoryStore(projectPath); + + // Get hot entities for suggestions + const hotEntities = store.getHotEntities(limitNum); + + const suggestions = hotEntities.map((e: HotEntity) => ({ + type: e.type, + value: e.value, + reason: `Frequently accessed (${e.stats.read_count} reads, ${e.stats.write_count} writes)`, + heat: e.stats.heat_score + })); + + if (json) { + console.log(JSON.stringify({ suggestions, context }, null, 2)); + return; + } + + console.log(chalk.bold.cyan('\n Memory Optimization Suggestions\n')); + + if (context) { + console.log(chalk.gray(` Context: ${context}\n`)); + } + + if (suggestions.length === 0) { + console.log(chalk.yellow(' No suggestions available yet.')); + console.log(chalk.gray(' Track more file access to get suggestions.\n')); + return; + } + + console.log(chalk.gray(' Based on your access patterns:\n')); + + for (let i = 0; i < suggestions.length; i++) { + const s = suggestions[i]; + console.log(chalk.cyan(` ${i + 1}. ${s.type}: `) + chalk.white(s.value)); + console.log(chalk.gray(` ${s.reason}`)); + } + + console.log(chalk.gray('\n Tip: Include frequently accessed files in your context for better results.\n')); + + } catch (error) { + if (json) { + console.log(JSON.stringify({ error: (error as Error).message }, null, 2)); + } else { + console.error(chalk.red(`\n Error: ${(error as Error).message}\n`)); + } + process.exit(1); + } +} + +/** + * Parse age string to milliseconds + */ +function parseAge(ageStr: string): number { + const match = ageStr.match(/^(\d+)([dhm])$/); + if (!match) { + throw new Error(`Invalid age format: ${ageStr}. Use format like 30d, 24h, or 60m`); + } + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case 'd': return value * 24 * 60 * 60 * 1000; + case 'h': return value * 60 * 60 * 1000; + case 'm': return value * 60 * 1000; + default: throw new Error(`Unknown unit: ${unit}`); + } +} + +/** + * Clean up old data + */ +async function pruneAction(options: PruneOptions): Promise { + const { olderThan = '30d', dryRun } = options; + + console.log(chalk.bold.cyan('\n Pruning Memory Data\n')); + console.log(chalk.gray(` Older than: ${olderThan}`)); + console.log(chalk.gray(` Mode: ${dryRun ? 'Dry run (preview)' : 'Delete'}\n`)); + + try { + const ageMs = parseAge(olderThan); + const cutoffDate = new Date(Date.now() - ageMs); + const cutoffStr = cutoffDate.toISOString(); + + const projectPath = getProjectPath(); + const memoryDir = join(projectPath, '.workflow', '.memory'); + const dbPath = join(memoryDir, 'memory.db'); + + if (!existsSync(dbPath)) { + console.log(chalk.yellow(' No memory database found. Nothing to prune.\n')); + return; + } + + // Use direct database access for pruning + const Database = require('better-sqlite3'); + const db = new Database(dbPath); + + // Count records to prune + const accessLogsCount = db.prepare(` + SELECT COUNT(*) as count FROM access_logs WHERE timestamp < ? + `).get(cutoffStr) as { count: number }; + + const entitiesCount = db.prepare(` + SELECT COUNT(*) as count FROM entities WHERE last_seen_at < ? + `).get(cutoffStr) as { count: number }; + + console.log(chalk.gray(` Access logs to prune: ${accessLogsCount.count}`)); + console.log(chalk.gray(` Entities to prune: ${entitiesCount.count}`)); + + if (dryRun) { + console.log(chalk.yellow('\n Dry run - no changes made.\n')); + db.close(); + return; + } + + if (accessLogsCount.count === 0 && entitiesCount.count === 0) { + console.log(chalk.green('\n Nothing to prune.\n')); + db.close(); + return; + } + + // Delete old access logs + const deleteAccessLogs = db.prepare(`DELETE FROM access_logs WHERE timestamp < ?`); + const accessResult = deleteAccessLogs.run(cutoffStr); + + // Delete entities not seen recently (and their stats) + const deleteStats = db.prepare(` + DELETE FROM entity_stats WHERE entity_id IN ( + SELECT id FROM entities WHERE last_seen_at < ? + ) + `); + deleteStats.run(cutoffStr); + + const deleteEntities = db.prepare(`DELETE FROM entities WHERE last_seen_at < ?`); + const entitiesResult = deleteEntities.run(cutoffStr); + + db.close(); + + console.log(chalk.green(`\n Pruned ${accessResult.changes} access logs`)); + console.log(chalk.green(` Pruned ${entitiesResult.changes} entities\n`)); + + } catch (error) { + console.error(chalk.red(`\n Error: ${(error as Error).message}\n`)); + process.exit(1); + } +} + +/** + * Memory command entry point + * @param {string} subcommand - Subcommand (track, import, stats, search, suggest, prune) + * @param {string|string[]} args - Arguments array + * @param {Object} options - CLI options + */ +export async function memoryCommand( + subcommand: string, + args: string | string[], + options: TrackOptions | ImportOptions | StatsOptions | SearchOptions | SuggestOptions | PruneOptions +): Promise { + const argsArray = Array.isArray(args) ? args : (args ? [args] : []); + + switch (subcommand) { + case 'track': + await trackAction(options as TrackOptions); + break; + + case 'import': + await importAction(options as ImportOptions); + break; + + case 'stats': + await statsAction(options as StatsOptions); + break; + + case 'search': + await searchAction(argsArray[0], options as SearchOptions); + break; + + case 'suggest': + await suggestAction(options as SuggestOptions); + break; + + case 'prune': + await pruneAction(options as PruneOptions); + break; + + default: + console.log(chalk.bold.cyan('\n CCW Memory Module\n')); + console.log(' Context tracking and prompt optimization.\n'); + console.log(' Subcommands:'); + console.log(chalk.gray(' track Track entity access (used by hooks)')); + console.log(chalk.gray(' import Import Claude Code history')); + console.log(chalk.gray(' stats Show hotspot statistics')); + console.log(chalk.gray(' search Search through prompt history')); + console.log(chalk.gray(' suggest Get optimization suggestions')); + console.log(chalk.gray(' prune Clean up old data')); + console.log(); + console.log(' Track Options:'); + console.log(chalk.gray(' --type Entity type: file, module, topic')); + console.log(chalk.gray(' --action Action: read, write, mention')); + console.log(chalk.gray(' --value Entity value (file path, etc.)')); + console.log(chalk.gray(' --session Session ID (optional)')); + console.log(); + console.log(' Import Options:'); + console.log(chalk.gray(' --source Source: history, sessions, all (default: all)')); + console.log(chalk.gray(' --project Project name filter (optional)')); + console.log(); + console.log(' Stats Options:'); + console.log(chalk.gray(' --type Filter: file, module, topic (optional)')); + console.log(chalk.gray(' --limit Number of results (default: 20)')); + console.log(chalk.gray(' --sort Sort by: heat, reads, writes (default: heat)')); + console.log(chalk.gray(' --json Output as JSON')); + console.log(); + console.log(' Search Options:'); + console.log(chalk.gray(' --limit Number of results (default: 20)')); + console.log(chalk.gray(' --json Output as JSON')); + console.log(); + console.log(' Suggest Options:'); + console.log(chalk.gray(' --context Current task context (optional)')); + console.log(chalk.gray(' --limit Number of suggestions (default: 5)')); + console.log(chalk.gray(' --json Output as JSON')); + console.log(); + console.log(' Prune Options:'); + console.log(chalk.gray(' --older-than Age threshold (default: 30d)')); + console.log(chalk.gray(' --dry-run Preview without deleting')); + console.log(); + console.log(' Examples:'); + console.log(chalk.gray(' ccw memory track --type file --action read --value "src/auth.ts"')); + console.log(chalk.gray(' ccw memory import --source history --project "my-app"')); + console.log(chalk.gray(' ccw memory stats --type file --sort heat --limit 10')); + console.log(chalk.gray(' ccw memory search "authentication patterns"')); + console.log(chalk.gray(' ccw memory suggest --context "implementing JWT auth"')); + console.log(chalk.gray(' ccw memory prune --older-than 60d --dry-run')); + console.log(); + } +} diff --git a/ccw/src/core/history-importer.ts b/ccw/src/core/history-importer.ts new file mode 100644 index 00000000..084fcb96 --- /dev/null +++ b/ccw/src/core/history-importer.ts @@ -0,0 +1,627 @@ +/** + * History Importer - Import Claude Code history into memory store + * Supports global history.jsonl and project session JSONL files + * + * Usage: + * ```typescript + * const importer = new HistoryImporter('path/to/database.db'); + * + * // Import global history (incremental) + * const globalResult = await importer.importGlobalHistory(); + * console.log(`Imported ${globalResult.imported} entries`); + * + * // Import all sessions for a project + * const projectResult = await importer.importProjectSessions('D--Claude-dms3'); + * console.log(`Imported ${projectResult.imported} messages from project sessions`); + * + * // Import specific session + * const sessionResult = await importer.importSession('path/to/session.jsonl'); + * + * // Get import status + * const status = importer.getImportStatus(); + * console.log(`Total imported: ${status.totalImported}`); + * + * importer.close(); + * ``` + */ + +import { createReadStream, existsSync, readdirSync, statSync } from 'fs'; +import { createInterface } from 'readline'; +import { join, basename } from 'path'; +import { createHash } from 'crypto'; +import Database from 'better-sqlite3'; + +// Type definitions +interface GlobalHistoryEntry { + display: string; + pastedContents: object; + timestamp: number; + project: string; + sessionId: string; +} + +interface SessionEntry { + type: 'user' | 'assistant' | 'file-history-snapshot'; + message?: { + role: 'user' | 'assistant'; + content: string | ContentBlock[]; + model?: string; + usage?: { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + }; + }; + sessionId: string; + timestamp: string; + cwd?: string; + gitBranch?: string; + todos?: any[]; + uuid?: string; + parentUuid?: string; +} + +interface ContentBlock { + type: 'text' | 'thinking' | 'tool_use' | 'tool_result'; + text?: string; + thinking?: string; + name?: string; + input?: object; + content?: string; + id?: string; +} + +export interface ImportResult { + imported: number; + skipped: number; + errors: number; +} + +export interface ImportStatus { + lastGlobalImport?: string; + lastSessionImport?: string; + totalImported: number; + sessions: Map; +} + +/** + * History Importer for Claude Code + */ +export class HistoryImporter { + private db: Database.Database; + private status: ImportStatus; + + constructor(dbPath: string) { + this.db = new Database(dbPath); + this.db.pragma('journal_mode = WAL'); + this.status = { + totalImported: 0, + sessions: new Map() + }; + this.initSchema(); + } + + /** + * Initialize database schema for conversation history + */ + private initSchema(): void { + this.db.exec(` + -- Conversations table + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + project_path TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + message_count INTEGER DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + metadata TEXT + ); + + -- Messages table + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + parent_id TEXT, + role TEXT NOT NULL, + content TEXT NOT NULL, + timestamp TEXT NOT NULL, + model TEXT, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + cwd TEXT, + git_branch TEXT, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE + ); + + -- Tool calls table + CREATE TABLE IF NOT EXISTS tool_calls ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + tool_input TEXT, + tool_result TEXT, + timestamp TEXT NOT NULL, + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE + ); + + -- Import tracking table + CREATE TABLE IF NOT EXISTS import_metadata ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TEXT + ); + + -- Deduplication table (hash-based) + CREATE TABLE IF NOT EXISTS message_hashes ( + hash TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + -- Indexes + CREATE INDEX IF NOT EXISTS idx_conversations_session ON conversations(session_id); + CREATE INDEX IF NOT EXISTS idx_conversations_project ON conversations(project_path); + CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id); + CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp DESC); + CREATE INDEX IF NOT EXISTS idx_tool_calls_message ON tool_calls(message_id); + CREATE INDEX IF NOT EXISTS idx_tool_calls_name ON tool_calls(tool_name); + `); + } + + /** + * Import from global history.jsonl (incremental) + */ + async importGlobalHistory(historyPath?: string): Promise { + const path = historyPath || join(process.env.USERPROFILE || process.env.HOME || '', '.claude', 'history.jsonl'); + + if (!existsSync(path)) { + return { imported: 0, skipped: 0, errors: 0 }; + } + + const result: ImportResult = { imported: 0, skipped: 0, errors: 0 }; + const lastImportTime = this.getLastImportTime('global_history'); + + const fileStream = createReadStream(path, { encoding: 'utf8' }); + const rl = createInterface({ input: fileStream, crlfDelay: Infinity }); + + const batch: GlobalHistoryEntry[] = []; + const BATCH_SIZE = 100; + + for await (const line of rl) { + if (!line.trim()) continue; + + try { + const entry: GlobalHistoryEntry = JSON.parse(line); + + // Skip if already imported + if (lastImportTime && entry.timestamp <= new Date(lastImportTime).getTime()) { + result.skipped++; + continue; + } + + batch.push(entry); + + if (batch.length >= BATCH_SIZE) { + const batchResult = this.insertGlobalHistoryBatch(batch); + result.imported += batchResult.imported; + result.skipped += batchResult.skipped; + result.errors += batchResult.errors; + batch.length = 0; + } + } catch (err) { + result.errors++; + console.error(`Failed to parse line: ${(err as Error).message}`); + } + } + + // Process remaining batch + if (batch.length > 0) { + const batchResult = this.insertGlobalHistoryBatch(batch); + result.imported += batchResult.imported; + result.skipped += batchResult.skipped; + result.errors += batchResult.errors; + } + + if (result.imported > 0) { + this.updateLastImportTime('global_history'); + } + + this.status.lastGlobalImport = new Date().toISOString(); + this.status.totalImported += result.imported; + + return result; + } + + /** + * Import full session from projects folder + */ + async importSession(sessionFilePath: string): Promise { + if (!existsSync(sessionFilePath)) { + return { imported: 0, skipped: 0, errors: 0 }; + } + + const result: ImportResult = { imported: 0, skipped: 0, errors: 0 }; + const sessionId = basename(sessionFilePath, '.jsonl'); + + const fileStream = createReadStream(sessionFilePath, { encoding: 'utf8' }); + const rl = createInterface({ input: fileStream, crlfDelay: Infinity }); + + const messages: SessionEntry[] = []; + let conversationMetadata: any = {}; + + for await (const line of rl) { + if (!line.trim()) continue; + + try { + const entry: SessionEntry = JSON.parse(line); + + if (entry.type === 'user' || entry.type === 'assistant') { + messages.push(entry); + + // Extract metadata from first message + if (messages.length === 1) { + conversationMetadata = { + sessionId: entry.sessionId, + cwd: entry.cwd, + gitBranch: entry.gitBranch + }; + } + } + } catch (err) { + result.errors++; + console.error(`Failed to parse session line: ${(err as Error).message}`); + } + } + + if (messages.length > 0) { + const importResult = this.insertSessionMessages(sessionId, messages, conversationMetadata); + result.imported = importResult.imported; + result.skipped = importResult.skipped; + result.errors += importResult.errors; + } + + this.status.lastSessionImport = new Date().toISOString(); + this.status.totalImported += result.imported; + this.status.sessions.set(sessionId, { + imported: result.imported, + lastUpdate: new Date().toISOString() + }); + + return result; + } + + /** + * Scan and import all sessions for a project + */ + async importProjectSessions(projectName: string): Promise { + const projectsDir = join(process.env.USERPROFILE || process.env.HOME || '', '.claude', 'projects'); + const projectDir = join(projectsDir, projectName); + + if (!existsSync(projectDir)) { + return { imported: 0, skipped: 0, errors: 0 }; + } + + const result: ImportResult = { imported: 0, skipped: 0, errors: 0 }; + const sessionFiles = readdirSync(projectDir).filter(f => f.endsWith('.jsonl')); + + for (const sessionFile of sessionFiles) { + const sessionPath = join(projectDir, sessionFile); + const sessionResult = await this.importSession(sessionPath); + + result.imported += sessionResult.imported; + result.skipped += sessionResult.skipped; + result.errors += sessionResult.errors; + } + + return result; + } + + /** + * Get import status + */ + getImportStatus(): ImportStatus { + return { ...this.status }; + } + + /** + * Insert global history batch + */ + private insertGlobalHistoryBatch(entries: GlobalHistoryEntry[]): ImportResult { + const result: ImportResult = { imported: 0, skipped: 0, errors: 0 }; + + const upsertConversation = this.db.prepare(` + INSERT INTO conversations (id, session_id, project_path, created_at, updated_at, message_count, metadata) + VALUES (@id, @session_id, @project_path, @created_at, @updated_at, 1, @metadata) + ON CONFLICT(id) DO UPDATE SET + updated_at = @updated_at, + message_count = message_count + 1 + `); + + const upsertMessage = this.db.prepare(` + INSERT INTO messages (id, conversation_id, role, content, timestamp, cwd) + VALUES (@id, @conversation_id, 'user', @content, @timestamp, @cwd) + ON CONFLICT(id) DO NOTHING + `); + + const insertHash = this.db.prepare(` + INSERT OR IGNORE INTO message_hashes (hash, message_id, created_at) + VALUES (@hash, @message_id, @created_at) + `); + + const transaction = this.db.transaction(() => { + for (const entry of entries) { + try { + const timestamp = new Date(entry.timestamp).toISOString(); + const messageId = `${entry.sessionId}-${entry.timestamp}`; + const hash = this.generateHash(entry.sessionId, timestamp, entry.display); + + // Check if hash exists + const existing = this.db.prepare('SELECT message_id FROM message_hashes WHERE hash = ?').get(hash); + if (existing) { + result.skipped++; + continue; + } + + // Insert conversation + upsertConversation.run({ + id: entry.sessionId, + session_id: entry.sessionId, + project_path: entry.project, + created_at: timestamp, + updated_at: timestamp, + metadata: JSON.stringify({ source: 'global_history' }) + }); + + // Insert message + upsertMessage.run({ + id: messageId, + conversation_id: entry.sessionId, + content: entry.display, + timestamp, + cwd: entry.project + }); + + // Insert hash + insertHash.run({ + hash, + message_id: messageId, + created_at: timestamp + }); + + result.imported++; + } catch (err) { + result.errors++; + console.error(`Failed to insert entry: ${(err as Error).message}`); + } + } + }); + + transaction(); + return result; + } + + /** + * Insert session messages + */ + private insertSessionMessages( + sessionId: string, + messages: SessionEntry[], + metadata: any + ): ImportResult { + const result: ImportResult = { imported: 0, skipped: 0, errors: 0 }; + + const upsertConversation = this.db.prepare(` + INSERT INTO conversations (id, session_id, project_path, created_at, updated_at, message_count, total_tokens, metadata) + VALUES (@id, @session_id, @project_path, @created_at, @updated_at, @message_count, @total_tokens, @metadata) + ON CONFLICT(id) DO UPDATE SET + updated_at = @updated_at, + message_count = @message_count, + total_tokens = @total_tokens + `); + + const upsertMessage = this.db.prepare(` + INSERT INTO messages (id, conversation_id, parent_id, role, content, timestamp, model, input_tokens, output_tokens, cwd, git_branch) + VALUES (@id, @conversation_id, @parent_id, @role, @content, @timestamp, @model, @input_tokens, @output_tokens, @cwd, @git_branch) + ON CONFLICT(id) DO NOTHING + `); + + const insertToolCall = this.db.prepare(` + INSERT INTO tool_calls (id, message_id, tool_name, tool_input, tool_result, timestamp) + VALUES (@id, @message_id, @tool_name, @tool_input, @tool_result, @timestamp) + ON CONFLICT(id) DO NOTHING + `); + + const insertHash = this.db.prepare(` + INSERT OR IGNORE INTO message_hashes (hash, message_id, created_at) + VALUES (@hash, @message_id, @created_at) + `); + + const transaction = this.db.transaction(() => { + let totalTokens = 0; + const firstMessage = messages[0]; + const lastMessage = messages[messages.length - 1]; + + // Insert conversation FIRST (before messages, for foreign key constraint) + upsertConversation.run({ + id: sessionId, + session_id: sessionId, + project_path: metadata.cwd || null, + created_at: firstMessage.timestamp, + updated_at: lastMessage.timestamp, + message_count: 0, + total_tokens: 0, + metadata: JSON.stringify({ ...metadata, source: 'session_file' }) + }); + + for (const msg of messages) { + if (!msg.message) continue; + + try { + const messageId = msg.uuid || `${sessionId}-${msg.timestamp}`; + const content = this.extractTextContent(msg.message.content); + const hash = this.generateHash(sessionId, msg.timestamp, content); + + // Check if hash exists + const existing = this.db.prepare('SELECT message_id FROM message_hashes WHERE hash = ?').get(hash); + if (existing) { + result.skipped++; + continue; + } + + // Calculate tokens + const inputTokens = msg.message.usage?.input_tokens || 0; + const outputTokens = msg.message.usage?.output_tokens || 0; + totalTokens += inputTokens + outputTokens; + + // Insert message + upsertMessage.run({ + id: messageId, + conversation_id: sessionId, + parent_id: msg.parentUuid || null, + role: msg.message.role, + content, + timestamp: msg.timestamp, + model: msg.message.model || null, + input_tokens: inputTokens, + output_tokens: outputTokens, + cwd: msg.cwd || metadata.cwd || null, + git_branch: msg.gitBranch || metadata.gitBranch || null + }); + + // Extract and insert tool calls + const toolCalls = this.extractToolCalls(msg.message.content); + for (const tool of toolCalls) { + insertToolCall.run({ + id: tool.id || `${messageId}-${tool.name}`, + message_id: messageId, + tool_name: tool.name, + tool_input: JSON.stringify(tool.input), + tool_result: tool.result || null, + timestamp: msg.timestamp + }); + } + + // Insert hash + insertHash.run({ + hash, + message_id: messageId, + created_at: msg.timestamp + }); + + result.imported++; + } catch (err) { + result.errors++; + console.error(`Failed to insert message: ${(err as Error).message}`); + } + } + + // Update conversation with final counts + upsertConversation.run({ + id: sessionId, + session_id: sessionId, + project_path: metadata.cwd || null, + created_at: firstMessage.timestamp, + updated_at: lastMessage.timestamp, + message_count: result.imported, + total_tokens: totalTokens, + metadata: JSON.stringify({ ...metadata, source: 'session_file' }) + }); + }); + + transaction(); + return result; + } + + /** + * Extract text content from message content + */ + private extractTextContent(content: string | ContentBlock[]): string { + if (typeof content === 'string') { + return content; + } + + return content + .filter(block => block.type === 'text' || block.type === 'thinking') + .map(block => block.text || block.thinking || '') + .join('\n\n'); + } + + /** + * Extract tool calls from content blocks + */ + private extractToolCalls(content: string | ContentBlock[]): Array<{ + id?: string; + name: string; + input?: object; + result?: string; + }> { + if (typeof content === 'string') { + return []; + } + + const toolCalls: Array<{ id?: string; name: string; input?: object; result?: string }> = []; + const toolResultMap = new Map(); + + // First pass: collect tool results + for (const block of content) { + if (block.type === 'tool_result' && block.id) { + toolResultMap.set(block.id, block.content || ''); + } + } + + // Second pass: collect tool uses with their results + for (const block of content) { + if (block.type === 'tool_use' && block.name) { + toolCalls.push({ + id: block.id, + name: block.name, + input: block.input, + result: block.id ? toolResultMap.get(block.id) : undefined + }); + } + } + + return toolCalls; + } + + /** + * Generate SHA256 hash for deduplication + */ + private generateHash(sessionId: string, timestamp: string, content: string): string { + const data = `${sessionId}:${timestamp}:${content}`; + return createHash('sha256').update(data).digest('hex'); + } + + /** + * Get last import time for a source + */ + private getLastImportTime(source: string): string | null { + const result = this.db.prepare('SELECT value FROM import_metadata WHERE key = ?').get(source) as any; + return result?.value || null; + } + + /** + * Update last import time + */ + private updateLastImportTime(source: string): void { + const now = new Date().toISOString(); + this.db.prepare(` + INSERT INTO import_metadata (key, value, updated_at) + VALUES (@key, @value, @updated_at) + ON CONFLICT(key) DO UPDATE SET value = @value, updated_at = @updated_at + `).run({ + key: source, + value: now, + updated_at: now + }); + } + + /** + * Close database connection + */ + close(): void { + this.db.close(); + } +} diff --git a/ccw/src/core/memory-store.ts b/ccw/src/core/memory-store.ts new file mode 100644 index 00000000..6638a6db --- /dev/null +++ b/ccw/src/core/memory-store.ts @@ -0,0 +1,702 @@ +/** + * Memory Store - SQLite Storage Backend + * Provides persistent storage for memory module with entity tracking, associations, and conversation history + */ + +import Database from 'better-sqlite3'; +import { existsSync, mkdirSync } from 'fs'; +import { join } from 'path'; + +// Types +export interface Entity { + id?: number; + type: 'file' | 'module' | 'topic' | 'url'; + value: string; + normalized_value: string; + first_seen_at: string; + last_seen_at: string; + metadata?: string; +} + +export interface AccessLog { + id?: number; + entity_id: number; + action: 'read' | 'write' | 'mention'; + session_id?: string; + timestamp: string; + context_summary?: string; +} + +export interface EntityStats { + entity_id: number; + read_count: number; + write_count: number; + mention_count: number; + heat_score: number; +} + +export interface Association { + source_id: number; + target_id: number; + weight: number; + last_interaction_at?: string; +} + +export interface PromptHistory { + id?: number; + session_id: string; + project_path?: string; + prompt_text?: string; + context_summary?: string; + timestamp: number; + hash?: string; + quality_score?: number; + intent_label?: string; +} + +export interface PromptPattern { + id?: number; + pattern_type?: string; + frequency: number; + example_ids?: string; + last_detected?: number; +} + +export interface Conversation { + id: string; + source?: string; + external_id?: string; + project_name?: string; + git_branch?: string; + created_at: string; + updated_at: string; + quality_score?: number; + turn_count: number; + prompt_preview?: string; +} + +export interface Message { + id?: number; + conversation_id: string; + role: 'user' | 'assistant' | 'system'; + content_text?: string; + content_json?: string; + timestamp: string; + token_count?: number; +} + +export interface ToolCall { + id?: number; + message_id: number; + tool_name: string; + tool_args?: string; + tool_output?: string; + status?: string; + duration_ms?: number; +} + +export interface HotEntity extends Entity { + stats: EntityStats; +} + +export interface EntityWithAssociations extends Entity { + associations: Array<{ + target: Entity; + weight: number; + last_interaction_at?: string; + }>; +} + +/** + * Memory Store using SQLite + */ +export class MemoryStore { + private db: Database.Database; + private dbPath: string; + + constructor(projectPath: string) { + const memoryDir = join(projectPath, '.workflow', '.memory'); + if (!existsSync(memoryDir)) { + mkdirSync(memoryDir, { recursive: true }); + } + + this.dbPath = join(memoryDir, 'memory.db'); + this.db = new Database(this.dbPath); + this.db.pragma('journal_mode = WAL'); + this.db.pragma('synchronous = NORMAL'); + + this.initDatabase(); + } + + /** + * Initialize database schema + */ + private initDatabase(): void { + this.db.exec(` + -- Entity table + CREATE TABLE IF NOT EXISTS entities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + value TEXT NOT NULL, + normalized_value TEXT NOT NULL, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + metadata TEXT, + UNIQUE(type, normalized_value) + ); + + -- Access logs table + CREATE TABLE IF NOT EXISTS access_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entity_id INTEGER NOT NULL, + action TEXT NOT NULL, + session_id TEXT, + timestamp TEXT NOT NULL, + context_summary TEXT, + FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE + ); + + -- Entity statistics table + CREATE TABLE IF NOT EXISTS entity_stats ( + entity_id INTEGER PRIMARY KEY, + read_count INTEGER DEFAULT 0, + write_count INTEGER DEFAULT 0, + mention_count INTEGER DEFAULT 0, + heat_score REAL DEFAULT 0, + FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE + ); + + -- Associations table + CREATE TABLE IF NOT EXISTS associations ( + source_id INTEGER NOT NULL, + target_id INTEGER NOT NULL, + weight INTEGER DEFAULT 0, + last_interaction_at TEXT, + PRIMARY KEY (source_id, target_id), + FOREIGN KEY (source_id) REFERENCES entities(id) ON DELETE CASCADE, + FOREIGN KEY (target_id) REFERENCES entities(id) ON DELETE CASCADE + ); + + -- Prompt history table + CREATE TABLE IF NOT EXISTS prompt_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + project_path TEXT, + prompt_text TEXT, + context_summary TEXT, + timestamp INTEGER, + hash TEXT UNIQUE, + quality_score INTEGER, + intent_label TEXT + ); + + -- Prompt patterns table + CREATE TABLE IF NOT EXISTS prompt_patterns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pattern_type TEXT, + frequency INTEGER, + example_ids TEXT, + last_detected INTEGER + ); + + -- Conversations table + CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + source TEXT DEFAULT 'ccw', + external_id TEXT, + project_name TEXT, + git_branch TEXT, + created_at TEXT, + updated_at TEXT, + quality_score INTEGER, + turn_count INTEGER, + prompt_preview TEXT + ); + + -- Messages table + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content_text TEXT, + content_json TEXT, + timestamp TEXT NOT NULL, + token_count INTEGER, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE + ); + + -- Tool calls table + CREATE TABLE IF NOT EXISTS tool_calls ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id INTEGER NOT NULL, + tool_name TEXT NOT NULL, + tool_args TEXT, + tool_output TEXT, + status TEXT, + duration_ms INTEGER, + FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE + ); + + -- Indexes for efficient queries + CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type); + CREATE INDEX IF NOT EXISTS idx_entities_normalized ON entities(normalized_value); + CREATE INDEX IF NOT EXISTS idx_entities_last_seen ON entities(last_seen_at DESC); + CREATE INDEX IF NOT EXISTS idx_access_logs_entity ON access_logs(entity_id); + CREATE INDEX IF NOT EXISTS idx_access_logs_timestamp ON access_logs(timestamp DESC); + CREATE INDEX IF NOT EXISTS idx_access_logs_session ON access_logs(session_id); + CREATE INDEX IF NOT EXISTS idx_entity_stats_heat ON entity_stats(heat_score DESC); + CREATE INDEX IF NOT EXISTS idx_associations_source ON associations(source_id); + CREATE INDEX IF NOT EXISTS idx_associations_target ON associations(target_id); + CREATE INDEX IF NOT EXISTS idx_prompt_history_session ON prompt_history(session_id); + CREATE INDEX IF NOT EXISTS idx_prompt_history_timestamp ON prompt_history(timestamp DESC); + CREATE INDEX IF NOT EXISTS idx_conversations_created ON conversations(created_at DESC); + CREATE INDEX IF NOT EXISTS idx_conversations_updated ON conversations(updated_at DESC); + CREATE INDEX IF NOT EXISTS idx_messages_conversation ON messages(conversation_id); + CREATE INDEX IF NOT EXISTS idx_tool_calls_message ON tool_calls(message_id); + + -- Full-text search for prompt history + CREATE VIRTUAL TABLE IF NOT EXISTS prompt_history_fts USING fts5( + prompt_text, + context_summary, + content='prompt_history', + content_rowid='id' + ); + + -- Triggers to keep FTS index updated + CREATE TRIGGER IF NOT EXISTS prompt_history_ai AFTER INSERT ON prompt_history BEGIN + INSERT INTO prompt_history_fts(rowid, prompt_text, context_summary) + VALUES (new.id, new.prompt_text, new.context_summary); + END; + + CREATE TRIGGER IF NOT EXISTS prompt_history_ad AFTER DELETE ON prompt_history BEGIN + INSERT INTO prompt_history_fts(prompt_history_fts, rowid, prompt_text, context_summary) + VALUES('delete', old.id, old.prompt_text, old.context_summary); + END; + + CREATE TRIGGER IF NOT EXISTS prompt_history_au AFTER UPDATE ON prompt_history BEGIN + INSERT INTO prompt_history_fts(prompt_history_fts, rowid, prompt_text, context_summary) + VALUES('delete', old.id, old.prompt_text, old.context_summary); + INSERT INTO prompt_history_fts(rowid, prompt_text, context_summary) + VALUES (new.id, new.prompt_text, new.context_summary); + END; + `); + } + + /** + * Upsert an entity + */ + upsertEntity(entity: Entity): number { + const stmt = this.db.prepare(` + INSERT INTO entities (type, value, normalized_value, first_seen_at, last_seen_at, metadata) + VALUES (@type, @value, @normalized_value, @first_seen_at, @last_seen_at, @metadata) + ON CONFLICT(type, normalized_value) DO UPDATE SET + value = @value, + last_seen_at = @last_seen_at, + metadata = @metadata + RETURNING id + `); + + const result = stmt.get({ + type: entity.type, + value: entity.value, + normalized_value: entity.normalized_value, + first_seen_at: entity.first_seen_at, + last_seen_at: entity.last_seen_at, + metadata: entity.metadata || null + }) as { id: number }; + + return result.id; + } + + /** + * Get entity by type and normalized value + */ + getEntity(type: string, normalizedValue: string): Entity | null { + const stmt = this.db.prepare(` + SELECT * FROM entities WHERE type = ? AND normalized_value = ? + `); + return stmt.get(type, normalizedValue) as Entity | null; + } + + /** + * Get entity by ID + */ + getEntityById(id: number): Entity | null { + const stmt = this.db.prepare(`SELECT * FROM entities WHERE id = ?`); + return stmt.get(id) as Entity | null; + } + + /** + * Get hot entities (by heat score) + */ + getHotEntities(limit: number = 20): HotEntity[] { + const stmt = this.db.prepare(` + SELECT e.*, s.read_count, s.write_count, s.mention_count, s.heat_score + FROM entities e + INNER JOIN entity_stats s ON e.id = s.entity_id + ORDER BY s.heat_score DESC + LIMIT ? + `); + + const rows = stmt.all(limit) as any[]; + return rows.map(row => ({ + id: row.id, + type: row.type, + value: row.value, + normalized_value: row.normalized_value, + first_seen_at: row.first_seen_at, + last_seen_at: row.last_seen_at, + metadata: row.metadata, + stats: { + entity_id: row.id, + read_count: row.read_count, + write_count: row.write_count, + mention_count: row.mention_count, + heat_score: row.heat_score + } + })); + } + + /** + * Log entity access + */ + logAccess(log: AccessLog): void { + const stmt = this.db.prepare(` + INSERT INTO access_logs (entity_id, action, session_id, timestamp, context_summary) + VALUES (@entity_id, @action, @session_id, @timestamp, @context_summary) + `); + + stmt.run({ + entity_id: log.entity_id, + action: log.action, + session_id: log.session_id || null, + timestamp: log.timestamp, + context_summary: log.context_summary || null + }); + } + + /** + * Get recent access logs for an entity + */ + getRecentAccess(entityId: number, limit: number = 50): AccessLog[] { + const stmt = this.db.prepare(` + SELECT * FROM access_logs + WHERE entity_id = ? + ORDER BY timestamp DESC + LIMIT ? + `); + return stmt.all(entityId, limit) as AccessLog[]; + } + + /** + * Update entity statistics + */ + updateStats(entityId: number, action: 'read' | 'write' | 'mention'): void { + const upsertStmt = this.db.prepare(` + INSERT INTO entity_stats (entity_id, read_count, write_count, mention_count, heat_score) + VALUES (@entity_id, 0, 0, 0, 0) + ON CONFLICT(entity_id) DO NOTHING + `); + + upsertStmt.run({ entity_id: entityId }); + + const field = `${action}_count`; + const updateStmt = this.db.prepare(` + UPDATE entity_stats + SET ${field} = ${field} + 1 + WHERE entity_id = ? + `); + + updateStmt.run(entityId); + } + + /** + * Get entity statistics + */ + getStats(entityId: number): EntityStats | null { + const stmt = this.db.prepare(`SELECT * FROM entity_stats WHERE entity_id = ?`); + return stmt.get(entityId) as EntityStats | null; + } + + /** + * Calculate and update heat score for an entity + */ + calculateHeatScore(entityId: number): number { + const stats = this.getStats(entityId); + if (!stats) return 0; + + const now = Date.now(); + const logs = this.getRecentAccess(entityId, 100); + + let recencyScore = 0; + for (const log of logs) { + const ageMs = now - new Date(log.timestamp).getTime(); + const ageDays = ageMs / (1000 * 60 * 60 * 24); + const decay = Math.exp(-ageDays / 7); // 7-day half-life + recencyScore += decay; + } + + const heatScore = ( + stats.read_count * 1 + + stats.write_count * 3 + + stats.mention_count * 2 + + recencyScore * 5 + ); + + const updateStmt = this.db.prepare(` + UPDATE entity_stats SET heat_score = ? WHERE entity_id = ? + `); + updateStmt.run(heatScore, entityId); + + return heatScore; + } + + /** + * Record association between entities + */ + recordAssociation(sourceId: number, targetId: number, timestamp?: string): void { + const stmt = this.db.prepare(` + INSERT INTO associations (source_id, target_id, weight, last_interaction_at) + VALUES (@source_id, @target_id, 1, @last_interaction_at) + ON CONFLICT(source_id, target_id) DO UPDATE SET + weight = weight + 1, + last_interaction_at = @last_interaction_at + `); + + stmt.run({ + source_id: sourceId, + target_id: targetId, + last_interaction_at: timestamp || new Date().toISOString() + }); + } + + /** + * Get associations for an entity + */ + getAssociations(entityId: number, limit: number = 20): EntityWithAssociations['associations'] { + const stmt = this.db.prepare(` + SELECT e.*, a.weight, a.last_interaction_at + FROM associations a + INNER JOIN entities e ON a.target_id = e.id + WHERE a.source_id = ? + ORDER BY a.weight DESC + LIMIT ? + `); + + const rows = stmt.all(entityId, limit) as any[]; + return rows.map(row => ({ + target: { + id: row.id, + type: row.type, + value: row.value, + normalized_value: row.normalized_value, + first_seen_at: row.first_seen_at, + last_seen_at: row.last_seen_at, + metadata: row.metadata + }, + weight: row.weight, + last_interaction_at: row.last_interaction_at + })); + } + + /** + * Save prompt to history + */ + savePrompt(prompt: PromptHistory): number { + const stmt = this.db.prepare(` + INSERT INTO prompt_history (session_id, project_path, prompt_text, context_summary, timestamp, hash, quality_score, intent_label) + VALUES (@session_id, @project_path, @prompt_text, @context_summary, @timestamp, @hash, @quality_score, @intent_label) + ON CONFLICT(hash) DO UPDATE SET + quality_score = @quality_score, + intent_label = @intent_label + RETURNING id + `); + + const result = stmt.get({ + session_id: prompt.session_id, + project_path: prompt.project_path || null, + prompt_text: prompt.prompt_text || null, + context_summary: prompt.context_summary || null, + timestamp: prompt.timestamp, + hash: prompt.hash || null, + quality_score: prompt.quality_score || null, + intent_label: prompt.intent_label || null + }) as { id: number }; + + return result.id; + } + + /** + * Get prompt history for a session + */ + getPromptHistory(sessionId: string, limit: number = 50): PromptHistory[] { + const stmt = this.db.prepare(` + SELECT * FROM prompt_history + WHERE session_id = ? + ORDER BY timestamp DESC + LIMIT ? + `); + return stmt.all(sessionId, limit) as PromptHistory[]; + } + + /** + * Search prompts by text + */ + searchPrompts(query: string, limit: number = 20): PromptHistory[] { + const stmt = this.db.prepare(` + SELECT ph.* FROM prompt_history ph + INNER JOIN prompt_history_fts fts ON fts.rowid = ph.id + WHERE prompt_history_fts MATCH ? + ORDER BY ph.timestamp DESC + LIMIT ? + `); + return stmt.all(query, limit) as PromptHistory[]; + } + + /** + * Save or update a conversation + */ + saveConversation(conversation: Conversation): void { + const stmt = this.db.prepare(` + INSERT INTO conversations (id, source, external_id, project_name, git_branch, created_at, updated_at, quality_score, turn_count, prompt_preview) + VALUES (@id, @source, @external_id, @project_name, @git_branch, @created_at, @updated_at, @quality_score, @turn_count, @prompt_preview) + ON CONFLICT(id) DO UPDATE SET + updated_at = @updated_at, + quality_score = @quality_score, + turn_count = @turn_count, + prompt_preview = @prompt_preview + `); + + stmt.run({ + id: conversation.id, + source: conversation.source || 'ccw', + external_id: conversation.external_id || null, + project_name: conversation.project_name || null, + git_branch: conversation.git_branch || null, + created_at: conversation.created_at, + updated_at: conversation.updated_at, + quality_score: conversation.quality_score || null, + turn_count: conversation.turn_count, + prompt_preview: conversation.prompt_preview || null + }); + } + + /** + * Get conversations + */ + getConversations(limit: number = 50, offset: number = 0): Conversation[] { + const stmt = this.db.prepare(` + SELECT * FROM conversations + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + `); + return stmt.all(limit, offset) as Conversation[]; + } + + /** + * Get conversation by ID + */ + getConversation(id: string): Conversation | null { + const stmt = this.db.prepare(`SELECT * FROM conversations WHERE id = ?`); + return stmt.get(id) as Conversation | null; + } + + /** + * Save message + */ + saveMessage(message: Message): number { + const stmt = this.db.prepare(` + INSERT INTO messages (conversation_id, role, content_text, content_json, timestamp, token_count) + VALUES (@conversation_id, @role, @content_text, @content_json, @timestamp, @token_count) + RETURNING id + `); + + const result = stmt.get({ + conversation_id: message.conversation_id, + role: message.role, + content_text: message.content_text || null, + content_json: message.content_json || null, + timestamp: message.timestamp, + token_count: message.token_count || null + }) as { id: number }; + + return result.id; + } + + /** + * Get messages for a conversation + */ + getMessages(conversationId: string): Message[] { + const stmt = this.db.prepare(` + SELECT * FROM messages + WHERE conversation_id = ? + ORDER BY timestamp ASC + `); + return stmt.all(conversationId) as Message[]; + } + + /** + * Save tool call + */ + saveToolCall(toolCall: ToolCall): number { + const stmt = this.db.prepare(` + INSERT INTO tool_calls (message_id, tool_name, tool_args, tool_output, status, duration_ms) + VALUES (@message_id, @tool_name, @tool_args, @tool_output, @status, @duration_ms) + RETURNING id + `); + + const result = stmt.get({ + message_id: toolCall.message_id, + tool_name: toolCall.tool_name, + tool_args: toolCall.tool_args || null, + tool_output: toolCall.tool_output || null, + status: toolCall.status || null, + duration_ms: toolCall.duration_ms || null + }) as { id: number }; + + return result.id; + } + + /** + * Get tool calls for a message + */ + getToolCalls(messageId: number): ToolCall[] { + const stmt = this.db.prepare(` + SELECT * FROM tool_calls + WHERE message_id = ? + `); + return stmt.all(messageId) as ToolCall[]; + } + + /** + * Close database connection + */ + close(): void { + this.db.close(); + } +} + +// Singleton instance cache +const storeCache = new Map(); + +/** + * Get or create a store instance for a project + */ +export function getMemoryStore(projectPath: string): MemoryStore { + if (!storeCache.has(projectPath)) { + storeCache.set(projectPath, new MemoryStore(projectPath)); + } + return storeCache.get(projectPath)!; +} + +/** + * Close all store instances + */ +export function closeAllStores(): void { + for (const store of storeCache.values()) { + store.close(); + } + storeCache.clear(); +} + +export default MemoryStore; diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index d7dcdb66..c31f740d 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -8,11 +8,12 @@ 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, getExecutionHistoryAsync, getExecutionDetail, getConversationDetail, deleteExecution, deleteExecutionAsync, batchDeleteExecutionsAsync, executeCliTool } from '../tools/cli-executor.js'; +import { getCliToolsStatus, getExecutionHistory, getExecutionHistoryAsync, getExecutionDetail, getConversationDetail, deleteExecution, deleteExecutionAsync, batchDeleteExecutionsAsync, executeCliTool, getNativeSessionContent, getFormattedNativeConversation, getEnrichedConversation, getHistoryWithNativeInfo } from '../tools/cli-executor.js'; import { getAllManifests } from './manifest.js'; import { checkVenvStatus, bootstrapVenv, executeCodexLens, checkSemanticStatus, installSemantic } from '../tools/codex-lens.js'; import { generateSmartContext, formatSmartContext } from '../tools/smart-context.js'; import { listTools } from '../tools/index.js'; +import { getMemoryStore } from './memory-store.js'; import type { ServerConfig } from '../types/config.js';interface ServerOptions { port?: number; initialPath?: string; host?: string; open?: boolean;}interface PostResult { error?: string; status?: number; [key: string]: unknown;}type PostHandler = (body: unknown) => Promise; // Claude config file paths @@ -54,7 +55,9 @@ const MODULE_CSS_FILES = [ '07-managers.css', '08-review.css', '09-explorer.css', - '10-cli.css' + '10-cli.css', + '11-memory.css', + '11-prompt-history.css' ]; /** @@ -121,6 +124,8 @@ const MODULE_FILES = [ 'views/cli-manager.js', 'views/history.js', 'views/explorer.js', + 'views/memory.js', + 'views/prompt-history.js', 'main.js' ]; /** @@ -643,11 +648,12 @@ export async function startServer(options: ServerOptions = {}): Promise { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(history)); @@ -718,6 +724,100 @@ export async function startServer(options: ServerOptions = {}): Promise { + if (!result) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Conversation not found' })); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + }) + .catch(err => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + }); + return; + } + + // API: Get History with Native Session Info + if (pathname === '/api/cli/history-native') { + const projectPath = url.searchParams.get('path') || initialPath; + const limit = parseInt(url.searchParams.get('limit') || '50', 10); + const tool = url.searchParams.get('tool') || null; + const status = url.searchParams.get('status') || null; + const category = url.searchParams.get('category') as 'user' | 'internal' | 'insight' | null; + const search = url.searchParams.get('search') || null; + + getHistoryWithNativeInfo(projectPath, { limit, tool, status, category, search }) + .then(history => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(history)); + }) + .catch(err => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (err as Error).message })); + }); + return; + } + // API: Execute CLI Tool if (pathname === '/api/cli/execute' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { @@ -817,6 +917,534 @@ export async function startServer(options: ServerOptions = {}): Promise e.type === type); + } + + // Sort by field + if (sort === 'reads') { + hotEntities.sort((a, b) => b.stats.read_count - a.stats.read_count); + } else if (sort === 'writes') { + hotEntities.sort((a, b) => b.stats.write_count - a.stats.write_count); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + items: hotEntities.map(e => ({ + value: e.value, + type: e.type, + read_count: e.stats.read_count, + write_count: e.stats.write_count, + mention_count: e.stats.mention_count, + heat_score: e.stats.heat_score + })) + })); + } catch (error: unknown) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + return; + } + + // API: Memory Module - Get association graph + if (pathname === '/api/memory/graph') { + const projectPath = url.searchParams.get('path') || initialPath; + const center = url.searchParams.get('center'); + const depth = parseInt(url.searchParams.get('depth') || '1', 10); + + if (!center) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'center parameter is required' })); + return; + } + + try { + const memoryStore = getMemoryStore(projectPath); + + // Find the center entity (assume it's a file for now) + const entity = memoryStore.getEntity('file', center); + if (!entity) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Entity not found' })); + return; + } + + // Get associations + const associations = memoryStore.getAssociations(entity.id!, 20); + const stats = memoryStore.getStats(entity.id!); + + // Build graph structure + const nodes = [ + { + id: entity.id!.toString(), + label: entity.value, + type: entity.type, + heat: stats?.heat_score || 0 + } + ]; + + const links = []; + for (const assoc of associations) { + nodes.push({ + id: assoc.target.id!.toString(), + label: assoc.target.value, + type: assoc.target.type, + heat: 0 + }); + + links.push({ + source: entity.id!.toString(), + target: assoc.target.id!.toString(), + weight: assoc.weight + }); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ nodes, links })); + } catch (error: unknown) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + return; + } + + // API: Memory Module - Track entity access + if (pathname === '/api/memory/track' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { type, action, value, sessionId, metadata, path: projectPath } = body; + + if (!type || !action || !value) { + return { error: 'type, action, and value are required', status: 400 }; + } + + const basePath = projectPath || initialPath; + + try { + const memoryStore = getMemoryStore(basePath); + const now = new Date().toISOString(); + + // Normalize the value + const normalizedValue = value.toLowerCase().trim(); + + // Upsert entity + const entityId = memoryStore.upsertEntity({ + type, + value, + normalized_value: normalizedValue, + first_seen_at: now, + last_seen_at: now, + metadata: metadata ? JSON.stringify(metadata) : undefined + }); + + // Log access + memoryStore.logAccess({ + entity_id: entityId, + action, + session_id: sessionId, + timestamp: now, + context_summary: metadata?.context + }); + + // Update stats + memoryStore.updateStats(entityId, action); + + // Calculate new heat score + const heatScore = memoryStore.calculateHeatScore(entityId); + const stats = memoryStore.getStats(entityId); + + // Broadcast MEMORY_UPDATED event via WebSocket + broadcastToClients({ + type: 'MEMORY_UPDATED', + payload: { + entity: { id: entityId, type, value }, + stats: { + read_count: stats?.read_count || 0, + write_count: stats?.write_count || 0, + mention_count: stats?.mention_count || 0, + heat_score: heatScore + }, + timestamp: now + } + }); + + return { + success: true, + entity_id: entityId, + heat_score: heatScore + }; + } catch (error: unknown) { + return { error: (error as Error).message, status: 500 }; + } + }); + return; + } + + // API: Memory Module - Get native Claude history from ~/.claude/history.jsonl + if (pathname === '/api/memory/native-history') { + const projectPath = url.searchParams.get('path') || initialPath; + const limit = parseInt(url.searchParams.get('limit') || '100', 10); + const historyFile = join(homedir(), '.claude', 'history.jsonl'); + + try { + if (!existsSync(historyFile)) { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ prompts: [], total: 0, message: 'No history file found' })); + return; + } + + const content = readFileSync(historyFile, 'utf8'); + const lines = content.trim().split('\n').filter(line => line.trim()); + const allPrompts = []; + + for (const line of lines) { + try { + const entry = JSON.parse(line); + // Filter by project if specified + if (projectPath && entry.project) { + const normalizedProject = entry.project.replace(/\\/g, '/').toLowerCase(); + const normalizedPath = projectPath.replace(/\\/g, '/').toLowerCase(); + if (!normalizedProject.includes(normalizedPath) && !normalizedPath.includes(normalizedProject)) { + continue; + } + } + + allPrompts.push({ + id: `${entry.sessionId}-${entry.timestamp}`, + text: entry.display || '', + timestamp: new Date(entry.timestamp).toISOString(), + project: entry.project || '', + session_id: entry.sessionId || '', + pasted_contents: entry.pastedContents || {}, + // Derive intent from content keywords + intent: derivePromptIntent(entry.display || ''), + quality_score: calculateQualityScore(entry.display || '') + }); + } catch (parseError) { + // Skip malformed lines + } + } + + // Sort by timestamp descending + allPrompts.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); + + // Apply limit + const prompts = allPrompts.slice(0, limit); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ prompts, total: allPrompts.length })); + } catch (error: unknown) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + return; + } + + // API: Memory Module - Get prompt history + if (pathname === '/api/memory/prompts') { + const projectPath = url.searchParams.get('path') || initialPath; + const limit = parseInt(url.searchParams.get('limit') || '50', 10); + const search = url.searchParams.get('search') || null; + + try { + const memoryStore = getMemoryStore(projectPath); + let prompts; + + if (search) { + prompts = memoryStore.searchPrompts(search, limit); + } else { + // Get all recent prompts (we'll need to add this method to MemoryStore) + const stmt = memoryStore['db'].prepare(` + SELECT * FROM prompt_history + ORDER BY timestamp DESC + LIMIT ? + `); + prompts = stmt.all(limit); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ prompts })); + } catch (error: unknown) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + return; + } + + // API: Memory Module - Get insights + if (pathname === '/api/memory/insights') { + const projectPath = url.searchParams.get('path') || initialPath; + + try { + const memoryStore = getMemoryStore(projectPath); + + // Get total prompt count + const countStmt = memoryStore['db'].prepare(`SELECT COUNT(*) as count FROM prompt_history`); + const { count: totalPrompts } = countStmt.get() as { count: number }; + + // Get top intent + const topIntentStmt = memoryStore['db'].prepare(` + SELECT intent_label, COUNT(*) as count + FROM prompt_history + WHERE intent_label IS NOT NULL + GROUP BY intent_label + ORDER BY count DESC + LIMIT 1 + `); + const topIntentRow = topIntentStmt.get() as { intent_label: string; count: number } | undefined; + + // Get average prompt length + const avgLengthStmt = memoryStore['db'].prepare(` + SELECT AVG(LENGTH(prompt_text)) as avg_length + FROM prompt_history + WHERE prompt_text IS NOT NULL + `); + const { avg_length: avgLength } = avgLengthStmt.get() as { avg_length: number }; + + // Get prompt patterns + const patternsStmt = memoryStore['db'].prepare(` + SELECT * FROM prompt_patterns + ORDER BY frequency DESC + LIMIT 10 + `); + const patterns = patternsStmt.all(); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + stats: { + totalPrompts, + topIntent: topIntentRow?.intent_label || 'unknown', + avgLength: Math.round(avgLength || 0) + }, + patterns: patterns.map((p: any) => ({ + type: p.pattern_type, + description: `Pattern detected in prompts`, + occurrences: p.frequency, + suggestion: `Consider using more specific prompts for ${p.pattern_type}` + })) + })); + } catch (error: unknown) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + return; + } + + // API: Memory Module - Trigger async CLI-based insights analysis + if (pathname === '/api/memory/insights/analyze' && req.method === 'POST') { + handlePostRequest(req, res, async (body: any) => { + const projectPath = body.path || initialPath; + const tool = body.tool || 'gemini'; // gemini, qwen, codex + const prompts = body.prompts || []; + const lang = body.lang || 'en'; // Language preference + + if (prompts.length === 0) { + return { error: 'No prompts provided for analysis', status: 400 }; + } + + // Prepare prompt summary for CLI analysis + const promptSummary = prompts.slice(0, 20).map((p: any, i: number) => { + return `${i + 1}. [${p.intent || 'unknown'}] ${(p.text || '').substring(0, 100)}...`; + }).join('\n'); + + const langInstruction = lang === 'zh' + ? '请用中文回复。所有 description、suggestion、title 字段必须使用中文。' + : 'Respond in English. All description, suggestion, title fields must be in English.'; + + const analysisPrompt = ` +PURPOSE: Analyze prompt patterns and provide optimization suggestions +TASK: +• Review the following prompt history summary +• Identify common patterns (vague requests, repetitive queries, incomplete context) +• Suggest specific improvements for prompt quality +• Detect areas where prompts could be more effective +MODE: analysis +CONTEXT: ${prompts.length} prompts from project: ${projectPath} +EXPECTED: JSON with patterns array and suggestions array +LANGUAGE: ${langInstruction} + +PROMPT HISTORY: +${promptSummary} + +Return ONLY valid JSON in this exact format (no markdown, no code blocks, just pure JSON): +{ + "patterns": [ + {"type": "pattern_type", "description": "description", "occurrences": count, "severity": "low|medium|high", "suggestion": "how to improve"} + ], + "suggestions": [ + {"title": "title", "description": "description", "example": "example prompt"} + ] +}`; + + try { + // Queue CLI execution + const result = await executeCliTool({ + tool, + prompt: analysisPrompt, + mode: 'analysis', + timeout: 120000 + }); + + // Try to parse JSON from response + let insights = { patterns: [], suggestions: [] }; + if (result.stdout) { + let outputText = result.stdout; + + // Strip markdown code blocks if present + const codeBlockMatch = outputText.match(/```(?:json)?\s*([\s\S]*?)```/); + if (codeBlockMatch) { + outputText = codeBlockMatch[1].trim(); + } + + // Find JSON object in the response + const jsonMatch = outputText.match(/\{[\s\S]*\}/); + if (jsonMatch) { + try { + insights = JSON.parse(jsonMatch[0]); + // Ensure arrays exist + if (!Array.isArray(insights.patterns)) insights.patterns = []; + if (!Array.isArray(insights.suggestions)) insights.suggestions = []; + } catch (e) { + console.error('[insights/analyze] JSON parse error:', e); + // Return raw output if JSON parse fails + insights = { + patterns: [{ type: 'raw_analysis', description: result.stdout.substring(0, 500), occurrences: 1, severity: 'low', suggestion: '' }], + suggestions: [] + }; + } + } else { + // No JSON found, wrap raw output + insights = { + patterns: [{ type: 'raw_analysis', description: result.stdout.substring(0, 500), occurrences: 1, severity: 'low', suggestion: '' }], + suggestions: [] + }; + } + } + + return { + success: true, + insights, + tool, + executionId: result.execution.id + }; + } catch (error: unknown) { + return { error: (error as Error).message, status: 500 }; + } + }); + return; + } + + // API: Memory Module - Get conversations index + if (pathname === '/api/memory/conversations') { + const projectPath = url.searchParams.get('path') || initialPath; + const project = url.searchParams.get('project') || null; + const limit = parseInt(url.searchParams.get('limit') || '20', 10); + + try { + const memoryStore = getMemoryStore(projectPath); + + let conversations; + if (project) { + const stmt = memoryStore['db'].prepare(` + SELECT * FROM conversations + WHERE project_name = ? + ORDER BY updated_at DESC + LIMIT ? + `); + conversations = stmt.all(project, limit); + } else { + conversations = memoryStore.getConversations(limit); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ conversations })); + } catch (error: unknown) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + return; + } + + // API: Memory Module - Replay conversation + if (pathname.startsWith('/api/memory/replay/')) { + const conversationId = pathname.replace('/api/memory/replay/', ''); + const projectPath = url.searchParams.get('path') || initialPath; + + if (!conversationId) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Conversation ID is required' })); + return; + } + + try { + const memoryStore = getMemoryStore(projectPath); + const conversation = memoryStore.getConversation(conversationId); + + if (!conversation) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Conversation not found' })); + return; + } + + const messages = memoryStore.getMessages(conversationId); + + // Enhance messages with tool calls + const messagesWithTools = []; + for (const message of messages) { + const toolCalls = message.id ? memoryStore.getToolCalls(message.id) : []; + messagesWithTools.push({ + ...message, + tool_calls: toolCalls + }); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + conversation, + messages: messagesWithTools + })); + } catch (error: unknown) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: (error as Error).message })); + } + return; + } + + // API: Memory Module - Import history (async task) + if (pathname === '/api/memory/import' && req.method === 'POST') { + handlePostRequest(req, res, async (body) => { + const { source = 'all', project, path: projectPath } = body; + const basePath = projectPath || initialPath; + + // Generate task ID for async operation + const taskId = `import-${Date.now()}`; + + // TODO: Implement actual history import using HistoryImporter + // For now, return a placeholder response + console.log(`[Memory] Import task ${taskId} started: source=${source}, project=${project}`); + + return { + success: true, + taskId, + message: 'Import task started (not yet implemented)', + source, + project + }; + }); + return; + } + // API: Update CLAUDE.md using CLI tools (Explorer view) if (pathname === '/api/update-claude-md' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { @@ -1520,6 +2148,65 @@ window.INITIAL_PATH = '${normalizePathForDisplay(initialPath).replace(/\\/g, '/' // MCP Configuration Functions // ======================================== +/** + * Derive prompt intent from text content + */ +function derivePromptIntent(text: string): string { + const lower = text.toLowerCase(); + + // Implementation/coding patterns + if (/实现|implement|create|add|build|write|develop|make/.test(lower)) return 'implement'; + if (/修复|fix|bug|error|issue|problem|解决/.test(lower)) return 'fix'; + if (/重构|refactor|optimize|improve|clean/.test(lower)) return 'refactor'; + if (/测试|test|spec|coverage/.test(lower)) return 'test'; + + // Analysis patterns + if (/分析|analyze|review|check|examine|audit/.test(lower)) return 'analyze'; + if (/解释|explain|what|how|why|understand/.test(lower)) return 'explain'; + if (/搜索|search|find|look|where|locate/.test(lower)) return 'search'; + + // Documentation patterns + if (/文档|document|readme|comment|注释/.test(lower)) return 'document'; + + // Planning patterns + if (/计划|plan|design|architect|strategy/.test(lower)) return 'plan'; + + // Configuration patterns + if (/配置|config|setup|install|设置/.test(lower)) return 'configure'; + + // Default + return 'general'; +} + +/** + * Calculate prompt quality score (0-100) + */ +function calculateQualityScore(text: string): number { + let score = 50; // Base score + + // Length factors + const length = text.length; + if (length > 50 && length < 500) score += 15; + else if (length >= 500 && length < 1000) score += 10; + else if (length < 20) score -= 20; + + // Specificity indicators + if (/file|path|function|class|method|variable/i.test(text)) score += 10; + if (/src\/|\.ts|\.js|\.py|\.go/i.test(text)) score += 10; + + // Context indicators + if (/when|after|before|because|since/i.test(text)) score += 5; + + // Action clarity + if (/please|要|请|帮|help/i.test(text)) score += 5; + + // Structure indicators + if (/\d+\.|•|-\s/.test(text)) score += 10; // Lists + + // Cap at 100 + return Math.min(100, Math.max(0, score)); +} + /** * Safely read and parse JSON file * @param {string} filePath diff --git a/ccw/src/templates/dashboard-css/10-cli.css b/ccw/src/templates/dashboard-css/10-cli.css index 54a1a065..a1acfc11 100644 --- a/ccw/src/templates/dashboard-css/10-cli.css +++ b/ccw/src/templates/dashboard-css/10-cli.css @@ -2754,3 +2754,477 @@ opacity: 0.5; pointer-events: none; } + +/* ======================================== + * Native Session Styles + * ======================================== */ + +/* Native badge in history list */ +.cli-native-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.125rem 0.375rem; + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); + border-radius: 0.25rem; + font-size: 0.625rem; +} + +.cli-history-item.has-native { + border-left: 2px solid hsl(var(--primary) / 0.5); +} + +/* Mode tag */ +.cli-mode-tag { + display: inline-flex; + align-items: center; + padding: 0.125rem 0.375rem; + font-size: 0.625rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); + background: hsl(var(--muted)); + border-radius: 0.25rem; +} + +/* Status badge */ +.cli-status-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.375rem; + font-size: 0.625rem; + font-weight: 500; + border-radius: 0.25rem; +} + +.cli-status-badge.text-success { + background: hsl(var(--success) / 0.1); + color: hsl(var(--success)); +} + +.cli-status-badge.text-warning { + background: hsl(var(--warning) / 0.1); + color: hsl(var(--warning)); +} + +.cli-status-badge.text-destructive { + background: hsl(var(--destructive) / 0.1); + color: hsl(var(--destructive)); +} + +/* Native Session Detail Modal */ +.native-session-detail { + font-family: system-ui, -apple-system, sans-serif; +} + +.native-session-header { + margin-bottom: 1rem; + padding-bottom: 1rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.native-session-info { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + margin-bottom: 0.5rem; +} + +.native-model, +.native-session-id { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.native-session-meta { + display: flex; + flex-wrap: wrap; + gap: 1rem; + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); +} + +.native-session-meta span { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +/* Tokens Summary */ +.native-tokens-summary { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + background: hsl(var(--muted) / 0.5); + border-radius: 0.5rem; + font-size: 0.75rem; + color: hsl(var(--foreground)); +} + +/* Native Turns Container */ +.native-turns-container { + max-height: 60vh; + overflow-y: auto; + padding-right: 0.5rem; +} + +/* Native Turn */ +.native-turn { + margin-bottom: 1rem; + padding: 0.875rem; + border-radius: 0.5rem; + border: 1px solid hsl(var(--border)); +} + +.native-turn.user { + background: hsl(var(--muted) / 0.3); + border-left: 3px solid hsl(var(--primary)); +} + +.native-turn.assistant { + background: hsl(var(--background)); + border-left: 3px solid hsl(var(--success)); +} + +.native-turn.latest { + box-shadow: 0 0 0 1px hsl(var(--primary) / 0.3); +} + +.native-turn-header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.625rem; + flex-wrap: wrap; +} + +.native-turn-role { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.native-turn-number { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); +} + +.native-turn-tokens { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.625rem; + color: hsl(var(--muted-foreground)); + padding: 0.125rem 0.375rem; + background: hsl(var(--muted)); + border-radius: 0.25rem; +} + +.native-turn-latest { + font-size: 0.625rem; + font-weight: 500; + padding: 0.125rem 0.375rem; + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); + border-radius: 0.25rem; +} + +.native-turn-content pre { + margin: 0; + padding: 0.75rem; + background: hsl(var(--muted)); + border-radius: 0.375rem; + font-family: monospace; + font-size: 0.75rem; + line-height: 1.5; + white-space: pre-wrap; + word-wrap: break-word; + max-height: 300px; + overflow-y: auto; +} + +/* Thoughts Section */ +.native-thoughts-section { + margin-top: 0.75rem; + padding: 0.625rem; + background: hsl(var(--warning) / 0.05); + border: 1px solid hsl(var(--warning) / 0.2); + border-radius: 0.375rem; +} + +.native-thoughts-section h5 { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.6875rem; + font-weight: 600; + color: hsl(var(--warning)); + margin: 0 0 0.5rem 0; +} + +.native-thoughts-list { + margin: 0; + padding-left: 1.25rem; + font-size: 0.6875rem; + color: hsl(var(--foreground)); +} + +.native-thoughts-list li { + margin-bottom: 0.25rem; +} + +/* Tool Calls Section */ +.native-tools-section { + margin-top: 0.75rem; + padding: 0.625rem; + background: hsl(var(--muted) / 0.5); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; +} + +.native-tools-section h5 { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.6875rem; + font-weight: 600; + color: hsl(var(--muted-foreground)); + margin: 0 0 0.5rem 0; +} + +.native-tools-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.native-tool-call { + padding: 0.5rem; + background: hsl(var(--background)); + border-radius: 0.25rem; +} + +.native-tool-name { + display: inline-block; + font-family: monospace; + font-size: 0.6875rem; + font-weight: 600; + color: hsl(var(--primary)); + margin-bottom: 0.25rem; +} + +.native-tool-output { + margin: 0.25rem 0 0 0; + padding: 0.375rem; + background: hsl(var(--muted)); + border-radius: 0.25rem; + font-family: monospace; + font-size: 0.625rem; + white-space: pre-wrap; + word-wrap: break-word; + max-height: 100px; + overflow-y: auto; +} + +/* Native Session Actions */ +.native-session-actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid hsl(var(--border)); + flex-wrap: wrap; +} + +/* ======================================== + * Task Queue Sidebar - CLI Tab Styles + * ======================================== */ + +/* Tab Navigation */ +.task-queue-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid hsl(var(--border)); + padding: 0 1rem; +} + +.task-queue-tab { + flex: 1; + padding: 0.625rem 0.75rem; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + color: hsl(var(--muted-foreground)); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; + gap: 0.375rem; +} + +.task-queue-tab:hover { + color: hsl(var(--foreground)); + background: hsl(var(--muted) / 0.3); +} + +.task-queue-tab.active { + color: hsl(var(--primary)); + border-bottom-color: hsl(var(--primary)); +} + +.task-queue-tab .tab-badge { + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); + padding: 0.125rem 0.375rem; + border-radius: 9999px; + font-size: 0.6875rem; + font-weight: 600; + min-width: 1.25rem; + text-align: center; +} + +.task-queue-tab.active .tab-badge { + background: hsl(var(--primary) / 0.15); + color: hsl(var(--primary)); +} + +/* CLI Filter Buttons */ +.cli-filter-btn { + padding: 0.375rem 0.625rem; + background: transparent; + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + color: hsl(var(--muted-foreground)); + font-size: 0.75rem; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.cli-filter-btn:hover { + background: hsl(var(--muted) / 0.5); + color: hsl(var(--foreground)); +} + +.cli-filter-btn.active { + background: hsl(var(--primary)); + border-color: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +/* CLI Queue Item */ +.cli-queue-item { + padding: 0.75rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + margin-bottom: 0.5rem; + cursor: pointer; + transition: all 0.15s; +} + +.cli-queue-item:hover { + background: hsl(var(--muted) / 0.5); + border-color: hsl(var(--primary) / 0.3); +} + +.cli-queue-item.category-user { + border-left: 3px solid #3b82f6; +} + +.cli-queue-item.category-insight { + border-left: 3px solid #a855f7; +} + +.cli-queue-item.category-internal { + border-left: 3px solid #22c55e; +} + +.cli-queue-item-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.375rem; +} + +.cli-queue-category-icon { + font-size: 0.875rem; +} + +.cli-queue-tool-tag { + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; +} + +.cli-queue-tool-tag.cli-tool-gemini { + background: hsl(210 100% 50% / 0.15); + color: hsl(210 100% 45%); +} + +.cli-queue-tool-tag.cli-tool-qwen { + background: hsl(280 100% 50% / 0.15); + color: hsl(280 100% 40%); +} + +.cli-queue-tool-tag.cli-tool-codex { + background: hsl(145 60% 45% / 0.15); + color: hsl(145 60% 35%); +} + +.cli-queue-status { + font-size: 0.75rem; +} + +.cli-queue-time { + margin-left: auto; + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); +} + +.cli-queue-prompt { + font-size: 0.75rem; + color: hsl(var(--foreground)); + line-height: 1.4; + margin-bottom: 0.375rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cli-queue-meta { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); +} + +.cli-queue-id { + font-family: monospace; +} + +.cli-queue-turns { + background: hsl(var(--muted)); + padding: 0.0625rem 0.25rem; + border-radius: 0.25rem; +} + +.cli-queue-native { + font-size: 0.75rem; +} diff --git a/ccw/src/templates/dashboard-css/11-memory.css b/ccw/src/templates/dashboard-css/11-memory.css new file mode 100644 index 00000000..8390afee --- /dev/null +++ b/ccw/src/templates/dashboard-css/11-memory.css @@ -0,0 +1,1360 @@ +/* ======================================== + * Memory Module Styles + * Three-column layout: Hotspots | Graph | Context + * ======================================== */ + +/* ======================================== + * Memory View Layout + * ======================================== */ +.memory-view { + height: 100%; + min-height: 600px; +} + +.memory-view.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.memory-columns { + display: grid; + grid-template-columns: 280px 1fr 320px; + gap: 1.5rem; + height: 100%; +} + +.memory-column { + display: flex; + flex-direction: column; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; + overflow: hidden; + min-width: 0; +} + +/* Memory Section inside columns */ +.memory-section { + display: flex; + flex-direction: column; + height: 100%; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.875rem 1rem; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.3); +} + +.section-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; +} + +.section-header-left { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.section-header-actions { + display: flex; + align-items: center; + gap: 0.25rem; +} + +/* Memory Filters */ +.memory-filters { + display: flex; + gap: 0.25rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.15); +} + +.filter-btn { + padding: 0.375rem 0.75rem; + font-size: 0.75rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); + background: transparent; + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.filter-btn:hover { + background: hsl(var(--hover)); + color: hsl(var(--foreground)); +} + +.filter-btn.active { + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); + border-color: hsl(var(--primary)); +} + +/* Hotspot Lists Container */ +.hotspot-lists { + flex: 1; + overflow-y: auto; + padding: 0.75rem; +} + +.hotspot-list-container { + margin-bottom: 1rem; +} + +.hotspot-list-container:last-child { + margin-bottom: 0; +} + +.hotspot-list-title { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.75rem; + font-weight: 600; + color: hsl(var(--muted-foreground)); + margin: 0 0 0.5rem 0; + padding-bottom: 0.375rem; + border-bottom: 1px solid hsl(var(--border)); +} + +/* Hotspot List Items */ +.hotspot-list { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.hotspot-item { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.75rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.hotspot-item:hover { + background: hsl(var(--hover)); + border-color: hsl(var(--primary) / 0.3); +} + +.hotspot-rank { + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + font-size: 0.6875rem; + font-weight: 700; + color: hsl(var(--muted-foreground)); + background: hsl(var(--muted)); + border-radius: 0.25rem; + flex-shrink: 0; +} + +.hotspot-info { + flex: 1; + min-width: 0; +} + +.hotspot-name { + font-size: 0.8125rem; + font-weight: 500; + color: hsl(var(--foreground)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hotspot-path { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-top: 0.125rem; +} + +.hotspot-heat { + display: flex; + align-items: center; + gap: 0.375rem; + flex-shrink: 0; +} + +.hotspot-heat .heat-badge { + padding: 0.25rem 0.5rem; + font-size: 0.6875rem; + font-weight: 600; + border-radius: 0.25rem; +} + +.hotspot-heat.high .heat-badge { + background: hsl(0 84% 92%); + color: hsl(0 84% 40%); +} + +.hotspot-heat.medium .heat-badge { + background: hsl(38 92% 90%); + color: hsl(38 92% 30%); +} + +.hotspot-heat.low .heat-badge { + background: hsl(142 71% 90%); + color: hsl(142 71% 30%); +} + +.hotspot-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1.5rem; + text-align: center; + color: hsl(var(--muted-foreground)); +} + +.hotspot-empty i { + margin-bottom: 0.5rem; + opacity: 0.5; +} + +.hotspot-empty p { + margin: 0; + font-size: 0.8125rem; +} + +/* Graph Legend */ +.graph-legend { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 1rem; + border-top: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.15); + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); +} + +.legend-item { + display: flex; + align-items: center; + gap: 0.375rem; +} + +.legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.legend-dot.file { background: hsl(var(--primary)); } +.legend-dot.module { background: hsl(var(--muted-foreground)); } +.legend-dot.component { background: hsl(var(--success)); } + +/* Graph Container */ +.graph-container, +.memory-graph-container { + flex: 1; + position: relative; + background: hsl(var(--background)); + min-height: 300px; + display: flex; + align-items: center; + justify-content: center; +} + +.memory-graph-container svg { + width: 100%; + height: 100%; +} + +/* Memory Graph Legend */ +.memory-graph-legend { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.5rem 1rem; + border-top: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.15); + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); +} + +/* Graph Empty State */ +.graph-empty-state, +.graph-error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + text-align: center; + color: hsl(var(--muted-foreground)); +} + +.graph-empty-state i, +.graph-error i { + margin-bottom: 0.75rem; + opacity: 0.5; +} + +.graph-empty-state p, +.graph-error p { + margin: 0; + font-size: 0.875rem; +} + +/* Section Count Badge */ +.section-count { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); + margin-left: 0.5rem; +} + +/* Graph Edge Styles */ +.graph-edge { + stroke: hsl(var(--border)); + stroke-opacity: 0.6; +} + +/* Graph Node Styles */ +.graph-node { + cursor: pointer; + transition: all 0.2s ease; +} + +.graph-node.file { + fill: hsl(var(--primary)); +} + +.graph-node.module { + fill: hsl(var(--muted-foreground)); +} + +.graph-node.component { + fill: hsl(var(--success)); +} + +.graph-node-label { + font-size: 10px; + fill: hsl(var(--foreground)); + pointer-events: none; +} + +/* Context Search */ +.context-search { + position: relative; + padding: 0.75rem 1rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.context-search input, +.context-search-input { + width: 100%; + padding: 0.5rem 0.75rem 0.5rem 2rem; + font-size: 0.8125rem; + color: hsl(var(--foreground)); + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + outline: none; + transition: border-color 0.15s ease; +} + +.context-search input:focus, +.context-search-input:focus { + border-color: hsl(var(--primary)); +} + +.context-search input::placeholder, +.context-search-input::placeholder { + color: hsl(var(--muted-foreground)); +} + +.context-search .search-icon { + position: absolute; + left: 1.5rem; + top: 50%; + transform: translateY(-50%); + color: hsl(var(--muted-foreground)); + pointer-events: none; +} + +/* Context Empty State */ +.context-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + text-align: center; + color: hsl(var(--muted-foreground)); +} + +.context-empty i { + margin-bottom: 0.75rem; + opacity: 0.5; +} + +.context-empty p { + margin: 0; + font-size: 0.875rem; +} + +/* Context Timeline */ +.context-timeline { + flex: 1; + overflow-y: auto; + padding: 0.75rem; +} + +.timeline-item { + display: flex; + gap: 0.75rem; + padding: 0.75rem; + margin-bottom: 0.5rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + transition: all 0.15s ease; +} + +.timeline-item:hover { + border-color: hsl(var(--primary) / 0.3); +} + +.timeline-item:last-child { + margin-bottom: 0; +} + +.timeline-icon { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 0.375rem; + flex-shrink: 0; +} + +.timeline-icon.read { + background: hsl(var(--primary-light)); + color: hsl(var(--primary)); +} + +.timeline-icon.edit { + background: hsl(var(--warning-light)); + color: hsl(var(--warning)); +} + +.timeline-icon.prompt, +.timeline-icon.unknown { + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); +} + +.timeline-content { + flex: 1; + min-width: 0; +} + +.timeline-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.25rem; +} + +.timeline-type { + font-size: 0.75rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.timeline-time { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); +} + +.timeline-prompt { + font-size: 0.8125rem; + color: hsl(var(--foreground)); + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.timeline-files { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + margin-top: 0.5rem; +} + +.file-tag { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; + background: hsl(var(--muted)); + color: hsl(var(--foreground)); + border-radius: 0.25rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.file-tag:hover { + background: hsl(var(--primary-light)); + color: hsl(var(--primary)); +} + +.file-tag.more { + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); + cursor: default; +} + +/* Context List */ +.context-list { + flex: 1; + overflow-y: auto; + padding: 0.75rem; +} + +.context-item { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem; + margin-bottom: 0.5rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.context-item:hover { + background: hsl(var(--hover)); + border-color: hsl(var(--primary) / 0.3); +} + +.context-item:last-child { + margin-bottom: 0; +} + +.context-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 0.375rem; + background: hsl(var(--muted)); + color: hsl(var(--muted-foreground)); + flex-shrink: 0; +} + +.context-icon.read { background: hsl(var(--primary-light)); color: hsl(var(--primary)); } +.context-icon.edit { background: hsl(var(--warning-light)); color: hsl(var(--warning)); } +.context-icon.prompt { background: hsl(var(--success-light)); color: hsl(var(--success)); } + +.context-info { + flex: 1; + min-width: 0; +} + +.context-file { + font-size: 0.8125rem; + font-weight: 500; + color: hsl(var(--foreground)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.context-time { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); + margin-top: 0.125rem; +} + +/* Context Stats Footer */ +.context-stats { + display: flex; + justify-content: space-around; + padding: 0.75rem; + border-top: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.15); +} + +.context-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.context-stat-value { + font-size: 1.125rem; + font-weight: 700; + color: hsl(var(--primary)); +} + +.context-stat-label { + font-size: 0.625rem; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; +} + +@media (max-width: 1400px) { + .memory-columns { + grid-template-columns: 260px 1fr 280px; + gap: 1rem; + } +} + +@media (max-width: 1024px) { + .memory-columns { + grid-template-columns: 1fr; + gap: 1rem; + } +} + +/* ======================================== + * Hotspot List (Left Column) + * ======================================== */ +.memory-hotspots { + display: flex; + flex-direction: column; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; + overflow: hidden; +} + +.hotspots-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.875rem 1rem; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.3); +} + +.hotspots-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; +} + +.hotspots-header h3 i { + color: hsl(var(--muted-foreground)); +} + +.hotspot-list { + flex: 1; + overflow-y: auto; + padding: 0.5rem; +} + +.hotspot-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem; + margin-bottom: 0.375rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.15s ease; +} + +.hotspot-item:last-child { + margin-bottom: 0; +} + +.hotspot-item:hover { + background: hsl(var(--hover)); + border-color: hsl(var(--primary) / 0.3); + box-shadow: 0 2px 8px hsl(var(--foreground) / 0.05); +} + +.hotspot-item-left { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; + min-width: 0; +} + +.hotspot-name { + font-size: 0.8125rem; + font-weight: 600; + color: hsl(var(--foreground)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hotspot-path { + font-size: 0.6875rem; + font-family: var(--font-mono); + color: hsl(var(--muted-foreground)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.hotspot-meta { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.625rem; + color: hsl(var(--muted-foreground)); + margin-top: 0.125rem; +} + +.hotspot-meta i { + width: 12px; +} + +/* Heat Badge */ +.heat-badge { + display: flex; + align-items: center; + justify-content: center; + min-width: 3rem; + padding: 0.375rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; + transition: transform 0.15s ease; +} + +.hotspot-item:hover .heat-badge { + transform: scale(1.05); +} + +.heat-high { + background: linear-gradient(135deg, hsl(0 84% 60% / 0.2), hsl(0 84% 60% / 0.12)); + color: hsl(0 84% 45%); + border: 1px solid hsl(0 84% 60% / 0.3); +} + +.heat-medium { + background: linear-gradient(135deg, hsl(38 92% 50% / 0.2), hsl(38 92% 50% / 0.12)); + color: hsl(38 92% 35%); + border: 1px solid hsl(38 92% 50% / 0.3); +} + +.heat-low { + background: linear-gradient(135deg, hsl(142 76% 36% / 0.2), hsl(142 76% 36% / 0.12)); + color: hsl(142 76% 30%); + border: 1px solid hsl(142 76% 36% / 0.3); +} + +/* ======================================== + * Memory Graph (Center Column - D3 SVG) + * ======================================== */ +.memory-graph-container { + display: flex; + flex-direction: column; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; + overflow: hidden; +} + +.graph-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.875rem 1rem; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.3); +} + +.graph-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; +} + +.graph-header h3 i { + color: hsl(var(--muted-foreground)); +} + +.graph-controls { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.memory-graph { + flex: 1; + position: relative; + background: hsl(var(--background)); + min-height: 400px; +} + +/* D3 Graph Elements */ +.graph-node { + cursor: pointer; + transition: all 0.2s ease; +} + +.graph-node:hover { + filter: brightness(1.1); +} + +.graph-node-file circle { + fill: hsl(var(--primary)); + stroke: hsl(var(--primary)); + stroke-width: 2; +} + +.graph-node-file.hot circle { + fill: hsl(0 84% 60%); + stroke: hsl(0 84% 60%); + animation: nodePulse 2s infinite; +} + +.graph-node-file.warm circle { + fill: hsl(38 92% 50%); + stroke: hsl(38 92% 50%); +} + +.graph-node-file.cool circle { + fill: hsl(142 76% 36%); + stroke: hsl(142 76% 36%); +} + +.graph-node-module circle { + fill: hsl(var(--muted)); + stroke: hsl(var(--muted-foreground)); + stroke-width: 2; + stroke-dasharray: 4 2; +} + +.graph-node text { + font-family: var(--font-sans); + font-size: 11px; + fill: hsl(var(--foreground)); + pointer-events: none; + user-select: none; +} + +.graph-link { + stroke: hsl(var(--border)); + stroke-width: 1.5; + fill: none; + opacity: 0.6; + transition: all 0.2s ease; +} + +.graph-link.strong { + stroke: hsl(var(--primary)); + stroke-width: 2; + opacity: 0.8; +} + +.graph-link:hover { + stroke: hsl(var(--primary)); + opacity: 1; +} + +/* Graph Tooltip */ +.graph-tooltip { + position: absolute; + padding: 0.5rem 0.75rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + font-size: 0.75rem; + color: hsl(var(--foreground)); + pointer-events: none; + opacity: 0; + transition: opacity 0.2s ease; + box-shadow: 0 4px 12px hsl(var(--foreground) / 0.15); + z-index: 100; + max-width: 200px; +} + +.graph-tooltip.visible { + opacity: 1; +} + +.tooltip-title { + font-weight: 600; + margin-bottom: 0.25rem; +} + +.tooltip-meta { + display: flex; + flex-direction: column; + gap: 0.125rem; + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); +} + +/* Node Pulse Animation */ +@keyframes nodePulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.1); + } +} + +/* ======================================== + * Context Timeline (Right Column) + * ======================================== */ +.memory-context { + display: flex; + flex-direction: column; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; + overflow: hidden; +} + +.context-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.875rem 1rem; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.3); +} + +.context-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.875rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; +} + +.context-header h3 i { + color: hsl(var(--muted-foreground)); +} + +.context-timeline { + flex: 1; + overflow-y: auto; + padding: 0.75rem; +} + +.timeline-item { + position: relative; + padding-left: 1.5rem; + padding-bottom: 1rem; + border-left: 2px solid hsl(var(--border)); +} + +.timeline-item:last-child { + border-left-color: transparent; + padding-bottom: 0; +} + +.timeline-item::before { + content: ''; + position: absolute; + left: -6px; + top: 0; + width: 10px; + height: 10px; + border-radius: 50%; + background: hsl(var(--primary)); + border: 2px solid hsl(var(--card)); +} + +.timeline-item.recent::before { + background: hsl(0 84% 60%); + box-shadow: 0 0 8px hsl(0 84% 60% / 0.5); + animation: timelinePulse 2s infinite; +} + +.timeline-timestamp { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); + margin-bottom: 0.25rem; +} + +.timeline-action { + font-size: 0.8125rem; + color: hsl(var(--foreground)); + margin-bottom: 0.375rem; + line-height: 1.4; +} + +.timeline-detail { + font-size: 0.75rem; + font-family: var(--font-mono); + color: hsl(var(--muted-foreground)); + background: hsl(var(--muted) / 0.3); + padding: 0.375rem 0.5rem; + border-radius: 0.25rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@keyframes timelinePulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +/* ======================================== + * Prompt History View + * ======================================== */ +.prompt-history-view { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; +} + +.prompt-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.prompt-item { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + overflow: hidden; + transition: all 0.2s ease; +} + +.prompt-item:hover { + border-color: hsl(var(--primary) / 0.3); + box-shadow: 0 2px 8px hsl(var(--foreground) / 0.05); +} + +.prompt-item-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: hsl(var(--muted) / 0.2); + cursor: pointer; +} + +.prompt-item-info { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; +} + +.prompt-item-time { + font-size: 0.75rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.prompt-item-meta { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); +} + +.prompt-preview { + padding: 0.75rem 1rem; + font-size: 0.8125rem; + color: hsl(var(--foreground)); + line-height: 1.5; + max-height: 4.5em; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.prompt-full { + display: none; + padding: 0.75rem 1rem; + font-size: 0.8125rem; + font-family: var(--font-mono); + color: hsl(var(--foreground)); + background: hsl(var(--muted) / 0.3); + border-top: 1px solid hsl(var(--border)); + max-height: 300px; + overflow-y: auto; + white-space: pre-wrap; + word-wrap: break-word; + line-height: 1.6; +} + +.prompt-item-expanded .prompt-preview { + display: none; +} + +.prompt-item-expanded .prompt-full { + display: block; +} + +/* ======================================== + * Insights Panel + * ======================================== */ +.insights-panel { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; + padding: 1rem; +} + +.insights-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 1rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid hsl(var(--border)); +} + +.insights-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9375rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0; +} + +.insights-header h3 i { + color: hsl(var(--primary)); +} + +.insights-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 0.75rem; +} + +.pattern-card { + padding: 0.875rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-left: 3px solid hsl(var(--primary)); + border-radius: 0.5rem; + transition: all 0.15s ease; +} + +.pattern-card:hover { + background: hsl(var(--hover)); + border-left-color: hsl(var(--primary)); + box-shadow: 0 2px 8px hsl(var(--foreground) / 0.05); +} + +.pattern-type { + font-size: 0.6875rem; + font-weight: 600; + color: hsl(var(--primary)); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 0.5rem; +} + +.pattern-suggestion { + font-size: 0.8125rem; + color: hsl(var(--foreground)); + margin-bottom: 0.625rem; + line-height: 1.5; +} + +.suggestion-example { + font-size: 0.75rem; + font-family: var(--font-mono); + color: hsl(var(--muted-foreground)); + background: hsl(var(--muted) / 0.5); + padding: 0.5rem; + border-radius: 0.25rem; + overflow-x: auto; + white-space: pre; +} + +/* ======================================== + * Stats Summary + * ======================================== */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.stat-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + text-align: center; + transition: all 0.2s ease; +} + +.stat-card:hover { + border-color: hsl(var(--primary) / 0.3); + box-shadow: 0 2px 8px hsl(var(--foreground) / 0.05); +} + +.stat-value { + font-size: 1.875rem; + font-weight: 700; + color: hsl(var(--primary)); + margin-bottom: 0.25rem; + line-height: 1; +} + +.stat-label { + font-size: 0.75rem; + font-weight: 500; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.stat-trend { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); + margin-top: 0.375rem; +} + +.stat-trend.up { + color: hsl(142 76% 36%); +} + +.stat-trend.down { + color: hsl(0 84% 60%); +} + +.stat-trend i { + font-size: 0.75rem; +} + +/* ======================================== + * Animations + * ======================================== */ + +/* Fade in for new items */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.timeline-item, +.hotspot-item, +.pattern-card { + animation: fadeIn 0.3s ease; +} + +/* ======================================== + * Empty States + * ======================================== */ +.memory-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 2rem; + text-align: center; + color: hsl(var(--muted-foreground)); +} + +.memory-empty-state i { + font-size: 3rem; + opacity: 0.3; + margin-bottom: 1rem; +} + +.memory-empty-state h3 { + font-size: 1rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin-bottom: 0.5rem; +} + +.memory-empty-state p { + font-size: 0.875rem; + max-width: 300px; +} + +/* ======================================== + * Responsive Adjustments + * ======================================== */ +@media (max-width: 768px) { + .memory-view { + grid-template-columns: 1fr; + } + + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .insights-grid { + grid-template-columns: 1fr; + } +} diff --git a/ccw/src/templates/dashboard-css/11-prompt-history.css b/ccw/src/templates/dashboard-css/11-prompt-history.css new file mode 100644 index 00000000..f0994c24 --- /dev/null +++ b/ccw/src/templates/dashboard-css/11-prompt-history.css @@ -0,0 +1,667 @@ +/* ======================================== + * Prompt History View + * ======================================== */ +.prompt-history-view { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + padding: 1.5rem; +} + +.prompt-history-header { + margin-bottom: 1.5rem; +} + +/* Stats Grid */ +.prompt-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.prompt-stat-card { + display: flex; + align-items: center; + gap: 0.875rem; + padding: 1rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + transition: all 0.2s; +} + +.prompt-stat-card:hover { + border-color: hsl(var(--primary) / 0.3); + box-shadow: 0 2px 8px hsl(var(--primary) / 0.1); +} + +.prompt-stat-card .stat-icon { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + background: hsl(var(--primary) / 0.1); + border-radius: 0.375rem; + color: hsl(var(--primary)); +} + +.prompt-stat-card .stat-content { + flex: 1; +} + +.prompt-stat-card .stat-value { + font-size: 1.25rem; + font-weight: 600; + color: hsl(var(--foreground)); + line-height: 1.2; +} + +.prompt-stat-card .stat-label { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + margin-top: 0.125rem; +} + +/* Quality Badges */ +.quality-badge { + padding: 0.125rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; + font-weight: 600; +} + +.quality-badge.high { + background: hsl(142, 71%, 90%); + color: hsl(142, 71%, 35%); +} + +.quality-badge.medium { + background: hsl(48, 96%, 89%); + color: hsl(48, 96%, 35%); +} + +.quality-badge.low { + background: hsl(0, 84%, 92%); + color: hsl(0, 84%, 40%); +} + +/* Content Layout */ +.prompt-history-content { + display: grid; + grid-template-columns: 1fr 400px; + gap: 1.5rem; + align-items: start; +} + +@media (max-width: 1200px) { + .prompt-history-content { + grid-template-columns: 1fr; + } +} + +/* Timeline Panel */ +.prompt-history-left { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; + overflow: hidden; +} + +.prompt-timeline-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.3); + gap: 1rem; +} + +.prompt-timeline-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9375rem; + font-weight: 600; + margin: 0; + flex-shrink: 0; +} + +.prompt-timeline-filters { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; + justify-content: flex-end; +} + +.prompt-search-wrapper { + position: relative; + flex: 1; + min-width: 150px; + max-width: 280px; +} + +.prompt-search-wrapper i { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: hsl(var(--muted-foreground)); + pointer-events: none; +} + +.prompt-search-input { + width: 100%; + padding: 0.5rem 0.75rem 0.5rem 2.25rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + font-size: 0.875rem; + color: hsl(var(--foreground)); + outline: none; + transition: all 0.2s; +} + +.prompt-search-input:focus { + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.1); +} + +.prompt-filter-select { + padding: 0.5rem 0.75rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + font-size: 0.875rem; + color: hsl(var(--foreground)); + cursor: pointer; + outline: none; +} + +/* Timeline List */ +.prompt-timeline-list { + max-height: 600px; + overflow-y: auto; + padding: 0.5rem; +} + +/* Session Groups */ +.prompt-session-group { + margin-bottom: 1rem; +} + +.prompt-session-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + background: hsl(var(--muted) / 0.2); + border-radius: 0.375rem; + margin-bottom: 0.5rem; +} + +.prompt-session-id { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + font-weight: 600; + color: hsl(var(--foreground)); + font-family: monospace; +} + +.prompt-session-date { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.prompt-session-count { + margin-left: auto; + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.prompt-session-items { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +/* Prompt Items */ +.prompt-item { + padding: 0.875rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s; +} + +.prompt-item:hover { + border-color: hsl(var(--primary) / 0.3); + box-shadow: 0 2px 8px hsl(var(--primary) / 0.1); +} + +.prompt-item-expanded { + border-color: hsl(var(--primary)); +} + +.prompt-item-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.prompt-intent-tag { + padding: 0.125rem 0.5rem; + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + text-transform: capitalize; +} + +.prompt-quality-badge { + display: flex; + align-items: center; + gap: 0.25rem; + padding: 0.125rem 0.5rem; + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 600; +} + +.prompt-quality-badge.quality-high { + background: hsl(142, 71%, 90%); + color: hsl(142, 71%, 35%); +} + +.prompt-quality-badge.quality-medium { + background: hsl(48, 96%, 89%); + color: hsl(48, 96%, 35%); +} + +.prompt-quality-badge.quality-low { + background: hsl(0, 84%, 92%); + color: hsl(0, 84%, 40%); +} + +.prompt-time { + margin-left: auto; + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.prompt-item-preview { + font-size: 0.875rem; + color: hsl(var(--foreground)); + line-height: 1.5; +} + +.prompt-item-full { + margin-top: 0.875rem; + padding-top: 0.875rem; + border-top: 1px solid hsl(var(--border)); +} + +.prompt-full-text { + padding: 0.75rem; + background: hsl(var(--muted) / 0.3); + border-radius: 0.375rem; + font-size: 0.875rem; + color: hsl(var(--foreground)); + line-height: 1.6; + white-space: pre-wrap; + font-family: monospace; + margin-bottom: 0.75rem; +} + +.prompt-item-meta { + display: flex; + align-items: center; + gap: 1rem; + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + margin-bottom: 0.75rem; +} + +.prompt-item-meta span { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.prompt-item-actions-full { + display: flex; + gap: 0.5rem; +} + +/* Insights Panel */ +.prompt-history-right { + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.75rem; + overflow: hidden; +} + +.insights-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + border-bottom: 1px solid hsl(var(--border)); + background: hsl(var(--muted) / 0.3); +} + +.insights-panel-header h3 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.9375rem; + font-weight: 600; + margin: 0; +} + +.insights-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.insights-tool-select { + padding: 0.375rem 0.625rem; + font-size: 0.75rem; + color: hsl(var(--foreground)); + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.375rem; + cursor: pointer; + outline: none; +} + +.insights-tool-select:focus { + border-color: hsl(var(--primary)); +} + +.insights-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1.5rem; + text-align: center; +} + +.insights-loading .loading-spinner { + color: hsl(var(--primary)); + margin-bottom: 1rem; +} + +.insights-loading p { + margin: 0; + color: hsl(var(--muted-foreground)); + font-size: 0.875rem; +} + +.insights-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1.5rem; + text-align: center; +} + +.insights-empty-state i { + color: hsl(var(--muted-foreground)); + margin-bottom: 1rem; +} + +.insights-empty-state p { + margin: 0.25rem 0; + color: hsl(var(--muted-foreground)); + font-size: 0.875rem; +} + +.insights-hint { + font-size: 0.75rem !important; +} + +.insights-list { + max-height: 600px; + overflow-y: auto; + padding: 0.5rem; +} + +.insights-section { + margin-bottom: 1.5rem; +} + +.insights-section:last-child { + margin-bottom: 0; +} + +.insights-section h4 { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0 0 0.75rem 0.5rem; +} + +/* Pattern Cards */ +.pattern-card { + padding: 0.875rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-left-width: 3px; + border-radius: 0.5rem; + margin-bottom: 0.75rem; +} + +.pattern-card.pattern-high { + border-left-color: hsl(0, 84%, 60%); + background: hsl(0, 84%, 97%); +} + +.pattern-card.pattern-medium { + border-left-color: hsl(48, 96%, 53%); + background: hsl(48, 96%, 95%); +} + +.pattern-card.pattern-low { + border-left-color: hsl(142, 71%, 45%); + background: hsl(142, 71%, 96%); +} + +.pattern-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.pattern-type { + font-size: 0.8125rem; + font-weight: 600; + text-transform: capitalize; + color: hsl(var(--foreground)); +} + +.pattern-count { + margin-left: auto; + padding: 0.125rem 0.5rem; + background: hsl(var(--muted)); + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.pattern-description { + font-size: 0.8125rem; + color: hsl(var(--foreground)); + line-height: 1.5; + margin-bottom: 0.5rem; +} + +.pattern-suggestion { + display: flex; + align-items: start; + gap: 0.375rem; + padding: 0.5rem; + background: hsl(var(--background)); + border-radius: 0.375rem; + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + line-height: 1.5; +} + +.pattern-suggestion i { + margin-top: 0.125rem; + flex-shrink: 0; +} + +/* Suggestion Cards */ +.suggestion-card { + padding: 0.875rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + margin-bottom: 0.75rem; +} + +.suggestion-title { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin-bottom: 0.5rem; +} + +.suggestion-description { + font-size: 0.8125rem; + color: hsl(var(--muted-foreground)); + line-height: 1.5; + margin-bottom: 0.5rem; +} + +.suggestion-example { + padding: 0.75rem; + background: hsl(var(--muted) / 0.3); + border-radius: 0.375rem; + margin-top: 0.75rem; +} + +.suggestion-example-label { + font-size: 0.75rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin-bottom: 0.375rem; +} + +.suggestion-example code { + display: block; + font-size: 0.75rem; + color: hsl(var(--foreground)); + line-height: 1.5; + font-family: monospace; + white-space: pre-wrap; +} + +/* Similar Prompt Cards */ +.similar-prompt-card { + padding: 0.875rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; + margin-bottom: 0.75rem; + cursor: pointer; + transition: all 0.2s; +} + +.similar-prompt-card:hover { + border-color: hsl(var(--primary) / 0.3); + box-shadow: 0 2px 8px hsl(var(--primary) / 0.1); +} + +.similar-prompt-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.similar-prompt-similarity { + padding: 0.125rem 0.5rem; + background: hsl(var(--primary) / 0.1); + color: hsl(var(--primary)); + border-radius: 0.25rem; + font-size: 0.75rem; + font-weight: 600; +} + +.similar-prompt-intent { + padding: 0.125rem 0.5rem; + background: hsl(var(--muted)); + color: hsl(var(--foreground)); + border-radius: 0.25rem; + font-size: 0.75rem; + text-transform: capitalize; +} + +.similar-prompt-preview { + font-size: 0.8125rem; + color: hsl(var(--foreground)); + line-height: 1.5; + margin-bottom: 0.5rem; +} + +.similar-prompt-meta { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.similar-prompt-quality { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +/* Empty State */ +.prompt-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem 1.5rem; + text-align: center; +} + +.prompt-empty-state i { + color: hsl(var(--muted-foreground)); + margin-bottom: 1rem; +} + +.prompt-empty-state h3 { + font-size: 1rem; + font-weight: 600; + color: hsl(var(--foreground)); + margin: 0 0 0.5rem 0; +} + +.prompt-empty-state p { + margin: 0; + color: hsl(var(--muted-foreground)); + font-size: 0.875rem; +} diff --git a/ccw/src/templates/dashboard-js/components/cli-history.js b/ccw/src/templates/dashboard-js/components/cli-history.js index 86a9c34d..ee8219c9 100644 --- a/ccw/src/templates/dashboard-js/components/cli-history.js +++ b/ccw/src/templates/dashboard-js/components/cli-history.js @@ -1,20 +1,24 @@ // CLI History Component // Displays execution history with filtering, search, and delete +// Supports native session linking and full conversation parsing // ========== CLI History State ========== let cliExecutionHistory = []; let cliHistoryFilter = null; // Filter by tool let cliHistorySearch = ''; // Search query let cliHistoryLimit = 50; +let showNativeOnly = false; // Filter to show only native-linked executions // ========== Data Loading ========== async function loadCliHistory(options = {}) { try { const { limit = cliHistoryLimit, tool = cliHistoryFilter, status = null } = options; - let url = `/api/cli/history?path=${encodeURIComponent(projectPath)}&limit=${limit}`; + // Use history-native endpoint to get native session info + let url = `/api/cli/history-native?path=${encodeURIComponent(projectPath)}&limit=${limit}`; if (tool) url += `&tool=${tool}`; if (status) url += `&status=${status}`; + if (cliHistorySearch) url += `&search=${encodeURIComponent(cliHistorySearch)}`; const response = await fetch(url); if (!response.ok) throw new Error('Failed to load CLI history'); @@ -28,6 +32,32 @@ async function loadCliHistory(options = {}) { } } +// Load native session content for a specific execution +async function loadNativeSessionContent(executionId) { + try { + const url = `/api/cli/native-session?path=${encodeURIComponent(projectPath)}&id=${encodeURIComponent(executionId)}`; + const response = await fetch(url); + if (!response.ok) return null; + return await response.json(); + } catch (err) { + console.error('Failed to load native session:', err); + return null; + } +} + +// Load enriched conversation (CCW + Native merged) +async function loadEnrichedConversation(executionId) { + try { + const url = `/api/cli/enriched?path=${encodeURIComponent(projectPath)}&id=${encodeURIComponent(executionId)}`; + const response = await fetch(url); + if (!response.ok) return null; + return await response.json(); + } catch (err) { + console.error('Failed to load enriched conversation:', err); + return null; + } +} + async function loadExecutionDetail(executionId, sourceDir) { try { // If sourceDir provided, use it to build the correct path @@ -95,22 +125,39 @@ function renderCliHistory() { ? `${exec.turn_count} turns` : ''; + // Native session indicator + const hasNative = exec.hasNativeSession || exec.nativeSessionId; + const nativeBadge = hasNative + ? ` + + ` + : ''; + return ` -
+
- ${exec.tool} - ${turnBadge} - ${timeAgo} - + ${exec.tool.toUpperCase()} + ${exec.mode || 'analysis'} + + ${exec.status} + + ${nativeBadge}
${escapeHtml(exec.prompt_preview)}
- ${duration} - ${exec.mode || 'analysis'} + ${timeAgo} + ${duration} + ${exec.id.split('-')[0]} + ${turnBadge}
+ ${hasNative ? ` + + ` : ''} @@ -588,6 +635,188 @@ async function copyConcatenatedPrompt(executionId) { } } +// ========== Native Session Detail ========== + +/** + * Show native session detail modal with full conversation content + */ +async function showNativeSessionDetail(executionId) { + // Load native session content + const nativeSession = await loadNativeSessionContent(executionId); + + if (!nativeSession) { + showRefreshToast('Native session not found', 'error'); + return; + } + + // Build turns HTML from native session + const turnsHtml = nativeSession.turns && nativeSession.turns.length > 0 + ? nativeSession.turns.map((turn, idx) => { + const isLast = idx === nativeSession.turns.length - 1; + const roleIcon = turn.role === 'user' ? 'user' : 'bot'; + const roleClass = turn.role === 'user' ? 'user' : 'assistant'; + + // Token info + const tokenInfo = turn.tokens + ? ` + + ${turn.tokens.total || 0} tokens + (in: ${turn.tokens.input || 0}, out: ${turn.tokens.output || 0}${turn.tokens.cached ? `, cached: ${turn.tokens.cached}` : ''}) + ` + : ''; + + // Thoughts section + const thoughtsHtml = turn.thoughts && turn.thoughts.length > 0 + ? `
+
Thoughts
+
    + ${turn.thoughts.map(t => `
  • ${escapeHtml(t)}
  • `).join('')} +
+
` + : ''; + + // Tool calls section + const toolCallsHtml = turn.toolCalls && turn.toolCalls.length > 0 + ? `
+
Tool Calls (${turn.toolCalls.length})
+
+ ${turn.toolCalls.map(tc => ` +
+ ${escapeHtml(tc.name)} + ${tc.output ? `
${escapeHtml(tc.output.substring(0, 500))}${tc.output.length > 500 ? '...' : ''}
` : ''} +
+ `).join('')} +
+
` + : ''; + + return ` +
+
+ + + ${turn.role === 'user' ? 'User' : 'Assistant'} + + Turn ${turn.turnNumber} + ${tokenInfo} + ${isLast ? 'Latest' : ''} +
+
+
${escapeHtml(turn.content)}
+
+ ${thoughtsHtml} + ${toolCallsHtml} +
+ `; + }).join('') + : '

No conversation turns found

'; + + // Total tokens summary + const totalTokensHtml = nativeSession.totalTokens + ? `
+ + Total Tokens: + ${nativeSession.totalTokens.total || 0} + (Input: ${nativeSession.totalTokens.input || 0}, + Output: ${nativeSession.totalTokens.output || 0} + ${nativeSession.totalTokens.cached ? `, Cached: ${nativeSession.totalTokens.cached}` : ''}) +
` + : ''; + + const modalContent = ` +
+
+
+ ${nativeSession.tool.toUpperCase()} + ${nativeSession.model ? ` ${nativeSession.model}` : ''} + ${nativeSession.sessionId} +
+
+ ${new Date(nativeSession.startTime).toLocaleString()} + ${nativeSession.workingDir ? ` ${nativeSession.workingDir}` : ''} + ${nativeSession.projectHash ? ` ${nativeSession.projectHash.substring(0, 12)}...` : ''} +
+
+ ${totalTokensHtml} +
+ ${turnsHtml} +
+
+ + + +
+
+ `; + + // Store for export + window._currentNativeSession = nativeSession; + + showModal('Native Session Detail', modalContent, 'modal-lg'); +} + +/** + * Copy native session ID to clipboard + */ +async function copyNativeSessionId(sessionId) { + if (navigator.clipboard) { + try { + await navigator.clipboard.writeText(sessionId); + showRefreshToast('Session ID copied', 'success'); + } catch (err) { + showRefreshToast('Failed to copy', 'error'); + } + } +} + +/** + * Copy native session file path + */ +async function copyNativeSessionPath(executionId) { + // Find execution in history + const exec = cliExecutionHistory.find(e => e.id === executionId); + if (exec && exec.nativeSessionPath) { + if (navigator.clipboard) { + try { + await navigator.clipboard.writeText(exec.nativeSessionPath); + showRefreshToast('File path copied', 'success'); + } catch (err) { + showRefreshToast('Failed to copy', 'error'); + } + } + } else { + showRefreshToast('Path not available', 'error'); + } +} + +/** + * Export native session as JSON file + */ +function exportNativeSession(executionId) { + const session = window._currentNativeSession; + if (!session) { + showRefreshToast('No session data', 'error'); + return; + } + + const blob = new Blob([JSON.stringify(session, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `native-session-${session.sessionId}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + showRefreshToast('Session exported', 'success'); +} + // ========== Helpers ========== function formatDuration(ms) { if (ms >= 60000) { diff --git a/ccw/src/templates/dashboard-js/components/cli-status.js b/ccw/src/templates/dashboard-js/components/cli-status.js index 28462637..bde3eebe 100644 --- a/ccw/src/templates/dashboard-js/components/cli-status.js +++ b/ccw/src/templates/dashboard-js/components/cli-status.js @@ -12,6 +12,9 @@ let promptConcatFormat = localStorage.getItem('ccw-prompt-format') || 'plain'; / let smartContextEnabled = localStorage.getItem('ccw-smart-context') === 'true'; let smartContextMaxFiles = parseInt(localStorage.getItem('ccw-smart-context-max-files') || '10', 10); +// Native Resume settings +let nativeResumeEnabled = localStorage.getItem('ccw-native-resume') !== 'false'; // default true + // ========== Initialization ========== function initCliStatus() { // Load CLI status on init @@ -256,6 +259,19 @@ function renderCliStatus() {

Auto-analyze prompt and add relevant file paths

+
+ +
+ +
+

Use native tool resume (gemini -r, qwen --resume, codex resume)

+