feat: Implement resume strategy engine and session content parser

- Added `resume-strategy.ts` to determine optimal resume approaches including native, prompt concatenation, and hybrid modes.
- Introduced `determineResumeStrategy` function to evaluate various resume scenarios.
- Created utility functions for building context prefixes and formatting outputs in plain, YAML, and JSON formats.
- Added `session-content-parser.ts` to parse native CLI tool session files supporting Gemini/Qwen JSON and Codex JSONL formats.
- Implemented parsing logic for different session formats, including error handling for invalid lines.
- Provided functions to format conversations and extract user-assistant pairs from parsed sessions.
This commit is contained in:
catlog22
2025-12-13 20:29:19 +08:00
parent 32217f87fd
commit 52935d4b8e
26 changed files with 9387 additions and 86 deletions

3
.gitignore vendored
View File

@@ -25,4 +25,5 @@ COMMAND_FLOW_STANDARD.md
COMMAND_TEMPLATE_EXECUTOR.md
COMMAND_TEMPLATE_ORCHESTRATOR.md
*.pyc
.codexlens/
.codexlens/
settings.json

View File

@@ -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 <n>', 'History limit')
.option('--status <status>', 'Filter by status')
.option('--resume [id]', 'Resume previous session (empty=last, or execution ID)')
.option('--category <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 <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 <type>', 'Entity type: file, module, topic')
.option('--action <action>', 'Action: read, write, mention')
.option('--value <value>', 'Entity value (file path, etc.)')
.option('--session <session>', 'Session ID')
.option('--stdin', 'Read input from stdin (for Claude Code hooks)')
.option('--source <source>', 'Import source: history, sessions, all', 'all')
.option('--project <project>', 'Project name filter')
.option('--limit <n>', 'Number of results', '20')
.option('--sort <field>', 'Sort by: heat, reads, writes', 'heat')
.option('--json', 'Output as JSON')
.option('--context <text>', 'Current task context')
.option('--older-than <age>', 'Age threshold for pruning', '30d')
.option('--dry-run', 'Preview without deleting')
.action((subcommand, args, options) => memoryCommand(subcommand, args, options));
program.parse(argv);
}

View File

@@ -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

705
ccw/src/commands/memory.ts Normal file
View File

@@ -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<string> {
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<void> {
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<void> {
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<void> {
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<void> {
if (!query) {
console.error(chalk.red('Error: Search query is required'));
console.error(chalk.gray('Usage: ccw memory search "<query>"'));
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<void> {
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<void> {
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<void> {
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 <query> 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 <type> Entity type: file, module, topic'));
console.log(chalk.gray(' --action <action> Action: read, write, mention'));
console.log(chalk.gray(' --value <value> Entity value (file path, etc.)'));
console.log(chalk.gray(' --session <id> Session ID (optional)'));
console.log();
console.log(' Import Options:');
console.log(chalk.gray(' --source <source> Source: history, sessions, all (default: all)'));
console.log(chalk.gray(' --project <name> Project name filter (optional)'));
console.log();
console.log(' Stats Options:');
console.log(chalk.gray(' --type <type> Filter: file, module, topic (optional)'));
console.log(chalk.gray(' --limit <n> Number of results (default: 20)'));
console.log(chalk.gray(' --sort <field> 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 <n> Number of results (default: 20)'));
console.log(chalk.gray(' --json Output as JSON'));
console.log();
console.log(' Suggest Options:');
console.log(chalk.gray(' --context <text> Current task context (optional)'));
console.log(chalk.gray(' --limit <n> 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> 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();
}
}

View File

@@ -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<string, { imported: number; lastUpdate: string }>;
}
/**
* 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<ImportResult> {
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<ImportResult> {
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<ImportResult> {
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<string, string>();
// 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();
}
}

View File

@@ -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<string, MemoryStore>();
/**
* 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;

View File

@@ -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<PostResult>;
// 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<http.Ser
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;
const recursive = url.searchParams.get('recursive') !== 'false'; // Default true
// Use async version to ensure SQLite is initialized
getExecutionHistoryAsync(projectPath, { limit, tool, status, search, recursive })
getExecutionHistoryAsync(projectPath, { limit, tool, status, category, search, recursive })
.then(history => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(history));
@@ -718,6 +724,100 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
return;
}
// API: Get Native Session Content (full conversation from native session file)
if (pathname === '/api/cli/native-session') {
const projectPath = url.searchParams.get('path') || initialPath;
const executionId = url.searchParams.get('id');
const format = url.searchParams.get('format') || 'json'; // json, text, pairs
if (!executionId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Execution ID is required' }));
return;
}
try {
let result;
if (format === 'text') {
// Get formatted text representation
result = await getFormattedNativeConversation(projectPath, executionId, {
includeThoughts: url.searchParams.get('thoughts') === 'true',
includeToolCalls: url.searchParams.get('tools') === 'true',
includeTokens: url.searchParams.get('tokens') === 'true'
});
} else if (format === 'pairs') {
// Get simple prompt/response pairs
const enriched = await getEnrichedConversation(projectPath, executionId);
result = enriched?.merged || null;
} else {
// Get full parsed session data
result = await getNativeSessionContent(projectPath, executionId);
}
if (!result) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Native session not found' }));
return;
}
res.writeHead(200, { 'Content-Type': format === 'text' ? 'text/plain' : 'application/json' });
res.end(format === 'text' ? result : JSON.stringify(result));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return;
}
// API: Get Enriched Conversation (CCW + Native merged)
if (pathname === '/api/cli/enriched') {
const projectPath = url.searchParams.get('path') || initialPath;
const executionId = url.searchParams.get('id');
if (!executionId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Execution ID is required' }));
return;
}
getEnrichedConversation(projectPath, executionId)
.then(result => {
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<http.Ser
return;
}
// API: Memory Module - Get hotspot statistics
if (pathname === '/api/memory/stats') {
const projectPath = url.searchParams.get('path') || initialPath;
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
const type = url.searchParams.get('type') || null;
const sort = url.searchParams.get('sort') || 'heat';
try {
const memoryStore = getMemoryStore(projectPath);
let hotEntities = memoryStore.getHotEntities(limit);
// Filter by type if specified
if (type) {
hotEntities = hotEntities.filter(e => 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

View File

@@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -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() {
? `<span class="cli-turn-badge">${exec.turn_count} turns</span>`
: '';
// Native session indicator
const hasNative = exec.hasNativeSession || exec.nativeSessionId;
const nativeBadge = hasNative
? `<span class="cli-native-badge" title="Native session: ${exec.nativeSessionId}">
<i data-lucide="file-json" class="w-3 h-3"></i>
</span>`
: '';
return `
<div class="cli-history-item">
<div class="cli-history-item ${hasNative ? 'has-native' : ''}">
<div class="cli-history-item-content" onclick="showExecutionDetail('${exec.id}')">
<div class="cli-history-item-header">
<span class="cli-tool-tag cli-tool-${exec.tool}">${exec.tool}</span>
${turnBadge}
<span class="cli-history-time">${timeAgo}</span>
<i data-lucide="${statusIcon}" class="w-3.5 h-3.5 ${statusClass}"></i>
<span class="cli-tool-tag cli-tool-${exec.tool}">${exec.tool.toUpperCase()}</span>
<span class="cli-mode-tag">${exec.mode || 'analysis'}</span>
<span class="cli-status-badge ${statusClass}">
<i data-lucide="${statusIcon}" class="w-3 h-3"></i> ${exec.status}
</span>
${nativeBadge}
</div>
<div class="cli-history-prompt">${escapeHtml(exec.prompt_preview)}</div>
<div class="cli-history-meta">
<span>${duration}</span>
<span>${exec.mode || 'analysis'}</span>
<span><i data-lucide="clock" class="w-3 h-3"></i> ${timeAgo}</span>
<span><i data-lucide="timer" class="w-3 h-3"></i> ${duration}</span>
<span><i data-lucide="hash" class="w-3 h-3"></i> ${exec.id.split('-')[0]}</span>
${turnBadge}
</div>
</div>
<div class="cli-history-actions">
${hasNative ? `
<button class="btn-icon" onclick="event.stopPropagation(); showNativeSessionDetail('${exec.id}')" title="View Native Session">
<i data-lucide="file-json" class="w-3.5 h-3.5"></i>
</button>
` : ''}
<button class="btn-icon" onclick="event.stopPropagation(); showExecutionDetail('${exec.id}')" title="View Details">
<i data-lucide="eye" class="w-3.5 h-3.5"></i>
</button>
@@ -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
? `<span class="native-turn-tokens">
<i data-lucide="coins" class="w-3 h-3"></i>
${turn.tokens.total || 0} tokens
(in: ${turn.tokens.input || 0}, out: ${turn.tokens.output || 0}${turn.tokens.cached ? `, cached: ${turn.tokens.cached}` : ''})
</span>`
: '';
// Thoughts section
const thoughtsHtml = turn.thoughts && turn.thoughts.length > 0
? `<div class="native-thoughts-section">
<h5><i data-lucide="brain" class="w-3 h-3"></i> Thoughts</h5>
<ul class="native-thoughts-list">
${turn.thoughts.map(t => `<li>${escapeHtml(t)}</li>`).join('')}
</ul>
</div>`
: '';
// Tool calls section
const toolCallsHtml = turn.toolCalls && turn.toolCalls.length > 0
? `<div class="native-tools-section">
<h5><i data-lucide="wrench" class="w-3 h-3"></i> Tool Calls (${turn.toolCalls.length})</h5>
<div class="native-tools-list">
${turn.toolCalls.map(tc => `
<div class="native-tool-call">
<span class="native-tool-name">${escapeHtml(tc.name)}</span>
${tc.output ? `<pre class="native-tool-output">${escapeHtml(tc.output.substring(0, 500))}${tc.output.length > 500 ? '...' : ''}</pre>` : ''}
</div>
`).join('')}
</div>
</div>`
: '';
return `
<div class="native-turn ${roleClass} ${isLast ? 'latest' : ''}">
<div class="native-turn-header">
<span class="native-turn-role">
<i data-lucide="${roleIcon}" class="w-3.5 h-3.5"></i>
${turn.role === 'user' ? 'User' : 'Assistant'}
</span>
<span class="native-turn-number">Turn ${turn.turnNumber}</span>
${tokenInfo}
${isLast ? '<span class="native-turn-latest">Latest</span>' : ''}
</div>
<div class="native-turn-content">
<pre>${escapeHtml(turn.content)}</pre>
</div>
${thoughtsHtml}
${toolCallsHtml}
</div>
`;
}).join('')
: '<p class="text-muted-foreground">No conversation turns found</p>';
// Total tokens summary
const totalTokensHtml = nativeSession.totalTokens
? `<div class="native-tokens-summary">
<i data-lucide="bar-chart-3" class="w-4 h-4"></i>
<strong>Total Tokens:</strong>
${nativeSession.totalTokens.total || 0}
(Input: ${nativeSession.totalTokens.input || 0},
Output: ${nativeSession.totalTokens.output || 0}
${nativeSession.totalTokens.cached ? `, Cached: ${nativeSession.totalTokens.cached}` : ''})
</div>`
: '';
const modalContent = `
<div class="native-session-detail">
<div class="native-session-header">
<div class="native-session-info">
<span class="cli-tool-tag cli-tool-${nativeSession.tool}">${nativeSession.tool.toUpperCase()}</span>
${nativeSession.model ? `<span class="native-model"><i data-lucide="cpu" class="w-3 h-3"></i> ${nativeSession.model}</span>` : ''}
<span class="native-session-id"><i data-lucide="fingerprint" class="w-3 h-3"></i> ${nativeSession.sessionId}</span>
</div>
<div class="native-session-meta">
<span><i data-lucide="calendar" class="w-3 h-3"></i> ${new Date(nativeSession.startTime).toLocaleString()}</span>
${nativeSession.workingDir ? `<span><i data-lucide="folder" class="w-3 h-3"></i> ${nativeSession.workingDir}</span>` : ''}
${nativeSession.projectHash ? `<span><i data-lucide="hash" class="w-3 h-3"></i> ${nativeSession.projectHash.substring(0, 12)}...</span>` : ''}
</div>
</div>
${totalTokensHtml}
<div class="native-turns-container">
${turnsHtml}
</div>
<div class="native-session-actions">
<button class="btn btn-sm btn-outline" onclick="copyNativeSessionId('${nativeSession.sessionId}')">
<i data-lucide="copy" class="w-3.5 h-3.5"></i> Copy Session ID
</button>
<button class="btn btn-sm btn-outline" onclick="copyNativeSessionPath('${executionId}')">
<i data-lucide="file" class="w-3.5 h-3.5"></i> Copy File Path
</button>
<button class="btn btn-sm btn-outline" onclick="exportNativeSession('${executionId}')">
<i data-lucide="download" class="w-3.5 h-3.5"></i> Export JSON
</button>
</div>
</div>
`;
// 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) {

View File

@@ -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() {
</div>
<p class="cli-setting-desc">Auto-analyze prompt and add relevant file paths</p>
</div>
<div class="cli-setting-item">
<label class="cli-setting-label">
<i data-lucide="refresh-cw" class="w-3 h-3"></i>
Native Resume
</label>
<div class="cli-setting-control">
<label class="cli-toggle">
<input type="checkbox" ${nativeResumeEnabled ? 'checked' : ''} onchange="setNativeResumeEnabled(this.checked)">
<span class="cli-toggle-slider"></span>
</label>
</div>
<p class="cli-setting-desc">Use native tool resume (gemini -r, qwen --resume, codex resume)</p>
</div>
<div class="cli-setting-item ${!smartContextEnabled ? 'disabled' : ''}">
<label class="cli-setting-label">
<i data-lucide="files" class="w-3 h-3"></i>
@@ -326,6 +342,12 @@ function setSmartContextMaxFiles(max) {
showRefreshToast(`Smart Context max files set to ${max}`, 'success');
}
function setNativeResumeEnabled(enabled) {
nativeResumeEnabled = enabled;
localStorage.setItem('ccw-native-resume', enabled.toString());
showRefreshToast(`Native Resume ${enabled ? 'enabled' : 'disabled'}`, 'success');
}
async function refreshAllCliStatus() {
await Promise.all([loadCliToolStatus(), loadCodexLensStatus()]);
renderCliStatus();

View File

@@ -12,8 +12,8 @@ const HOOK_TEMPLATES = {
'ccw-notify': {
event: 'PostToolUse',
matcher: 'Write',
command: 'curl',
args: ['-s', '-X', 'POST', '-H', 'Content-Type: application/json', '-d', '{"type":"summary_written","filePath":"$CLAUDE_FILE_PATHS"}', 'http://localhost:3456/api/hook'],
command: 'bash',
args: ['-c', 'INPUT=$(cat); FILE_PATH=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -n "$FILE_PATH" ] && curl -s -X POST -H "Content-Type: application/json" -d "{\\"type\\":\\"file_written\\",\\"filePath\\":\\"$FILE_PATH\\"}" http://localhost:3456/api/hook || true'],
description: 'Notify CCW dashboard when files are written',
category: 'notification'
},
@@ -21,7 +21,7 @@ const HOOK_TEMPLATES = {
event: 'PostToolUse',
matcher: '',
command: 'bash',
args: ['-c', 'echo "[$(date)] Tool: $CLAUDE_TOOL_NAME, Files: $CLAUDE_FILE_PATHS" >> ~/.claude/tool-usage.log'],
args: ['-c', 'INPUT=$(cat); TOOL=$(echo "$INPUT" | jq -r ".tool_name // empty"); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); echo "[$(date)] Tool: $TOOL, File: $FILE" >> ~/.claude/tool-usage.log'],
description: 'Log all tool executions to a file',
category: 'logging'
},
@@ -29,7 +29,7 @@ const HOOK_TEMPLATES = {
event: 'PostToolUse',
matcher: 'Write',
command: 'bash',
args: ['-c', 'for f in $CLAUDE_FILE_PATHS; do if [[ "$f" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx eslint "$f" --fix 2>/dev/null || true; fi; done'],
args: ['-c', 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty"); if [[ "$FILE" =~ \\.(js|ts|jsx|tsx)$ ]]; then npx eslint "$FILE" --fix 2>/dev/null || true; fi'],
description: 'Run ESLint on JavaScript/TypeScript files after write',
category: 'quality'
},
@@ -37,7 +37,7 @@ const HOOK_TEMPLATES = {
event: 'PostToolUse',
matcher: 'Write',
command: 'bash',
args: ['-c', 'for f in $CLAUDE_FILE_PATHS; do git add "$f" 2>/dev/null || true; done'],
args: ['-c', 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // empty"); [ -n "$FILE" ] && git add "$FILE" 2>/dev/null || true'],
description: 'Automatically stage written files to git',
category: 'git'
},
@@ -45,7 +45,7 @@ const HOOK_TEMPLATES = {
event: 'PostToolUse',
matcher: 'Write|Edit',
command: 'bash',
args: ['-c', 'if [ -d ".codexlens" ] && [ -n "$CLAUDE_FILE_PATHS" ]; then python -m codexlens update $CLAUDE_FILE_PATHS --json 2>/dev/null || ~/.codexlens/venv/bin/python -m codexlens update $CLAUDE_FILE_PATHS --json 2>/dev/null || true; fi'],
args: ['-c', 'INPUT=$(cat); FILE=$(echo "$INPUT" | jq -r ".tool_input.file_path // .tool_input.path // empty"); [ -d ".codexlens" ] && [ -n "$FILE" ] && (python -m codexlens update "$FILE" --json 2>/dev/null || ~/.codexlens/venv/bin/python -m codexlens update "$FILE" --json 2>/dev/null || true)'],
description: 'Auto-update code index when files are written or edited',
category: 'indexing'
},
@@ -80,7 +80,7 @@ const HOOK_TEMPLATES = {
event: 'UserPromptSubmit',
matcher: '',
command: 'bash',
args: ['-c', 'ccw tool exec skill_context_loader \'{"keywords":"$SKILL_KEYWORDS","skills":"$SKILL_NAMES","prompt":"$CLAUDE_PROMPT"}\''],
args: ['-c', 'ccw tool exec skill_context_loader --stdin'],
description: 'Load SKILL context based on keyword matching in user prompt',
category: 'skill',
configurable: true,
@@ -93,10 +93,37 @@ const HOOK_TEMPLATES = {
event: 'UserPromptSubmit',
matcher: '',
command: 'bash',
args: ['-c', 'ccw tool exec skill_context_loader \'{"mode":"auto","prompt":"$CLAUDE_PROMPT"}\''],
args: ['-c', 'ccw tool exec skill_context_loader --stdin --mode auto'],
description: 'Auto-detect and load SKILL based on skill name in prompt',
category: 'skill',
configurable: false
},
'memory-file-read': {
event: 'PostToolUse',
matcher: 'Read|mcp__ccw-tools__read_file',
command: 'ccw',
args: ['memory', 'track', '--type', 'file', '--action', 'read', '--stdin'],
description: 'Track file reads to build context heatmap',
category: 'memory',
timeout: 5000
},
'memory-file-write': {
event: 'PostToolUse',
matcher: 'Write|Edit|mcp__ccw-tools__write_file|mcp__ccw-tools__edit_file',
command: 'ccw',
args: ['memory', 'track', '--type', 'file', '--action', 'write', '--stdin'],
description: 'Track file modifications to identify core modules',
category: 'memory',
timeout: 5000
},
'memory-prompt-track': {
event: 'UserPromptSubmit',
matcher: '',
command: 'ccw',
args: ['memory', 'track', '--type', 'topic', '--action', 'mention', '--stdin'],
description: 'Record user prompts for pattern analysis',
category: 'memory',
timeout: 5000
}
};
@@ -147,6 +174,33 @@ const WIZARD_TEMPLATES = {
configFields: [],
requiresSkillDiscovery: true,
customRenderer: 'renderSkillContextConfig'
},
'memory-setup': {
name: 'Memory Module Setup',
description: 'Configure automatic context tracking',
icon: 'brain',
options: [
{
id: 'file-read',
name: 'File Read Tracker',
description: 'Track file reads to build context heatmap',
templateId: 'memory-file-read'
},
{
id: 'file-write',
name: 'File Write Tracker',
description: 'Track file modifications to identify core modules',
templateId: 'memory-file-write'
},
{
id: 'prompts',
name: 'Prompt Tracker',
description: 'Record user prompts for pattern analysis',
templateId: 'memory-prompt-track'
}
],
configFields: [],
multiSelect: true
}
};
@@ -181,8 +235,60 @@ async function loadHookConfig() {
}
}
/**
* Convert internal hook format to Claude Code format
* Internal: { command, args, matcher, timeout }
* Claude Code: { matcher, hooks: [{ type: "command", command: "...", timeout }] }
*/
function convertToClaudeCodeFormat(hookData) {
// If already in correct format, return as-is
if (hookData.hooks && Array.isArray(hookData.hooks)) {
return hookData;
}
// Build command string from command + args
let commandStr = hookData.command || '';
if (hookData.args && Array.isArray(hookData.args)) {
// Join args, properly quoting if needed
const quotedArgs = hookData.args.map(arg => {
if (arg.includes(' ') && !arg.startsWith('"') && !arg.startsWith("'")) {
return `"${arg.replace(/"/g, '\\"')}"`;
}
return arg;
});
commandStr = `${commandStr} ${quotedArgs.join(' ')}`.trim();
}
const converted = {
hooks: [{
type: 'command',
command: commandStr
}]
};
// Add matcher if present (not needed for UserPromptSubmit, Stop, etc.)
if (hookData.matcher) {
converted.matcher = hookData.matcher;
}
// Add timeout if present (in seconds for Claude Code)
if (hookData.timeout) {
converted.hooks[0].timeout = Math.ceil(hookData.timeout / 1000);
}
// Preserve replaceIndex for updates
if (hookData.replaceIndex !== undefined) {
converted.replaceIndex = hookData.replaceIndex;
}
return converted;
}
async function saveHook(scope, event, hookData) {
try {
// Convert to Claude Code format before saving
const convertedHookData = convertToClaudeCodeFormat(hookData);
const response = await fetch('/api/hooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -190,7 +296,7 @@ async function saveHook(scope, event, hookData) {
projectPath: projectPath,
scope: scope,
event: event,
hookData: hookData
hookData: convertedHookData
})
});
@@ -419,6 +525,11 @@ function openHookWizardModal(wizardId) {
wizardConfig[field.key] = field.default;
});
// Initialize selectedOptions for multi-select wizards
if (wizard.multiSelect) {
wizardConfig.selectedOptions = [];
}
const modal = document.getElementById('hookWizardModal');
if (modal) {
renderWizardModalContent();
@@ -445,8 +556,10 @@ function renderWizardModalContent() {
// Get translated wizard name and description
const wizardName = wizardId === 'memory-update' ? t('hook.wizard.memoryUpdate') :
wizardId === 'memory-setup' ? t('hook.wizard.memorySetup') :
wizardId === 'skill-context' ? t('hook.wizard.skillContext') : wizard.name;
const wizardDesc = wizardId === 'memory-update' ? t('hook.wizard.memoryUpdateDesc') :
wizardId === 'memory-setup' ? t('hook.wizard.memorySetupDesc') :
wizardId === 'skill-context' ? t('hook.wizard.skillContextDesc') : wizard.description;
// Helper to get translated option names
@@ -455,6 +568,11 @@ function renderWizardModalContent() {
if (optId === 'on-stop') return t('hook.wizard.onSessionEnd');
if (optId === 'periodic') return t('hook.wizard.periodicUpdate');
}
if (wizardId === 'memory-setup') {
if (optId === 'file-read') return t('hook.wizard.fileReadTracker');
if (optId === 'file-write') return t('hook.wizard.fileWriteTracker');
if (optId === 'prompts') return t('hook.wizard.promptTracker');
}
if (wizardId === 'skill-context') {
if (optId === 'keyword') return t('hook.wizard.keywordMatching');
if (optId === 'auto') return t('hook.wizard.autoDetection');
@@ -467,6 +585,11 @@ function renderWizardModalContent() {
if (optId === 'on-stop') return t('hook.wizard.onSessionEndDesc');
if (optId === 'periodic') return t('hook.wizard.periodicUpdateDesc');
}
if (wizardId === 'memory-setup') {
if (optId === 'file-read') return t('hook.wizard.fileReadTrackerDesc');
if (optId === 'file-write') return t('hook.wizard.fileWriteTrackerDesc');
if (optId === 'prompts') return t('hook.wizard.promptTrackerDesc');
}
if (wizardId === 'skill-context') {
if (optId === 'keyword') return t('hook.wizard.keywordMatchingDesc');
if (optId === 'auto') return t('hook.wizard.autoDetectionDesc');
@@ -508,9 +631,23 @@ function renderWizardModalContent() {
<!-- Trigger Type Selection -->
<div class="space-y-3">
<label class="block text-sm font-medium text-foreground">${t('hook.wizard.whenToTrigger')}</label>
<label class="block text-sm font-medium text-foreground">${wizard.multiSelect ? t('hook.wizard.selectTrackers') : t('hook.wizard.whenToTrigger')}</label>
<div class="grid grid-cols-1 gap-3">
${wizard.options.map(opt => `
${wizard.multiSelect ? wizard.options.map(opt => {
const isSelected = wizardConfig.selectedOptions?.includes(opt.id) || false;
return `
<label class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-all ${isSelected ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground'}">
<input type="checkbox" name="wizardTrigger" value="${opt.id}"
${isSelected ? 'checked' : ''}
onchange="toggleWizardOption('${opt.id}')"
class="mt-1">
<div class="flex-1">
<span class="font-medium text-foreground">${escapeHtml(getOptionName(opt.id))}</span>
<p class="text-sm text-muted-foreground">${escapeHtml(getOptionDesc(opt.id))}</p>
</div>
</label>
`;
}).join('') : wizard.options.map(opt => `
<label class="flex items-start gap-3 p-3 border rounded-lg cursor-pointer transition-all ${selectedOption === opt.id ? 'border-primary bg-primary/5' : 'border-border hover:border-muted-foreground'}">
<input type="radio" name="wizardTrigger" value="${opt.id}"
${selectedOption === opt.id ? 'checked' : ''}
@@ -609,6 +746,21 @@ function updateWizardTrigger(triggerId) {
renderWizardModalContent();
}
function toggleWizardOption(optionId) {
if (!wizardConfig.selectedOptions) {
wizardConfig.selectedOptions = [];
}
const index = wizardConfig.selectedOptions.indexOf(optionId);
if (index === -1) {
wizardConfig.selectedOptions.push(optionId);
} else {
wizardConfig.selectedOptions.splice(index, 1);
}
renderWizardModalContent();
}
function updateWizardConfig(key, value) {
wizardConfig[key] = value;
// Update command preview
@@ -793,6 +945,75 @@ async function submitHookWizard() {
if (!currentWizardTemplate) return;
const wizard = currentWizardTemplate;
const scope = document.querySelector('input[name="wizardScope"]:checked')?.value || 'project';
// Handle multi-select wizards
if (wizard.multiSelect) {
const selectedOptions = wizardConfig.selectedOptions || [];
if (selectedOptions.length === 0) {
showRefreshToast('Please select at least one option', 'error');
return;
}
// Install each selected hook (skip if already exists)
let installedCount = 0;
let skippedCount = 0;
for (const optionId of selectedOptions) {
const selectedOption = wizard.options.find(o => o.id === optionId);
if (!selectedOption) continue;
const baseTemplate = HOOK_TEMPLATES[selectedOption.templateId];
if (!baseTemplate) continue;
// Check if hook already exists
const existingHooks = scope === 'global'
? hookConfig.global?.hooks?.[baseTemplate.event] || []
: hookConfig.project?.hooks?.[baseTemplate.event] || [];
const hookList = Array.isArray(existingHooks) ? existingHooks : [existingHooks];
const alreadyExists = hookList.some(h => {
// Check by matcher and command
const existingMatcher = h.matcher || '';
const templateMatcher = baseTemplate.matcher || '';
const existingCmd = h.hooks?.[0]?.command || h.command || '';
const templateCmd = baseTemplate.command + ' ' + (baseTemplate.args || []).join(' ');
return existingMatcher === templateMatcher && existingCmd.includes(baseTemplate.command);
});
if (alreadyExists) {
skippedCount++;
continue;
}
const hookData = {
command: baseTemplate.command,
args: baseTemplate.args
};
if (baseTemplate.matcher) {
hookData.matcher = baseTemplate.matcher;
}
if (baseTemplate.timeout) {
hookData.timeout = baseTemplate.timeout;
}
await saveHook(scope, baseTemplate.event, hookData);
installedCount++;
}
closeHookWizardModal();
if (skippedCount > 0 && installedCount === 0) {
showRefreshToast(`All ${skippedCount} hook(s) already installed`, 'info');
} else if (skippedCount > 0) {
showRefreshToast(`Installed ${installedCount}, skipped ${skippedCount} (already exists)`, 'success');
}
return;
}
// Handle single-select wizards
const triggerType = wizardConfig.triggerType || wizard.options[0].id;
const selectedOption = wizard.options.find(o => o.id === triggerType);
if (!selectedOption) return;
@@ -800,7 +1021,6 @@ async function submitHookWizard() {
const baseTemplate = HOOK_TEMPLATES[selectedOption.templateId];
if (!baseTemplate) return;
const scope = document.querySelector('input[name="wizardScope"]:checked')?.value || 'project';
const command = generateWizardCommand();
const hookData = {

View File

@@ -104,6 +104,10 @@ function initNavigation() {
renderCliHistoryView();
} else if (currentView === 'hook-manager') {
renderHookManager();
} else if (currentView === 'memory') {
renderMemoryView();
} else if (currentView === 'prompt-history') {
renderPromptHistoryView();
}
});
});
@@ -128,6 +132,10 @@ function updateContentTitle() {
titleEl.textContent = t('title.cliHistory');
} else if (currentView === 'hook-manager') {
titleEl.textContent = t('title.hookManager');
} else if (currentView === 'memory') {
titleEl.textContent = t('title.memoryModule');
} else if (currentView === 'prompt-history') {
titleEl.textContent = t('title.promptHistory');
} else if (currentView === 'liteTasks') {
const names = { 'lite-plan': t('title.litePlanSessions'), 'lite-fix': t('title.liteFixSessions') };
titleEl.textContent = names[currentLiteType] || t('title.liteTasks');

View File

@@ -1,23 +1,30 @@
// ==========================================
// TASK QUEUE SIDEBAR - Right Sidebar
// ==========================================
// Right-side slide-out toolbar for task queue management
// Right-side slide-out toolbar for task queue and CLI execution management
let isTaskQueueSidebarVisible = false;
let taskQueueData = [];
let cliQueueData = [];
let currentQueueTab = 'tasks'; // 'tasks' | 'cli'
let cliCategoryFilter = 'all'; // 'all' | 'user' | 'internal' | 'insight'
/**
* Initialize task queue sidebar
*/
function initTaskQueueSidebar() {
// Create sidebar if not exists
// Create sidebar if not exists - check for container to handle partial creation
var existingContainer = document.getElementById('taskQueueContainer');
if (existingContainer) {
existingContainer.remove();
}
if (!document.getElementById('taskQueueSidebar')) {
const sidebarHtml = `
<div class="task-queue-sidebar" id="taskQueueSidebar">
<div class="task-queue-header">
<div class="task-queue-title">
<span class="task-queue-title-icon">📋</span>
<span>Task Queue</span>
<span>Execution Queue</span>
<span class="task-queue-count-badge" id="taskQueueCountBadge">0</span>
</div>
<button class="task-queue-close" onclick="toggleTaskQueueSidebar()" title="Close">
@@ -27,12 +34,28 @@ function initTaskQueueSidebar() {
</button>
</div>
<div class="task-queue-filters">
<div class="task-queue-tabs">
<button class="task-queue-tab active" data-tab="tasks" onclick="switchQueueTab('tasks')">
📋 Tasks <span class="tab-badge" id="tasksTabBadge">0</span>
</button>
<button class="task-queue-tab" data-tab="cli" onclick="switchQueueTab('cli')">
⚡ CLI <span class="tab-badge" id="cliTabBadge">0</span>
</button>
</div>
<div class="task-queue-filters" id="taskQueueFilters">
<button class="task-filter-btn active" data-filter="all" onclick="filterTaskQueue('all')">All</button>
<button class="task-filter-btn" data-filter="in_progress" onclick="filterTaskQueue('in_progress')">In Progress</button>
<button class="task-filter-btn" data-filter="pending" onclick="filterTaskQueue('pending')">Pending</button>
</div>
<div class="task-queue-filters" id="cliQueueFilters" style="display: none;">
<button class="cli-filter-btn active" data-filter="all" onclick="filterCliQueue('all')">All</button>
<button class="cli-filter-btn" data-filter="user" onclick="filterCliQueue('user')">🔵 User</button>
<button class="cli-filter-btn" data-filter="insight" onclick="filterCliQueue('insight')">🟣 Insight</button>
<button class="cli-filter-btn" data-filter="internal" onclick="filterCliQueue('internal')">🟢 Internal</button>
</div>
<div class="task-queue-content" id="taskQueueContent">
<div class="task-queue-empty-state">
<div class="task-queue-empty-icon">📋</div>
@@ -40,9 +63,17 @@ function initTaskQueueSidebar() {
<div class="task-queue-empty-hint">Active workflow tasks will appear here</div>
</div>
</div>
<div class="task-queue-content" id="cliQueueContent" style="display: none;">
<div class="task-queue-empty-state">
<div class="task-queue-empty-icon">⚡</div>
<div class="task-queue-empty-text">No CLI executions</div>
<div class="task-queue-empty-hint">CLI tool executions will appear here</div>
</div>
</div>
</div>
<div class="task-queue-toggle" id="taskQueueToggle" onclick="toggleTaskQueueSidebar()" title="Task Queue">
<div class="task-queue-toggle" id="taskQueueToggle" onclick="toggleTaskQueueSidebar()" title="Execution Queue">
<span class="toggle-icon">📋</span>
<span class="toggle-badge" id="taskQueueToggleBadge"></span>
</div>
@@ -57,7 +88,9 @@ function initTaskQueueSidebar() {
}
updateTaskQueueData();
updateCliQueueData();
renderTaskQueue();
renderCliQueue();
updateTaskQueueBadge();
}
@@ -96,8 +129,14 @@ function toggleTaskQueueSidebar() {
function updateTaskQueueData() {
taskQueueData = [];
// Safety check for global state
if (typeof workflowData === 'undefined' || !workflowData) {
console.warn('[TaskQueue] workflowData not initialized');
return;
}
// Collect tasks from active sessions
const activeSessions = workflowData.activeSessions || [];
var activeSessions = workflowData.activeSessions || [];
activeSessions.forEach(session => {
const sessionKey = `session-${session.session_id}`.replace(/[^a-zA-Z0-9-]/g, '-');
@@ -115,7 +154,10 @@ function updateTaskQueueData() {
});
// Also check lite task sessions
Object.keys(liteTaskDataStore).forEach(key => {
if (typeof liteTaskDataStore === 'undefined' || !liteTaskDataStore) {
return;
}
Object.keys(liteTaskDataStore).forEach(function(key) {
const liteSession = liteTaskDataStore[key];
if (liteSession && liteSession.tasks) {
liteSession.tasks.forEach(task => {
@@ -142,9 +184,13 @@ function updateTaskQueueData() {
/**
* Render task queue list
*/
function renderTaskQueue(filter = 'all') {
const contentEl = document.getElementById('taskQueueContent');
if (!contentEl) return;
function renderTaskQueue(filter) {
filter = filter || 'all';
var contentEl = document.getElementById('taskQueueContent');
if (!contentEl) {
console.warn('[TaskQueue] taskQueueContent element not found');
return;
}
let filteredTasks = taskQueueData;
if (filter !== 'all') {
@@ -260,6 +306,156 @@ function updateTaskQueueBadge() {
*/
function refreshTaskQueue() {
updateTaskQueueData();
updateCliQueueData();
renderTaskQueue();
renderCliQueue();
updateTaskQueueBadge();
}
/**
* Switch between Tasks and CLI tabs
*/
function switchQueueTab(tab) {
currentQueueTab = tab;
// Update tab button states
document.querySelectorAll('.task-queue-tab').forEach(btn => {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
// Show/hide filters and content
const taskFilters = document.getElementById('taskQueueFilters');
const cliFilters = document.getElementById('cliQueueFilters');
const taskContent = document.getElementById('taskQueueContent');
const cliContent = document.getElementById('cliQueueContent');
if (tab === 'tasks') {
if (taskFilters) taskFilters.style.display = 'flex';
if (cliFilters) cliFilters.style.display = 'none';
if (taskContent) taskContent.style.display = 'block';
if (cliContent) cliContent.style.display = 'none';
} else {
if (taskFilters) taskFilters.style.display = 'none';
if (cliFilters) cliFilters.style.display = 'flex';
if (taskContent) taskContent.style.display = 'none';
if (cliContent) cliContent.style.display = 'block';
// Refresh CLI data when switching to CLI tab
updateCliQueueData();
renderCliQueue();
}
}
/**
* Update CLI queue data from API
*/
async function updateCliQueueData() {
try {
// Fetch recent CLI executions with category info
const response = await fetch(`/api/cli/history-native?path=${encodeURIComponent(projectPath)}&limit=20`);
if (!response.ok) return;
const data = await response.json();
cliQueueData = data.executions || [];
} catch (err) {
console.warn('[TaskQueue] Failed to load CLI queue:', err);
cliQueueData = [];
}
}
/**
* Render CLI queue list
*/
function renderCliQueue() {
const contentEl = document.getElementById('cliQueueContent');
if (!contentEl) return;
// Filter by category
let filtered = cliQueueData;
if (cliCategoryFilter !== 'all') {
filtered = cliQueueData.filter(exec => (exec.category || 'user') === cliCategoryFilter);
}
// Update tab badge
const cliTabBadge = document.getElementById('cliTabBadge');
if (cliTabBadge) {
cliTabBadge.textContent = cliQueueData.length;
cliTabBadge.style.display = cliQueueData.length > 0 ? 'inline' : 'none';
}
if (filtered.length === 0) {
const emptyText = cliCategoryFilter === 'all'
? 'No CLI executions'
: `No ${cliCategoryFilter} executions`;
contentEl.innerHTML = `
<div class="task-queue-empty-state">
<div class="task-queue-empty-icon">⚡</div>
<div class="task-queue-empty-text">${emptyText}</div>
<div class="task-queue-empty-hint">CLI tool executions will appear here</div>
</div>
`;
return;
}
contentEl.innerHTML = filtered.map(exec => {
const category = exec.category || 'user';
const categoryIcon = { user: '🔵', internal: '🟢', insight: '🟣' }[category] || '⚪';
const statusIcon = exec.status === 'success' ? '✅' : exec.status === 'timeout' ? '⏰' : '❌';
const timeAgo = getCliTimeAgo(new Date(exec.updated_at || exec.timestamp));
const promptPreview = (exec.prompt_preview || '').substring(0, 60);
return `
<div class="cli-queue-item category-${category}" onclick="showCliExecutionFromQueue('${escapeHtml(exec.id)}')">
<div class="cli-queue-item-header">
<span class="cli-queue-category-icon">${categoryIcon}</span>
<span class="cli-queue-tool-tag cli-tool-${exec.tool}">${exec.tool.toUpperCase()}</span>
<span class="cli-queue-status">${statusIcon}</span>
<span class="cli-queue-time">${timeAgo}</span>
</div>
<div class="cli-queue-prompt">${escapeHtml(promptPreview)}${promptPreview.length >= 60 ? '...' : ''}</div>
<div class="cli-queue-meta">
<span class="cli-queue-id">#${exec.id.split('-')[0]}</span>
${exec.turn_count > 1 ? `<span class="cli-queue-turns">${exec.turn_count} turns</span>` : ''}
${exec.hasNativeSession ? '<span class="cli-queue-native">📎</span>' : ''}
</div>
</div>
`;
}).join('');
}
/**
* Filter CLI queue by category
*/
function filterCliQueue(category) {
cliCategoryFilter = category;
// Update filter button states
document.querySelectorAll('.cli-filter-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.filter === category);
});
renderCliQueue();
}
/**
* Show CLI execution detail from queue
*/
function showCliExecutionFromQueue(executionId) {
toggleTaskQueueSidebar();
// Use the showExecutionDetail function from cli-history.js if available
if (typeof showExecutionDetail === 'function') {
showExecutionDetail(executionId);
} else {
console.warn('[TaskQueue] showExecutionDetail not available');
}
}
/**
* Helper to format time ago
*/
function getCliTimeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
if (seconds < 60) return 'now';
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`;
}

View File

@@ -27,6 +27,9 @@ const i18n = {
'nav.explorer': 'Explorer',
'nav.status': 'Status',
'nav.history': 'History',
'nav.memory': 'Memory',
'nav.contextMemory': 'Context',
'nav.promptHistory': 'Prompts',
// Sidebar - Sessions section
'nav.sessions': 'Sessions',
@@ -78,6 +81,8 @@ const i18n = {
'title.sessionDetail': 'Session Detail',
'title.liteTaskDetail': 'Lite Task Detail',
'title.hookManager': 'Hook Manager',
'title.memoryModule': 'Memory Module',
'title.promptHistory': 'Prompt History',
// Search
'search.placeholder': 'Search...',
@@ -207,6 +212,8 @@ const i18n = {
'cli.storageBackendDesc': 'CLI history stored in SQLite with FTS search',
'cli.smartContext': 'Smart Context',
'cli.smartContextDesc': 'Auto-analyze prompt and add relevant file paths',
'cli.nativeResume': 'Native Resume',
'cli.nativeResumeDesc': 'Use native tool resume (gemini -r, qwen --resume, codex resume)',
'cli.maxContextFiles': 'Max Context Files',
'cli.maxContextFilesDesc': 'Maximum files to include in smart context',
@@ -424,6 +431,15 @@ const i18n = {
'hook.wizard.keywordMatchingDesc': 'Load specific SKILLs when keywords are detected in prompt',
'hook.wizard.autoDetection': 'Auto Detection',
'hook.wizard.autoDetectionDesc': 'Automatically detect and load SKILLs by name in prompt',
'hook.wizard.memorySetup': 'Memory Module Setup',
'hook.wizard.memorySetupDesc': 'Configure automatic context tracking (lightweight metadata recording)',
'hook.wizard.fileReadTracker': 'File Read Tracker',
'hook.wizard.fileReadTrackerDesc': 'Track file reads to build context heatmap',
'hook.wizard.fileWriteTracker': 'File Write Tracker',
'hook.wizard.fileWriteTrackerDesc': 'Track file modifications to identify core modules',
'hook.wizard.promptTracker': 'Prompt Tracker',
'hook.wizard.promptTrackerDesc': 'Record user prompts for pattern analysis',
'hook.wizard.selectTrackers': 'Select Trackers',
// Hook Wizard Labels
'hook.wizard.cliTools': 'CLI Tools:',
@@ -510,6 +526,71 @@ const i18n = {
'footer.generated': 'Generated:',
'footer.version': 'CCW Dashboard v1.0',
// Prompt History
'prompt.timeline': 'Prompt Timeline',
'prompt.searchPlaceholder': 'Search prompts...',
'prompt.allProjects': 'All Projects',
'prompt.currentProject': 'Current Project',
'prompt.noPromptsFound': 'No Prompts Found',
'prompt.noPromptsText': 'No prompts found matching your search criteria.',
'prompt.insights': 'Insights & Suggestions',
'prompt.analyze': 'Analyze',
'prompt.analyzing': 'Analyzing...',
'prompt.selectTool': 'Select Tool',
'prompt.quality': 'Quality',
'prompt.intent': 'Intent',
'prompt.project': 'Project',
'prompt.session': 'Session',
'prompt.noInsights': 'No insights yet',
'prompt.noInsightsText': 'Select a CLI tool and click Analyze to generate insights.',
'prompt.loadingInsights': 'Generating insights...',
'prompt.insightsError': 'Failed to generate insights',
'prompt.intent.implement': 'Implement',
'prompt.intent.fix': 'Fix',
'prompt.intent.explore': 'Explore',
'prompt.intent.debug': 'Debug',
'prompt.intent.refactor': 'Refactor',
'prompt.intent.test': 'Test',
'prompt.intent.document': 'Document',
'prompt.intent.general': 'General',
'prompt.timeJustNow': 'Just now',
'prompt.timeMinutesAgo': '{count} min ago',
'prompt.timeHoursAgo': '{count} hours ago',
'prompt.timeDaysAgo': '{count} days ago',
// Memory Module
'memory.contextHotspots': 'Context Hotspots',
'memory.mostRead': 'Most Read Files',
'memory.mostEdited': 'Most Edited Files',
'memory.today': 'Today',
'memory.week': 'Week',
'memory.allTime': 'All Time',
'memory.noData': 'No data available',
'memory.memoryGraph': 'Memory Graph',
'memory.nodes': 'nodes',
'memory.resetView': 'Reset View',
'memory.file': 'File',
'memory.module': 'Module',
'memory.component': 'Component',
'memory.noGraphData': 'No graph data available',
'memory.d3NotLoaded': 'D3.js not loaded',
'memory.recentContext': 'Recent Context',
'memory.activities': 'activities',
'memory.searchContext': 'Search context...',
'memory.noRecentActivity': 'No recent activity',
'memory.reads': 'Reads',
'memory.edits': 'Edits',
'memory.prompts': 'Prompts',
'memory.nodeDetails': 'Node Details',
'memory.heat': 'Heat',
'memory.associations': 'Associations',
'memory.type': 'Type',
'memory.relatedNodes': 'Related Nodes',
'memory.noAssociations': 'No associations found',
'memory.justNow': 'Just now',
'memory.minutesAgo': 'minutes ago',
'memory.hoursAgo': 'hours ago',
// Common
'common.cancel': 'Cancel',
'common.create': 'Create',
@@ -547,6 +628,9 @@ const i18n = {
'nav.explorer': '文件浏览器',
'nav.status': '状态',
'nav.history': '历史',
'nav.memory': '记忆',
'nav.contextMemory': '活动',
'nav.promptHistory': '洞察',
// Sidebar - Sessions section
'nav.sessions': '会话',
@@ -598,7 +682,9 @@ const i18n = {
'title.sessionDetail': '会话详情',
'title.liteTaskDetail': '轻量任务详情',
'title.hookManager': '钩子管理',
'title.memoryModule': '记忆模块',
'title.promptHistory': '提示历史',
// Search
'search.placeholder': '搜索...',
@@ -727,6 +813,8 @@ const i18n = {
'cli.storageBackendDesc': 'CLI 历史使用 SQLite 存储,支持全文搜索',
'cli.smartContext': '智能上下文',
'cli.smartContextDesc': '自动分析提示词并添加相关文件路径',
'cli.nativeResume': '原生恢复',
'cli.nativeResumeDesc': '使用工具原生恢复命令 (gemini -r, qwen --resume, codex resume)',
'cli.maxContextFiles': '最大上下文文件数',
'cli.maxContextFilesDesc': '智能上下文包含的最大文件数',
@@ -944,6 +1032,15 @@ const i18n = {
'hook.wizard.keywordMatchingDesc': '当提示中检测到关键词时加载特定 SKILL',
'hook.wizard.autoDetection': '自动检测',
'hook.wizard.autoDetectionDesc': '根据提示中的名称自动检测并加载 SKILL',
'hook.wizard.memorySetup': '记忆模块设置',
'hook.wizard.memorySetupDesc': '配置自动上下文跟踪(轻量级元数据记录)',
'hook.wizard.fileReadTracker': '文件读取追踪器',
'hook.wizard.fileReadTrackerDesc': '追踪文件读取以构建上下文热图',
'hook.wizard.fileWriteTracker': '文件写入追踪器',
'hook.wizard.fileWriteTrackerDesc': '追踪文件修改以识别核心模块',
'hook.wizard.promptTracker': '提示追踪器',
'hook.wizard.promptTrackerDesc': '记录用户提示用于模式分析',
'hook.wizard.selectTrackers': '选择追踪器',
// Hook Wizard Labels
'hook.wizard.cliTools': 'CLI 工具:',
@@ -1029,7 +1126,72 @@ const i18n = {
// Footer
'footer.generated': '生成时间:',
'footer.version': 'CCW 控制面板 v1.0',
// Prompt History
'prompt.timeline': '提示词时间线',
'prompt.searchPlaceholder': '搜索提示词...',
'prompt.allProjects': '所有项目',
'prompt.currentProject': '当前项目',
'prompt.noPromptsFound': '未找到提示词',
'prompt.noPromptsText': '没有符合搜索条件的提示词。',
'prompt.insights': '洞察与建议',
'prompt.analyze': '分析',
'prompt.analyzing': '分析中...',
'prompt.selectTool': '选择工具',
'prompt.quality': '质量',
'prompt.intent': '意图',
'prompt.project': '项目',
'prompt.session': '会话',
'prompt.noInsights': '暂无洞察',
'prompt.noInsightsText': '选择 CLI 工具并点击分析以生成洞察。',
'prompt.loadingInsights': '正在生成洞察...',
'prompt.insightsError': '生成洞察失败',
'prompt.intent.implement': '实现',
'prompt.intent.fix': '修复',
'prompt.intent.explore': '探索',
'prompt.intent.debug': '调试',
'prompt.intent.refactor': '重构',
'prompt.intent.test': '测试',
'prompt.intent.document': '文档',
'prompt.intent.general': '通用',
'prompt.timeJustNow': '刚刚',
'prompt.timeMinutesAgo': '{count} 分钟前',
'prompt.timeHoursAgo': '{count} 小时前',
'prompt.timeDaysAgo': '{count} 天前',
// Memory Module
'memory.contextHotspots': '上下文热点',
'memory.mostRead': '最常读取的文件',
'memory.mostEdited': '最常编辑的文件',
'memory.today': '今天',
'memory.week': '本周',
'memory.allTime': '全部时间',
'memory.noData': '无可用数据',
'memory.memoryGraph': '记忆图谱',
'memory.nodes': '节点',
'memory.resetView': '重置视图',
'memory.file': '文件',
'memory.module': '模块',
'memory.component': '组件',
'memory.noGraphData': '无图谱数据',
'memory.d3NotLoaded': 'D3.js 未加载',
'memory.recentContext': '最近上下文',
'memory.activities': '活动',
'memory.searchContext': '搜索上下文...',
'memory.noRecentActivity': '无最近活动',
'memory.reads': '读取',
'memory.edits': '编辑',
'memory.prompts': '提示',
'memory.nodeDetails': '节点详情',
'memory.heat': '热度',
'memory.associations': '关联',
'memory.type': '类型',
'memory.relatedNodes': '相关节点',
'memory.noAssociations': '未找到关联',
'memory.justNow': '刚刚',
'memory.minutesAgo': '分钟前',
'memory.hoursAgo': '小时前',
// Common
'common.cancel': '取消',
'common.create': '创建',

View File

@@ -291,6 +291,19 @@ function renderCliSettingsSection() {
'</div>' +
'<p class="cli-setting-desc">' + t('cli.smartContextDesc') + '</p>' +
'</div>' +
'<div class="cli-setting-item">' +
'<label class="cli-setting-label">' +
'<i data-lucide="refresh-cw" class="w-3 h-3"></i>' +
t('cli.nativeResume') +
'</label>' +
'<div class="cli-setting-control">' +
'<label class="cli-toggle">' +
'<input type="checkbox"' + (nativeResumeEnabled ? ' checked' : '') + ' onchange="setNativeResumeEnabled(this.checked)">' +
'<span class="cli-toggle-slider"></span>' +
'</label>' +
'</div>' +
'<p class="cli-setting-desc">' + t('cli.nativeResumeDesc') + '</p>' +
'</div>' +
'<div class="cli-setting-item' + (!smartContextEnabled ? ' disabled' : '') + '">' +
'<label class="cli-setting-label">' +
'<i data-lucide="files" class="w-3 h-3"></i>' +

View File

@@ -11,10 +11,8 @@ async function renderHookManager() {
if (statsGrid) statsGrid.style.display = 'none';
if (searchInput) searchInput.parentElement.style.display = 'none';
// Load hook config if not already loaded
if (!hookConfig.global.hooks && !hookConfig.project.hooks) {
await loadHookConfig();
}
// Always reload hook config to get latest data
await loadHookConfig();
const globalHooks = hookConfig.global?.hooks || {};
const projectHooks = hookConfig.project?.hooks || {};
@@ -84,8 +82,9 @@ async function renderHookManager() {
<span class="text-sm text-muted-foreground">${t('hook.wizardsDesc')}</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
${renderWizardCard('memory-update')}
${renderWizardCard('memory-setup')}
${renderWizardCard('skill-context')}
</div>
</div>
@@ -206,8 +205,10 @@ function renderWizardCard(wizardId) {
// Get translated wizard name and description
const wizardName = wizardId === 'memory-update' ? t('hook.wizard.memoryUpdate') :
wizardId === 'memory-setup' ? t('hook.wizard.memorySetup') :
wizardId === 'skill-context' ? t('hook.wizard.skillContext') : wizard.name;
const wizardDesc = wizardId === 'memory-update' ? t('hook.wizard.memoryUpdateDesc') :
wizardId === 'memory-setup' ? t('hook.wizard.memorySetupDesc') :
wizardId === 'skill-context' ? t('hook.wizard.skillContextDesc') : wizard.description;
// Translate options
@@ -216,6 +217,11 @@ function renderWizardCard(wizardId) {
if (optId === 'on-stop') return t('hook.wizard.onSessionEnd');
if (optId === 'periodic') return t('hook.wizard.periodicUpdate');
}
if (wizardId === 'memory-setup') {
if (optId === 'file-read') return t('hook.wizard.fileReadTracker');
if (optId === 'file-write') return t('hook.wizard.fileWriteTracker');
if (optId === 'prompts') return t('hook.wizard.promptTracker');
}
if (wizardId === 'skill-context') {
if (optId === 'keyword') return t('hook.wizard.keywordMatching');
if (optId === 'auto') return t('hook.wizard.autoDetection');
@@ -228,6 +234,11 @@ function renderWizardCard(wizardId) {
if (optId === 'on-stop') return t('hook.wizard.onSessionEndDesc');
if (optId === 'periodic') return t('hook.wizard.periodicUpdateDesc');
}
if (wizardId === 'memory-setup') {
if (optId === 'file-read') return t('hook.wizard.fileReadTrackerDesc');
if (optId === 'file-write') return t('hook.wizard.fileWriteTrackerDesc');
if (optId === 'prompts') return t('hook.wizard.promptTrackerDesc');
}
if (wizardId === 'skill-context') {
if (optId === 'keyword') return t('hook.wizard.keywordMatchingDesc');
if (optId === 'auto') return t('hook.wizard.autoDetectionDesc');
@@ -236,8 +247,9 @@ function renderWizardCard(wizardId) {
};
// Determine what to show in the tools/skills section
const toolsSection = wizard.requiresSkillDiscovery
? `
let toolsSection = '';
if (wizard.requiresSkillDiscovery) {
toolsSection = `
<div class="flex items-center gap-2 text-xs text-muted-foreground mb-4">
<span class="font-mono bg-muted px-1.5 py-0.5 rounded">${t('hook.wizard.event')}</span>
<span class="px-2 py-0.5 bg-amber-500/10 text-amber-500 rounded">UserPromptSubmit</span>
@@ -246,8 +258,12 @@ function renderWizardCard(wizardId) {
<span class="font-mono bg-muted px-1.5 py-0.5 rounded">${t('hook.wizard.availableSkills')}</span>
<span class="text-muted-foreground ml-2">${t('hook.wizard.loading')}</span>
</div>
`
: `
`;
} else if (wizard.multiSelect) {
// memory-setup: lightweight tracking, no CLI tools
toolsSection = '';
} else {
toolsSection = `
<div class="flex items-center gap-2 text-xs text-muted-foreground mb-4">
<span class="font-mono bg-muted px-1.5 py-0.5 rounded">${t('hook.wizard.cliTools')}</span>
<span class="px-2 py-0.5 bg-blue-500/10 text-blue-500 rounded">gemini</span>
@@ -255,6 +271,7 @@ function renderWizardCard(wizardId) {
<span class="px-2 py-0.5 bg-green-500/10 text-green-500 rounded">codex</span>
</div>
`;
}
return `
<div class="hook-wizard-card bg-gradient-to-br from-primary/5 to-primary/10 border border-primary/20 rounded-lg p-5 hover:shadow-lg transition-all">
@@ -308,8 +325,10 @@ function renderHooksByEvent(hooks, scope) {
return hookList.map((hook, index) => {
const matcher = hook.matcher || 'All tools';
const command = hook.command || 'N/A';
// Support both old format (hook.command) and new Claude Code format (hook.hooks[0].command)
const command = hook.hooks?.[0]?.command || hook.command || 'N/A';
const args = hook.args || [];
const timeout = hook.hooks?.[0]?.timeout || hook.timeout;
return `
<div class="hook-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all">
@@ -424,18 +443,28 @@ function isHookTemplateInstalled(templateId) {
const template = HOOK_TEMPLATES[templateId];
if (!template) return false;
// Build expected command string
const templateCmd = template.command + (template.args ? ' ' + template.args.join(' ') : '');
// Check project hooks
const projectHooks = hookConfig.project?.hooks?.[template.event];
if (projectHooks) {
const hookList = Array.isArray(projectHooks) ? projectHooks : [projectHooks];
if (hookList.some(h => h.command === template.command)) return true;
if (hookList.some(h => {
// Check both old format (h.command) and new format (h.hooks[0].command)
const cmd = h.hooks?.[0]?.command || h.command || '';
return cmd.includes(template.command);
})) return true;
}
// Check global hooks
const globalHooks = hookConfig.global?.hooks?.[template.event];
if (globalHooks) {
const hookList = Array.isArray(globalHooks) ? globalHooks : [globalHooks];
if (hookList.some(h => h.command === template.command)) return true;
if (hookList.some(h => {
const cmd = h.hooks?.[0]?.command || h.command || '';
return cmd.includes(template.command);
})) return true;
}
return false;
@@ -448,6 +477,12 @@ async function installHookTemplate(templateId, scope) {
return;
}
// Check if already installed
if (isHookTemplateInstalled(templateId)) {
showRefreshToast('Hook already installed', 'info');
return;
}
const hookData = {
command: template.command,
args: template.args

View File

@@ -0,0 +1,588 @@
// Memory Module View
// Three-column layout: Context Hotspots | Memory Graph | Recent Context
// ========== Memory State ==========
var memoryStats = null;
var memoryGraphData = null;
var recentContext = [];
var memoryTimeFilter = 'all'; // 'today', 'week', 'all'
var selectedNode = null;
// ========== Main Render Function ==========
async function renderMemoryView() {
var container = document.getElementById('mainContent');
if (!container) return;
// Hide stats grid and search for memory view
var statsGrid = document.getElementById('statsGrid');
var searchInput = document.getElementById('searchInput');
if (statsGrid) statsGrid.style.display = 'none';
if (searchInput) searchInput.parentElement.style.display = 'none';
// Show loading state
container.innerHTML = '<div class="memory-view loading">' +
'<div class="loading-spinner"><i data-lucide="loader-2" class="w-8 h-8 animate-spin"></i></div>' +
'<p>' + t('common.loading') + '</p>' +
'</div>';
// Load data
await Promise.all([
loadMemoryStats(),
loadMemoryGraph(),
loadRecentContext()
]);
// Render three-column layout
container.innerHTML = '<div class="memory-view">' +
'<div class="memory-columns">' +
'<div class="memory-column left" id="memory-hotspots"></div>' +
'<div class="memory-column center" id="memory-graph"></div>' +
'<div class="memory-column right" id="memory-context"></div>' +
'</div>' +
'</div>';
// Render each column
renderHotspotsColumn();
renderGraphColumn();
renderContextColumn();
// Initialize Lucide icons
if (window.lucide) lucide.createIcons();
}
// ========== Data Loading ==========
async function loadMemoryStats() {
try {
var response = await fetch('/api/memory/stats?filter=' + memoryTimeFilter);
if (!response.ok) throw new Error('Failed to load memory stats');
var data = await response.json();
memoryStats = data.stats || { mostRead: [], mostEdited: [] };
return memoryStats;
} catch (err) {
console.error('Failed to load memory stats:', err);
memoryStats = { mostRead: [], mostEdited: [] };
return memoryStats;
}
}
async function loadMemoryGraph() {
try {
var response = await fetch('/api/memory/graph');
if (!response.ok) throw new Error('Failed to load memory graph');
var data = await response.json();
memoryGraphData = data.graph || { nodes: [], edges: [] };
return memoryGraphData;
} catch (err) {
console.error('Failed to load memory graph:', err);
memoryGraphData = { nodes: [], edges: [] };
return memoryGraphData;
}
}
async function loadRecentContext() {
try {
var response = await fetch('/api/memory/recent');
if (!response.ok) throw new Error('Failed to load recent context');
var data = await response.json();
recentContext = data.recent || [];
return recentContext;
} catch (err) {
console.error('Failed to load recent context:', err);
recentContext = [];
return [];
}
}
// ========== Left Column: Context Hotspots ==========
function renderHotspotsColumn() {
var container = document.getElementById('memory-hotspots');
if (!container) return;
var mostRead = memoryStats.mostRead || [];
var mostEdited = memoryStats.mostEdited || [];
container.innerHTML = '<div class="memory-section">' +
'<div class="section-header">' +
'<div class="section-header-left">' +
'<h3><i data-lucide="flame" class="w-4 h-4"></i> ' + t('memory.contextHotspots') + '</h3>' +
'</div>' +
'<div class="section-header-actions">' +
'<button class="btn-icon" onclick="refreshMemoryData()" title="' + t('common.refresh') + '">' +
'<i data-lucide="refresh-cw" class="w-4 h-4"></i>' +
'</button>' +
'</div>' +
'</div>' +
'<div class="memory-filters">' +
'<button class="filter-btn ' + (memoryTimeFilter === 'today' ? 'active' : '') + '" onclick="setMemoryTimeFilter(\'today\')">' + t('memory.today') + '</button>' +
'<button class="filter-btn ' + (memoryTimeFilter === 'week' ? 'active' : '') + '" onclick="setMemoryTimeFilter(\'week\')">' + t('memory.week') + '</button>' +
'<button class="filter-btn ' + (memoryTimeFilter === 'all' ? 'active' : '') + '" onclick="setMemoryTimeFilter(\'all\')">' + t('memory.allTime') + '</button>' +
'</div>' +
'<div class="hotspot-lists">' +
'<div class="hotspot-list-container">' +
'<h4 class="hotspot-list-title"><i data-lucide="eye" class="w-3.5 h-3.5"></i> ' + t('memory.mostRead') + '</h4>' +
renderHotspotList(mostRead, 'read') +
'</div>' +
'<div class="hotspot-list-container">' +
'<h4 class="hotspot-list-title"><i data-lucide="pencil" class="w-3.5 h-3.5"></i> ' + t('memory.mostEdited') + '</h4>' +
renderHotspotList(mostEdited, 'edit') +
'</div>' +
'</div>' +
'</div>';
if (window.lucide) lucide.createIcons();
}
function renderHotspotList(items, type) {
if (!items || items.length === 0) {
return '<div class="hotspot-empty">' +
'<i data-lucide="inbox" class="w-6 h-6"></i>' +
'<p>' + t('memory.noData') + '</p>' +
'</div>';
}
return '<div class="hotspot-list">' +
items.map(function(item, index) {
var heat = item.heat || item.count || 0;
var heatClass = heat > 50 ? 'high' : heat > 20 ? 'medium' : 'low';
var path = item.path || item.file || 'Unknown';
var fileName = path.split('/').pop().split('\\').pop();
return '<div class="hotspot-item" onclick="highlightNode(\'' + escapeHtml(path) + '\')">' +
'<div class="hotspot-rank">' + (index + 1) + '</div>' +
'<div class="hotspot-info">' +
'<div class="hotspot-name" title="' + escapeHtml(path) + '">' + escapeHtml(fileName) + '</div>' +
'<div class="hotspot-path">' + escapeHtml(path.substring(0, path.lastIndexOf(fileName))) + '</div>' +
'</div>' +
'<div class="hotspot-heat ' + heatClass + '">' +
'<span class="heat-badge">' + heat + '</span>' +
'<i data-lucide="' + (type === 'read' ? 'eye' : 'pencil') + '" class="w-3 h-3"></i>' +
'</div>' +
'</div>';
}).join('') +
'</div>';
}
// ========== Center Column: Memory Graph ==========
function renderGraphColumn() {
var container = document.getElementById('memory-graph');
if (!container) return;
container.innerHTML = '<div class="memory-section">' +
'<div class="section-header">' +
'<div class="section-header-left">' +
'<h3><i data-lucide="network" class="w-4 h-4"></i> ' + t('memory.memoryGraph') + '</h3>' +
'<span class="section-count">' + (memoryGraphData.nodes || []).length + ' ' + t('memory.nodes') + '</span>' +
'</div>' +
'<div class="section-header-actions">' +
'<button class="btn-icon" onclick="resetGraphView()" title="' + t('memory.resetView') + '">' +
'<i data-lucide="maximize-2" class="w-4 h-4"></i>' +
'</button>' +
'</div>' +
'</div>' +
'<div class="memory-graph-container" id="memoryGraphSvg"></div>' +
'<div class="memory-graph-legend">' +
'<div class="legend-item"><span class="legend-dot file"></span> ' + t('memory.file') + '</div>' +
'<div class="legend-item"><span class="legend-dot module"></span> ' + t('memory.module') + '</div>' +
'<div class="legend-item"><span class="legend-dot component"></span> ' + t('memory.component') + '</div>' +
'</div>' +
'</div>';
// Render D3 graph
renderMemoryGraph(memoryGraphData);
if (window.lucide) lucide.createIcons();
}
function renderMemoryGraph(graphData) {
if (!graphData || !graphData.nodes || graphData.nodes.length === 0) {
var container = document.getElementById('memoryGraphSvg');
if (container) {
container.innerHTML = '<div class="graph-empty-state">' +
'<i data-lucide="network" class="w-12 h-12"></i>' +
'<p>' + t('memory.noGraphData') + '</p>' +
'</div>';
if (window.lucide) lucide.createIcons();
}
return;
}
// Check if D3 is available
if (typeof d3 === 'undefined') {
var container = document.getElementById('memoryGraphSvg');
if (container) {
container.innerHTML = '<div class="graph-error">' +
'<i data-lucide="alert-triangle" class="w-8 h-8"></i>' +
'<p>' + t('memory.d3NotLoaded') + '</p>' +
'</div>';
if (window.lucide) lucide.createIcons();
}
return;
}
var container = document.getElementById('memoryGraphSvg');
if (!container) return;
var width = container.clientWidth || 600;
var height = container.clientHeight || 500;
// Clear existing
container.innerHTML = '';
var svg = d3.select('#memoryGraphSvg')
.append('svg')
.attr('width', width)
.attr('height', height)
.attr('class', 'memory-graph-svg');
// Create force simulation
var simulation = d3.forceSimulation(graphData.nodes)
.force('link', d3.forceLink(graphData.edges).id(function(d) { return d.id; }).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(function(d) { return (d.heat || 10) + 5; }));
// Draw edges
var link = svg.append('g')
.selectAll('line')
.data(graphData.edges)
.enter()
.append('line')
.attr('class', 'graph-edge')
.attr('stroke-width', function(d) { return Math.sqrt(d.weight || 1); });
// Draw nodes
var node = svg.append('g')
.selectAll('circle')
.data(graphData.nodes)
.enter()
.append('circle')
.attr('class', function(d) { return 'graph-node ' + (d.type || 'file'); })
.attr('r', function(d) { return (d.heat || 10); })
.attr('data-id', function(d) { return d.id; })
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended))
.on('click', function(event, d) {
selectNode(d);
});
// Node labels
var label = svg.append('g')
.selectAll('text')
.data(graphData.nodes)
.enter()
.append('text')
.attr('class', 'graph-label')
.text(function(d) { return d.name || d.id; })
.attr('x', 8)
.attr('y', 3);
// Update positions on simulation tick
simulation.on('tick', function() {
link
.attr('x1', function(d) { return d.source.x; })
.attr('y1', function(d) { return d.source.y; })
.attr('x2', function(d) { return d.target.x; })
.attr('y2', function(d) { return d.target.y; });
node
.attr('cx', function(d) { return d.x; })
.attr('cy', function(d) { return d.y; });
label
.attr('x', function(d) { return d.x + 8; })
.attr('y', function(d) { return d.y + 3; });
});
// Drag functions
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
}
function selectNode(node) {
selectedNode = node;
// Highlight in graph
d3.selectAll('.graph-node').classed('selected', false);
d3.selectAll('.graph-node[data-id="' + node.id + '"]').classed('selected', true);
// Show node details in context column
showNodeDetails(node);
}
function highlightNode(path) {
var node = memoryGraphData.nodes.find(function(n) { return n.path === path || n.id === path; });
if (node) {
selectNode(node);
// Center graph on node if possible
if (typeof d3 !== 'undefined') {
var container = document.getElementById('memoryGraphSvg');
if (container) {
container.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
}
function resetGraphView() {
selectedNode = null;
d3.selectAll('.graph-node').classed('selected', false);
renderContextColumn();
}
// ========== Right Column: Recent Context ==========
function renderContextColumn() {
var container = document.getElementById('memory-context');
if (!container) return;
if (selectedNode) {
showNodeDetails(selectedNode);
return;
}
container.innerHTML = '<div class="memory-section">' +
'<div class="section-header">' +
'<div class="section-header-left">' +
'<h3><i data-lucide="clock" class="w-4 h-4"></i> ' + t('memory.recentContext') + '</h3>' +
'<span class="section-count">' + recentContext.length + ' ' + t('memory.activities') + '</span>' +
'</div>' +
'</div>' +
'<div class="context-search">' +
'<input type="text" id="contextSearchInput" class="context-search-input" placeholder="' + t('memory.searchContext') + '" onkeyup="filterRecentContext(this.value)">' +
'<i data-lucide="search" class="w-4 h-4 search-icon"></i>' +
'</div>' +
renderContextTimeline(recentContext) +
renderContextStats() +
'</div>';
if (window.lucide) lucide.createIcons();
}
function renderContextTimeline(prompts) {
if (!prompts || prompts.length === 0) {
return '<div class="context-empty">' +
'<i data-lucide="inbox" class="w-8 h-8"></i>' +
'<p>' + t('memory.noRecentActivity') + '</p>' +
'</div>';
}
return '<div class="context-timeline">' +
prompts.map(function(item) {
var timestamp = item.timestamp ? formatTimestamp(item.timestamp) : 'Unknown time';
var type = item.type || 'unknown';
var typeIcon = type === 'read' ? 'eye' : type === 'edit' ? 'pencil' : 'file-text';
var files = item.files || [];
return '<div class="timeline-item">' +
'<div class="timeline-icon ' + type + '">' +
'<i data-lucide="' + typeIcon + '" class="w-3.5 h-3.5"></i>' +
'</div>' +
'<div class="timeline-content">' +
'<div class="timeline-header">' +
'<span class="timeline-type">' + escapeHtml(type.charAt(0).toUpperCase() + type.slice(1)) + '</span>' +
'<span class="timeline-time">' + timestamp + '</span>' +
'</div>' +
'<div class="timeline-prompt">' + escapeHtml(item.prompt || item.description || 'No description') + '</div>' +
(files.length > 0 ? '<div class="timeline-files">' +
files.slice(0, 3).map(function(f) {
return '<span class="file-tag" onclick="highlightNode(\'' + escapeHtml(f) + '\')">' +
'<i data-lucide="file" class="w-3 h-3"></i> ' + escapeHtml(f.split('/').pop().split('\\').pop()) +
'</span>';
}).join('') +
(files.length > 3 ? '<span class="file-tag more">+' + (files.length - 3) + ' more</span>' : '') +
'</div>' : '') +
'</div>' +
'</div>';
}).join('') +
'</div>';
}
function renderContextStats() {
var totalReads = recentContext.filter(function(c) { return c.type === 'read'; }).length;
var totalEdits = recentContext.filter(function(c) { return c.type === 'edit'; }).length;
var totalPrompts = recentContext.filter(function(c) { return c.type === 'prompt'; }).length;
return '<div class="context-stats">' +
'<div class="context-stat-item">' +
'<i data-lucide="eye" class="w-4 h-4"></i>' +
'<span class="stat-label">' + t('memory.reads') + '</span>' +
'<span class="stat-value">' + totalReads + '</span>' +
'</div>' +
'<div class="context-stat-item">' +
'<i data-lucide="pencil" class="w-4 h-4"></i>' +
'<span class="stat-label">' + t('memory.edits') + '</span>' +
'<span class="stat-value">' + totalEdits + '</span>' +
'</div>' +
'<div class="context-stat-item">' +
'<i data-lucide="message-square" class="w-4 h-4"></i>' +
'<span class="stat-label">' + t('memory.prompts') + '</span>' +
'<span class="stat-value">' + totalPrompts + '</span>' +
'</div>' +
'</div>';
}
function showNodeDetails(node) {
var container = document.getElementById('memory-context');
if (!container) return;
var associations = memoryGraphData.edges
.filter(function(e) { return e.source.id === node.id || e.target.id === node.id; })
.map(function(e) {
var other = e.source.id === node.id ? e.target : e.source;
return { node: other, weight: e.weight || 1 };
})
.sort(function(a, b) { return b.weight - a.weight; });
container.innerHTML = '<div class="memory-section">' +
'<div class="section-header">' +
'<div class="section-header-left">' +
'<h3><i data-lucide="info" class="w-4 h-4"></i> ' + t('memory.nodeDetails') + '</h3>' +
'</div>' +
'<div class="section-header-actions">' +
'<button class="btn-icon" onclick="resetGraphView()" title="' + t('common.close') + '">' +
'<i data-lucide="x" class="w-4 h-4"></i>' +
'</button>' +
'</div>' +
'</div>' +
'<div class="node-details">' +
'<div class="node-detail-header">' +
'<div class="node-detail-icon ' + (node.type || 'file') + '">' +
'<i data-lucide="' + (node.type === 'module' ? 'package' : node.type === 'component' ? 'box' : 'file') + '" class="w-5 h-5"></i>' +
'</div>' +
'<div class="node-detail-info">' +
'<div class="node-detail-name">' + escapeHtml(node.name || node.id) + '</div>' +
'<div class="node-detail-path">' + escapeHtml(node.path || node.id) + '</div>' +
'</div>' +
'</div>' +
'<div class="node-detail-stats">' +
'<div class="detail-stat">' +
'<span class="detail-stat-label">' + t('memory.heat') + '</span>' +
'<span class="detail-stat-value">' + (node.heat || 0) + '</span>' +
'</div>' +
'<div class="detail-stat">' +
'<span class="detail-stat-label">' + t('memory.associations') + '</span>' +
'<span class="detail-stat-value">' + associations.length + '</span>' +
'</div>' +
'<div class="detail-stat">' +
'<span class="detail-stat-label">' + t('memory.type') + '</span>' +
'<span class="detail-stat-value">' + (node.type || 'file') + '</span>' +
'</div>' +
'</div>' +
(associations.length > 0 ? '<div class="node-associations">' +
'<h4 class="associations-title">' + t('memory.relatedNodes') + '</h4>' +
'<div class="associations-list">' +
associations.slice(0, 10).map(function(a) {
return '<div class="association-item" onclick="selectNode(' + JSON.stringify(a.node).replace(/"/g, '&quot;') + ')">' +
'<div class="association-node">' +
'<i data-lucide="' + (a.node.type === 'module' ? 'package' : a.node.type === 'component' ? 'box' : 'file') + '" class="w-3.5 h-3.5"></i>' +
'<span>' + escapeHtml(a.node.name || a.node.id) + '</span>' +
'</div>' +
'<div class="association-weight">' + a.weight + '</div>' +
'</div>';
}).join('') +
(associations.length > 10 ? '<div class="associations-more">+' + (associations.length - 10) + ' more</div>' : '') +
'</div>' +
'</div>' : '<div class="node-no-associations">' + t('memory.noAssociations') + '</div>') +
'</div>' +
'</div>';
if (window.lucide) lucide.createIcons();
}
// ========== Actions ==========
async function setMemoryTimeFilter(filter) {
memoryTimeFilter = filter;
await loadMemoryStats();
renderHotspotsColumn();
}
async function refreshMemoryData() {
await Promise.all([
loadMemoryStats(),
loadMemoryGraph(),
loadRecentContext()
]);
renderHotspotsColumn();
renderGraphColumn();
renderContextColumn();
}
function filterRecentContext(query) {
var filtered = recentContext;
if (query && query.trim()) {
var q = query.toLowerCase();
filtered = recentContext.filter(function(item) {
var promptMatch = (item.prompt || '').toLowerCase().includes(q);
var filesMatch = (item.files || []).some(function(f) { return f.toLowerCase().includes(q); });
return promptMatch || filesMatch;
});
}
var container = document.getElementById('memory-context');
if (!container) return;
var timeline = container.querySelector('.context-timeline');
if (timeline) {
timeline.outerHTML = renderContextTimeline(filtered);
if (window.lucide) lucide.createIcons();
}
}
// ========== WebSocket Event Handlers ==========
function handleMemoryUpdated(payload) {
// Refresh graph and stats without full re-render
if (payload.type === 'stats') {
loadMemoryStats().then(function() { renderHotspotsColumn(); });
} else if (payload.type === 'graph') {
loadMemoryGraph().then(function() { renderGraphColumn(); });
} else if (payload.type === 'context') {
loadRecentContext().then(function() { renderContextColumn(); });
} else {
// Full refresh
refreshMemoryData();
}
// Highlight updated node if provided
if (payload.nodeId) {
highlightNode(payload.nodeId);
}
}
// ========== Utilities ==========
function formatTimestamp(timestamp) {
var date = new Date(timestamp);
var now = new Date();
var diff = now - date;
// Less than 1 minute
if (diff < 60000) {
return t('memory.justNow');
}
// Less than 1 hour
if (diff < 3600000) {
var minutes = Math.floor(diff / 60000);
return minutes + ' ' + t('memory.minutesAgo');
}
// Less than 1 day
if (diff < 86400000) {
var hours = Math.floor(diff / 3600000);
return hours + ' ' + t('memory.hoursAgo');
}
// Otherwise show date
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}

View File

@@ -0,0 +1,538 @@
// Prompt History View
// Displays prompt history and optimization insights
// ========== State ==========
var promptHistoryData = [];
var promptInsights = null;
var promptHistorySearch = '';
var promptHistoryDateFilter = null;
var promptHistoryProjectFilter = null;
var selectedPromptId = null;
// ========== Data Loading ==========
async function loadPromptHistory() {
try {
// Use native Claude history.jsonl as primary source
var response = await fetch('/api/memory/native-history?path=' + encodeURIComponent(projectPath) + '&limit=200');
if (!response.ok) throw new Error('Failed to load prompt history');
var data = await response.json();
promptHistoryData = data.prompts || [];
console.log('[PromptHistory] Loaded', promptHistoryData.length, 'prompts from native history');
return promptHistoryData;
} catch (err) {
console.error('Failed to load prompt history:', err);
promptHistoryData = [];
return [];
}
}
async function loadPromptInsights() {
try {
var response = await fetch('/api/memory/insights?path=' + encodeURIComponent(projectPath));
if (!response.ok) throw new Error('Failed to load insights');
var data = await response.json();
promptInsights = data.insights || null;
return promptInsights;
} catch (err) {
console.error('Failed to load insights:', err);
promptInsights = null;
return null;
}
}
// ========== Rendering ==========
async function renderPromptHistoryView() {
var container = document.getElementById('mainContent');
if (!container) return;
// Hide stats grid and search
var statsGrid = document.getElementById('statsGrid');
var searchInput = document.getElementById('searchInput');
if (statsGrid) statsGrid.style.display = 'none';
if (searchInput) searchInput.parentElement.style.display = 'none';
// Load data
await Promise.all([loadPromptHistory(), loadPromptInsights()]);
// Calculate stats
var totalPrompts = promptHistoryData.length;
var intentDistribution = calculateIntentDistribution(promptHistoryData);
var avgLength = calculateAverageLength(promptHistoryData);
var qualityDistribution = calculateQualityDistribution(promptHistoryData);
container.innerHTML = '<div class="prompt-history-view">' +
'<div class="prompt-history-header">' +
renderStatsSection(totalPrompts, intentDistribution, avgLength, qualityDistribution) +
'</div>' +
'<div class="prompt-history-content">' +
'<div class="prompt-history-left">' +
renderPromptTimeline() +
'</div>' +
'<div class="prompt-history-right">' +
renderInsightsPanel() +
'</div>' +
'</div>' +
'</div>';
if (window.lucide) lucide.createIcons();
}
function renderStatsSection(totalPrompts, intentDist, avgLength, qualityDist) {
var topIntent = intentDist.length > 0 ? intentDist[0].intent : 'N/A';
var topIntentCount = intentDist.length > 0 ? intentDist[0].count : 0;
var intentLabel = t('prompt.intent.' + topIntent) || topIntent;
return '<div class="prompt-stats-grid">' +
'<div class="prompt-stat-card">' +
'<div class="stat-icon"><i data-lucide="message-square" class="w-5 h-5"></i></div>' +
'<div class="stat-content">' +
'<div class="stat-value">' + totalPrompts + '</div>' +
'<div class="stat-label">' + (isZh() ? '总提示词' : 'Total Prompts') + '</div>' +
'</div>' +
'</div>' +
'<div class="prompt-stat-card">' +
'<div class="stat-icon"><i data-lucide="target" class="w-5 h-5"></i></div>' +
'<div class="stat-content">' +
'<div class="stat-value">' + intentLabel + '</div>' +
'<div class="stat-label">' + (isZh() ? '主要意图' : 'Top Intent') + ' (' + topIntentCount + ')</div>' +
'</div>' +
'</div>' +
'<div class="prompt-stat-card">' +
'<div class="stat-icon"><i data-lucide="align-left" class="w-5 h-5"></i></div>' +
'<div class="stat-content">' +
'<div class="stat-value">' + avgLength + '</div>' +
'<div class="stat-label">' + (isZh() ? '平均长度' : 'Avg Length') + '</div>' +
'</div>' +
'</div>' +
'<div class="prompt-stat-card">' +
'<div class="stat-icon"><i data-lucide="bar-chart-2" class="w-5 h-5"></i></div>' +
'<div class="stat-content">' +
'<div class="stat-value">' + renderQualityBadge(qualityDist.average) + '</div>' +
'<div class="stat-label">' + t('prompt.quality') + '</div>' +
'</div>' +
'</div>' +
'</div>';
}
function renderPromptTimeline() {
var filteredPrompts = filterPrompts(promptHistoryData);
var groupedBySession = groupPromptsBySession(filteredPrompts);
var html = '<div class="prompt-timeline-header">' +
'<h3><i data-lucide="clock" class="w-4 h-4"></i> ' + t('prompt.timeline') + '</h3>' +
'<div class="prompt-timeline-filters">' +
'<div class="prompt-search-wrapper">' +
'<i data-lucide="search" class="w-4 h-4"></i>' +
'<input type="text" class="prompt-search-input" placeholder="' + t('prompt.searchPlaceholder') + '" ' +
'value="' + escapeHtml(promptHistorySearch) + '" ' +
'oninput="searchPrompts(this.value)">' +
'</div>' +
'<button class="btn-icon" onclick="refreshPromptHistory()" title="' + t('common.refresh') + '">' +
'<i data-lucide="refresh-cw" class="w-4 h-4"></i>' +
'</button>' +
'</div>' +
'</div>';
if (filteredPrompts.length === 0) {
html += '<div class="prompt-empty-state">' +
'<i data-lucide="message-circle-off" class="w-12 h-12"></i>' +
'<h3>' + t('prompt.noPromptsFound') + '</h3>' +
'<p>' + t('prompt.noPromptsText') + '</p>' +
'</div>';
} else {
html += '<div class="prompt-timeline-list">';
for (var sessionId in groupedBySession) {
html += renderSessionGroup(sessionId, groupedBySession[sessionId]);
}
html += '</div>';
}
return html;
}
function renderSessionGroup(sessionId, prompts) {
var sessionDate = new Date(prompts[0].timestamp).toLocaleDateString();
var shortSessionId = sessionId.substring(0, 8);
var html = '<div class="prompt-session-group">' +
'<div class="prompt-session-header">' +
'<span class="prompt-session-id">' +
'<i data-lucide="layers" class="w-3.5 h-3.5"></i> ' +
shortSessionId +
'</span>' +
'<span class="prompt-session-date">' + sessionDate + '</span>' +
'<span class="prompt-session-count">' + prompts.length + ' prompt' + (prompts.length > 1 ? 's' : '') + '</span>' +
'</div>' +
'<div class="prompt-session-items">';
for (var i = 0; i < prompts.length; i++) {
html += renderPromptItem(prompts[i]);
}
html += '</div></div>';
return html;
}
function renderPromptItem(prompt) {
var isExpanded = selectedPromptId === prompt.id;
var timeAgo = getTimeAgo(new Date(prompt.timestamp));
var preview = prompt.text.substring(0, 120) + (prompt.text.length > 120 ? '...' : '');
var qualityClass = getQualityClass(prompt.quality_score);
var html = '<div class="prompt-item' + (isExpanded ? ' prompt-item-expanded' : '') + '" ' +
'onclick="togglePromptExpand(\'' + prompt.id + '\')">' +
'<div class="prompt-item-header">' +
'<span class="prompt-intent-tag">' + (prompt.intent || 'unknown') + '</span>' +
'<span class="prompt-quality-badge ' + qualityClass + '">' +
'<i data-lucide="sparkles" class="w-3 h-3"></i> ' + (prompt.quality_score || 0) +
'</span>' +
'<span class="prompt-time">' + timeAgo + '</span>' +
'</div>' +
'<div class="prompt-item-preview">' + escapeHtml(preview) + '</div>';
if (isExpanded) {
html += '<div class="prompt-item-full">' +
'<div class="prompt-full-text">' + escapeHtml(prompt.text) + '</div>' +
'<div class="prompt-item-meta">' +
'<span><i data-lucide="type" class="w-3 h-3"></i> ' + prompt.text.length + ' chars</span>' +
(prompt.project ? '<span><i data-lucide="folder" class="w-3 h-3"></i> ' + escapeHtml(prompt.project) + '</span>' : '') +
'</div>' +
'<div class="prompt-item-actions-full">' +
'<button class="btn btn-sm btn-outline" onclick="event.stopPropagation(); copyPrompt(\'' + prompt.id + '\')">' +
'<i data-lucide="copy" class="w-3.5 h-3.5"></i> ' + t('common.copy') +
'</button>' +
'</div>' +
'</div>';
}
html += '</div>';
return html;
}
function renderInsightsPanel() {
var html = '<div class="insights-panel-header">' +
'<h3><i data-lucide="lightbulb" class="w-4 h-4"></i> ' + t('prompt.insights') + '</h3>' +
'<div class="insights-actions">' +
'<select id="insightsTool" class="insights-tool-select">' +
'<option value="gemini">Gemini</option>' +
'<option value="qwen">Qwen</option>' +
'</select>' +
'<button class="btn btn-sm btn-primary" onclick="triggerCliInsightsAnalysis()" id="analyzeBtn">' +
'<i data-lucide="sparkles" class="w-3.5 h-3.5"></i> ' + t('prompt.analyze') +
'</button>' +
'</div>' +
'</div>';
// Show loading state
if (window.insightsAnalyzing) {
html += '<div class="insights-loading">' +
'<div class="loading-spinner"><i data-lucide="loader-2" class="w-6 h-6 animate-spin"></i></div>' +
'<p>' + t('prompt.loadingInsights') + '</p>' +
'</div>';
return html;
}
if (!promptInsights || !promptInsights.patterns || promptInsights.patterns.length === 0) {
html += '<div class="insights-empty-state">' +
'<i data-lucide="brain" class="w-10 h-10"></i>' +
'<p>' + t('prompt.noInsights') + '</p>' +
'<p class="insights-hint">' + t('prompt.noInsightsText') + '</p>' +
'</div>';
} else {
html += '<div class="insights-list">';
// Render detected patterns
if (promptInsights.patterns && promptInsights.patterns.length > 0) {
html += '<div class="insights-section">' +
'<h4><i data-lucide="alert-circle" class="w-4 h-4"></i> Detected Patterns</h4>';
for (var i = 0; i < promptInsights.patterns.length; i++) {
html += renderPatternCard(promptInsights.patterns[i]);
}
html += '</div>';
}
// Render suggestions
if (promptInsights.suggestions && promptInsights.suggestions.length > 0) {
html += '<div class="insights-section">' +
'<h4><i data-lucide="zap" class="w-4 h-4"></i> Optimization Suggestions</h4>';
for (var j = 0; j < promptInsights.suggestions.length; j++) {
html += renderSuggestionCard(promptInsights.suggestions[j]);
}
html += '</div>';
}
// Render similar successful prompts
if (promptInsights.similar_prompts && promptInsights.similar_prompts.length > 0) {
html += '<div class="insights-section">' +
'<h4><i data-lucide="stars" class="w-4 h-4"></i> Similar Successful Prompts</h4>';
for (var k = 0; k < promptInsights.similar_prompts.length; k++) {
html += renderSimilarPromptCard(promptInsights.similar_prompts[k]);
}
html += '</div>';
}
html += '</div>';
}
return html;
}
function renderPatternCard(pattern) {
var iconMap = {
'vague': 'help-circle',
'correction': 'rotate-ccw',
'repetitive': 'repeat',
'incomplete': 'alert-triangle'
};
var icon = iconMap[pattern.type] || 'info';
var severityClass = pattern.severity || 'medium';
return '<div class="pattern-card pattern-' + severityClass + '">' +
'<div class="pattern-header">' +
'<i data-lucide="' + icon + '" class="w-4 h-4"></i>' +
'<span class="pattern-type">' + (pattern.type || 'Unknown') + '</span>' +
'<span class="pattern-count">' + (pattern.occurrences || 0) + 'x</span>' +
'</div>' +
'<div class="pattern-description">' + escapeHtml(pattern.description || '') + '</div>' +
'<div class="pattern-suggestion">' +
'<i data-lucide="arrow-right" class="w-3 h-3"></i> ' +
escapeHtml(pattern.suggestion || '') +
'</div>' +
'</div>';
}
function renderSuggestionCard(suggestion) {
return '<div class="suggestion-card">' +
'<div class="suggestion-title">' +
'<i data-lucide="sparkle" class="w-3.5 h-3.5"></i> ' +
escapeHtml(suggestion.title || 'Suggestion') +
'</div>' +
'<div class="suggestion-description">' + escapeHtml(suggestion.description || '') + '</div>' +
(suggestion.example ?
'<div class="suggestion-example">' +
'<div class="suggestion-example-label">Example:</div>' +
'<code>' + escapeHtml(suggestion.example) + '</code>' +
'</div>' : '') +
'</div>';
}
function renderSimilarPromptCard(prompt) {
var similarity = Math.round((prompt.similarity || 0) * 100);
var preview = prompt.text.substring(0, 80) + (prompt.text.length > 80 ? '...' : '');
return '<div class="similar-prompt-card" onclick="showPromptDetail(\'' + prompt.id + '\')">' +
'<div class="similar-prompt-header">' +
'<span class="similar-prompt-similarity">' + similarity + '% match</span>' +
'<span class="similar-prompt-intent">' + (prompt.intent || 'unknown') + '</span>' +
'</div>' +
'<div class="similar-prompt-preview">' + escapeHtml(preview) + '</div>' +
'<div class="similar-prompt-meta">' +
'<span class="similar-prompt-quality">' +
'<i data-lucide="star" class="w-3 h-3"></i> ' + (prompt.quality_score || 0) +
'</span>' +
'</div>' +
'</div>';
}
function renderProjectOptions() {
var projects = getUniqueProjects(promptHistoryData);
var html = '';
for (var i = 0; i < projects.length; i++) {
var selected = projects[i] === promptHistoryProjectFilter ? 'selected' : '';
html += '<option value="' + escapeHtml(projects[i]) + '" ' + selected + '>' +
escapeHtml(projects[i]) + '</option>';
}
return html;
}
function renderQualityBadge(score) {
if (score >= 80) return '<span class="quality-badge high">' + score + '</span>';
if (score >= 60) return '<span class="quality-badge medium">' + score + '</span>';
return '<span class="quality-badge low">' + score + '</span>';
}
// ========== Helper Functions ==========
function calculateIntentDistribution(prompts) {
var distribution = {};
for (var i = 0; i < prompts.length; i++) {
var intent = prompts[i].intent || 'unknown';
distribution[intent] = (distribution[intent] || 0) + 1;
}
var result = [];
for (var key in distribution) {
result.push({ intent: key, count: distribution[key] });
}
result.sort(function(a, b) { return b.count - a.count; });
return result;
}
function calculateAverageLength(prompts) {
if (prompts.length === 0) return 0;
var total = 0;
for (var i = 0; i < prompts.length; i++) {
total += (prompts[i].text || '').length;
}
return Math.round(total / prompts.length);
}
function calculateQualityDistribution(prompts) {
if (prompts.length === 0) return { average: 0, distribution: {} };
var total = 0;
var distribution = { high: 0, medium: 0, low: 0 };
for (var i = 0; i < prompts.length; i++) {
var score = prompts[i].quality_score || 0;
total += score;
if (score >= 80) distribution.high++;
else if (score >= 60) distribution.medium++;
else distribution.low++;
}
return {
average: Math.round(total / prompts.length),
distribution: distribution
};
}
function getQualityClass(score) {
if (score >= 80) return 'quality-high';
if (score >= 60) return 'quality-medium';
return 'quality-low';
}
function filterPrompts(prompts) {
return prompts.filter(function(prompt) {
var matchesSearch = !promptHistorySearch ||
prompt.text.toLowerCase().includes(promptHistorySearch.toLowerCase());
var matchesProject = !promptHistoryProjectFilter ||
prompt.project === promptHistoryProjectFilter;
var matchesDate = !promptHistoryDateFilter ||
isSameDay(new Date(prompt.timestamp), promptHistoryDateFilter);
return matchesSearch && matchesProject && matchesDate;
});
}
function groupPromptsBySession(prompts) {
var grouped = {};
for (var i = 0; i < prompts.length; i++) {
var sessionId = prompts[i].session_id || 'unknown';
if (!grouped[sessionId]) grouped[sessionId] = [];
grouped[sessionId].push(prompts[i]);
}
return grouped;
}
function getUniqueProjects(prompts) {
var projects = {};
for (var i = 0; i < prompts.length; i++) {
if (prompts[i].project) projects[prompts[i].project] = true;
}
return Object.keys(projects).sort();
}
function isSameDay(date1, date2) {
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate();
}
// ========== Actions ==========
function searchPrompts(query) {
promptHistorySearch = query;
renderPromptHistoryView();
}
function filterByProject(project) {
promptHistoryProjectFilter = project || null;
renderPromptHistoryView();
}
function togglePromptExpand(promptId) {
if (selectedPromptId === promptId) {
selectedPromptId = null;
} else {
selectedPromptId = promptId;
}
renderPromptHistoryView();
}
function copyPrompt(promptId) {
var prompt = promptHistoryData.find(function(p) { return p.id === promptId; });
if (!prompt) return;
if (navigator.clipboard) {
navigator.clipboard.writeText(prompt.text).then(function() {
showRefreshToast('Prompt copied to clipboard', 'success');
}).catch(function() {
showRefreshToast('Failed to copy prompt', 'error');
});
}
}
function showPromptDetail(promptId) {
togglePromptExpand(promptId);
}
async function refreshPromptHistory() {
await Promise.all([loadPromptHistory(), loadPromptInsights()]);
renderPromptHistoryView();
showRefreshToast('Prompt history refreshed', 'success');
}
// ========== CLI-based Insights Analysis ==========
async function triggerCliInsightsAnalysis() {
if (promptHistoryData.length === 0) {
showRefreshToast(t('prompt.noPromptsFound'), 'error');
return;
}
var toolSelect = document.getElementById('insightsTool');
var tool = toolSelect ? toolSelect.value : 'gemini';
var analyzeBtn = document.getElementById('analyzeBtn');
// Show loading state
window.insightsAnalyzing = true;
if (analyzeBtn) {
analyzeBtn.disabled = true;
analyzeBtn.innerHTML = '<i data-lucide="loader-2" class="w-3.5 h-3.5 animate-spin"></i> ' + t('prompt.analyzing');
}
renderPromptHistoryView();
try {
var response = await fetch('/api/memory/insights/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: projectPath,
tool: tool,
lang: getLang(), // Send current language preference
prompts: promptHistoryData.slice(0, 50) // Send top 50 prompts for analysis
})
});
var data = await response.json();
if (data.error) {
throw new Error(data.error);
}
// Update insights with CLI analysis results
if (data.insights) {
promptInsights = data.insights;
console.log('[PromptHistory] Insights parsed:', promptInsights);
}
showRefreshToast(t('toast.completed') + ' (' + tool + ')', 'success');
} catch (err) {
console.error('CLI insights analysis failed:', err);
showRefreshToast(t('prompt.insightsError') + ': ' + err.message, 'error');
} finally {
window.insightsAnalyzing = false;
renderPromptHistoryView();
}
}

View File

@@ -409,6 +409,24 @@
</ul>
</div>
<!-- Memory Section -->
<div class="mb-2" id="memoryNav">
<div class="flex items-center px-4 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wide">
<i data-lucide="brain" class="nav-section-icon mr-2"></i>
<span class="nav-section-title" data-i18n="nav.memory">Memory</span>
</div>
<ul class="space-y-0.5">
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="memory" data-tooltip="Context Memory">
<i data-lucide="database" class="nav-icon"></i>
<span class="nav-text flex-1" data-i18n="nav.contextMemory">Context</span>
</li>
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="prompt-history" data-tooltip="Prompt History">
<i data-lucide="message-square" class="nav-icon"></i>
<span class="nav-text flex-1" data-i18n="nav.promptHistory">Prompts</span>
</li>
</ul>
</div>
</nav>
<!-- Sidebar Footer -->

View File

@@ -9,6 +9,20 @@ import { spawn, ChildProcess } from 'child_process';
import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, readdirSync, statSync } from 'fs';
import { join, relative } from 'path';
// Native resume support
import {
trackNewSession,
getNativeResumeArgs,
supportsNativeResume,
calculateProjectHash
} from './native-session-discovery.js';
import {
determineResumeStrategy,
buildContextPrefix,
getResumeModeDescription,
type ResumeDecision
} from './resume-strategy.js';
// CLI History storage path
const CLI_HISTORY_DIR = join(process.cwd(), '.workflow', '.cli-history');
@@ -47,8 +61,13 @@ const ParamsSchema = z.object({
timeout: z.number().default(300000),
resume: z.union([z.boolean(), z.string()]).optional(), // true = last, string = single ID or comma-separated IDs
id: z.string().optional(), // Custom execution ID (e.g., IMPL-001-step1)
noNative: z.boolean().optional(), // Force prompt concatenation instead of native resume
category: z.enum(['user', 'internal', 'insight']).default('user'), // Execution category for tracking
});
// Execution category types
export type ExecutionCategory = 'user' | 'internal' | 'insight';
type Params = z.infer<typeof ParamsSchema>;
// Prompt concatenation format types
@@ -82,6 +101,7 @@ interface ConversationRecord {
tool: string;
model: string;
mode: string;
category: ExecutionCategory; // user | internal | insight
total_duration_ms: number;
turn_count: number;
latest_status: 'success' | 'error' | 'timeout';
@@ -165,6 +185,13 @@ async function checkToolAvailability(tool: string): Promise<ToolAvailability> {
});
}
// Native resume configuration
interface NativeResumeConfig {
enabled: boolean;
sessionId?: string; // Native UUID
isLatest?: boolean; // Use latest/--last flag
}
/**
* Build command arguments based on tool and options
*/
@@ -175,8 +202,9 @@ function buildCommand(params: {
model?: string;
dir?: string;
include?: string;
nativeResume?: NativeResumeConfig;
}): { command: string; args: string[]; useStdin: boolean } {
const { tool, prompt, mode = 'analysis', model, dir, include } = params;
const { tool, prompt, mode = 'analysis', model, dir, include, nativeResume } = params;
let command = tool;
let args: string[] = [];
@@ -185,7 +213,14 @@ function buildCommand(params: {
switch (tool) {
case 'gemini':
// gemini reads from stdin when no positional prompt is provided
// Native resume: gemini -r <uuid> or -r latest
if (nativeResume?.enabled) {
if (nativeResume.isLatest) {
args.push('-r', 'latest');
} else if (nativeResume.sessionId) {
args.push('-r', nativeResume.sessionId);
}
}
if (model) {
args.push('-m', model);
}
@@ -198,7 +233,14 @@ function buildCommand(params: {
break;
case 'qwen':
// qwen reads from stdin when no positional prompt is provided
// Native resume: qwen --continue (latest) or --resume <uuid>
if (nativeResume?.enabled) {
if (nativeResume.isLatest) {
args.push('--continue');
} else if (nativeResume.sessionId) {
args.push('--resume', nativeResume.sessionId);
}
}
if (model) {
args.push('-m', model);
}
@@ -211,26 +253,50 @@ function buildCommand(params: {
break;
case 'codex':
// codex reads from stdin for prompt
args.push('exec');
if (dir) {
args.push('-C', dir);
}
args.push('--full-auto');
if (mode === 'write' || mode === 'auto') {
args.push('--skip-git-repo-check', '-s', 'danger-full-access');
}
if (model) {
args.push('-m', model);
}
if (include) {
// Codex uses --add-dir for additional directories
const dirs = include.split(',').map(d => d.trim()).filter(d => d);
for (const addDir of dirs) {
args.push('--add-dir', addDir);
// Native resume: codex resume <uuid> [prompt] or --last
if (nativeResume?.enabled) {
args.push('resume');
if (nativeResume.isLatest) {
args.push('--last');
} else if (nativeResume.sessionId) {
args.push(nativeResume.sessionId);
}
// Codex resume still supports additional flags
if (dir) {
args.push('-C', dir);
}
if (mode === 'write' || mode === 'auto') {
args.push('--skip-git-repo-check', '-s', 'danger-full-access');
}
if (model) {
args.push('-m', model);
}
if (include) {
const dirs = include.split(',').map(d => d.trim()).filter(d => d);
for (const addDir of dirs) {
args.push('--add-dir', addDir);
}
}
} else {
// Standard exec mode
args.push('exec');
if (dir) {
args.push('-C', dir);
}
args.push('--full-auto');
if (mode === 'write' || mode === 'auto') {
args.push('--skip-git-repo-check', '-s', 'danger-full-access');
}
if (model) {
args.push('-m', model);
}
if (include) {
const dirs = include.split(',').map(d => d.trim()).filter(d => d);
for (const addDir of dirs) {
args.push('--add-dir', addDir);
}
}
}
// Prompt passed via stdin (default)
break;
default:
@@ -310,6 +376,7 @@ function convertToConversation(record: ExecutionRecord): ConversationRecord {
tool: record.tool,
model: record.model,
mode: record.mode,
category: 'user', // Legacy records default to user category
total_duration_ms: record.duration_ms,
turn_count: 1,
latest_status: record.status,
@@ -406,12 +473,15 @@ async function executeCliTool(
throw new Error(`Invalid params: ${parsed.error.message}`);
}
const { tool, prompt, mode, format, model, cd, includeDirs, timeout, resume, id: customId } = parsed.data;
const { tool, prompt, mode, format, model, cd, includeDirs, timeout, resume, id: customId, noNative, category } = parsed.data;
// Determine working directory early (needed for conversation lookup)
const workingDir = cd || process.cwd();
const historyDir = ensureHistoryDir(workingDir);
// Get SQLite store for native session lookup
const store = await getSqliteStore(workingDir);
// Determine conversation ID and load existing conversation
// Logic:
// - If --resume <id1,id2,...> (multiple IDs): merge conversations
@@ -484,14 +554,61 @@ async function executeCliTool(
conversationId = `${Date.now()}-${tool}`;
}
// Determine resume strategy (native vs prompt-concat vs hybrid)
let resumeDecision: ResumeDecision | null = null;
let nativeResumeConfig: NativeResumeConfig | undefined;
// resume=true (latest) - use native latest if supported
if (resume === true && !noNative && supportsNativeResume(tool)) {
resumeDecision = {
strategy: 'native',
isLatest: true,
primaryConversationId: conversationId
};
}
// Use strategy engine for complex scenarios
else if (resumeIds.length > 0 && !noNative) {
resumeDecision = determineResumeStrategy({
tool,
resumeIds,
customId,
forcePromptConcat: noNative,
getNativeSessionId: (ccwId) => store.getNativeSessionId(ccwId),
getConversation: (ccwId) => loadConversation(historyDir, ccwId),
getConversationTool: (ccwId) => {
const conv = loadConversation(historyDir, ccwId);
return conv?.tool || null;
}
});
}
// Configure native resume if strategy decided to use it
if (resumeDecision && (resumeDecision.strategy === 'native' || resumeDecision.strategy === 'hybrid')) {
nativeResumeConfig = {
enabled: true,
sessionId: resumeDecision.nativeSessionId,
isLatest: resumeDecision.isLatest
};
}
// Build final prompt with conversation context
// For merge: use merged context from all source conversations
// For fork: use contextConversation (from resume ID) for prompt context
// For append: use existingConversation (from target ID)
// For native: minimal prompt (native tool handles context)
// For hybrid: context prefix from other conversations + new prompt
// For prompt-concat: full multi-turn prompt
let finalPrompt = prompt;
if (mergeResult && mergeResult.mergedTurns.length > 0) {
if (resumeDecision?.strategy === 'native') {
// Native mode: just use the new prompt, tool handles context
finalPrompt = prompt;
} else if (resumeDecision?.strategy === 'hybrid' && resumeDecision.contextTurns?.length) {
// Hybrid mode: add context prefix from other conversations
const contextPrefix = buildContextPrefix(resumeDecision.contextTurns, format);
finalPrompt = contextPrefix + prompt;
} else if (mergeResult && mergeResult.mergedTurns.length > 0) {
// Full merge: use merged prompt
finalPrompt = buildMergedPrompt(mergeResult, prompt, format);
} else {
// Standard prompt-concat
const conversationForContext = contextConversation || existingConversation;
if (conversationForContext && conversationForContext.turns.length > 0) {
finalPrompt = buildMultiTurnPrompt(conversationForContext, prompt, format);
@@ -504,6 +621,14 @@ async function executeCliTool(
throw new Error(`CLI tool not available: ${tool}. Please ensure it is installed and in PATH.`);
}
// Log resume mode for debugging
if (resumeDecision) {
const modeDesc = getResumeModeDescription(resumeDecision);
if (onOutput) {
onOutput({ type: 'stderr', data: `[Resume mode: ${modeDesc}]\n` });
}
}
// Build command
const { command, args, useStdin } = buildCommand({
tool,
@@ -511,7 +636,8 @@ async function executeCliTool(
mode,
model,
dir: cd,
include: includeDirs
include: includeDirs,
nativeResume: nativeResumeConfig
});
const startTime = Date.now();
@@ -668,6 +794,7 @@ async function executeCliTool(
tool,
model: model || 'default',
mode,
category,
total_duration_ms: mergeResult.totalDuration + duration,
turn_count: mergedTurns.length + 1,
latest_status: status,
@@ -697,6 +824,7 @@ async function executeCliTool(
tool,
model: model || 'default',
mode,
category,
total_duration_ms: duration,
turn_count: 1,
latest_status: status,
@@ -711,6 +839,29 @@ async function executeCliTool(
}
}
// Track native session after execution (async, non-blocking)
trackNewSession(tool, new Date(startTime), workingDir)
.then((nativeSession) => {
if (nativeSession) {
// Save native session mapping
try {
store.saveNativeSessionMapping({
ccw_id: conversationId,
tool,
native_session_id: nativeSession.sessionId,
native_session_path: nativeSession.filePath,
project_hash: nativeSession.projectHash,
created_at: new Date().toISOString()
});
} catch (err) {
console.error('[CLI Executor] Failed to save native session mapping:', (err as Error).message);
}
}
})
.catch((err) => {
console.error('[CLI Executor] Failed to track native session:', (err as Error).message);
});
// Create legacy execution record for backward compatibility
const execution: ExecutionRecord = {
id: conversationId,
@@ -860,6 +1011,7 @@ export async function getExecutionHistoryAsync(baseDir: string, options: {
limit?: number;
tool?: string | null;
status?: string | null;
category?: ExecutionCategory | null;
search?: string | null;
recursive?: boolean;
} = {}): Promise<{
@@ -867,7 +1019,7 @@ export async function getExecutionHistoryAsync(baseDir: string, options: {
count: number;
executions: (HistoryIndex['executions'][0] & { sourceDir?: string })[];
}> {
const { limit = 50, tool = null, status = null, search = null, recursive = false } = options;
const { limit = 50, tool = null, status = null, category = null, search = null, recursive = false } = options;
if (recursive) {
// For recursive, we need to check multiple directories
@@ -878,7 +1030,7 @@ export async function getExecutionHistoryAsync(baseDir: string, options: {
for (const historyDir of historyDirs) {
const dirBase = historyDir.replace(/[\\\/]\.workflow[\\\/]\.cli-history$/, '');
const store = await getSqliteStore(dirBase);
const result = store.getHistory({ limit: 100, tool, status, search });
const result = store.getHistory({ limit: 100, tool, status, category, search });
totalCount += result.total;
const relativeSource = relative(baseDir, dirBase) || '.';
@@ -898,7 +1050,7 @@ export async function getExecutionHistoryAsync(baseDir: string, options: {
}
const store = await getSqliteStore(baseDir);
return store.getHistory({ limit, tool, status, search });
return store.getHistory({ limit, tool, status, category, search });
}
/**
@@ -1447,6 +1599,61 @@ export function getLatestExecution(baseDir: string, tool?: string): ExecutionRec
return getExecutionDetail(baseDir, history.executions[0].id);
}
// ========== Native Session Content Functions ==========
/**
* Get native session content by CCW ID
* Parses the native session file and returns full conversation data
*/
export async function getNativeSessionContent(baseDir: string, ccwId: string) {
const store = await getSqliteStore(baseDir);
return store.getNativeSessionContent(ccwId);
}
/**
* Get formatted native conversation text
*/
export async function getFormattedNativeConversation(baseDir: string, ccwId: string, options?: {
includeThoughts?: boolean;
includeToolCalls?: boolean;
includeTokens?: boolean;
maxContentLength?: number;
}) {
const store = await getSqliteStore(baseDir);
return store.getFormattedNativeConversation(ccwId, options);
}
/**
* Get conversation pairs from native session
*/
export async function getNativeConversationPairs(baseDir: string, ccwId: string) {
const store = await getSqliteStore(baseDir);
return store.getNativeConversationPairs(ccwId);
}
/**
* Get enriched conversation (CCW + native session merged)
*/
export async function getEnrichedConversation(baseDir: string, ccwId: string) {
const store = await getSqliteStore(baseDir);
return store.getEnrichedConversation(ccwId);
}
/**
* Get history with native session info
*/
export async function getHistoryWithNativeInfo(baseDir: string, options?: {
limit?: number;
offset?: number;
tool?: string | null;
status?: string | null;
category?: ExecutionCategory | null;
search?: string | null;
}) {
const store = await getSqliteStore(baseDir);
return store.getHistoryWithNativeInfo(options || {});
}
// Export types
export type { ConversationRecord, ConversationTurn, ExecutionRecord, PromptFormat, ConcatOptions };

View File

@@ -6,6 +6,7 @@
import Database from 'better-sqlite3';
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, rmdirSync } from 'fs';
import { join } from 'path';
import { parseSessionFile, formatConversation, extractConversationPairs, type ParsedSession, type ParsedTurn } from './session-content-parser.js';
// Types
export interface ConversationTurn {
@@ -22,6 +23,9 @@ export interface ConversationTurn {
};
}
// Execution category types
export type ExecutionCategory = 'user' | 'internal' | 'insight';
export interface ConversationRecord {
id: string;
created_at: string;
@@ -29,6 +33,7 @@ export interface ConversationRecord {
tool: string;
model: string;
mode: string;
category: ExecutionCategory; // user | internal | insight
total_duration_ms: number;
turn_count: number;
latest_status: 'success' | 'error' | 'timeout';
@@ -40,6 +45,7 @@ export interface HistoryQueryOptions {
offset?: number;
tool?: string | null;
status?: string | null;
category?: ExecutionCategory | null;
search?: string | null;
startDate?: string | null;
endDate?: string | null;
@@ -51,12 +57,23 @@ export interface HistoryIndexEntry {
updated_at?: string;
tool: string;
status: string;
category?: ExecutionCategory;
duration_ms: number;
turn_count?: number;
prompt_preview: string;
sourceDir?: string;
}
// Native session mapping interface
export interface NativeSessionMapping {
ccw_id: string; // CCW execution ID (e.g., 1702123456789-gemini)
tool: string; // gemini | qwen | codex
native_session_id: string; // Native UUID
native_session_path?: string; // Native file path
project_hash?: string; // Project hash (Gemini/Qwen)
created_at: string;
}
/**
* CLI History Store using SQLite
*/
@@ -92,6 +109,7 @@ export class CliHistoryStore {
tool TEXT NOT NULL,
model TEXT DEFAULT 'default',
mode TEXT DEFAULT 'analysis',
category TEXT DEFAULT 'user',
total_duration_ms INTEGER DEFAULT 0,
turn_count INTEGER DEFAULT 0,
latest_status TEXT DEFAULT 'success',
@@ -118,6 +136,7 @@ export class CliHistoryStore {
-- Indexes for efficient queries
CREATE INDEX IF NOT EXISTS idx_conversations_tool ON conversations(tool);
CREATE INDEX IF NOT EXISTS idx_conversations_status ON conversations(latest_status);
CREATE INDEX IF NOT EXISTS idx_conversations_category ON conversations(category);
CREATE INDEX IF NOT EXISTS idx_conversations_updated ON conversations(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_conversations_created ON conversations(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_turns_conversation ON turns(conversation_id);
@@ -143,7 +162,41 @@ export class CliHistoryStore {
INSERT INTO turns_fts(turns_fts, rowid, prompt, stdout) VALUES('delete', old.id, old.prompt, old.stdout);
INSERT INTO turns_fts(rowid, prompt, stdout) VALUES (new.id, new.prompt, new.stdout);
END;
-- Native session mapping table (CCW ID <-> Native Session ID)
CREATE TABLE IF NOT EXISTS native_session_mapping (
ccw_id TEXT PRIMARY KEY,
tool TEXT NOT NULL,
native_session_id TEXT NOT NULL,
native_session_path TEXT,
project_hash TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(tool, native_session_id)
);
-- Indexes for native session lookups
CREATE INDEX IF NOT EXISTS idx_native_tool_session ON native_session_mapping(tool, native_session_id);
CREATE INDEX IF NOT EXISTS idx_native_session_id ON native_session_mapping(native_session_id);
`);
// Migration: Add category column if not exists (for existing databases)
this.migrateSchema();
}
/**
* Migrate schema for existing databases
*/
private migrateSchema(): void {
// Check if category column exists
const tableInfo = this.db.prepare('PRAGMA table_info(conversations)').all() as Array<{ name: string }>;
const hasCategory = tableInfo.some(col => col.name === 'category');
if (!hasCategory) {
this.db.exec(`
ALTER TABLE conversations ADD COLUMN category TEXT DEFAULT 'user';
CREATE INDEX IF NOT EXISTS idx_conversations_category ON conversations(category);
`);
}
}
/**
@@ -208,6 +261,7 @@ export class CliHistoryStore {
tool: data.tool,
model: data.model || 'default',
mode: data.mode || 'analysis',
category: data.category || 'user',
total_duration_ms: data.duration_ms || 0,
turn_count: 1,
latest_status: data.status || 'success',
@@ -232,8 +286,8 @@ export class CliHistoryStore {
: '';
const upsertConversation = this.db.prepare(`
INSERT INTO conversations (id, created_at, updated_at, tool, model, mode, total_duration_ms, turn_count, latest_status, prompt_preview)
VALUES (@id, @created_at, @updated_at, @tool, @model, @mode, @total_duration_ms, @turn_count, @latest_status, @prompt_preview)
INSERT INTO conversations (id, created_at, updated_at, tool, model, mode, category, total_duration_ms, turn_count, latest_status, prompt_preview)
VALUES (@id, @created_at, @updated_at, @tool, @model, @mode, @category, @total_duration_ms, @turn_count, @latest_status, @prompt_preview)
ON CONFLICT(id) DO UPDATE SET
updated_at = @updated_at,
total_duration_ms = @total_duration_ms,
@@ -264,6 +318,7 @@ export class CliHistoryStore {
tool: conversation.tool,
model: conversation.model,
mode: conversation.mode,
category: conversation.category || 'user',
total_duration_ms: conversation.total_duration_ms,
turn_count: conversation.turn_count,
latest_status: conversation.latest_status,
@@ -310,6 +365,7 @@ export class CliHistoryStore {
tool: conv.tool,
model: conv.model,
mode: conv.mode,
category: conv.category || 'user',
total_duration_ms: conv.total_duration_ms,
turn_count: conv.turn_count,
latest_status: conv.latest_status,
@@ -337,7 +393,7 @@ export class CliHistoryStore {
count: number;
executions: HistoryIndexEntry[];
} {
const { limit = 50, offset = 0, tool, status, search, startDate, endDate } = options;
const { limit = 50, offset = 0, tool, status, category, search, startDate, endDate } = options;
let whereClause = '1=1';
const params: any = {};
@@ -352,6 +408,11 @@ export class CliHistoryStore {
params.status = status;
}
if (category) {
whereClause += ' AND category = @category';
params.category = category;
}
if (startDate) {
whereClause += ' AND created_at >= @startDate';
params.startDate = startDate;
@@ -398,6 +459,7 @@ export class CliHistoryStore {
updated_at: r.updated_at,
tool: r.tool,
status: r.latest_status,
category: r.category || 'user',
duration_ms: r.total_duration_ms,
turn_count: r.turn_count,
prompt_preview: r.prompt_preview || ''
@@ -496,6 +558,252 @@ export class CliHistoryStore {
return { total, byTool, byStatus, totalDuration };
}
// ========== Native Session Mapping Methods ==========
/**
* Save or update native session mapping
*/
saveNativeSessionMapping(mapping: NativeSessionMapping): void {
const stmt = this.db.prepare(`
INSERT INTO native_session_mapping (ccw_id, tool, native_session_id, native_session_path, project_hash, created_at)
VALUES (@ccw_id, @tool, @native_session_id, @native_session_path, @project_hash, @created_at)
ON CONFLICT(ccw_id) DO UPDATE SET
native_session_id = @native_session_id,
native_session_path = @native_session_path,
project_hash = @project_hash
`);
stmt.run({
ccw_id: mapping.ccw_id,
tool: mapping.tool,
native_session_id: mapping.native_session_id,
native_session_path: mapping.native_session_path || null,
project_hash: mapping.project_hash || null,
created_at: mapping.created_at || new Date().toISOString()
});
}
/**
* Get native session ID by CCW ID
*/
getNativeSessionId(ccwId: string): string | null {
const row = this.db.prepare(`
SELECT native_session_id FROM native_session_mapping WHERE ccw_id = ?
`).get(ccwId) as any;
return row?.native_session_id || null;
}
/**
* Get CCW ID by native session ID
*/
getCcwIdByNativeSession(tool: string, nativeSessionId: string): string | null {
const row = this.db.prepare(`
SELECT ccw_id FROM native_session_mapping WHERE tool = ? AND native_session_id = ?
`).get(tool, nativeSessionId) as any;
return row?.ccw_id || null;
}
/**
* Get full mapping by CCW ID
*/
getNativeSessionMapping(ccwId: string): NativeSessionMapping | null {
const row = this.db.prepare(`
SELECT * FROM native_session_mapping WHERE ccw_id = ?
`).get(ccwId) as any;
if (!row) return null;
return {
ccw_id: row.ccw_id,
tool: row.tool,
native_session_id: row.native_session_id,
native_session_path: row.native_session_path,
project_hash: row.project_hash,
created_at: row.created_at
};
}
/**
* Get latest native session mapping for a tool
*/
getLatestNativeMapping(tool: string): NativeSessionMapping | null {
const row = this.db.prepare(`
SELECT * FROM native_session_mapping
WHERE tool = ?
ORDER BY created_at DESC
LIMIT 1
`).get(tool) as any;
if (!row) return null;
return {
ccw_id: row.ccw_id,
tool: row.tool,
native_session_id: row.native_session_id,
native_session_path: row.native_session_path,
project_hash: row.project_hash,
created_at: row.created_at
};
}
/**
* Delete native session mapping
*/
deleteNativeSessionMapping(ccwId: string): boolean {
const result = this.db.prepare('DELETE FROM native_session_mapping WHERE ccw_id = ?').run(ccwId);
return result.changes > 0;
}
/**
* Check if CCW ID has native session mapping
*/
hasNativeSession(ccwId: string): boolean {
const row = this.db.prepare(`
SELECT 1 FROM native_session_mapping WHERE ccw_id = ? LIMIT 1
`).get(ccwId);
return !!row;
}
// ========== Native Session Content Methods ==========
/**
* Get parsed native session content by CCW ID
* Returns full conversation with all turns from native session file
*/
getNativeSessionContent(ccwId: string): ParsedSession | null {
const mapping = this.getNativeSessionMapping(ccwId);
if (!mapping || !mapping.native_session_path) {
return null;
}
return parseSessionFile(mapping.native_session_path, mapping.tool);
}
/**
* Get formatted conversation text from native session
*/
getFormattedNativeConversation(ccwId: string, options?: {
includeThoughts?: boolean;
includeToolCalls?: boolean;
includeTokens?: boolean;
maxContentLength?: number;
}): string | null {
const session = this.getNativeSessionContent(ccwId);
if (!session) {
return null;
}
return formatConversation(session, options);
}
/**
* Get conversation pairs (user prompt + assistant response) from native session
*/
getNativeConversationPairs(ccwId: string): Array<{
turn: number;
userPrompt: string;
assistantResponse: string;
timestamp: string;
}> | null {
const session = this.getNativeSessionContent(ccwId);
if (!session) {
return null;
}
return extractConversationPairs(session);
}
/**
* Get conversation with enriched native session data
* Merges CCW history with native session content
*/
getEnrichedConversation(ccwId: string): {
ccw: ConversationRecord | null;
native: ParsedSession | null;
merged: Array<{
turn: number;
timestamp: string;
ccwPrompt?: string;
ccwOutput?: string;
nativeUserContent?: string;
nativeAssistantContent?: string;
nativeThoughts?: string[];
nativeToolCalls?: Array<{ name: string; arguments?: string; output?: string }>;
}>;
} | null {
const ccwConv = this.getConversation(ccwId);
const nativeSession = this.getNativeSessionContent(ccwId);
if (!ccwConv && !nativeSession) {
return null;
}
const merged: Array<{
turn: number;
timestamp: string;
ccwPrompt?: string;
ccwOutput?: string;
nativeUserContent?: string;
nativeAssistantContent?: string;
nativeThoughts?: string[];
nativeToolCalls?: Array<{ name: string; arguments?: string; output?: string }>;
}> = [];
// Determine max turn count
const maxTurns = Math.max(
ccwConv?.turn_count || 0,
nativeSession?.turns.filter(t => t.role === 'user').length || 0
);
for (let i = 1; i <= maxTurns; i++) {
const ccwTurn = ccwConv?.turns.find(t => t.turn === i);
const nativeUserTurn = nativeSession?.turns.find(t => t.turnNumber === i && t.role === 'user');
const nativeAssistantTurn = nativeSession?.turns.find(t => t.turnNumber === i && t.role === 'assistant');
merged.push({
turn: i,
timestamp: ccwTurn?.timestamp || nativeUserTurn?.timestamp || '',
ccwPrompt: ccwTurn?.prompt,
ccwOutput: ccwTurn?.output.stdout,
nativeUserContent: nativeUserTurn?.content,
nativeAssistantContent: nativeAssistantTurn?.content,
nativeThoughts: nativeAssistantTurn?.thoughts,
nativeToolCalls: nativeAssistantTurn?.toolCalls
});
}
return { ccw: ccwConv, native: nativeSession, merged };
}
/**
* List all conversations with native session info
*/
getHistoryWithNativeInfo(options: HistoryQueryOptions = {}): {
total: number;
count: number;
executions: Array<HistoryIndexEntry & {
hasNativeSession: boolean;
nativeSessionId?: string;
nativeSessionPath?: string;
}>;
} {
const history = this.getHistory(options);
const enrichedExecutions = history.executions.map(exec => {
const mapping = this.getNativeSessionMapping(exec.id);
return {
...exec,
hasNativeSession: !!mapping,
nativeSessionId: mapping?.native_session_id,
nativeSessionPath: mapping?.native_session_path
};
});
return {
total: history.total,
count: history.count,
executions: enrichedExecutions
};
}
/**
* Close database connection
*/
@@ -526,3 +834,6 @@ export function closeAllStores(): void {
}
storeCache.clear();
}
// Re-export types from session-content-parser
export type { ParsedSession, ParsedTurn } from './session-content-parser.js';

View File

@@ -0,0 +1,542 @@
/**
* Native Session Discovery - Discovers and tracks native CLI tool sessions
* Supports Gemini, Qwen, and Codex session formats
*/
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
import { join, basename, resolve } from 'path';
// basename is used for extracting session ID from filename
import { createHash } from 'crypto';
import { homedir } from 'os';
// Types
export interface NativeSession {
sessionId: string; // Native UUID
tool: string; // gemini | qwen | codex
filePath: string; // Full path to session file
projectHash?: string; // Project directory hash (Gemini/Qwen)
createdAt: Date;
updatedAt: Date;
}
export interface SessionDiscoveryOptions {
workingDir?: string; // Project working directory
limit?: number; // Max sessions to return
afterTimestamp?: Date; // Only sessions after this time
}
/**
* Calculate project hash (same algorithm as Gemini/Qwen)
* Note: Gemini/Qwen use the absolute path AS-IS without normalization
* On Windows, this means using backslashes and original case
*/
export function calculateProjectHash(projectDir: string): string {
// resolve() returns absolute path with native separators (backslash on Windows)
const absolutePath = resolve(projectDir);
return createHash('sha256').update(absolutePath).digest('hex');
}
/**
* Get home directory path
*/
function getHomePath(): string {
return homedir().replace(/\\/g, '/');
}
/**
* Base session discoverer interface
*/
abstract class SessionDiscoverer {
abstract tool: string;
abstract basePath: string;
/**
* Get all sessions for a project
*/
abstract getSessions(options?: SessionDiscoveryOptions): NativeSession[];
/**
* Get the latest session
*/
getLatestSession(options?: SessionDiscoveryOptions): NativeSession | null {
const sessions = this.getSessions({ ...options, limit: 1 });
return sessions.length > 0 ? sessions[0] : null;
}
/**
* Find session by ID
*/
abstract findSessionById(sessionId: string): NativeSession | null;
/**
* Track new session created during execution
*/
async trackNewSession(
beforeTimestamp: Date,
workingDir: string
): Promise<NativeSession | null> {
const sessions = this.getSessions({
workingDir,
afterTimestamp: beforeTimestamp,
limit: 1
});
return sessions.length > 0 ? sessions[0] : null;
}
}
/**
* Gemini Session Discoverer
* Path: ~/.gemini/tmp/<projectHash>/chats/session-*.json
*/
class GeminiSessionDiscoverer extends SessionDiscoverer {
tool = 'gemini';
basePath = join(getHomePath(), '.gemini', 'tmp');
getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] {
const { workingDir, limit, afterTimestamp } = options;
const sessions: NativeSession[] = [];
try {
if (!existsSync(this.basePath)) return [];
// If workingDir provided, only look in that project's folder
let projectDirs: string[];
if (workingDir) {
const projectHash = calculateProjectHash(workingDir);
const projectPath = join(this.basePath, projectHash);
projectDirs = existsSync(projectPath) ? [projectHash] : [];
} else {
projectDirs = readdirSync(this.basePath).filter(d => {
const fullPath = join(this.basePath, d);
return statSync(fullPath).isDirectory();
});
}
for (const projectHash of projectDirs) {
const chatsDir = join(this.basePath, projectHash, 'chats');
if (!existsSync(chatsDir)) continue;
const sessionFiles = readdirSync(chatsDir)
.filter(f => f.startsWith('session-') && f.endsWith('.json'))
.map(f => ({
name: f,
path: join(chatsDir, f),
stat: statSync(join(chatsDir, f))
}))
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
for (const file of sessionFiles) {
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
try {
const content = JSON.parse(readFileSync(file.path, 'utf8'));
sessions.push({
sessionId: content.sessionId,
tool: this.tool,
filePath: file.path,
projectHash,
createdAt: new Date(content.startTime || file.stat.birthtime),
updatedAt: new Date(content.lastUpdated || file.stat.mtime)
});
} catch {
// Skip invalid files
}
}
}
// Sort by updatedAt descending
sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
return limit ? sessions.slice(0, limit) : sessions;
} catch {
return [];
}
}
findSessionById(sessionId: string): NativeSession | null {
const sessions = this.getSessions();
return sessions.find(s => s.sessionId === sessionId) || null;
}
}
/**
* Encode a path to Qwen's project folder name format
* D:\Claude_dms3 -> D--Claude-dms3
* Rules: : -> -, \ -> -, _ -> -
*/
function encodeQwenProjectPath(projectDir: string): string {
const absolutePath = resolve(projectDir);
// Replace : -> -, \ -> -, _ -> -
return absolutePath
.replace(/:/g, '-')
.replace(/\\/g, '-')
.replace(/_/g, '-');
}
/**
* Qwen Session Discoverer
* New path: ~/.qwen/projects/<path-encoded>/chats/<uuid>.jsonl
* Old path: ~/.qwen/tmp/<projectHash>/chats/session-*.json (deprecated, fallback)
*/
class QwenSessionDiscoverer extends SessionDiscoverer {
tool = 'qwen';
basePath = join(getHomePath(), '.qwen', 'projects');
legacyBasePath = join(getHomePath(), '.qwen', 'tmp');
getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] {
const { workingDir, limit, afterTimestamp } = options;
const sessions: NativeSession[] = [];
// Try new format first (projects folder)
try {
if (existsSync(this.basePath)) {
let projectDirs: string[];
if (workingDir) {
const encodedPath = encodeQwenProjectPath(workingDir);
const projectPath = join(this.basePath, encodedPath);
projectDirs = existsSync(projectPath) ? [encodedPath] : [];
} else {
projectDirs = readdirSync(this.basePath).filter(d => {
const fullPath = join(this.basePath, d);
return statSync(fullPath).isDirectory();
});
}
for (const projectFolder of projectDirs) {
const chatsDir = join(this.basePath, projectFolder, 'chats');
if (!existsSync(chatsDir)) continue;
// New format: <uuid>.jsonl files
const sessionFiles = readdirSync(chatsDir)
.filter(f => f.endsWith('.jsonl'))
.map(f => ({
name: f,
path: join(chatsDir, f),
stat: statSync(join(chatsDir, f))
}))
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
for (const file of sessionFiles) {
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
try {
// Parse JSONL - read first line for session info
const content = readFileSync(file.path, 'utf8');
const firstLine = content.split('\n')[0];
const firstEntry = JSON.parse(firstLine);
// Session ID is in the filename or first entry
const sessionId = firstEntry.sessionId || basename(file.name, '.jsonl');
// Find timestamp from entries
let createdAt = file.stat.birthtime;
let updatedAt = file.stat.mtime;
if (firstEntry.timestamp) {
createdAt = new Date(firstEntry.timestamp);
}
// Get last entry for updatedAt
const lines = content.trim().split('\n').filter(l => l.trim());
if (lines.length > 0) {
try {
const lastEntry = JSON.parse(lines[lines.length - 1]);
if (lastEntry.timestamp) {
updatedAt = new Date(lastEntry.timestamp);
}
} catch { /* ignore */ }
}
sessions.push({
sessionId,
tool: this.tool,
filePath: file.path,
projectHash: projectFolder, // Using encoded path as project identifier
createdAt,
updatedAt
});
} catch {
// Skip invalid files
}
}
}
}
} catch { /* ignore errors */ }
// Fallback to legacy format (tmp folder with hash)
try {
if (existsSync(this.legacyBasePath)) {
let projectDirs: string[];
if (workingDir) {
const projectHash = calculateProjectHash(workingDir);
const projectPath = join(this.legacyBasePath, projectHash);
projectDirs = existsSync(projectPath) ? [projectHash] : [];
} else {
projectDirs = readdirSync(this.legacyBasePath).filter(d => {
const fullPath = join(this.legacyBasePath, d);
return statSync(fullPath).isDirectory();
});
}
for (const projectHash of projectDirs) {
const chatsDir = join(this.legacyBasePath, projectHash, 'chats');
if (!existsSync(chatsDir)) continue;
const sessionFiles = readdirSync(chatsDir)
.filter(f => f.startsWith('session-') && f.endsWith('.json'))
.map(f => ({
name: f,
path: join(chatsDir, f),
stat: statSync(join(chatsDir, f))
}))
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
for (const file of sessionFiles) {
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
try {
const content = JSON.parse(readFileSync(file.path, 'utf8'));
sessions.push({
sessionId: content.sessionId,
tool: this.tool,
filePath: file.path,
projectHash,
createdAt: new Date(content.startTime || file.stat.birthtime),
updatedAt: new Date(content.lastUpdated || file.stat.mtime)
});
} catch {
// Skip invalid files
}
}
}
}
} catch { /* ignore errors */ }
// Sort by updatedAt descending and dedupe by sessionId
sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
// Dedupe (new format takes precedence as it's checked first)
const seen = new Set<string>();
const uniqueSessions = sessions.filter(s => {
if (seen.has(s.sessionId)) return false;
seen.add(s.sessionId);
return true;
});
return limit ? uniqueSessions.slice(0, limit) : uniqueSessions;
}
findSessionById(sessionId: string): NativeSession | null {
const sessions = this.getSessions();
return sessions.find(s => s.sessionId === sessionId) || null;
}
}
/**
* Codex Session Discoverer
* Path: ~/.codex/sessions/YYYY/MM/DD/rollout-*-<uuid>.jsonl
*/
class CodexSessionDiscoverer extends SessionDiscoverer {
tool = 'codex';
basePath = join(getHomePath(), '.codex', 'sessions');
getSessions(options: SessionDiscoveryOptions = {}): NativeSession[] {
const { limit, afterTimestamp } = options;
const sessions: NativeSession[] = [];
try {
if (!existsSync(this.basePath)) return [];
// Get year directories (e.g., 2025)
const yearDirs = readdirSync(this.basePath)
.filter(d => /^\d{4}$/.test(d))
.sort((a, b) => b.localeCompare(a)); // Descending
for (const year of yearDirs) {
const yearPath = join(this.basePath, year);
if (!statSync(yearPath).isDirectory()) continue;
// Get month directories
const monthDirs = readdirSync(yearPath)
.filter(d => /^\d{2}$/.test(d))
.sort((a, b) => b.localeCompare(a));
for (const month of monthDirs) {
const monthPath = join(yearPath, month);
if (!statSync(monthPath).isDirectory()) continue;
// Get day directories
const dayDirs = readdirSync(monthPath)
.filter(d => /^\d{2}$/.test(d))
.sort((a, b) => b.localeCompare(a));
for (const day of dayDirs) {
const dayPath = join(monthPath, day);
if (!statSync(dayPath).isDirectory()) continue;
// Get session files
const sessionFiles = readdirSync(dayPath)
.filter(f => f.startsWith('rollout-') && f.endsWith('.jsonl'))
.map(f => ({
name: f,
path: join(dayPath, f),
stat: statSync(join(dayPath, f))
}))
.sort((a, b) => b.stat.mtimeMs - a.stat.mtimeMs);
for (const file of sessionFiles) {
if (afterTimestamp && file.stat.mtime <= afterTimestamp) continue;
try {
// Parse first line for session_meta
const firstLine = readFileSync(file.path, 'utf8').split('\n')[0];
const meta = JSON.parse(firstLine);
if (meta.type === 'session_meta' && meta.payload?.id) {
sessions.push({
sessionId: meta.payload.id,
tool: this.tool,
filePath: file.path,
createdAt: new Date(meta.payload.timestamp || file.stat.birthtime),
updatedAt: file.stat.mtime
});
}
} catch {
// Try extracting UUID from filename
const uuidMatch = file.name.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i);
if (uuidMatch) {
sessions.push({
sessionId: uuidMatch[1],
tool: this.tool,
filePath: file.path,
createdAt: file.stat.birthtime,
updatedAt: file.stat.mtime
});
}
}
}
}
}
}
sessions.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
return limit ? sessions.slice(0, limit) : sessions;
} catch {
return [];
}
}
findSessionById(sessionId: string): NativeSession | null {
const sessions = this.getSessions();
return sessions.find(s => s.sessionId === sessionId) || null;
}
}
// Singleton discoverers
const discoverers: Record<string, SessionDiscoverer> = {
gemini: new GeminiSessionDiscoverer(),
qwen: new QwenSessionDiscoverer(),
codex: new CodexSessionDiscoverer()
};
/**
* Get session discoverer for a tool
*/
export function getDiscoverer(tool: string): SessionDiscoverer | null {
return discoverers[tool] || null;
}
/**
* Get latest native session for a tool
*/
export function getLatestNativeSession(
tool: string,
workingDir?: string
): NativeSession | null {
const discoverer = discoverers[tool];
if (!discoverer) return null;
return discoverer.getLatestSession({ workingDir });
}
/**
* Find native session by ID
*/
export function findNativeSessionById(
tool: string,
sessionId: string
): NativeSession | null {
const discoverer = discoverers[tool];
if (!discoverer) return null;
return discoverer.findSessionById(sessionId);
}
/**
* Track new session created during execution
*/
export async function trackNewSession(
tool: string,
beforeTimestamp: Date,
workingDir: string
): Promise<NativeSession | null> {
const discoverer = discoverers[tool];
if (!discoverer) return null;
return discoverer.trackNewSession(beforeTimestamp, workingDir);
}
/**
* Get all sessions for a tool
*/
export function getNativeSessions(
tool: string,
options?: SessionDiscoveryOptions
): NativeSession[] {
const discoverer = discoverers[tool];
if (!discoverer) return [];
return discoverer.getSessions(options);
}
/**
* Check if a tool supports native resume
*/
export function supportsNativeResume(tool: string): boolean {
return tool in discoverers;
}
/**
* Get native resume command arguments for a tool
*/
export function getNativeResumeArgs(
tool: string,
sessionId: string | 'latest'
): string[] {
switch (tool) {
case 'gemini':
// gemini -r <uuid> or -r latest
return ['-r', sessionId];
case 'qwen':
// qwen --continue (latest) or --resume <uuid>
if (sessionId === 'latest') {
return ['--continue'];
}
return ['--resume', sessionId];
case 'codex':
// codex resume <uuid> or codex resume --last
if (sessionId === 'latest') {
return ['resume', '--last'];
}
return ['resume', sessionId];
default:
return [];
}
}
/**
* Get base path for a tool's sessions
*/
export function getToolSessionPath(tool: string): string | null {
const discoverer = discoverers[tool];
return discoverer?.basePath || null;
}

View File

@@ -0,0 +1,345 @@
/**
* Resume Strategy Engine - Determines optimal resume approach
* Supports native resume, prompt concatenation, and hybrid modes
*/
import type { ConversationTurn, ConversationRecord, NativeSessionMapping } from './cli-history-store.js';
// Strategy types
export type ResumeStrategy = 'native' | 'prompt-concat' | 'hybrid';
// Resume decision result
export interface ResumeDecision {
strategy: ResumeStrategy;
nativeSessionId?: string; // Native UUID for native/hybrid modes
isLatest?: boolean; // Use latest/--last flag
contextTurns?: ConversationTurn[]; // Turns to include as context prefix
primaryConversationId?: string; // Primary conversation for append
}
// Resume strategy options
export interface ResumeStrategyOptions {
tool: string;
resumeIds: string[]; // CCW IDs to resume from
customId?: string; // New custom ID (fork scenario)
forceNative?: boolean; // Force native resume
forcePromptConcat?: boolean; // Force prompt concatenation
// Lookup functions (dependency injection)
getNativeSessionId: (ccwId: string) => string | null;
getConversation: (ccwId: string) => ConversationRecord | null;
getConversationTool: (ccwId: string) => string | null;
}
/**
* Determine the optimal resume strategy based on scenario
*
* Scenarios:
* 1. Single append (no customId) → native if mapping exists
* 2. Fork (customId provided) → prompt-concat (new conversation)
* 3. Merge multiple → hybrid (primary native + others as context)
* 4. Cross-tool → prompt-concat (tools differ)
* 5. resume=true (latest) → native with isLatest flag
*/
export function determineResumeStrategy(options: ResumeStrategyOptions): ResumeDecision {
const {
tool,
resumeIds,
customId,
forceNative,
forcePromptConcat,
getNativeSessionId,
getConversation,
getConversationTool
} = options;
// Force prompt concatenation
if (forcePromptConcat) {
return buildPromptConcatDecision(resumeIds, getConversation);
}
// No resume IDs - new conversation
if (resumeIds.length === 0) {
return { strategy: 'prompt-concat' };
}
// Scenario 5: resume=true (latest) - use native latest
// This is handled before this function is called, but included for completeness
// Scenario 2: Fork (customId provided) → always prompt-concat
if (customId) {
return buildPromptConcatDecision(resumeIds, getConversation);
}
// Scenario 4: Check for cross-tool resume
const crossTool = resumeIds.some(id => {
const convTool = getConversationTool(id);
return convTool && convTool !== tool;
});
if (crossTool) {
return buildPromptConcatDecision(resumeIds, getConversation);
}
// Scenario 1: Single append
if (resumeIds.length === 1) {
const nativeId = getNativeSessionId(resumeIds[0]);
if (nativeId || forceNative) {
return {
strategy: 'native',
nativeSessionId: nativeId || undefined,
primaryConversationId: resumeIds[0]
};
}
// No native mapping, fall back to prompt-concat
return buildPromptConcatDecision(resumeIds, getConversation);
}
// Scenario 3: Merge multiple conversations → hybrid mode
return buildHybridDecision(resumeIds, tool, getNativeSessionId, getConversation);
}
/**
* Build prompt-concat decision with all turns loaded
*/
function buildPromptConcatDecision(
resumeIds: string[],
getConversation: (ccwId: string) => ConversationRecord | null
): ResumeDecision {
const allTurns: ConversationTurn[] = [];
for (const id of resumeIds) {
const conversation = getConversation(id);
if (conversation) {
// Add source ID to each turn for tracking
const turnsWithSource = conversation.turns.map(turn => ({
...turn,
_sourceId: id
}));
allTurns.push(...turnsWithSource as ConversationTurn[]);
}
}
// Sort by timestamp
allTurns.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
return {
strategy: 'prompt-concat',
contextTurns: allTurns,
primaryConversationId: resumeIds[0]
};
}
/**
* Build hybrid decision: primary uses native, others as context prefix
*/
function buildHybridDecision(
resumeIds: string[],
tool: string,
getNativeSessionId: (ccwId: string) => string | null,
getConversation: (ccwId: string) => ConversationRecord | null
): ResumeDecision {
// Find the first ID with native session mapping
let primaryId: string | null = null;
let nativeId: string | null = null;
for (const id of resumeIds) {
const native = getNativeSessionId(id);
if (native) {
primaryId = id;
nativeId = native;
break;
}
}
// If no native mapping found, use first as primary
if (!primaryId) {
primaryId = resumeIds[0];
}
// Collect context turns from non-primary conversations
const contextTurns: ConversationTurn[] = [];
for (const id of resumeIds) {
if (id === primaryId && nativeId) {
// Skip primary if using native - its context is handled natively
continue;
}
const conversation = getConversation(id);
if (conversation) {
const turnsWithSource = conversation.turns.map(turn => ({
...turn,
_sourceId: id
}));
contextTurns.push(...turnsWithSource as ConversationTurn[]);
}
}
// Sort context turns by timestamp
contextTurns.sort((a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// If we have native ID, use hybrid; otherwise fall back to prompt-concat
if (nativeId) {
return {
strategy: 'hybrid',
nativeSessionId: nativeId,
contextTurns: contextTurns.length > 0 ? contextTurns : undefined,
primaryConversationId: primaryId
};
}
// No native mapping, use full prompt-concat
return buildPromptConcatDecision(resumeIds, getConversation);
}
/**
* Build context prefix for hybrid mode
* Formats non-primary conversation turns as context
*/
export function buildContextPrefix(
contextTurns: ConversationTurn[],
format: 'plain' | 'yaml' | 'json' = 'plain'
): string {
if (!contextTurns || contextTurns.length === 0) {
return '';
}
const maxOutputLength = 4096; // Truncate long outputs
switch (format) {
case 'yaml':
return buildYamlContext(contextTurns, maxOutputLength);
case 'json':
return buildJsonContext(contextTurns, maxOutputLength);
default:
return buildPlainContext(contextTurns, maxOutputLength);
}
}
function buildPlainContext(turns: ConversationTurn[], maxLength: number): string {
const lines: string[] = [
'=== MERGED CONTEXT FROM OTHER CONVERSATIONS ===',
''
];
for (const turn of turns) {
const sourceId = (turn as any)._sourceId || 'unknown';
lines.push(`--- Turn ${turn.turn} [${sourceId}] ---`);
lines.push(`USER:`);
lines.push(turn.prompt);
lines.push('');
lines.push(`ASSISTANT:`);
const output = turn.output.stdout || '';
lines.push(output.length > maxLength ? output.substring(0, maxLength) + '\n[truncated]' : output);
lines.push('');
}
lines.push('=== END MERGED CONTEXT ===');
lines.push('');
return lines.join('\n');
}
function buildYamlContext(turns: ConversationTurn[], maxLength: number): string {
const lines: string[] = [
'merged_context:',
' source: "other_conversations"',
' turns:'
];
for (const turn of turns) {
const sourceId = (turn as any)._sourceId || 'unknown';
const output = turn.output.stdout || '';
const truncatedOutput = output.length > maxLength
? output.substring(0, maxLength) + '\n[truncated]'
: output;
lines.push(` - turn: ${turn.turn}`);
lines.push(` source: "${sourceId}"`);
lines.push(` user: |`);
lines.push(turn.prompt.split('\n').map(l => ` ${l}`).join('\n'));
lines.push(` assistant: |`);
lines.push(truncatedOutput.split('\n').map(l => ` ${l}`).join('\n'));
}
lines.push('');
return lines.join('\n');
}
function buildJsonContext(turns: ConversationTurn[], maxLength: number): string {
const context = {
merged_context: {
source: 'other_conversations',
turns: turns.map(turn => {
const output = turn.output.stdout || '';
return {
turn: turn.turn,
source: (turn as any)._sourceId || 'unknown',
user: turn.prompt,
assistant: output.length > maxLength
? output.substring(0, maxLength) + '\n[truncated]'
: output
};
})
}
};
return JSON.stringify(context, null, 2) + '\n\n';
}
/**
* Check if a resume scenario requires native resume
*/
export function shouldUseNativeResume(
tool: string,
resumeIds: string[],
customId: string | undefined,
getNativeSessionId: (ccwId: string) => string | null,
getConversationTool: (ccwId: string) => string | null
): boolean {
// Fork always uses prompt-concat
if (customId) return false;
// No resume IDs
if (resumeIds.length === 0) return false;
// Cross-tool not supported natively
const crossTool = resumeIds.some(id => {
const convTool = getConversationTool(id);
return convTool && convTool !== tool;
});
if (crossTool) return false;
// Single resume with native mapping
if (resumeIds.length === 1) {
return !!getNativeSessionId(resumeIds[0]);
}
// Merge: at least one needs native mapping for hybrid
return resumeIds.some(id => !!getNativeSessionId(id));
}
/**
* Get resume mode description for logging
*/
export function getResumeModeDescription(decision: ResumeDecision): string {
switch (decision.strategy) {
case 'native':
return `Native resume (session: ${decision.nativeSessionId || 'latest'})`;
case 'hybrid':
const contextCount = decision.contextTurns?.length || 0;
return `Hybrid (native + ${contextCount} context turns)`;
case 'prompt-concat':
const turnCount = decision.contextTurns?.length || 0;
return `Prompt concat (${turnCount} turns)`;
default:
return 'Unknown';
}
}

View File

@@ -0,0 +1,619 @@
/**
* Session Content Parser - Parses native CLI tool session files
* Supports Gemini/Qwen JSON and Codex JSONL formats
*/
import { readFileSync, existsSync } from 'fs';
// Standardized conversation turn
export interface ParsedTurn {
turnNumber: number;
timestamp: string;
role: 'user' | 'assistant';
content: string;
thoughts?: string[]; // Assistant reasoning/thoughts
toolCalls?: ToolCallInfo[]; // Tool calls made
tokens?: TokenInfo; // Token usage
}
export interface ToolCallInfo {
name: string;
arguments?: string;
output?: string;
}
export interface TokenInfo {
input?: number;
output?: number;
cached?: number;
total?: number;
}
// Full parsed session
export interface ParsedSession {
sessionId: string;
tool: string;
projectHash?: string;
workingDir?: string;
startTime: string;
lastUpdated: string;
turns: ParsedTurn[];
totalTokens?: TokenInfo;
model?: string;
}
// Gemini/Qwen session file structure
interface GeminiQwenSession {
sessionId: string;
projectHash: string;
startTime: string;
lastUpdated: string;
messages: GeminiQwenMessage[];
}
interface GeminiQwenMessage {
id: string;
timestamp: string;
type: 'user' | 'gemini' | 'qwen';
content: string;
thoughts?: Array<{ subject: string; description: string; timestamp: string }>;
tokens?: {
input: number;
output: number;
cached?: number;
thoughts?: number;
tool?: number;
total: number;
};
model?: string;
}
// Codex JSONL line types
interface CodexSessionMeta {
timestamp: string;
type: 'session_meta';
payload: {
id: string;
timestamp: string;
cwd: string;
cli_version?: string;
model_provider?: string;
};
}
interface CodexResponseItem {
timestamp: string;
type: 'response_item';
payload: {
type: string;
role?: string;
content?: Array<{ type: string; text?: string }>;
name?: string;
arguments?: string;
call_id?: string;
output?: string;
summary?: string[];
};
}
interface CodexEventMsg {
timestamp: string;
type: 'event_msg';
payload: {
type: string;
info?: {
total_token_usage?: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
};
};
};
}
type CodexLine = CodexSessionMeta | CodexResponseItem | CodexEventMsg;
// Qwen new JSONL format
interface QwenJSONLEntry {
uuid: string;
parentUuid: string | null;
sessionId: string;
timestamp: string;
type: 'user' | 'assistant' | 'system';
cwd?: string;
version?: string;
gitBranch?: string;
model?: string;
subtype?: string; // e.g., 'ui_telemetry'
message?: {
role: string;
parts: Array<{ text?: string }>;
};
usageMetadata?: {
promptTokenCount: number;
candidatesTokenCount: number;
thoughtsTokenCount?: number;
totalTokenCount: number;
cachedContentTokenCount?: number;
};
systemPayload?: {
uiEvent?: {
model?: string;
input_token_count?: number;
output_token_count?: number;
cached_content_token_count?: number;
thoughts_token_count?: number;
tool_token_count?: number;
total_token_count?: number;
};
};
}
/**
* Detect if content is JSONL or JSON format
*/
function isJSONL(content: string): boolean {
const trimmed = content.trim();
// JSON starts with { or [, but JSONL has multiple lines each starting with {
if (trimmed.startsWith('[')) return false; // JSON array
if (!trimmed.startsWith('{')) return false;
// Check if first line is complete JSON
const firstLine = trimmed.split('\n')[0];
try {
JSON.parse(firstLine);
// If multiple lines each parse as JSON, it's JSONL
const lines = trimmed.split('\n').filter(l => l.trim());
if (lines.length > 1) {
// Try to parse second line
JSON.parse(lines[1]);
return true; // Multiple lines of JSON = JSONL
}
return false; // Single JSON object
} catch {
return false;
}
}
/**
* Parse a native session file and return standardized conversation data
*/
export function parseSessionFile(filePath: string, tool: string): ParsedSession | null {
if (!existsSync(filePath)) {
return null;
}
try {
const content = readFileSync(filePath, 'utf8');
switch (tool) {
case 'gemini':
return parseGeminiQwenSession(content, tool);
case 'qwen':
// Qwen can be either JSON (legacy) or JSONL (new format)
if (isJSONL(content)) {
return parseQwenJSONLSession(content);
}
return parseGeminiQwenSession(content, tool);
case 'codex':
return parseCodexSession(content);
default:
return null;
}
} catch (error) {
console.error(`Failed to parse session file ${filePath}:`, error);
return null;
}
}
/**
* Parse Gemini or Qwen JSON session file
*/
function parseGeminiQwenSession(content: string, tool: string): ParsedSession {
const session: GeminiQwenSession = JSON.parse(content);
const turns: ParsedTurn[] = [];
let turnNumber = 0;
let totalTokens: TokenInfo = { input: 0, output: 0, cached: 0, total: 0 };
let model: string | undefined;
for (const msg of session.messages) {
if (msg.type === 'user') {
turnNumber++;
turns.push({
turnNumber,
timestamp: msg.timestamp,
role: 'user',
content: msg.content
});
} else if (msg.type === 'gemini' || msg.type === 'qwen') {
// Find the corresponding user turn
const userTurn = turns.find(t => t.turnNumber === turnNumber && t.role === 'user');
// Extract thoughts
const thoughts = msg.thoughts?.map(t => `${t.subject}: ${t.description}`) || [];
turns.push({
turnNumber,
timestamp: msg.timestamp,
role: 'assistant',
content: msg.content,
thoughts: thoughts.length > 0 ? thoughts : undefined,
tokens: msg.tokens ? {
input: msg.tokens.input,
output: msg.tokens.output,
cached: msg.tokens.cached,
total: msg.tokens.total
} : undefined
});
// Accumulate tokens
if (msg.tokens) {
totalTokens.input = (totalTokens.input || 0) + msg.tokens.input;
totalTokens.output = (totalTokens.output || 0) + msg.tokens.output;
totalTokens.cached = (totalTokens.cached || 0) + (msg.tokens.cached || 0);
totalTokens.total = (totalTokens.total || 0) + msg.tokens.total;
}
if (msg.model) {
model = msg.model;
}
}
}
return {
sessionId: session.sessionId,
tool,
projectHash: session.projectHash,
startTime: session.startTime,
lastUpdated: session.lastUpdated,
turns,
totalTokens,
model
};
}
/**
* Parse Qwen JSONL session file (new format)
*/
function parseQwenJSONLSession(content: string): ParsedSession {
const lines = content.split('\n').filter(l => l.trim());
const turns: ParsedTurn[] = [];
let sessionId = '';
let workingDir = '';
let startTime = '';
let lastUpdated = '';
let model: string | undefined;
let totalTokens: TokenInfo = { input: 0, output: 0, cached: 0, total: 0 };
let currentTurn = 0;
for (const line of lines) {
try {
const entry: QwenJSONLEntry = JSON.parse(line);
lastUpdated = entry.timestamp;
// Get session info from first entry
if (!sessionId && entry.sessionId) {
sessionId = entry.sessionId;
}
if (!workingDir && entry.cwd) {
workingDir = entry.cwd;
}
if (!startTime) {
startTime = entry.timestamp;
}
if (entry.type === 'user' && entry.message) {
// User message
currentTurn++;
const textContent = entry.message.parts
.map(p => p.text || '')
.filter(t => t)
.join('\n');
turns.push({
turnNumber: currentTurn,
timestamp: entry.timestamp,
role: 'user',
content: textContent
});
} else if (entry.type === 'assistant' && entry.message) {
// Assistant response
const textContent = entry.message.parts
.map(p => p.text || '')
.filter(t => t)
.join('\n');
const tokens = entry.usageMetadata ? {
input: entry.usageMetadata.promptTokenCount,
output: entry.usageMetadata.candidatesTokenCount,
cached: entry.usageMetadata.cachedContentTokenCount || 0,
total: entry.usageMetadata.totalTokenCount
} : undefined;
turns.push({
turnNumber: currentTurn,
timestamp: entry.timestamp,
role: 'assistant',
content: textContent,
tokens
});
// Accumulate tokens
if (tokens) {
totalTokens.input = (totalTokens.input || 0) + tokens.input;
totalTokens.output = (totalTokens.output || 0) + tokens.output;
totalTokens.cached = (totalTokens.cached || 0) + (tokens.cached || 0);
totalTokens.total = (totalTokens.total || 0) + tokens.total;
}
if (entry.model) {
model = entry.model;
}
} else if (entry.type === 'system' && entry.subtype === 'ui_telemetry') {
// Telemetry event - extract model info if available
if (entry.systemPayload?.uiEvent?.model && !model) {
model = entry.systemPayload.uiEvent.model;
}
}
} catch {
// Skip invalid lines
}
}
return {
sessionId,
tool: 'qwen',
workingDir,
startTime,
lastUpdated,
turns,
totalTokens,
model
};
}
/**
* Parse Codex JSONL session file
*/
function parseCodexSession(content: string): ParsedSession {
const lines = content.split('\n').filter(l => l.trim());
const turns: ParsedTurn[] = [];
let sessionId = '';
let workingDir = '';
let startTime = '';
let lastUpdated = '';
let model: string | undefined;
let totalTokens: TokenInfo = { input: 0, output: 0, total: 0 };
let currentTurn = 0;
let currentToolCalls: ToolCallInfo[] = [];
let pendingToolCalls: Map<string, ToolCallInfo> = new Map();
for (const line of lines) {
try {
const parsed: CodexLine = JSON.parse(line);
lastUpdated = parsed.timestamp;
if (parsed.type === 'session_meta') {
const meta = parsed as CodexSessionMeta;
sessionId = meta.payload.id;
workingDir = meta.payload.cwd;
startTime = meta.payload.timestamp;
} else if (parsed.type === 'response_item') {
const item = parsed as CodexResponseItem;
if (item.payload.type === 'message' && item.payload.role === 'user') {
// User message
currentTurn++;
const textContent = item.payload.content
?.filter(c => c.type === 'input_text')
.map(c => c.text)
.join('\n') || '';
turns.push({
turnNumber: currentTurn,
timestamp: parsed.timestamp,
role: 'user',
content: textContent
});
// Reset tool calls for new turn
currentToolCalls = [];
pendingToolCalls.clear();
} else if (item.payload.type === 'function_call') {
// Tool call
const toolCall: ToolCallInfo = {
name: item.payload.name || 'unknown',
arguments: item.payload.arguments
};
if (item.payload.call_id) {
pendingToolCalls.set(item.payload.call_id, toolCall);
}
currentToolCalls.push(toolCall);
} else if (item.payload.type === 'function_call_output') {
// Tool result
if (item.payload.call_id && pendingToolCalls.has(item.payload.call_id)) {
const toolCall = pendingToolCalls.get(item.payload.call_id)!;
toolCall.output = item.payload.output;
}
} else if (item.payload.type === 'message' && item.payload.role === 'assistant') {
// Assistant message (final response)
const textContent = item.payload.content
?.filter(c => c.type === 'output_text' || c.type === 'text')
.map(c => c.text)
.join('\n') || '';
if (textContent) {
turns.push({
turnNumber: currentTurn,
timestamp: parsed.timestamp,
role: 'assistant',
content: textContent,
toolCalls: currentToolCalls.length > 0 ? [...currentToolCalls] : undefined
});
}
} else if (item.payload.type === 'reasoning') {
// Reasoning (may be encrypted, extract summary if available)
const summary = item.payload.summary;
if (summary && summary.length > 0) {
// Add reasoning summary to the last assistant turn
const lastAssistantTurn = turns.findLast(t => t.role === 'assistant');
if (lastAssistantTurn) {
lastAssistantTurn.thoughts = summary;
}
}
}
} else if (parsed.type === 'event_msg') {
const event = parsed as CodexEventMsg;
if (event.payload.type === 'token_count' && event.payload.info?.total_token_usage) {
const usage = event.payload.info.total_token_usage;
totalTokens = {
input: usage.input_tokens,
output: usage.output_tokens,
total: usage.total_tokens
};
}
}
} catch {
// Skip invalid lines
}
}
// If we have tool calls but no final assistant message, create one
if (currentToolCalls.length > 0) {
const lastTurn = turns[turns.length - 1];
if (lastTurn && lastTurn.role === 'user') {
// Find if there's already an assistant response for this turn
const hasAssistant = turns.some(t => t.turnNumber === currentTurn && t.role === 'assistant');
if (!hasAssistant) {
turns.push({
turnNumber: currentTurn,
timestamp: lastUpdated,
role: 'assistant',
content: '[Tool execution completed]',
toolCalls: currentToolCalls
});
}
}
}
return {
sessionId,
tool: 'codex',
workingDir,
startTime,
lastUpdated,
turns,
totalTokens,
model
};
}
/**
* Get conversation as formatted text
*/
export function formatConversation(session: ParsedSession, options?: {
includeThoughts?: boolean;
includeToolCalls?: boolean;
includeTokens?: boolean;
maxContentLength?: number;
}): string {
const {
includeThoughts = false,
includeToolCalls = false,
includeTokens = false,
maxContentLength = 4096
} = options || {};
const lines: string[] = [];
lines.push(`=== Session: ${session.sessionId} ===`);
lines.push(`Tool: ${session.tool}`);
lines.push(`Started: ${session.startTime}`);
lines.push(`Updated: ${session.lastUpdated}`);
if (session.model) {
lines.push(`Model: ${session.model}`);
}
lines.push('');
for (const turn of session.turns) {
const roleLabel = turn.role === 'user' ? 'USER' : 'ASSISTANT';
lines.push(`--- Turn ${turn.turnNumber} [${roleLabel}] ---`);
const content = turn.content.length > maxContentLength
? turn.content.substring(0, maxContentLength) + '\n[truncated]'
: turn.content;
lines.push(content);
if (includeThoughts && turn.thoughts && turn.thoughts.length > 0) {
lines.push('');
lines.push('Thoughts:');
for (const thought of turn.thoughts) {
lines.push(` - ${thought}`);
}
}
if (includeToolCalls && turn.toolCalls && turn.toolCalls.length > 0) {
lines.push('');
lines.push('Tool Calls:');
for (const tc of turn.toolCalls) {
lines.push(` - ${tc.name}`);
if (tc.output) {
const output = tc.output.length > 200
? tc.output.substring(0, 200) + '...'
: tc.output;
lines.push(` Output: ${output}`);
}
}
}
if (includeTokens && turn.tokens) {
lines.push(`Tokens: ${turn.tokens.total} (in: ${turn.tokens.input}, out: ${turn.tokens.output})`);
}
lines.push('');
}
if (session.totalTokens) {
lines.push(`=== Total Tokens: ${session.totalTokens.total} ===`);
}
return lines.join('\n');
}
/**
* Extract just user prompts and assistant responses as simple pairs
*/
export function extractConversationPairs(session: ParsedSession): Array<{
turn: number;
userPrompt: string;
assistantResponse: string;
timestamp: string;
}> {
const pairs: Array<{
turn: number;
userPrompt: string;
assistantResponse: string;
timestamp: string;
}> = [];
const turnNumbers = [...new Set(session.turns.map(t => t.turnNumber))];
for (const turnNum of turnNumbers) {
const userTurn = session.turns.find(t => t.turnNumber === turnNum && t.role === 'user');
const assistantTurn = session.turns.find(t => t.turnNumber === turnNum && t.role === 'assistant');
if (userTurn) {
pairs.push({
turn: turnNum,
userPrompt: userTurn.content,
assistantResponse: assistantTurn?.content || '',
timestamp: userTurn.timestamp
});
}
}
return pairs;
}