mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -25,4 +25,5 @@ COMMAND_FLOW_STANDARD.md
|
||||
COMMAND_TEMPLATE_EXECUTOR.md
|
||||
COMMAND_TEMPLATE_ORCHESTRATOR.md
|
||||
*.pyc
|
||||
.codexlens/
|
||||
.codexlens/
|
||||
settings.json
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
705
ccw/src/commands/memory.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
627
ccw/src/core/history-importer.ts
Normal file
627
ccw/src/core/history-importer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
702
ccw/src/core/memory-store.ts
Normal file
702
ccw/src/core/memory-store.ts
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
1360
ccw/src/templates/dashboard-css/11-memory.css
Normal file
1360
ccw/src/templates/dashboard-css/11-memory.css
Normal file
File diff suppressed because it is too large
Load Diff
667
ccw/src/templates/dashboard-css/11-prompt-history.css
Normal file
667
ccw/src/templates/dashboard-css/11-prompt-history.css
Normal 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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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': '创建',
|
||||
|
||||
@@ -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>' +
|
||||
|
||||
@@ -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
|
||||
|
||||
588
ccw/src/templates/dashboard-js/views/memory.js
Normal file
588
ccw/src/templates/dashboard-js/views/memory.js
Normal 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, '"') + ')">' +
|
||||
'<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();
|
||||
}
|
||||
538
ccw/src/templates/dashboard-js/views/prompt-history.js
Normal file
538
ccw/src/templates/dashboard-js/views/prompt-history.js
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 };
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
542
ccw/src/tools/native-session-discovery.ts
Normal file
542
ccw/src/tools/native-session-discovery.ts
Normal 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;
|
||||
}
|
||||
345
ccw/src/tools/resume-strategy.ts
Normal file
345
ccw/src/tools/resume-strategy.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
619
ccw/src/tools/session-content-parser.ts
Normal file
619
ccw/src/tools/session-content-parser.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user