Add comprehensive tests for vector/semantic search functionality

- Implement full coverage tests for Embedder model loading and embedding generation
- Add CRUD operations and caching tests for VectorStore
- Include cosine similarity computation tests
- Validate semantic search accuracy and relevance through various queries
- Establish performance benchmarks for embedding and search operations
- Ensure edge cases and error handling are covered
- Test thread safety and concurrent access scenarios
- Verify availability of semantic search dependencies
This commit is contained in:
catlog22
2025-12-14 17:17:09 +08:00
parent 8d542b8e45
commit 79a2953862
47 changed files with 11208 additions and 4336 deletions

View File

@@ -0,0 +1,469 @@
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
interface TaskMeta {
type: string;
agent: string | null;
scope: string | null;
module: string | null;
}
interface TaskContext {
requirements: string[];
focus_paths: string[];
acceptance: string[];
depends_on: string[];
}
interface TaskFlowControl {
implementation_approach: Array<{
step: string;
action: string;
}>;
}
interface NormalizedTask {
id: string;
title: string;
status: string;
meta: TaskMeta;
context: TaskContext;
flow_control: TaskFlowControl;
_raw: unknown;
}
interface Progress {
total: number;
completed: number;
percentage: number;
}
interface DiagnosisItem {
id: string;
filename: string;
[key: string]: unknown;
}
interface Diagnoses {
manifest: unknown | null;
items: DiagnosisItem[];
}
interface LiteSession {
id: string;
type: string;
path: string;
createdAt: string;
plan: unknown | null;
tasks: NormalizedTask[];
diagnoses?: Diagnoses;
progress: Progress;
}
interface LiteTasks {
litePlan: LiteSession[];
liteFix: LiteSession[];
}
interface LiteTaskDetail {
id: string;
type: string;
path: string;
plan: unknown | null;
tasks: NormalizedTask[];
explorations: unknown[];
clarifications: unknown | null;
diagnoses?: Diagnoses;
}
/**
* Scan lite-plan and lite-fix directories for task sessions
* @param workflowDir - Path to .workflow directory
* @returns Lite tasks data
*/
export async function scanLiteTasks(workflowDir: string): Promise<LiteTasks> {
const litePlanDir = join(workflowDir, '.lite-plan');
const liteFixDir = join(workflowDir, '.lite-fix');
return {
litePlan: scanLiteDir(litePlanDir, 'lite-plan'),
liteFix: scanLiteDir(liteFixDir, 'lite-fix')
};
}
/**
* Scan a lite task directory
* @param dir - Directory path
* @param type - Task type ('lite-plan' or 'lite-fix')
* @returns Array of lite task sessions
*/
function scanLiteDir(dir: string, type: string): LiteSession[] {
if (!existsSync(dir)) return [];
try {
const sessions = readdirSync(dir, { withFileTypes: true })
.filter(d => d.isDirectory())
.map(d => {
const sessionPath = join(dir, d.name);
const session: LiteSession = {
id: d.name,
type,
path: sessionPath,
createdAt: getCreatedTime(sessionPath),
plan: loadPlanJson(sessionPath),
tasks: loadTaskJsons(sessionPath),
progress: { total: 0, completed: 0, percentage: 0 }
};
// For lite-fix sessions, also load diagnoses separately
if (type === 'lite-fix') {
session.diagnoses = loadDiagnoses(sessionPath);
}
// Calculate progress
session.progress = calculateProgress(session.tasks);
return session;
})
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
return sessions;
} catch (err) {
console.error(`Error scanning ${dir}:`, (err as Error).message);
return [];
}
}
/**
* Load plan.json or fix-plan.json from session directory
* @param sessionPath - Session directory path
* @returns Plan data or null
*/
function loadPlanJson(sessionPath: string): unknown | null {
// Try fix-plan.json first (for lite-fix), then plan.json (for lite-plan)
const fixPlanPath = join(sessionPath, 'fix-plan.json');
const planPath = join(sessionPath, 'plan.json');
// Try fix-plan.json first
if (existsSync(fixPlanPath)) {
try {
const content = readFileSync(fixPlanPath, 'utf8');
return JSON.parse(content);
} catch {
// Continue to try plan.json
}
}
// Fallback to plan.json
if (existsSync(planPath)) {
try {
const content = readFileSync(planPath, 'utf8');
return JSON.parse(content);
} catch {
return null;
}
}
return null;
}
/**
* Load all task JSON files from session directory
* Supports multiple task formats:
* 1. .task/IMPL-*.json files
* 2. tasks array in plan.json
* 3. task-*.json files in session root
* @param sessionPath - Session directory path
* @returns Array of task objects
*/
function loadTaskJsons(sessionPath: string): NormalizedTask[] {
let tasks: NormalizedTask[] = [];
// Method 1: Check .task/IMPL-*.json files
const taskDir = join(sessionPath, '.task');
if (existsSync(taskDir)) {
try {
const implTasks = readdirSync(taskDir)
.filter(f => f.endsWith('.json') && (
f.startsWith('IMPL-') ||
f.startsWith('TASK-') ||
f.startsWith('task-') ||
f.startsWith('diagnosis-') ||
/^T\d+\.json$/i.test(f)
))
.map(f => {
const taskPath = join(taskDir, f);
try {
const content = readFileSync(taskPath, 'utf8');
return normalizeTask(JSON.parse(content));
} catch {
return null;
}
})
.filter((t): t is NormalizedTask => t !== null);
tasks = tasks.concat(implTasks);
} catch {
// Continue to other methods
}
}
// Method 2: Check plan.json or fix-plan.json for embedded tasks array
if (tasks.length === 0) {
// Try fix-plan.json first (for lite-fix), then plan.json (for lite-plan)
const fixPlanPath = join(sessionPath, 'fix-plan.json');
const planPath = join(sessionPath, 'plan.json');
const planFile = existsSync(fixPlanPath) ? fixPlanPath :
existsSync(planPath) ? planPath : null;
if (planFile) {
try {
const plan = JSON.parse(readFileSync(planFile, 'utf8')) as { tasks?: unknown[] };
if (Array.isArray(plan.tasks)) {
tasks = plan.tasks.map(t => normalizeTask(t)).filter((t): t is NormalizedTask => t !== null);
}
} catch {
// Continue to other methods
}
}
}
// Method 3: Check for task-*.json and diagnosis-*.json files in session root
if (tasks.length === 0) {
try {
const rootTasks = readdirSync(sessionPath)
.filter(f => f.endsWith('.json') && (
f.startsWith('task-') ||
f.startsWith('TASK-') ||
f.startsWith('diagnosis-') ||
/^T\d+\.json$/i.test(f)
))
.map(f => {
const taskPath = join(sessionPath, f);
try {
const content = readFileSync(taskPath, 'utf8');
return normalizeTask(JSON.parse(content));
} catch {
return null;
}
})
.filter((t): t is NormalizedTask => t !== null);
tasks = tasks.concat(rootTasks);
} catch {
// No tasks found
}
}
// Sort tasks by ID
return tasks.sort((a, b) => {
const aNum = parseInt(a.id?.replace(/\D/g, '') || '0');
const bNum = parseInt(b.id?.replace(/\D/g, '') || '0');
return aNum - bNum;
});
}
/**
* Normalize task object to consistent structure
* @param task - Raw task object
* @returns Normalized task
*/
function normalizeTask(task: unknown): NormalizedTask | null {
if (!task || typeof task !== 'object') return null;
const taskObj = task as Record<string, unknown>;
// Determine status - support various status formats
let status = (taskObj.status as string | { state?: string; value?: string }) || 'pending';
if (typeof status === 'object') {
status = status.state || status.value || 'pending';
}
const meta = taskObj.meta as Record<string, unknown> | undefined;
const context = taskObj.context as Record<string, unknown> | undefined;
const flowControl = taskObj.flow_control as Record<string, unknown> | undefined;
const implementation = taskObj.implementation as unknown[] | undefined;
const modificationPoints = taskObj.modification_points as Array<{ file?: string }> | undefined;
return {
id: (taskObj.id as string) || (taskObj.task_id as string) || 'unknown',
title: (taskObj.title as string) || (taskObj.name as string) || (taskObj.summary as string) || 'Untitled Task',
status: (status as string).toLowerCase(),
// Preserve original fields for flexible rendering
meta: meta ? {
type: (meta.type as string) || (taskObj.type as string) || (taskObj.action as string) || 'task',
agent: (meta.agent as string) || (taskObj.agent as string) || null,
scope: (meta.scope as string) || (taskObj.scope as string) || null,
module: (meta.module as string) || (taskObj.module as string) || null
} : {
type: (taskObj.type as string) || (taskObj.action as string) || 'task',
agent: (taskObj.agent as string) || null,
scope: (taskObj.scope as string) || null,
module: (taskObj.module as string) || null
},
context: context ? {
requirements: (context.requirements as string[]) || [],
focus_paths: (context.focus_paths as string[]) || [],
acceptance: (context.acceptance as string[]) || [],
depends_on: (context.depends_on as string[]) || []
} : {
requirements: (taskObj.requirements as string[]) || (taskObj.description ? [taskObj.description as string] : []),
focus_paths: (taskObj.focus_paths as string[]) || modificationPoints?.map(m => m.file).filter((f): f is string => !!f) || [],
acceptance: (taskObj.acceptance as string[]) || [],
depends_on: (taskObj.depends_on as string[]) || []
},
flow_control: flowControl ? {
implementation_approach: (flowControl.implementation_approach as Array<{ step: string; action: string }>) || []
} : {
implementation_approach: implementation?.map((step, i) => ({
step: `Step ${i + 1}`,
action: step as string
})) || []
},
// Keep all original fields for raw JSON view
_raw: task
};
}
/**
* Get directory creation time
* @param dirPath - Directory path
* @returns ISO date string
*/
function getCreatedTime(dirPath: string): string {
try {
const stat = statSync(dirPath);
return stat.birthtime.toISOString();
} catch {
return new Date().toISOString();
}
}
/**
* Calculate progress from tasks
* @param tasks - Array of task objects
* @returns Progress info
*/
function calculateProgress(tasks: NormalizedTask[]): Progress {
if (!tasks || tasks.length === 0) {
return { total: 0, completed: 0, percentage: 0 };
}
const total = tasks.length;
const completed = tasks.filter(t => t.status === 'completed').length;
const percentage = Math.round((completed / total) * 100);
return { total, completed, percentage };
}
/**
* Get detailed lite task info
* @param workflowDir - Workflow directory
* @param type - 'lite-plan' or 'lite-fix'
* @param sessionId - Session ID
* @returns Detailed task info
*/
export function getLiteTaskDetail(workflowDir: string, type: string, sessionId: string): LiteTaskDetail | null {
const dir = type === 'lite-plan'
? join(workflowDir, '.lite-plan', sessionId)
: join(workflowDir, '.lite-fix', sessionId);
if (!existsSync(dir)) return null;
const detail: LiteTaskDetail = {
id: sessionId,
type,
path: dir,
plan: loadPlanJson(dir),
tasks: loadTaskJsons(dir),
explorations: loadExplorations(dir),
clarifications: loadClarifications(dir)
};
// For lite-fix sessions, also load diagnoses
if (type === 'lite-fix') {
detail.diagnoses = loadDiagnoses(dir);
}
return detail;
}
/**
* Load exploration results
* @param sessionPath - Session directory path
* @returns Exploration results
*/
function loadExplorations(sessionPath: string): unknown[] {
const explorePath = join(sessionPath, 'explorations.json');
if (!existsSync(explorePath)) return [];
try {
const content = readFileSync(explorePath, 'utf8');
return JSON.parse(content);
} catch {
return [];
}
}
/**
* Load clarification data
* @param sessionPath - Session directory path
* @returns Clarification data
*/
function loadClarifications(sessionPath: string): unknown | null {
const clarifyPath = join(sessionPath, 'clarifications.json');
if (!existsSync(clarifyPath)) return null;
try {
const content = readFileSync(clarifyPath, 'utf8');
return JSON.parse(content);
} catch {
return null;
}
}
/**
* Load diagnosis files for lite-fix sessions
* Loads diagnosis-*.json files from session root directory
* @param sessionPath - Session directory path
* @returns Diagnoses data with manifest and items
*/
function loadDiagnoses(sessionPath: string): Diagnoses {
const result: Diagnoses = {
manifest: null,
items: []
};
// Try to load diagnoses-manifest.json first
const manifestPath = join(sessionPath, 'diagnoses-manifest.json');
if (existsSync(manifestPath)) {
try {
result.manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
} catch {
// Continue without manifest
}
}
// Load all diagnosis-*.json files from session root
try {
const diagnosisFiles = readdirSync(sessionPath)
.filter(f => f.startsWith('diagnosis-') && f.endsWith('.json'));
for (const file of diagnosisFiles) {
const filePath = join(sessionPath, file);
try {
const content = JSON.parse(readFileSync(filePath, 'utf8')) as Record<string, unknown>;
result.items.push({
id: file.replace('diagnosis-', '').replace('.json', ''),
filename: file,
...content
});
} catch {
// Skip invalid files
}
}
} catch {
// Return empty items if directory read fails
}
return result;
}

View File

@@ -0,0 +1,96 @@
// @ts-nocheck
/**
* CCW Routes Module
* Handles all CCW-related API endpoints
*/
import type { IncomingMessage, ServerResponse } from 'http';
import { getAllManifests } from '../manifest.js';
import { listTools } from '../../tools/index.js';
export interface RouteContext {
pathname: string;
url: URL;
req: IncomingMessage;
res: ServerResponse;
initialPath: string;
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
broadcastToClients: (data: unknown) => void;
}
/**
* Handle CCW routes
* @returns true if route was handled, false otherwise
*/
export async function handleCcwRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
// API: CCW Installation Status
if (pathname === '/api/ccw/installations') {
const manifests = getAllManifests();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ installations: manifests }));
return true;
}
// API: CCW Endpoint Tools List
if (pathname === '/api/ccw/tools') {
const tools = listTools();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ tools }));
return true;
}
// API: CCW Upgrade
if (pathname === '/api/ccw/upgrade' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: installPath } = body;
try {
const { spawn } = await import('child_process');
// Run ccw upgrade command
const args = installPath ? ['upgrade', '--all'] : ['upgrade', '--all'];
const upgradeProcess = spawn('ccw', args, {
shell: true,
stdio: ['ignore', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
upgradeProcess.stdout.on('data', (data) => {
stdout += data.toString();
});
upgradeProcess.stderr.on('data', (data) => {
stderr += data.toString();
});
return new Promise((resolve) => {
upgradeProcess.on('close', (code) => {
if (code === 0) {
resolve({ success: true, message: 'Upgrade completed', output: stdout });
} else {
resolve({ success: false, error: stderr || 'Upgrade failed', output: stdout, status: 500 });
}
});
upgradeProcess.on('error', (err) => {
resolve({ success: false, error: err.message, status: 500 });
});
// Timeout after 2 minutes
setTimeout(() => {
upgradeProcess.kill();
resolve({ success: false, error: 'Upgrade timed out', status: 504 });
}, 120000);
});
} catch (err) {
return { success: false, error: err.message, status: 500 };
}
});
return true;
}
return false;
}

View File

@@ -0,0 +1,561 @@
// @ts-nocheck
/**
* CLI Routes Module
* Handles all CLI-related API endpoints
*/
import type { IncomingMessage, ServerResponse } from 'http';
import {
getCliToolsStatus,
getCliToolsFullStatus,
installCliTool,
uninstallCliTool,
enableCliTool,
disableCliTool,
getExecutionHistory,
getExecutionHistoryAsync,
getExecutionDetail,
getConversationDetail,
getConversationDetailWithNativeInfo,
deleteExecution,
deleteExecutionAsync,
batchDeleteExecutionsAsync,
executeCliTool,
getNativeSessionContent,
getFormattedNativeConversation,
getEnrichedConversation,
getHistoryWithNativeInfo
} from '../../tools/cli-executor.js';
import { generateSmartContext, formatSmartContext } from '../../tools/smart-context.js';
import {
loadCliConfig,
getToolConfig,
updateToolConfig,
getFullConfigResponse,
PREDEFINED_MODELS
} from '../../tools/cli-config-manager.js';
export interface RouteContext {
pathname: string;
url: URL;
req: IncomingMessage;
res: ServerResponse;
initialPath: string;
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
broadcastToClients: (data: unknown) => void;
}
/**
* Handle CLI routes
* @returns true if route was handled, false otherwise
*/
export async function handleCliRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
// API: CLI Tools Status
if (pathname === '/api/cli/status') {
const status = await getCliToolsStatus();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(status));
return true;
}
// API: CLI Tools Full Status (with enabled state)
if (pathname === '/api/cli/full-status') {
const status = await getCliToolsFullStatus();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(status));
return true;
}
// API: Install CLI Tool
if (pathname === '/api/cli/install' && req.method === 'POST') {
handlePostRequest(req, res, async (body: unknown) => {
const { tool } = body as { tool: string };
if (!tool) {
return { error: 'Tool name is required', status: 400 };
}
const result = await installCliTool(tool);
if (result.success) {
// Broadcast tool installed event
broadcastToClients({
type: 'CLI_TOOL_INSTALLED',
payload: { tool, timestamp: new Date().toISOString() }
});
return { success: true, message: `${tool} installed successfully` };
} else {
return { success: false, error: result.error, status: 500 };
}
});
return true;
}
// API: Uninstall CLI Tool
if (pathname === '/api/cli/uninstall' && req.method === 'POST') {
handlePostRequest(req, res, async (body: unknown) => {
const { tool } = body as { tool: string };
if (!tool) {
return { error: 'Tool name is required', status: 400 };
}
const result = await uninstallCliTool(tool);
if (result.success) {
// Broadcast tool uninstalled event
broadcastToClients({
type: 'CLI_TOOL_UNINSTALLED',
payload: { tool, timestamp: new Date().toISOString() }
});
return { success: true, message: `${tool} uninstalled successfully` };
} else {
return { success: false, error: result.error, status: 500 };
}
});
return true;
}
// API: Enable CLI Tool
if (pathname === '/api/cli/enable' && req.method === 'POST') {
handlePostRequest(req, res, async (body: unknown) => {
const { tool } = body as { tool: string };
if (!tool) {
return { error: 'Tool name is required', status: 400 };
}
const result = enableCliTool(tool);
// Broadcast tool enabled event
broadcastToClients({
type: 'CLI_TOOL_ENABLED',
payload: { tool, timestamp: new Date().toISOString() }
});
return { success: true, message: `${tool} enabled` };
});
return true;
}
// API: Disable CLI Tool
if (pathname === '/api/cli/disable' && req.method === 'POST') {
handlePostRequest(req, res, async (body: unknown) => {
const { tool } = body as { tool: string };
if (!tool) {
return { error: 'Tool name is required', status: 400 };
}
const result = disableCliTool(tool);
// Broadcast tool disabled event
broadcastToClients({
type: 'CLI_TOOL_DISABLED',
payload: { tool, timestamp: new Date().toISOString() }
});
return { success: true, message: `${tool} disabled` };
});
return true;
}
// API: Get Full CLI Config (with predefined models)
if (pathname === '/api/cli/config' && req.method === 'GET') {
try {
const response = getFullConfigResponse(initialPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(response));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// API: Get/Update Tool Config
const configMatch = pathname.match(/^\/api\/cli\/config\/(gemini|qwen|codex)$/);
if (configMatch) {
const tool = configMatch[1];
// GET: Get single tool config
if (req.method === 'GET') {
try {
const toolConfig = getToolConfig(initialPath, tool);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(toolConfig));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (err as Error).message }));
}
return true;
}
// PUT: Update tool config
if (req.method === 'PUT') {
handlePostRequest(req, res, async (body: unknown) => {
try {
const updates = body as { enabled?: boolean; primaryModel?: string; secondaryModel?: string };
const updated = updateToolConfig(initialPath, tool, updates);
// Broadcast config updated event
broadcastToClients({
type: 'CLI_CONFIG_UPDATED',
payload: { tool, config: updated, timestamp: new Date().toISOString() }
});
return { success: true, config: updated };
} catch (err) {
return { error: (err as Error).message, status: 500 };
}
});
return true;
}
}
// API: CLI Execution History
if (pathname === '/api/cli/history') {
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;
const recursive = url.searchParams.get('recursive') !== 'false';
getExecutionHistoryAsync(projectPath, { limit, tool, status, category, search, recursive })
.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.message }));
});
return true;
}
// API: CLI Execution Detail (GET) or Delete (DELETE)
if (pathname === '/api/cli/execution') {
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 true;
}
// Handle DELETE request
if (req.method === 'DELETE') {
deleteExecutionAsync(projectPath, executionId)
.then(result => {
if (result.success) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: 'Execution deleted' }));
} else {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: result.error || 'Delete failed' }));
}
})
.catch(err => {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
});
return true;
}
// Handle GET request - return conversation with native session info
const conversation = getConversationDetailWithNativeInfo(projectPath, executionId);
if (!conversation) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Conversation not found' }));
return true;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(conversation));
return true;
}
// API: Batch Delete CLI Executions
if (pathname === '/api/cli/batch-delete' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: projectPath, ids } = body as { path?: string; ids: string[] };
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return { error: 'ids array is required', status: 400 };
}
const basePath = projectPath || initialPath;
return await batchDeleteExecutionsAsync(basePath, ids);
});
return true;
}
// API: Get Native Session Content
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';
if (!executionId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Execution ID is required' }));
return true;
}
try {
let result;
if (format === 'text') {
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') {
const enriched = await getEnrichedConversation(projectPath, executionId);
result = enriched?.merged || null;
} else {
result = await getNativeSessionContent(projectPath, executionId);
}
if (!result) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Native session not found' }));
return true;
}
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 true;
}
// API: Get Enriched Conversation
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 true;
}
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 true;
}
// 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 true;
}
// API: Execute CLI Tool
if (pathname === '/api/cli/execute' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { tool, prompt, mode, format, model, dir, includeDirs, timeout, smartContext, parentExecutionId, category } = body as any;
if (!tool || !prompt) {
return { error: 'tool and prompt are required', status: 400 };
}
// Generate smart context if enabled
let finalPrompt = prompt;
if (smartContext?.enabled) {
try {
const contextResult = await generateSmartContext(prompt, {
enabled: true,
maxFiles: smartContext.maxFiles || 10,
searchMode: 'text'
}, dir || initialPath);
const contextAppendage = formatSmartContext(contextResult);
if (contextAppendage) {
finalPrompt = prompt + contextAppendage;
}
} catch (err) {
console.warn('[Smart Context] Failed to generate:', err);
}
}
const executionId = `${Date.now()}-${tool}`;
// Broadcast execution started
broadcastToClients({
type: 'CLI_EXECUTION_STARTED',
payload: {
executionId,
tool,
mode: mode || 'analysis',
parentExecutionId,
timestamp: new Date().toISOString()
}
});
try {
const result = await executeCliTool({
tool,
prompt: finalPrompt,
mode: mode || 'analysis',
format: format || 'plain',
model,
cd: dir || initialPath,
includeDirs,
timeout: timeout || 300000,
category: category || 'user',
parentExecutionId,
stream: true
}, (chunk) => {
broadcastToClients({
type: 'CLI_OUTPUT',
payload: {
executionId,
chunkType: chunk.type,
data: chunk.data
}
});
});
// Broadcast completion
broadcastToClients({
type: 'CLI_EXECUTION_COMPLETED',
payload: {
executionId,
success: result.success,
status: result.execution.status,
duration_ms: result.execution.duration_ms
}
});
return {
success: result.success,
execution: result.execution
};
} catch (error: unknown) {
broadcastToClients({
type: 'CLI_EXECUTION_ERROR',
payload: {
executionId,
error: (error as Error).message
}
});
return { error: (error as Error).message, status: 500 };
}
});
return true;
}
// API: CLI Review - Submit review for an execution
if (pathname.startsWith('/api/cli/review/') && req.method === 'POST') {
const executionId = pathname.replace('/api/cli/review/', '');
handlePostRequest(req, res, async (body) => {
const { status, rating, comments, reviewer } = body as {
status: 'pending' | 'approved' | 'rejected' | 'changes_requested';
rating?: number;
comments?: string;
reviewer?: string;
};
if (!status) {
return { error: 'status is required', status: 400 };
}
try {
const historyStore = await import('../../tools/cli-history-store.js').then(m => m.getHistoryStore(initialPath));
const execution = historyStore.getConversation(executionId);
if (!execution) {
return { error: 'Execution not found', status: 404 };
}
const review = historyStore.saveReview({
execution_id: executionId,
status,
rating,
comments,
reviewer
});
broadcastToClients({
type: 'CLI_REVIEW_UPDATED',
payload: {
executionId,
review,
timestamp: new Date().toISOString()
}
});
return { success: true, review };
} catch (error: unknown) {
return { error: (error as Error).message, status: 500 };
}
});
return true;
}
// API: CLI Review - Get review for an execution
if (pathname.startsWith('/api/cli/review/') && req.method === 'GET') {
const executionId = pathname.replace('/api/cli/review/', '');
try {
const historyStore = await import('../../tools/cli-history-store.js').then(m => m.getHistoryStore(initialPath));
const review = historyStore.getReview(executionId);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ review }));
} catch (error: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
}
return true;
}
// API: CLI Reviews - List all reviews
if (pathname === '/api/cli/reviews' && req.method === 'GET') {
try {
const historyStore = await import('../../tools/cli-history-store.js').then(m => m.getHistoryStore(initialPath));
const statusFilter = url.searchParams.get('status') as 'pending' | 'approved' | 'rejected' | 'changes_requested' | null;
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
const reviews = historyStore.getReviews({
status: statusFilter || undefined,
limit
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ reviews, count: reviews.length }));
} catch (error: unknown) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: (error as Error).message }));
}
return true;
}
return false;
}

View File

@@ -0,0 +1,175 @@
// @ts-nocheck
/**
* CodexLens Routes Module
* Handles all CodexLens-related API endpoints
*/
import type { IncomingMessage, ServerResponse } from 'http';
import {
checkVenvStatus,
bootstrapVenv,
executeCodexLens,
checkSemanticStatus,
installSemantic
} from '../../tools/codex-lens.js';
export interface RouteContext {
pathname: string;
url: URL;
req: IncomingMessage;
res: ServerResponse;
initialPath: string;
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
broadcastToClients: (data: unknown) => void;
}
/**
* Handle CodexLens routes
* @returns true if route was handled, false otherwise
*/
export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
// API: CodexLens Status
if (pathname === '/api/codexlens/status') {
const status = await checkVenvStatus();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(status));
return true;
}
// API: CodexLens Bootstrap (Install)
if (pathname === '/api/codexlens/bootstrap' && req.method === 'POST') {
handlePostRequest(req, res, async () => {
try {
const result = await bootstrapVenv();
if (result.success) {
const status = await checkVenvStatus();
return { success: true, message: 'CodexLens installed successfully', version: status.version };
} else {
return { success: false, error: result.error, status: 500 };
}
} catch (err) {
return { success: false, error: err.message, status: 500 };
}
});
return true;
}
// API: CodexLens Init (Initialize workspace index)
if (pathname === '/api/codexlens/init' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: projectPath } = body;
const targetPath = projectPath || initialPath;
try {
const result = await executeCodexLens(['init', targetPath, '--json'], { cwd: targetPath });
if (result.success) {
try {
const parsed = JSON.parse(result.output);
return { success: true, result: parsed };
} catch {
return { success: true, output: result.output };
}
} else {
return { success: false, error: result.error, status: 500 };
}
} catch (err) {
return { success: false, error: err.message, status: 500 };
}
});
return true;
}
// API: CodexLens Semantic Search Status
if (pathname === '/api/codexlens/semantic/status') {
const status = await checkSemanticStatus();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(status));
return true;
}
// API: CodexLens Semantic Metadata List
if (pathname === '/api/codexlens/semantic/metadata') {
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
const limit = parseInt(url.searchParams.get('limit') || '50', 10);
const tool = url.searchParams.get('tool') || '';
const projectPath = url.searchParams.get('path') || initialPath;
try {
const args = [
'semantic-list',
'--path', projectPath,
'--offset', offset.toString(),
'--limit', limit.toString(),
'--json'
];
if (tool) {
args.push('--tool', tool);
}
const result = await executeCodexLens(args, { cwd: projectPath });
if (result.success) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(result.output);
} else {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: result.error }));
}
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, error: err.message }));
}
return true;
}
// API: CodexLens LLM Enhancement (run enhance command)
if (pathname === '/api/codexlens/enhance' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: projectPath, tool = 'gemini', batchSize = 5, timeoutMs = 300000 } = body;
const targetPath = projectPath || initialPath;
try {
const args = ['enhance', targetPath, '--tool', tool, '--batch-size', batchSize.toString()];
const result = await executeCodexLens(args, { cwd: targetPath, timeout: timeoutMs + 30000 });
if (result.success) {
try {
const parsed = JSON.parse(result.output);
return { success: true, result: parsed };
} catch {
return { success: true, output: result.output };
}
} else {
return { success: false, error: result.error, status: 500 };
}
} catch (err) {
return { success: false, error: err.message, status: 500 };
}
});
return true;
}
// API: CodexLens Semantic Search Install (fastembed, ONNX-based, ~200MB)
if (pathname === '/api/codexlens/semantic/install' && req.method === 'POST') {
handlePostRequest(req, res, async () => {
try {
const result = await installSemantic();
if (result.success) {
const status = await checkSemanticStatus();
return {
success: true,
message: 'Semantic search installed successfully (fastembed)',
...status
};
} else {
return { success: false, error: result.error, status: 500 };
}
} catch (err) {
return { success: false, error: err.message, status: 500 };
}
});
return true;
}
return false;
}

View File

@@ -0,0 +1,428 @@
// @ts-nocheck
/**
* Files Routes Module
* Handles all file browsing related API endpoints
*/
import type { IncomingMessage, ServerResponse } from 'http';
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
import { join } from 'path';
export interface RouteContext {
pathname: string;
url: URL;
req: IncomingMessage;
res: ServerResponse;
initialPath: string;
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
broadcastToClients: (data: unknown) => void;
}
// ========================================
// Constants
// ========================================
// Directories to always exclude from file tree
const EXPLORER_EXCLUDE_DIRS = [
'.git', '__pycache__', 'node_modules', '.venv', 'venv', 'env',
'dist', 'build', '.cache', '.pytest_cache', '.mypy_cache',
'coverage', '.nyc_output', 'logs', 'tmp', 'temp', '.next',
'.nuxt', '.output', '.turbo', '.parcel-cache'
];
// File extensions to language mapping for syntax highlighting
const EXT_TO_LANGUAGE = {
'.js': 'javascript',
'.jsx': 'javascript',
'.ts': 'typescript',
'.tsx': 'typescript',
'.py': 'python',
'.rb': 'ruby',
'.java': 'java',
'.go': 'go',
'.rs': 'rust',
'.c': 'c',
'.cpp': 'cpp',
'.h': 'c',
'.hpp': 'cpp',
'.cs': 'csharp',
'.php': 'php',
'.swift': 'swift',
'.kt': 'kotlin',
'.scala': 'scala',
'.sh': 'bash',
'.bash': 'bash',
'.zsh': 'bash',
'.ps1': 'powershell',
'.sql': 'sql',
'.html': 'html',
'.htm': 'html',
'.css': 'css',
'.scss': 'scss',
'.sass': 'sass',
'.less': 'less',
'.json': 'json',
'.xml': 'xml',
'.yaml': 'yaml',
'.yml': 'yaml',
'.toml': 'toml',
'.ini': 'ini',
'.cfg': 'ini',
'.conf': 'nginx',
'.md': 'markdown',
'.markdown': 'markdown',
'.txt': 'plaintext',
'.log': 'plaintext',
'.env': 'bash',
'.dockerfile': 'dockerfile',
'.vue': 'html',
'.svelte': 'html'
};
// ========================================
// Helper Functions
// ========================================
/**
* Parse .gitignore file and return patterns
* @param {string} gitignorePath - Path to .gitignore file
* @returns {string[]} Array of gitignore patterns
*/
function parseGitignore(gitignorePath) {
try {
if (!existsSync(gitignorePath)) return [];
const content = readFileSync(gitignorePath, 'utf8');
return content
.split('\n')
.map(line => line.trim())
.filter(line => line && !line.startsWith('#'));
} catch {
return [];
}
}
/**
* Check if a file/directory should be ignored based on gitignore patterns
* Simple pattern matching (supports basic glob patterns)
* @param {string} name - File or directory name
* @param {string[]} patterns - Gitignore patterns
* @param {boolean} isDirectory - Whether the entry is a directory
* @returns {boolean}
*/
function shouldIgnore(name, patterns, isDirectory) {
// Always exclude certain directories
if (isDirectory && EXPLORER_EXCLUDE_DIRS.includes(name)) {
return true;
}
// Skip hidden files/directories (starting with .)
if (name.startsWith('.') && name !== '.claude' && name !== '.workflow') {
return true;
}
for (const pattern of patterns) {
let p = pattern;
// Handle negation patterns (we skip them for simplicity)
if (p.startsWith('!')) continue;
// Handle directory-only patterns
if (p.endsWith('/')) {
if (!isDirectory) continue;
p = p.slice(0, -1);
}
// Simple pattern matching
if (p === name) return true;
// Handle wildcard patterns
if (p.includes('*')) {
const regex = new RegExp('^' + p.replace(/\./g, '\\.').replace(/\*/g, '.*') + '$');
if (regex.test(name)) return true;
}
// Handle extension patterns like *.log
if (p.startsWith('*.')) {
const ext = p.slice(1);
if (name.endsWith(ext)) return true;
}
}
return false;
}
/**
* List directory files with .gitignore filtering
* @param {string} dirPath - Directory path to list
* @returns {Promise<Object>}
*/
async function listDirectoryFiles(dirPath) {
try {
// Normalize path
let normalizedPath = dirPath.replace(/\\/g, '/');
if (normalizedPath.match(/^\/[a-zA-Z]\//)) {
normalizedPath = normalizedPath.charAt(1).toUpperCase() + ':' + normalizedPath.slice(2);
}
if (!existsSync(normalizedPath)) {
return { error: 'Directory not found', files: [] };
}
if (!statSync(normalizedPath).isDirectory()) {
return { error: 'Not a directory', files: [] };
}
// Parse .gitignore patterns
const gitignorePath = join(normalizedPath, '.gitignore');
const gitignorePatterns = parseGitignore(gitignorePath);
// Read directory entries
const entries = readdirSync(normalizedPath, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const isDirectory = entry.isDirectory();
// Check if should be ignored
if (shouldIgnore(entry.name, gitignorePatterns, isDirectory)) {
continue;
}
const entryPath = join(normalizedPath, entry.name);
const fileInfo = {
name: entry.name,
type: isDirectory ? 'directory' : 'file',
path: entryPath.replace(/\\/g, '/')
};
// Check if directory has CLAUDE.md
if (isDirectory) {
const claudeMdPath = join(entryPath, 'CLAUDE.md');
fileInfo.hasClaudeMd = existsSync(claudeMdPath);
}
files.push(fileInfo);
}
// Sort: directories first, then alphabetically
files.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1;
if (a.type !== 'directory' && b.type === 'directory') return 1;
return a.name.localeCompare(b.name);
});
return {
path: normalizedPath.replace(/\\/g, '/'),
files,
gitignorePatterns
};
} catch (error: unknown) {
console.error('Error listing directory:', error);
return { error: (error as Error).message, files: [] };
}
}
/**
* Get file content for preview
* @param {string} filePath - Path to file
* @returns {Promise<Object>}
*/
async function getFileContent(filePath) {
try {
// Normalize path
let normalizedPath = filePath.replace(/\\/g, '/');
if (normalizedPath.match(/^\/[a-zA-Z]\//)) {
normalizedPath = normalizedPath.charAt(1).toUpperCase() + ':' + normalizedPath.slice(2);
}
if (!existsSync(normalizedPath)) {
return { error: 'File not found' };
}
const stats = statSync(normalizedPath);
if (stats.isDirectory()) {
return { error: 'Cannot read directory' };
}
// Check file size (limit to 1MB for preview)
if (stats.size > 1024 * 1024) {
return { error: 'File too large for preview (max 1MB)', size: stats.size };
}
// Read file content
const content = readFileSync(normalizedPath, 'utf8');
const ext = normalizedPath.substring(normalizedPath.lastIndexOf('.')).toLowerCase();
const language = EXT_TO_LANGUAGE[ext] || 'plaintext';
const isMarkdown = ext === '.md' || ext === '.markdown';
const fileName = normalizedPath.split('/').pop();
return {
content,
language,
isMarkdown,
fileName,
path: normalizedPath,
size: stats.size,
lines: content.split('\n').length
};
} catch (error: unknown) {
console.error('Error reading file:', error);
return { error: (error as Error).message };
}
}
/**
* Trigger update-module-claude tool (async execution)
* @param {string} targetPath - Directory path to update
* @param {string} tool - CLI tool to use (gemini, qwen, codex, claude)
* @param {string} strategy - Update strategy (single-layer, multi-layer)
* @returns {Promise<Object>}
*/
async function triggerUpdateClaudeMd(targetPath, tool, strategy) {
const { spawn } = await import('child_process');
// Normalize path
let normalizedPath = targetPath.replace(/\\/g, '/');
if (normalizedPath.match(/^\/[a-zA-Z]\//)) {
normalizedPath = normalizedPath.charAt(1).toUpperCase() + ':' + normalizedPath.slice(2);
}
if (!existsSync(normalizedPath)) {
return { error: 'Directory not found' };
}
if (!statSync(normalizedPath).isDirectory()) {
return { error: 'Not a directory' };
}
// Build ccw tool command with JSON parameters
const params = JSON.stringify({
strategy,
path: normalizedPath,
tool
});
console.log(`[Explorer] Running async: ccw tool exec update_module_claude with ${tool} (${strategy})`);
return new Promise((resolve) => {
const isWindows = process.platform === 'win32';
// Spawn the process
const child = spawn('ccw', ['tool', 'exec', 'update_module_claude', params], {
cwd: normalizedPath,
shell: isWindows,
stdio: ['ignore', 'pipe', 'pipe']
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
if (code === 0) {
// Parse the JSON output from the tool
let result;
try {
result = JSON.parse(stdout);
} catch {
result = { output: stdout };
}
if (result.success === false || result.error) {
resolve({
success: false,
error: result.error || result.message || 'Update failed',
output: stdout
});
} else {
resolve({
success: true,
message: result.message || `CLAUDE.md updated successfully using ${tool} (${strategy})`,
output: stdout,
path: normalizedPath
});
}
} else {
resolve({
success: false,
error: stderr || `Process exited with code ${code}`,
output: stdout + stderr
});
}
});
child.on('error', (error) => {
console.error('Error spawning process:', error);
resolve({
success: false,
error: (error as Error).message,
output: ''
});
});
// Timeout after 5 minutes
setTimeout(() => {
child.kill();
resolve({
success: false,
error: 'Timeout: Process took longer than 5 minutes',
output: stdout
});
}, 300000);
});
}
// ========================================
// Route Handler
// ========================================
/**
* Handle files routes
* @returns true if route was handled, false otherwise
*/
export async function handleFilesRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
// API: List directory files with .gitignore filtering (Explorer view)
if (pathname === '/api/files') {
const dirPath = url.searchParams.get('path') || initialPath;
const filesData = await listDirectoryFiles(dirPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(filesData));
return true;
}
// API: Get file content for preview (Explorer view)
if (pathname === '/api/file-content') {
const filePath = url.searchParams.get('path');
if (!filePath) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File path is required' }));
return true;
}
const fileData = await getFileContent(filePath);
res.writeHead(fileData.error ? 404 : 200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(fileData));
return true;
}
// API: Update CLAUDE.md using CLI tools (Explorer view)
if (pathname === '/api/update-claude-md' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path: targetPath, tool = 'gemini', strategy = 'single-layer' } = body;
if (!targetPath) {
return { error: 'path is required', status: 400 };
}
return await triggerUpdateClaudeMd(targetPath, tool, strategy);
});
return true;
}
return false;
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,266 @@
// @ts-nocheck
/**
* Rules Routes Module
* Handles all Rules-related API endpoints
*/
import type { IncomingMessage, ServerResponse } from 'http';
import { readFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
export interface RouteContext {
pathname: string;
url: URL;
req: IncomingMessage;
res: ServerResponse;
initialPath: string;
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
broadcastToClients: (data: unknown) => void;
}
/**
* Parse rule frontmatter
* @param {string} content
* @returns {Object}
*/
function parseRuleFrontmatter(content) {
const result = {
paths: [],
content: content
};
// Check for YAML frontmatter
if (content.startsWith('---')) {
const endIndex = content.indexOf('---', 3);
if (endIndex > 0) {
const frontmatter = content.substring(3, endIndex).trim();
result.content = content.substring(endIndex + 3).trim();
// Parse frontmatter lines
const lines = frontmatter.split('\n');
for (const line of lines) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim().toLowerCase();
const value = line.substring(colonIndex + 1).trim();
if (key === 'paths') {
// Parse as comma-separated or YAML array
result.paths = value.replace(/^\[|\]$/g, '').split(',').map(t => t.trim()).filter(Boolean);
}
}
}
}
}
return result;
}
/**
* Recursively scan rules directory for .md files
* @param {string} dirPath
* @param {string} location
* @param {string} subdirectory
* @returns {Object[]}
*/
function scanRulesDirectory(dirPath, location, subdirectory) {
const rules = [];
try {
const entries = readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dirPath, entry.name);
if (entry.isFile() && entry.name.endsWith('.md')) {
const content = readFileSync(fullPath, 'utf8');
const parsed = parseRuleFrontmatter(content);
rules.push({
name: entry.name,
paths: parsed.paths,
content: parsed.content,
location,
path: fullPath,
subdirectory: subdirectory || null
});
} else if (entry.isDirectory()) {
// Recursively scan subdirectories
const subRules = scanRulesDirectory(fullPath, location, subdirectory ? `${subdirectory}/${entry.name}` : entry.name);
rules.push(...subRules);
}
}
} catch (e) {
// Ignore errors
}
return rules;
}
/**
* Get rules configuration from project and user directories
* @param {string} projectPath
* @returns {Object}
*/
function getRulesConfig(projectPath) {
const result = {
projectRules: [],
userRules: []
};
try {
// Project rules: .claude/rules/
const projectRulesDir = join(projectPath, '.claude', 'rules');
if (existsSync(projectRulesDir)) {
const rules = scanRulesDirectory(projectRulesDir, 'project', '');
result.projectRules = rules;
}
// User rules: ~/.claude/rules/
const userRulesDir = join(homedir(), '.claude', 'rules');
if (existsSync(userRulesDir)) {
const rules = scanRulesDirectory(userRulesDir, 'user', '');
result.userRules = rules;
}
} catch (error) {
console.error('Error reading rules config:', error);
}
return result;
}
/**
* Find rule file in directory (including subdirectories)
* @param {string} baseDir
* @param {string} ruleName
* @returns {string|null}
*/
function findRuleFile(baseDir, ruleName) {
try {
// Direct path
const directPath = join(baseDir, ruleName);
if (existsSync(directPath)) {
return directPath;
}
// Search in subdirectories
const entries = readdirSync(baseDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const subPath = findRuleFile(join(baseDir, entry.name), ruleName);
if (subPath) return subPath;
}
}
} catch (e) {
// Ignore errors
}
return null;
}
/**
* Get single rule detail
* @param {string} ruleName
* @param {string} location - 'project' or 'user'
* @param {string} projectPath
* @returns {Object}
*/
function getRuleDetail(ruleName, location, projectPath) {
try {
const baseDir = location === 'project'
? join(projectPath, '.claude', 'rules')
: join(homedir(), '.claude', 'rules');
// Find the rule file (could be in subdirectory)
const rulePath = findRuleFile(baseDir, ruleName);
if (!rulePath) {
return { error: 'Rule not found' };
}
const content = readFileSync(rulePath, 'utf8');
const parsed = parseRuleFrontmatter(content);
return {
rule: {
name: ruleName,
paths: parsed.paths,
content: parsed.content,
location,
path: rulePath
}
};
} catch (error) {
return { error: (error as Error).message };
}
}
/**
* Delete a rule
* @param {string} ruleName
* @param {string} location
* @param {string} projectPath
* @returns {Object}
*/
function deleteRule(ruleName, location, projectPath) {
try {
const baseDir = location === 'project'
? join(projectPath, '.claude', 'rules')
: join(homedir(), '.claude', 'rules');
const rulePath = findRuleFile(baseDir, ruleName);
if (!rulePath) {
return { error: 'Rule not found' };
}
unlinkSync(rulePath);
return { success: true, ruleName, location };
} catch (error) {
return { error: (error as Error).message };
}
}
/**
* Handle Rules routes
* @returns true if route was handled, false otherwise
*/
export async function handleRulesRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
// API: Get all rules
if (pathname === '/api/rules') {
const projectPathParam = url.searchParams.get('path') || initialPath;
const rulesData = getRulesConfig(projectPathParam);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(rulesData));
return true;
}
// API: Get single rule detail
if (pathname.startsWith('/api/rules/') && req.method === 'GET' && !pathname.endsWith('/rules/')) {
const ruleName = decodeURIComponent(pathname.replace('/api/rules/', ''));
const location = url.searchParams.get('location') || 'project';
const projectPathParam = url.searchParams.get('path') || initialPath;
const ruleDetail = getRuleDetail(ruleName, location, projectPathParam);
if (ruleDetail.error) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(ruleDetail));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(ruleDetail));
}
return true;
}
// API: Delete rule
if (pathname.startsWith('/api/rules/') && req.method === 'DELETE') {
const ruleName = decodeURIComponent(pathname.replace('/api/rules/', ''));
handlePostRequest(req, res, async (body) => {
const { location, projectPath: projectPathParam } = body;
return deleteRule(ruleName, location, projectPathParam || initialPath);
});
return true;
}
return false;
}

View File

@@ -0,0 +1,406 @@
// @ts-nocheck
/**
* Session Routes Module
* Handles all Session/Task-related API endpoints
*/
import type { IncomingMessage, ServerResponse } from 'http';
import { readFileSync, writeFileSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
export interface RouteContext {
pathname: string;
url: URL;
req: IncomingMessage;
res: ServerResponse;
initialPath: string;
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
broadcastToClients: (data: unknown) => void;
}
/**
* Get session detail data (context, summaries, impl-plan, review)
* @param {string} sessionPath - Path to session directory
* @param {string} dataType - Type of data to load ('all', 'context', 'tasks', 'summary', 'plan', 'explorations', 'conflict', 'impl-plan', 'review')
* @returns {Promise<Object>}
*/
async function getSessionDetailData(sessionPath, dataType) {
const result = {};
// Normalize path
const normalizedPath = sessionPath.replace(/\\/g, '/');
try {
// Load context-package.json (in .process/ subfolder)
if (dataType === 'context' || dataType === 'all') {
// Try .process/context-package.json first (common location)
let contextFile = join(normalizedPath, '.process', 'context-package.json');
if (!existsSync(contextFile)) {
// Fallback to session root
contextFile = join(normalizedPath, 'context-package.json');
}
if (existsSync(contextFile)) {
try {
result.context = JSON.parse(readFileSync(contextFile, 'utf8'));
} catch (e) {
result.context = null;
}
}
}
// Load task JSONs from .task/ folder
if (dataType === 'tasks' || dataType === 'all') {
const taskDir = join(normalizedPath, '.task');
result.tasks = [];
if (existsSync(taskDir)) {
const files = readdirSync(taskDir).filter(f => f.endsWith('.json') && f.startsWith('IMPL-'));
for (const file of files) {
try {
const content = JSON.parse(readFileSync(join(taskDir, file), 'utf8'));
result.tasks.push({
filename: file,
task_id: file.replace('.json', ''),
...content
});
} catch (e) {
// Skip unreadable files
}
}
// Sort by task ID
result.tasks.sort((a, b) => a.task_id.localeCompare(b.task_id));
}
}
// Load summaries from .summaries/
if (dataType === 'summary' || dataType === 'all') {
const summariesDir = join(normalizedPath, '.summaries');
result.summaries = [];
if (existsSync(summariesDir)) {
const files = readdirSync(summariesDir).filter(f => f.endsWith('.md'));
for (const file of files) {
try {
const content = readFileSync(join(summariesDir, file), 'utf8');
result.summaries.push({ name: file.replace('.md', ''), content });
} catch (e) {
// Skip unreadable files
}
}
}
}
// Load plan.json (for lite tasks)
if (dataType === 'plan' || dataType === 'all') {
const planFile = join(normalizedPath, 'plan.json');
if (existsSync(planFile)) {
try {
result.plan = JSON.parse(readFileSync(planFile, 'utf8'));
} catch (e) {
result.plan = null;
}
}
}
// Load explorations (exploration-*.json files) - check .process/ first, then session root
if (dataType === 'context' || dataType === 'explorations' || dataType === 'all') {
result.explorations = { manifest: null, data: {} };
// Try .process/ first (standard workflow sessions), then session root (lite tasks)
const searchDirs = [
join(normalizedPath, '.process'),
normalizedPath
];
for (const searchDir of searchDirs) {
if (!existsSync(searchDir)) continue;
// Look for explorations-manifest.json
const manifestFile = join(searchDir, 'explorations-manifest.json');
if (existsSync(manifestFile)) {
try {
result.explorations.manifest = JSON.parse(readFileSync(manifestFile, 'utf8'));
// Load each exploration file based on manifest
const explorations = result.explorations.manifest.explorations || [];
for (const exp of explorations) {
const expFile = join(searchDir, exp.file);
if (existsSync(expFile)) {
try {
result.explorations.data[exp.angle] = JSON.parse(readFileSync(expFile, 'utf8'));
} catch (e) {
// Skip unreadable exploration files
}
}
}
break; // Found manifest, stop searching
} catch (e) {
result.explorations.manifest = null;
}
} else {
// Fallback: scan for exploration-*.json files directly
try {
const files = readdirSync(searchDir).filter(f => f.startsWith('exploration-') && f.endsWith('.json'));
if (files.length > 0) {
// Create synthetic manifest
result.explorations.manifest = {
exploration_count: files.length,
explorations: files.map((f, i) => ({
angle: f.replace('exploration-', '').replace('.json', ''),
file: f,
index: i + 1
}))
};
// Load each file
for (const file of files) {
const angle = file.replace('exploration-', '').replace('.json', '');
try {
result.explorations.data[angle] = JSON.parse(readFileSync(join(searchDir, file), 'utf8'));
} catch (e) {
// Skip unreadable files
}
}
break; // Found explorations, stop searching
}
} catch (e) {
// Directory read failed
}
}
}
}
// Load conflict resolution decisions (conflict-resolution-decisions.json)
if (dataType === 'context' || dataType === 'conflict' || dataType === 'all') {
result.conflictResolution = null;
// Try .process/ first (standard workflow sessions)
const conflictFiles = [
join(normalizedPath, '.process', 'conflict-resolution-decisions.json'),
join(normalizedPath, 'conflict-resolution-decisions.json')
];
for (const conflictFile of conflictFiles) {
if (existsSync(conflictFile)) {
try {
result.conflictResolution = JSON.parse(readFileSync(conflictFile, 'utf8'));
break; // Found file, stop searching
} catch (e) {
// Skip unreadable file
}
}
}
}
// Load IMPL_PLAN.md
if (dataType === 'impl-plan' || dataType === 'all') {
const implPlanFile = join(normalizedPath, 'IMPL_PLAN.md');
if (existsSync(implPlanFile)) {
try {
result.implPlan = readFileSync(implPlanFile, 'utf8');
} catch (e) {
result.implPlan = null;
}
}
}
// Load review data from .review/
if (dataType === 'review' || dataType === 'all') {
const reviewDir = join(normalizedPath, '.review');
result.review = {
state: null,
dimensions: [],
severityDistribution: null,
totalFindings: 0
};
if (existsSync(reviewDir)) {
// Load review-state.json
const stateFile = join(reviewDir, 'review-state.json');
if (existsSync(stateFile)) {
try {
const state = JSON.parse(readFileSync(stateFile, 'utf8'));
result.review.state = state;
result.review.severityDistribution = state.severity_distribution || {};
result.review.totalFindings = state.total_findings || 0;
result.review.phase = state.phase || 'unknown';
result.review.dimensionSummaries = state.dimension_summaries || {};
result.review.crossCuttingConcerns = state.cross_cutting_concerns || [];
result.review.criticalFiles = state.critical_files || [];
} catch (e) {
// Skip unreadable state
}
}
// Load dimension findings
const dimensionsDir = join(reviewDir, 'dimensions');
if (existsSync(dimensionsDir)) {
const files = readdirSync(dimensionsDir).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const dimName = file.replace('.json', '');
const data = JSON.parse(readFileSync(join(dimensionsDir, file), 'utf8'));
// Handle array structure: [ { findings: [...] } ]
let findings = [];
let summary = null;
if (Array.isArray(data) && data.length > 0) {
const dimData = data[0];
findings = dimData.findings || [];
summary = dimData.summary || null;
} else if (data.findings) {
findings = data.findings;
summary = data.summary || null;
}
result.review.dimensions.push({
name: dimName,
findings: findings,
summary: summary,
count: findings.length
});
} catch (e) {
// Skip unreadable files
}
}
}
}
}
} catch (error: unknown) {
console.error('Error loading session detail:', error);
result.error = (error as Error).message;
}
return result;
}
/**
* Update task status in a task JSON file
* @param {string} sessionPath - Path to session directory
* @param {string} taskId - Task ID (e.g., IMPL-001)
* @param {string} newStatus - New status (pending, in_progress, completed)
* @returns {Promise<Object>}
*/
async function updateTaskStatus(sessionPath, taskId, newStatus) {
// Normalize path (handle both forward and back slashes)
let normalizedPath = sessionPath.replace(/\\/g, '/');
// Handle Windows drive letter format
if (normalizedPath.match(/^[a-zA-Z]:\//)) {
// Already in correct format
} else if (normalizedPath.match(/^\/[a-zA-Z]\//)) {
// Convert /D/path to D:/path
normalizedPath = normalizedPath.charAt(1).toUpperCase() + ':' + normalizedPath.slice(2);
}
const taskDir = join(normalizedPath, '.task');
// Check if task directory exists
if (!existsSync(taskDir)) {
throw new Error(`Task directory not found: ${taskDir}`);
}
// Try to find the task file
let taskFile = join(taskDir, `${taskId}.json`);
if (!existsSync(taskFile)) {
// Try without .json if taskId already has it
if (taskId.endsWith('.json')) {
taskFile = join(taskDir, taskId);
}
if (!existsSync(taskFile)) {
throw new Error(`Task file not found: ${taskId}.json in ${taskDir}`);
}
}
try {
const content = JSON.parse(readFileSync(taskFile, 'utf8'));
const oldStatus = content.status || 'pending';
content.status = newStatus;
// Add status change timestamp
if (!content.status_history) {
content.status_history = [];
}
content.status_history.push({
from: oldStatus,
to: newStatus,
changed_at: new Date().toISOString()
});
writeFileSync(taskFile, JSON.stringify(content, null, 2), 'utf8');
return {
success: true,
taskId,
oldStatus,
newStatus,
file: taskFile
};
} catch (error: unknown) {
throw new Error(`Failed to update task ${taskId}: ${(error as Error).message}`);
}
}
/**
* Handle Session routes
* @returns true if route was handled, false otherwise
*/
export async function handleSessionRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, handlePostRequest } = ctx;
// API: Get session detail data (context, summaries, impl-plan, review)
if (pathname === '/api/session-detail') {
const sessionPath = url.searchParams.get('path');
const dataType = url.searchParams.get('type') || 'all';
if (!sessionPath) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Session path is required' }));
return true;
}
const detail = await getSessionDetailData(sessionPath, dataType);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(detail));
return true;
}
// API: Update task status
if (pathname === '/api/update-task-status' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { sessionPath, taskId, newStatus } = body;
if (!sessionPath || !taskId || !newStatus) {
return { error: 'sessionPath, taskId, and newStatus are required', status: 400 };
}
return await updateTaskStatus(sessionPath, taskId, newStatus);
});
return true;
}
// API: Bulk update task status
if (pathname === '/api/bulk-update-task-status' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { sessionPath, taskIds, newStatus } = body;
if (!sessionPath || !taskIds || !newStatus) {
return { error: 'sessionPath, taskIds, and newStatus are required', status: 400 };
}
const results = [];
for (const taskId of taskIds) {
try {
const result = await updateTaskStatus(sessionPath, taskId, newStatus);
results.push(result);
} catch (err) {
results.push({ taskId, error: err.message });
}
}
return { success: true, results };
});
return true;
}
return false;
}

View File

@@ -0,0 +1,300 @@
// @ts-nocheck
/**
* Skills Routes Module
* Handles all Skills-related API endpoints
*/
import type { IncomingMessage, ServerResponse } from 'http';
import { readFileSync, existsSync, readdirSync, statSync, unlinkSync, promises as fsPromises } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
export interface RouteContext {
pathname: string;
url: URL;
req: IncomingMessage;
res: ServerResponse;
initialPath: string;
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
broadcastToClients: (data: unknown) => void;
}
// ========== Skills Helper Functions ==========
/**
* Parse skill frontmatter (YAML header)
* @param {string} content - Skill file content
* @returns {Object} Parsed frontmatter and content
*/
function parseSkillFrontmatter(content) {
const result = {
name: '',
description: '',
version: null,
allowedTools: [],
content: ''
};
// Check for YAML frontmatter
if (content.startsWith('---')) {
const endIndex = content.indexOf('---', 3);
if (endIndex > 0) {
const frontmatter = content.substring(3, endIndex).trim();
result.content = content.substring(endIndex + 3).trim();
// Parse frontmatter lines
const lines = frontmatter.split('\n');
for (const line of lines) {
const colonIndex = line.indexOf(':');
if (colonIndex > 0) {
const key = line.substring(0, colonIndex).trim().toLowerCase();
const value = line.substring(colonIndex + 1).trim();
if (key === 'name') {
result.name = value.replace(/^["']|["']$/g, '');
} else if (key === 'description') {
result.description = value.replace(/^["']|["']$/g, '');
} else if (key === 'version') {
result.version = value.replace(/^["']|["']$/g, '');
} else if (key === 'allowed-tools' || key === 'allowedtools') {
// Parse as comma-separated or YAML array
result.allowedTools = value.replace(/^\[|\]$/g, '').split(',').map(t => t.trim()).filter(Boolean);
}
}
}
}
} else {
result.content = content;
}
return result;
}
/**
* Get list of supporting files for a skill
* @param {string} skillDir
* @returns {string[]}
*/
function getSupportingFiles(skillDir) {
const files = [];
try {
const entries = readdirSync(skillDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name !== 'SKILL.md') {
if (entry.isFile()) {
files.push(entry.name);
} else if (entry.isDirectory()) {
files.push(entry.name + '/');
}
}
}
} catch (e) {
// Ignore errors
}
return files;
}
/**
* Get skills configuration from project and user directories
* @param {string} projectPath
* @returns {Object}
*/
function getSkillsConfig(projectPath) {
const result = {
projectSkills: [],
userSkills: []
};
try {
// Project skills: .claude/skills/
const projectSkillsDir = join(projectPath, '.claude', 'skills');
if (existsSync(projectSkillsDir)) {
const skills = readdirSync(projectSkillsDir, { withFileTypes: true });
for (const skill of skills) {
if (skill.isDirectory()) {
const skillMdPath = join(projectSkillsDir, skill.name, 'SKILL.md');
if (existsSync(skillMdPath)) {
const content = readFileSync(skillMdPath, 'utf8');
const parsed = parseSkillFrontmatter(content);
// Get supporting files
const skillDir = join(projectSkillsDir, skill.name);
const supportingFiles = getSupportingFiles(skillDir);
result.projectSkills.push({
name: parsed.name || skill.name,
description: parsed.description,
version: parsed.version,
allowedTools: parsed.allowedTools,
location: 'project',
path: skillDir,
supportingFiles
});
}
}
}
}
// User skills: ~/.claude/skills/
const userSkillsDir = join(homedir(), '.claude', 'skills');
if (existsSync(userSkillsDir)) {
const skills = readdirSync(userSkillsDir, { withFileTypes: true });
for (const skill of skills) {
if (skill.isDirectory()) {
const skillMdPath = join(userSkillsDir, skill.name, 'SKILL.md');
if (existsSync(skillMdPath)) {
const content = readFileSync(skillMdPath, 'utf8');
const parsed = parseSkillFrontmatter(content);
// Get supporting files
const skillDir = join(userSkillsDir, skill.name);
const supportingFiles = getSupportingFiles(skillDir);
result.userSkills.push({
name: parsed.name || skill.name,
description: parsed.description,
version: parsed.version,
allowedTools: parsed.allowedTools,
location: 'user',
path: skillDir,
supportingFiles
});
}
}
}
}
} catch (error) {
console.error('Error reading skills config:', error);
}
return result;
}
/**
* Get single skill detail
* @param {string} skillName
* @param {string} location - 'project' or 'user'
* @param {string} projectPath
* @returns {Object}
*/
function getSkillDetail(skillName, location, projectPath) {
try {
const baseDir = location === 'project'
? join(projectPath, '.claude', 'skills')
: join(homedir(), '.claude', 'skills');
const skillDir = join(baseDir, skillName);
const skillMdPath = join(skillDir, 'SKILL.md');
if (!existsSync(skillMdPath)) {
return { error: 'Skill not found' };
}
const content = readFileSync(skillMdPath, 'utf8');
const parsed = parseSkillFrontmatter(content);
const supportingFiles = getSupportingFiles(skillDir);
return {
skill: {
name: parsed.name || skillName,
description: parsed.description,
version: parsed.version,
allowedTools: parsed.allowedTools,
content: parsed.content,
location,
path: skillDir,
supportingFiles
}
};
} catch (error) {
return { error: (error as Error).message };
}
}
/**
* Delete a skill
* @param {string} skillName
* @param {string} location
* @param {string} projectPath
* @returns {Object}
*/
function deleteSkill(skillName, location, projectPath) {
try {
const baseDir = location === 'project'
? join(projectPath, '.claude', 'skills')
: join(homedir(), '.claude', 'skills');
const skillDir = join(baseDir, skillName);
if (!existsSync(skillDir)) {
return { error: 'Skill not found' };
}
// Recursively delete directory
const deleteRecursive = (dirPath) => {
if (existsSync(dirPath)) {
readdirSync(dirPath).forEach((file) => {
const curPath = join(dirPath, file);
if (statSync(curPath).isDirectory()) {
deleteRecursive(curPath);
} else {
unlinkSync(curPath);
}
});
fsPromises.rmdir(dirPath);
}
};
deleteRecursive(skillDir);
return { success: true, skillName, location };
} catch (error) {
return { error: (error as Error).message };
}
}
// ========== Skills API Routes ==========
/**
* Handle Skills routes
* @returns true if route was handled, false otherwise
*/
export async function handleSkillsRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest } = ctx;
// API: Get all skills (project and user)
if (pathname === '/api/skills') {
const projectPathParam = url.searchParams.get('path') || initialPath;
const skillsData = getSkillsConfig(projectPathParam);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(skillsData));
return true;
}
// API: Get single skill detail
if (pathname.startsWith('/api/skills/') && req.method === 'GET' && !pathname.endsWith('/skills/')) {
const skillName = decodeURIComponent(pathname.replace('/api/skills/', ''));
const location = url.searchParams.get('location') || 'project';
const projectPathParam = url.searchParams.get('path') || initialPath;
const skillDetail = getSkillDetail(skillName, location, projectPathParam);
if (skillDetail.error) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(skillDetail));
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(skillDetail));
}
return true;
}
// API: Delete skill
if (pathname.startsWith('/api/skills/') && req.method === 'DELETE') {
const skillName = decodeURIComponent(pathname.replace('/api/skills/', ''));
handlePostRequest(req, res, async (body) => {
const { location, projectPath: projectPathParam } = body;
return deleteSkill(skillName, location, projectPathParam || initialPath);
});
return true;
}
return false;
}

View File

@@ -0,0 +1,329 @@
// @ts-nocheck
/**
* System Routes Module
* Handles all system-related API endpoints
*/
import type { IncomingMessage, ServerResponse } from 'http';
import type { Server } from 'http';
import { readFileSync, existsSync, promises as fsPromises } from 'fs';
import { join } from 'path';
import { resolvePath, getRecentPaths, trackRecentPath, removeRecentPath, normalizePathForDisplay } from '../../utils/path-resolver.js';
import { scanSessions } from '../session-scanner.js';
import { aggregateData } from '../data-aggregator.js';
export interface RouteContext {
pathname: string;
url: URL;
req: IncomingMessage;
res: ServerResponse;
initialPath: string;
handlePostRequest: (req: IncomingMessage, res: ServerResponse, handler: (body: unknown) => Promise<any>) => void;
broadcastToClients: (data: unknown) => void;
server: Server;
}
// ========================================
// Helper Functions
// ========================================
// Package name on npm registry
const NPM_PACKAGE_NAME = 'claude-code-workflow';
// Cache for version check (avoid too frequent requests)
let versionCheckCache = null;
let versionCheckTime = 0;
const VERSION_CHECK_CACHE_TTL = 3600000; // 1 hour
/**
* Get current package version from package.json
* @returns {string}
*/
function getCurrentVersion(): string {
try {
const packageJsonPath = join(import.meta.dirname, '../../../package.json');
if (existsSync(packageJsonPath)) {
const pkg = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
return pkg.version || '0.0.0';
}
} catch (e) {
console.error('Error reading package.json:', e);
}
return '0.0.0';
}
/**
* Compare two semver versions
* @param {string} v1
* @param {string} v2
* @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal
*/
function compareVersions(v1: string, v2: string): number {
const parts1 = v1.split('.').map(Number);
const parts2 = v2.split('.').map(Number);
for (let i = 0; i < 3; i++) {
const p1 = parts1[i] || 0;
const p2 = parts2[i] || 0;
if (p1 > p2) return 1;
if (p1 < p2) return -1;
}
return 0;
}
/**
* Check npm registry for latest version
* @returns {Promise<Object>}
*/
async function checkNpmVersion(): Promise<any> {
// Return cached result if still valid
const now = Date.now();
if (versionCheckCache && (now - versionCheckTime) < VERSION_CHECK_CACHE_TTL) {
return versionCheckCache;
}
const currentVersion = getCurrentVersion();
try {
// Fetch latest version from npm registry
const npmUrl = 'https://registry.npmjs.org/' + encodeURIComponent(NPM_PACKAGE_NAME) + '/latest';
const response = await fetch(npmUrl, {
headers: { 'Accept': 'application/json' }
});
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
const data = await response.json();
const latestVersion = data.version;
// Compare versions
const hasUpdate = compareVersions(latestVersion, currentVersion) > 0;
const result = {
currentVersion,
latestVersion,
hasUpdate,
packageName: NPM_PACKAGE_NAME,
updateCommand: 'npm update -g ' + NPM_PACKAGE_NAME,
checkedAt: new Date().toISOString()
};
// Cache the result
versionCheckCache = result;
versionCheckTime = now;
return result;
} catch (error: unknown) {
console.error('Version check failed:', (error as Error).message);
return {
currentVersion,
latestVersion: null,
hasUpdate: false,
error: (error as Error).message,
checkedAt: new Date().toISOString()
};
}
}
/**
* Get workflow data for a project path
* @param {string} projectPath
* @returns {Promise<Object>}
*/
async function getWorkflowData(projectPath: string): Promise<any> {
const resolvedPath = resolvePath(projectPath);
const workflowDir = join(resolvedPath, '.workflow');
// Track this path
trackRecentPath(resolvedPath);
// Check if .workflow exists
if (!existsSync(workflowDir)) {
return {
generatedAt: new Date().toISOString(),
activeSessions: [],
archivedSessions: [],
liteTasks: { litePlan: [], liteFix: [] },
reviewData: { dimensions: {} },
projectOverview: null,
statistics: {
totalSessions: 0,
activeSessions: 0,
totalTasks: 0,
completedTasks: 0,
reviewFindings: 0,
litePlanCount: 0,
liteFixCount: 0
},
projectPath: normalizePathForDisplay(resolvedPath),
recentPaths: getRecentPaths()
};
}
// Scan and aggregate data
const sessions = await scanSessions(workflowDir);
const data = await aggregateData(sessions, workflowDir);
data.projectPath = normalizePathForDisplay(resolvedPath);
data.recentPaths = getRecentPaths();
return data;
}
// ========================================
// Route Handler
// ========================================
/**
* Handle System routes
* @returns true if route was handled, false otherwise
*/
export async function handleSystemRoutes(ctx: RouteContext): Promise<boolean> {
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients, server } = ctx;
// API: Get workflow data for a path
if (pathname === '/api/data') {
const projectPath = url.searchParams.get('path') || initialPath;
const data = await getWorkflowData(projectPath);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
return true;
}
// API: Get recent paths
if (pathname === '/api/recent-paths') {
const paths = getRecentPaths();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ paths }));
return true;
}
// API: Switch workspace path (for ccw view command)
if (pathname === '/api/switch-path') {
const newPath = url.searchParams.get('path');
if (!newPath) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Path is required' }));
return true;
}
const resolved = resolvePath(newPath);
if (!existsSync(resolved)) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Path does not exist' }));
return true;
}
// Track the path and return success
trackRecentPath(resolved);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
path: resolved,
recentPaths: getRecentPaths()
}));
return true;
}
// API: Health check (for ccw view to detect running server)
if (pathname === '/api/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', timestamp: Date.now() }));
return true;
}
// API: Version check (check for npm updates)
if (pathname === '/api/version-check') {
const versionData = await checkNpmVersion();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(versionData));
return true;
}
// API: Shutdown server (for ccw stop command)
if (pathname === '/api/shutdown' && req.method === 'POST') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'shutting_down' }));
// Graceful shutdown
console.log('\n Received shutdown signal...');
setTimeout(() => {
server.close(() => {
console.log(' Server stopped.\n');
process.exit(0);
});
// Force exit after 3 seconds if graceful shutdown fails
setTimeout(() => process.exit(0), 3000);
}, 100);
return true;
}
// API: Remove a recent path
if (pathname === '/api/remove-recent-path' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { path } = body as { path?: string };
if (!path) {
return { error: 'path is required', status: 400 };
}
const removed = removeRecentPath(path);
return { success: removed, paths: getRecentPaths() };
});
return true;
}
// API: Read a JSON file (for fix progress tracking)
if (pathname === '/api/file') {
const filePath = url.searchParams.get('path');
if (!filePath) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File path is required' }));
return true;
}
try {
const content = await fsPromises.readFile(filePath, 'utf-8');
const json = JSON.parse(content);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(json));
} catch (err) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File not found or invalid JSON' }));
}
return true;
}
// API: System notify - CLI to Server communication bridge
// Allows CLI commands to trigger WebSocket broadcasts for UI updates
if (pathname === '/api/system/notify' && req.method === 'POST') {
handlePostRequest(req, res, async (body) => {
const { type, scope, data } = body as {
type: 'REFRESH_REQUIRED' | 'MEMORY_UPDATED' | 'HISTORY_UPDATED' | 'INSIGHT_GENERATED';
scope: 'memory' | 'history' | 'insights' | 'all';
data?: Record<string, unknown>;
};
if (!type || !scope) {
return { error: 'type and scope are required', status: 400 };
}
// Map CLI notification types to WebSocket broadcast format
const notification = {
type,
payload: {
scope,
timestamp: new Date().toISOString(),
...data
}
};
broadcastToClients(notification);
return { success: true, broadcast: true };
});
return true;
}
return false;
}

File diff suppressed because it is too large Load Diff

190
ccw/src/core/websocket.ts Normal file
View File

@@ -0,0 +1,190 @@
// @ts-nocheck
import { createHash } from 'crypto';
// WebSocket clients for real-time notifications
export const wsClients = new Set();
export function handleWebSocketUpgrade(req, socket, head) {
const key = req.headers['sec-websocket-key'];
const acceptKey = createHash('sha1')
.update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
.digest('base64');
const responseHeaders = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${acceptKey}`,
'',
''
].join('\r\n');
socket.write(responseHeaders);
// Add to clients set
wsClients.add(socket);
console.log(`[WS] Client connected (${wsClients.size} total)`);
// Handle incoming messages
socket.on('data', (buffer) => {
try {
const frame = parseWebSocketFrame(buffer);
if (!frame) return;
const { opcode, payload } = frame;
switch (opcode) {
case 0x1: // Text frame
if (payload) {
console.log('[WS] Received:', payload);
}
break;
case 0x8: // Close frame
socket.end();
break;
case 0x9: // Ping frame - respond with Pong
const pongFrame = Buffer.alloc(2);
pongFrame[0] = 0x8A; // Pong opcode with FIN bit
pongFrame[1] = 0x00; // No payload
socket.write(pongFrame);
break;
case 0xA: // Pong frame - ignore
break;
default:
// Ignore other frame types (binary, continuation)
break;
}
} catch (e) {
// Ignore parse errors
}
});
// Handle disconnect
socket.on('close', () => {
wsClients.delete(socket);
console.log(`[WS] Client disconnected (${wsClients.size} remaining)`);
});
socket.on('error', () => {
wsClients.delete(socket);
});
}
/**
* Parse WebSocket frame (simplified)
* Returns { opcode, payload } or null
*/
export function parseWebSocketFrame(buffer) {
if (buffer.length < 2) return null;
const firstByte = buffer[0];
const opcode = firstByte & 0x0f; // Extract opcode (bits 0-3)
// Opcode types:
// 0x0 = continuation, 0x1 = text, 0x2 = binary
// 0x8 = close, 0x9 = ping, 0xA = pong
const secondByte = buffer[1];
const isMasked = (secondByte & 0x80) !== 0;
let payloadLength = secondByte & 0x7f;
let offset = 2;
if (payloadLength === 126) {
payloadLength = buffer.readUInt16BE(2);
offset = 4;
} else if (payloadLength === 127) {
payloadLength = Number(buffer.readBigUInt64BE(2));
offset = 10;
}
let mask = null;
if (isMasked) {
mask = buffer.slice(offset, offset + 4);
offset += 4;
}
const payload = buffer.slice(offset, offset + payloadLength);
if (isMasked && mask) {
for (let i = 0; i < payload.length; i++) {
payload[i] ^= mask[i % 4];
}
}
return { opcode, payload: payload.toString('utf8') };
}
/**
* Create WebSocket frame
*/
export function createWebSocketFrame(data) {
const payload = Buffer.from(JSON.stringify(data), 'utf8');
const length = payload.length;
let frame;
if (length <= 125) {
frame = Buffer.alloc(2 + length);
frame[0] = 0x81; // Text frame, FIN
frame[1] = length;
payload.copy(frame, 2);
} else if (length <= 65535) {
frame = Buffer.alloc(4 + length);
frame[0] = 0x81;
frame[1] = 126;
frame.writeUInt16BE(length, 2);
payload.copy(frame, 4);
} else {
frame = Buffer.alloc(10 + length);
frame[0] = 0x81;
frame[1] = 127;
frame.writeBigUInt64BE(BigInt(length), 2);
payload.copy(frame, 10);
}
return frame;
}
/**
* Broadcast message to all connected WebSocket clients
*/
export function broadcastToClients(data) {
const frame = createWebSocketFrame(data);
for (const client of wsClients) {
try {
client.write(frame);
} catch (e) {
wsClients.delete(client);
}
}
console.log(`[WS] Broadcast to ${wsClients.size} clients:`, data.type);
}
/**
* Extract session ID from file path
*/
export function extractSessionIdFromPath(filePath) {
// Normalize path
const normalized = filePath.replace(/\\/g, '/');
// Look for session pattern: WFS-xxx, WRS-xxx, etc.
const sessionMatch = normalized.match(/\/(W[A-Z]S-[^/]+)\//);
if (sessionMatch) {
return sessionMatch[1];
}
// Look for .workflow/.sessions/xxx pattern
const sessionsMatch = normalized.match(/\.workflow\/\.sessions\/([^/]+)/);
if (sessionsMatch) {
return sessionsMatch[1];
}
// Look for lite-plan/lite-fix pattern
const liteMatch = normalized.match(/\.(lite-plan|lite-fix)\/([^/]+)/);
if (liteMatch) {
return liteMatch[2];
}
return null;
}

View File

@@ -198,6 +198,11 @@
color: hsl(var(--primary));
}
.tool-type-badge.llm {
background: hsl(142 76% 36% / 0.15);
color: hsl(142 76% 36%);
}
.tool-item-right {
display: flex;
align-items: center;
@@ -814,6 +819,15 @@
border-color: hsl(260 80% 60% / 0.7);
}
.cli-tool-card.tool-semantic.clickable {
cursor: pointer;
}
.cli-tool-card.tool-semantic.clickable:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px hsl(260 80% 60% / 0.15);
}
/* Execute Panel */
.cli-execute-header {
display: flex;
@@ -3064,6 +3078,211 @@
flex-wrap: wrap;
}
/* ========================================
* Enhanced Native Session Display
* ======================================== */
/* View Full Process Button in Execution Detail */
.cli-detail-native-action {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid hsl(var(--border) / 0.5);
}
.cli-detail-native-action .btn {
font-size: 0.8125rem;
gap: 0.5rem;
}
/* Collapsible Thinking Process */
.turn-thinking-details {
border: none;
margin: 0;
}
.turn-thinking-summary {
display: flex;
align-items: center;
gap: 0.375rem;
cursor: pointer;
padding: 0.5rem;
background: hsl(var(--warning) / 0.08);
border: 1px solid hsl(var(--warning) / 0.25);
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--warning));
transition: all 0.2s ease;
list-style: none;
}
.turn-thinking-summary::-webkit-details-marker {
display: none;
}
.turn-thinking-summary:hover {
background: hsl(var(--warning) / 0.15);
border-color: hsl(var(--warning) / 0.4);
}
.turn-thinking-summary::before {
content: '▶';
display: inline-block;
margin-right: 0.25rem;
transition: transform 0.2s ease;
font-size: 0.6875rem;
}
.turn-thinking-details[open] .turn-thinking-summary::before {
transform: rotate(90deg);
}
.turn-thinking-content {
padding: 0.75rem;
margin-top: 0.5rem;
background: hsl(var(--warning) / 0.03);
border: 1px solid hsl(var(--warning) / 0.15);
border-radius: 0.375rem;
font-style: italic;
}
.turn-thinking-content ul {
margin: 0;
padding-left: 1.25rem;
}
.turn-thinking-content li {
margin-bottom: 0.375rem;
font-size: 0.6875rem;
line-height: 1.6;
color: hsl(var(--foreground) / 0.85);
}
/* Tool Calls Header */
.turn-tool-calls-header {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--muted-foreground));
margin-bottom: 0.625rem;
padding-bottom: 0.375rem;
border-bottom: 1px solid hsl(var(--border) / 0.5);
}
/* Collapsible Tool Calls */
.turn-tool-call-details {
border: none;
margin-bottom: 0.5rem;
}
.turn-tool-call-summary {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
padding: 0.5rem 0.75rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
font-size: 0.7rem;
transition: all 0.2s ease;
list-style: none;
}
.turn-tool-call-summary::-webkit-details-marker {
display: none;
}
.turn-tool-call-summary:hover {
background: hsl(var(--muted) / 0.5);
border-color: hsl(var(--primary) / 0.4);
}
.turn-tool-call-summary::before {
content: '▶';
display: inline-block;
margin-right: 0.5rem;
transition: transform 0.2s ease;
font-size: 0.625rem;
color: hsl(var(--muted-foreground));
}
.turn-tool-call-details[open] .turn-tool-call-summary::before {
transform: rotate(90deg);
}
.native-tool-size {
font-size: 0.625rem;
color: hsl(var(--muted-foreground));
font-weight: 400;
}
.turn-tool-call-content {
padding: 0.75rem;
margin-top: 0.5rem;
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.turn-tool-input,
.turn-tool-output {
margin-bottom: 0.75rem;
}
.turn-tool-input:last-child,
.turn-tool-output:last-child {
margin-bottom: 0;
}
.turn-tool-input strong,
.turn-tool-output strong {
display: block;
font-size: 0.6875rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.375rem;
}
.turn-tool-input pre,
.turn-tool-output pre {
margin: 0;
padding: 0.5rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.25rem;
font-family: monospace;
font-size: 0.6875rem;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 400px;
overflow-y: auto;
}
/* Improved scrollbar for tool output */
.turn-tool-output pre::-webkit-scrollbar {
width: 6px;
}
.turn-tool-output pre::-webkit-scrollbar-track {
background: hsl(var(--muted));
border-radius: 3px;
}
.turn-tool-output pre::-webkit-scrollbar-thumb {
background: hsl(var(--muted-foreground) / 0.3);
border-radius: 3px;
}
.turn-tool-output pre::-webkit-scrollbar-thumb:hover {
background: hsl(var(--muted-foreground) / 0.5);
}
/* ========================================
* Task Queue Sidebar - CLI Tab Styles
* ======================================== */
@@ -3251,3 +3470,290 @@
.cli-queue-native {
font-size: 0.75rem;
}
/* ========================================
* CLI Tool Management Styles
* ======================================== */
/* Disabled tool card */
.cli-tool-card.disabled {
opacity: 0.6;
}
.cli-tool-card.disabled .cli-tool-header {
opacity: 0.8;
}
/* Disabled status indicator */
.cli-tool-status.status-disabled {
background: hsl(var(--warning));
}
/* Warning badge */
.cli-tool-badge.badge-warning {
background: hsl(var(--warning) / 0.15);
color: hsl(var(--warning));
font-size: 0.65rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
margin-left: 0.25rem;
}
/* Compact toggle for tool cards */
.cli-toggle-compact {
position: relative;
display: inline-block;
width: 28px;
height: 16px;
cursor: pointer;
}
.cli-toggle-compact input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.cli-toggle-slider-compact {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: hsl(var(--muted));
transition: 0.2s;
border-radius: 16px;
}
.cli-toggle-slider-compact:before {
position: absolute;
content: "";
height: 12px;
width: 12px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
}
.cli-toggle-compact input:checked + .cli-toggle-slider-compact {
background-color: hsl(var(--primary));
}
.cli-toggle-compact input:checked + .cli-toggle-slider-compact:before {
transform: translateX(12px);
}
.cli-toggle-compact input:focus + .cli-toggle-slider-compact {
box-shadow: 0 0 0 2px hsl(var(--ring) / 0.5);
}
/* Ghost button variant for destructive actions */
.btn-ghost.text-destructive {
color: hsl(var(--destructive));
}
/* ========================================
* Tool Configuration Modal
* ======================================== */
/* Tool item clickable */
.tool-item.clickable {
cursor: pointer;
transition: all 0.15s ease;
}
.tool-item.clickable:hover {
background: hsl(var(--accent));
border-color: hsl(var(--primary) / 0.3);
}
.tool-item.clickable:hover .tool-config-icon {
opacity: 1;
}
.tool-config-icon {
margin-left: 0.375rem;
color: hsl(var(--muted-foreground));
opacity: 0;
transition: opacity 0.15s ease;
}
/* Tool Config Modal */
.tool-config-modal {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.tool-config-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tool-config-section h4 {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
color: hsl(var(--muted-foreground));
margin: 0;
}
.tool-config-section h4 .text-muted {
font-weight: 400;
text-transform: none;
color: hsl(var(--muted-foreground) / 0.7);
}
/* Status Badges */
.tool-config-badges {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.6875rem;
font-weight: 500;
border-radius: 9999px;
}
.badge-success {
background: hsl(var(--success) / 0.15);
color: hsl(var(--success));
}
.badge-primary {
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
}
.badge-muted {
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
}
/* Config Actions */
.tool-config-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-danger-outline {
border-color: hsl(var(--destructive) / 0.5);
color: hsl(var(--destructive));
}
.btn-danger-outline:hover {
background: hsl(var(--destructive) / 0.1);
border-color: hsl(var(--destructive));
}
/* Config Selects and Inputs */
.tool-config-select,
.tool-config-input {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.8125rem;
font-family: inherit;
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
background: hsl(var(--background));
color: hsl(var(--foreground));
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.tool-config-select:focus,
.tool-config-input:focus {
outline: none;
border-color: hsl(var(--primary));
box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2);
}
.tool-config-select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.25rem;
padding-right: 2rem;
}
.tool-config-input.hidden {
display: none;
}
.tool-config-input {
margin-top: 0.375rem;
}
/* Config Footer */
.tool-config-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid hsl(var(--border));
margin-top: 0.5rem;
}
.tool-config-footer .btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
font-size: 0.8125rem;
font-weight: 500;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease;
}
.tool-config-footer .btn-outline {
background: transparent;
border: 1px solid hsl(var(--border));
color: hsl(var(--foreground));
}
.tool-config-footer .btn-outline:hover {
background: hsl(var(--muted));
border-color: hsl(var(--muted-foreground) / 0.3);
}
.tool-config-footer .btn-primary {
background: hsl(var(--primary));
border: 1px solid hsl(var(--primary));
color: hsl(var(--primary-foreground));
}
.tool-config-footer .btn-primary:hover {
background: hsl(var(--primary) / 0.9);
}
/* Model Select Group */
.model-select-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.model-select-group .tool-config-input {
margin-top: 0;
}
.btn-ghost.text-destructive:hover {
background: hsl(var(--destructive) / 0.1);
}

View File

@@ -887,6 +887,216 @@
text-transform: uppercase;
}
/* ========== Node Details Panel ========== */
.node-details {
padding: 1rem;
}
.node-detail-header {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding-bottom: 1rem;
border-bottom: 1px solid hsl(var(--border));
margin-bottom: 1rem;
}
.node-detail-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5rem;
flex-shrink: 0;
}
.node-detail-icon.file {
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
}
.node-detail-icon.module {
background: hsl(267, 84%, 95%);
color: hsl(267, 84%, 50%);
}
.node-detail-icon.component {
background: hsl(142, 71%, 92%);
color: hsl(142, 71%, 40%);
}
.node-detail-info {
flex: 1;
min-width: 0;
}
.node-detail-name {
font-size: 0.9375rem;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 0.25rem;
word-break: break-word;
}
.node-detail-path {
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
font-family: monospace;
word-break: break-all;
}
.node-detail-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
padding: 0.875rem;
background: hsl(var(--muted) / 0.3);
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.detail-stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
text-align: center;
}
.detail-stat-label {
font-size: 0.6875rem;
color: hsl(var(--muted-foreground));
text-transform: uppercase;
letter-spacing: 0.025em;
}
.detail-stat-value {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--foreground));
}
.node-associations {
margin-top: 1rem;
}
.associations-title {
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;
}
.associations-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.association-item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem;
background: hsl(var(--background));
border: 1px solid hsl(var(--border));
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease;
}
.association-item:hover {
background: hsl(var(--hover));
border-color: hsl(var(--primary) / 0.3);
}
.association-node {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
}
.association-node i {
color: hsl(var(--muted-foreground));
flex-shrink: 0;
}
.association-node span {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.association-icon {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
flex-shrink: 0;
}
.association-icon.file {
background: hsl(var(--primary) / 0.1);
color: hsl(var(--primary));
}
.association-icon.module {
background: hsl(267, 84%, 95%);
color: hsl(267, 84%, 50%);
}
.association-info {
flex: 1;
min-width: 0;
}
.association-name {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.association-weight {
font-size: 0.6875rem;
color: hsl(var(--muted-foreground));
padding: 0.125rem 0.375rem;
background: hsl(var(--muted));
border-radius: 0.25rem;
flex-shrink: 0;
}
.node-no-associations {
padding: 1.5rem;
text-align: center;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
}
.associations-more {
text-align: center;
padding: 0.5rem;
font-size: 0.75rem;
color: hsl(var(--muted-foreground));
border-top: 1px solid hsl(var(--border));
margin-top: 0.5rem;
}
@media (max-width: 1400px) {
.memory-columns {
grid-template-columns: 260px 1fr 280px;

View File

@@ -330,6 +330,9 @@ async function showExecutionDetail(executionId, sourceDir) {
`;
}
// Check if native session is available
const hasNativeSession = conversation.hasNativeSession || conversation.nativeSessionId;
const modalContent = `
<div class="cli-detail-header">
<div class="cli-detail-info">
@@ -344,6 +347,13 @@ async function showExecutionDetail(executionId, sourceDir) {
<span><i data-lucide="calendar" class="w-3 h-3"></i> ${new Date(createdAt).toLocaleString()}</span>
<span><i data-lucide="hash" class="w-3 h-3"></i> ${executionId.split('-')[0]}</span>
</div>
${hasNativeSession ? `
<div class="cli-detail-native-action">
<button class="btn btn-sm btn-primary" onclick="showNativeSessionDetail('${executionId}')">
<i data-lucide="eye" class="w-3.5 h-3.5"></i> View Full Process Conversation
</button>
</div>
` : ''}
</div>
${turnCount > 1 ? `
<div class="cli-view-toggle">
@@ -665,26 +675,52 @@ async function showNativeSessionDetail(executionId) {
</span>`
: '';
// Thoughts section
// Thoughts section (collapsible)
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>
<details class="turn-thinking-details">
<summary class="turn-thinking-summary">
<i data-lucide="brain" class="w-3 h-3"></i>
💭 Thinking Process (${turn.thoughts.length} thoughts)
</summary>
<div class="turn-thinking-content">
<ul class="native-thoughts-list">
${turn.thoughts.map(t => `<li>${escapeHtml(t)}</li>`).join('')}
</ul>
</div>
</details>
</div>`
: '';
// Tool calls section
// Tool calls section (collapsible for each call)
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="turn-tool-calls-header">
<i data-lucide="wrench" class="w-3 h-3"></i>
<strong>Tool Calls (${turn.toolCalls.length})</strong>
</div>
<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>
${turn.toolCalls.map((tc, tcIdx) => `
<details class="turn-tool-call-details" ${tcIdx === 0 ? 'open' : ''}>
<summary class="turn-tool-call-summary">
<span class="native-tool-name">🔧 ${escapeHtml(tc.name)}</span>
${tc.output ? `<span class="native-tool-size">(${tc.output.length} chars)</span>` : ''}
</summary>
<div class="turn-tool-call-content">
${tc.input ? `
<div class="turn-tool-input">
<strong>Input:</strong>
<pre>${escapeHtml(JSON.stringify(tc.input, null, 2))}</pre>
</div>
` : ''}
${tc.output ? `
<div class="turn-tool-output">
<strong>Output:</strong>
<pre class="native-tool-output">${escapeHtml(tc.output)}</pre>
</div>
` : ''}
</div>
</details>
`).join('')}
</div>
</div>`
@@ -758,7 +794,7 @@ async function showNativeSessionDetail(executionId) {
// Store for export
window._currentNativeSession = nativeSession;
showModal('Native Session Detail', modalContent, 'modal-lg');
showModal('Native Session Detail', modalContent, { size: 'lg' });
}
/**

View File

@@ -15,6 +15,15 @@ let smartContextMaxFiles = parseInt(localStorage.getItem('ccw-smart-context-max-
// Native Resume settings
let nativeResumeEnabled = localStorage.getItem('ccw-native-resume') !== 'false'; // default true
// LLM Enhancement settings for Semantic Search
let llmEnhancementSettings = {
enabled: localStorage.getItem('ccw-llm-enhancement-enabled') === 'true',
tool: localStorage.getItem('ccw-llm-enhancement-tool') || 'gemini',
fallbackTool: localStorage.getItem('ccw-llm-enhancement-fallback') || 'qwen',
batchSize: parseInt(localStorage.getItem('ccw-llm-enhancement-batch-size') || '5', 10),
timeoutMs: parseInt(localStorage.getItem('ccw-llm-enhancement-timeout') || '300000', 10)
};
// ========== Initialization ==========
function initCliStatus() {
// Load CLI status on init
@@ -182,12 +191,17 @@ function renderCliStatus() {
`;
// Semantic Search card (only show if CodexLens is installed)
const llmStatusBadge = llmEnhancementSettings.enabled
? `<span class="badge px-1.5 py-0.5 text-xs rounded bg-success/20 text-success">LLM</span>`
: '';
const semanticHtml = codexLensStatus.ready ? `
<div class="cli-tool-card tool-semantic ${semanticStatus.available ? 'available' : 'unavailable'}">
<div class="cli-tool-card tool-semantic clickable ${semanticStatus.available ? 'available' : 'unavailable'}"
onclick="openSemanticSettingsModal()">
<div class="cli-tool-header">
<span class="cli-tool-status ${semanticStatus.available ? 'status-available' : 'status-unavailable'}"></span>
<span class="cli-tool-name">Semantic Search</span>
<span class="badge px-1.5 py-0.5 text-xs rounded ${semanticStatus.available ? 'bg-primary/20 text-primary' : 'bg-muted text-muted-foreground'}">AI</span>
${llmStatusBadge}
</div>
<div class="cli-tool-desc text-xs text-muted-foreground mt-1">
${semanticStatus.available ? 'AI-powered code understanding' : 'Natural language code search'}
@@ -200,17 +214,27 @@ function renderCliStatus() {
</div>
<div class="cli-tool-actions flex flex-col gap-2 mt-3">
${!semanticStatus.available ? `
<button class="btn-sm btn-primary w-full flex items-center justify-center gap-1" onclick="openSemanticInstallWizard()">
<button class="btn-sm btn-primary w-full flex items-center justify-center gap-1" onclick="event.stopPropagation(); openSemanticInstallWizard()">
<i data-lucide="brain" class="w-3 h-3"></i> Install AI Model
</button>
<div class="flex items-center justify-center gap-1 text-xs text-muted-foreground">
<i data-lucide="hard-drive" class="w-3 h-3"></i>
<span>~500MB download</span>
<div class="flex items-center justify-between w-full mt-1">
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<i data-lucide="hard-drive" class="w-3 h-3"></i>
<span>~500MB</span>
</div>
<button class="btn-sm btn-outline flex items-center gap-1" onclick="event.stopPropagation(); openSemanticSettingsModal()">
<i data-lucide="settings" class="w-3 h-3"></i>
</button>
</div>
` : `
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<i data-lucide="cpu" class="w-3 h-3"></i>
<span>bge-small-en-v1.5</span>
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-1 text-xs text-muted-foreground">
<i data-lucide="cpu" class="w-3 h-3"></i>
<span>bge-small-en-v1.5</span>
</div>
<button class="btn-sm btn-outline flex items-center gap-1" onclick="event.stopPropagation(); openSemanticSettingsModal()">
<i data-lucide="settings" class="w-3 h-3"></i>
</button>
</div>
`}
</div>
@@ -550,3 +574,535 @@ async function startSemanticInstall() {
if (window.lucide) lucide.createIcons();
}
}
// ========== Semantic Search Settings Modal ==========
function openSemanticSettingsModal() {
const availableTools = Object.entries(cliToolStatus)
.filter(function(entry) { return entry[1].available; })
.map(function(entry) { return entry[0]; });
const modal = document.createElement('div');
modal.id = 'semanticSettingsModal';
modal.className = 'fixed inset-0 bg-black/50 flex items-center justify-center z-50';
modal.onclick = function(e) { if (e.target === modal) closeSemanticSettingsModal(); };
const toolOptions = availableTools.map(function(tool) {
return '<option value="' + tool + '"' + (llmEnhancementSettings.tool === tool ? ' selected' : '') + '>' +
tool.charAt(0).toUpperCase() + tool.slice(1) + '</option>';
}).join('');
const fallbackOptions = '<option value="">None</option>' + availableTools.map(function(tool) {
return '<option value="' + tool + '"' + (llmEnhancementSettings.fallbackTool === tool ? ' selected' : '') + '>' +
tool.charAt(0).toUpperCase() + tool.slice(1) + '</option>';
}).join('');
const disabled = !llmEnhancementSettings.enabled ? 'disabled' : '';
const opacityClass = !llmEnhancementSettings.enabled ? 'opacity-50' : '';
modal.innerHTML =
'<div class="bg-card rounded-lg shadow-xl w-full max-w-lg mx-4 overflow-hidden" onclick="event.stopPropagation()">' +
'<div class="p-6">' +
'<div class="flex items-center gap-3 mb-4">' +
'<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">' +
'<i data-lucide="sparkles" class="w-5 h-5 text-primary"></i>' +
'</div>' +
'<div>' +
'<h3 class="text-lg font-semibold">Semantic Search Settings</h3>' +
'<p class="text-sm text-muted-foreground">Configure LLM enhancement for semantic indexing</p>' +
'</div>' +
'</div>' +
'<div class="space-y-4">' +
'<div class="flex items-center justify-between p-4 bg-muted/50 rounded-lg">' +
'<div>' +
'<h4 class="font-medium flex items-center gap-2">' +
'<i data-lucide="brain" class="w-4 h-4"></i>LLM Enhancement</h4>' +
'<p class="text-sm text-muted-foreground mt-1">Use LLM to generate code summaries for better semantic search</p>' +
'</div>' +
'<label class="cli-toggle">' +
'<input type="checkbox" id="llmEnhancementToggle" ' + (llmEnhancementSettings.enabled ? 'checked' : '') +
' onchange="toggleLlmEnhancement(this.checked)">' +
'<span class="cli-toggle-slider"></span>' +
'</label>' +
'</div>' +
'<div class="p-4 bg-muted/30 rounded-lg space-y-4 ' + opacityClass + '" id="llmSettingsSection">' +
'<div class="grid grid-cols-2 gap-4">' +
'<div>' +
'<label class="block text-sm font-medium mb-2">' +
'<i data-lucide="cpu" class="w-3 h-3 inline mr-1"></i>Primary LLM Tool</label>' +
'<select class="cli-setting-select w-full" id="llmToolSelect" onchange="updateLlmTool(this.value)" ' + disabled + '>' + toolOptions + '</select>' +
'</div>' +
'<div>' +
'<label class="block text-sm font-medium mb-2">' +
'<i data-lucide="refresh-cw" class="w-3 h-3 inline mr-1"></i>Fallback Tool</label>' +
'<select class="cli-setting-select w-full" id="llmFallbackSelect" onchange="updateLlmFallback(this.value)" ' + disabled + '>' + fallbackOptions + '</select>' +
'</div>' +
'</div>' +
'<div class="grid grid-cols-2 gap-4">' +
'<div>' +
'<label class="block text-sm font-medium mb-2">' +
'<i data-lucide="layers" class="w-3 h-3 inline mr-1"></i>Batch Size</label>' +
'<select class="cli-setting-select w-full" id="llmBatchSelect" onchange="updateLlmBatchSize(this.value)" ' + disabled + '>' +
'<option value="1"' + (llmEnhancementSettings.batchSize === 1 ? ' selected' : '') + '>1 file</option>' +
'<option value="3"' + (llmEnhancementSettings.batchSize === 3 ? ' selected' : '') + '>3 files</option>' +
'<option value="5"' + (llmEnhancementSettings.batchSize === 5 ? ' selected' : '') + '>5 files</option>' +
'<option value="10"' + (llmEnhancementSettings.batchSize === 10 ? ' selected' : '') + '>10 files</option>' +
'</select>' +
'</div>' +
'<div>' +
'<label class="block text-sm font-medium mb-2">' +
'<i data-lucide="clock" class="w-3 h-3 inline mr-1"></i>Timeout</label>' +
'<select class="cli-setting-select w-full" id="llmTimeoutSelect" onchange="updateLlmTimeout(this.value)" ' + disabled + '>' +
'<option value="60000"' + (llmEnhancementSettings.timeoutMs === 60000 ? ' selected' : '') + '>1 min</option>' +
'<option value="180000"' + (llmEnhancementSettings.timeoutMs === 180000 ? ' selected' : '') + '>3 min</option>' +
'<option value="300000"' + (llmEnhancementSettings.timeoutMs === 300000 ? ' selected' : '') + '>5 min</option>' +
'<option value="600000"' + (llmEnhancementSettings.timeoutMs === 600000 ? ' selected' : '') + '>10 min</option>' +
'</select>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="bg-primary/5 border border-primary/20 rounded-lg p-3">' +
'<div class="flex items-start gap-2">' +
'<i data-lucide="info" class="w-4 h-4 text-primary mt-0.5"></i>' +
'<div class="text-sm text-muted-foreground">' +
'<p>LLM enhancement generates code summaries and keywords for each file, improving semantic search accuracy.</p>' +
'<p class="mt-1">Run <code class="bg-muted px-1 rounded">codex-lens enhance</code> after enabling to process existing files.</p>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="flex gap-2 pt-2">' +
'<button class="btn-sm btn-outline flex items-center gap-1 flex-1" onclick="runEnhanceCommand()" ' + disabled + '>' +
'<i data-lucide="zap" class="w-3 h-3"></i>Run Enhance Now</button>' +
'<button class="btn-sm btn-outline flex items-center gap-1 flex-1" onclick="viewEnhanceStatus()">' +
'<i data-lucide="bar-chart-2" class="w-3 h-3"></i>View Status</button>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="border-t border-border p-4 flex justify-end gap-3 bg-muted/30">' +
'<button class="btn-outline px-4 py-2" onclick="closeSemanticSettingsModal()">Close</button>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
var handleEscape = function(e) {
if (e.key === 'Escape') {
closeSemanticSettingsModal();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
if (window.lucide) {
lucide.createIcons();
}
}
function closeSemanticSettingsModal() {
var modal = document.getElementById('semanticSettingsModal');
if (modal) modal.remove();
}
function toggleLlmEnhancement(enabled) {
llmEnhancementSettings.enabled = enabled;
localStorage.setItem('ccw-llm-enhancement-enabled', enabled.toString());
var settingsSection = document.getElementById('llmSettingsSection');
if (settingsSection) {
settingsSection.classList.toggle('opacity-50', !enabled);
settingsSection.querySelectorAll('select').forEach(function(el) { el.disabled = !enabled; });
}
renderCliStatus();
showRefreshToast('LLM Enhancement ' + (enabled ? 'enabled' : 'disabled'), 'success');
}
function updateLlmTool(tool) {
llmEnhancementSettings.tool = tool;
localStorage.setItem('ccw-llm-enhancement-tool', tool);
showRefreshToast('Primary LLM tool set to ' + tool, 'success');
}
function updateLlmFallback(tool) {
llmEnhancementSettings.fallbackTool = tool;
localStorage.setItem('ccw-llm-enhancement-fallback', tool);
showRefreshToast('Fallback tool set to ' + (tool || 'none'), 'success');
}
function updateLlmBatchSize(size) {
llmEnhancementSettings.batchSize = parseInt(size, 10);
localStorage.setItem('ccw-llm-enhancement-batch-size', size);
showRefreshToast('Batch size set to ' + size + ' files', 'success');
}
function updateLlmTimeout(ms) {
llmEnhancementSettings.timeoutMs = parseInt(ms, 10);
localStorage.setItem('ccw-llm-enhancement-timeout', ms);
var mins = parseInt(ms, 10) / 60000;
showRefreshToast('Timeout set to ' + mins + ' minute' + (mins > 1 ? 's' : ''), 'success');
}
async function runEnhanceCommand() {
if (!llmEnhancementSettings.enabled) {
showRefreshToast('Please enable LLM Enhancement first', 'warning');
return;
}
showRefreshToast('Starting LLM enhancement...', 'info');
closeSemanticSettingsModal();
try {
var response = await fetch('/api/codexlens/enhance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: projectPath,
tool: llmEnhancementSettings.tool,
batchSize: llmEnhancementSettings.batchSize,
timeoutMs: llmEnhancementSettings.timeoutMs
})
});
var result = await response.json();
if (result.success) {
var enhanced = result.result?.enhanced || 0;
showRefreshToast('Enhanced ' + enhanced + ' files with LLM', 'success');
} else {
showRefreshToast('Enhance failed: ' + result.error, 'error');
}
} catch (err) {
showRefreshToast('Enhance error: ' + err.message, 'error');
}
}
function viewEnhanceStatus() {
openSemanticMetadataViewer();
}
// ========== Semantic Metadata Viewer ==========
var semanticMetadataCache = {
entries: [],
total: 0,
offset: 0,
limit: 50,
loading: false
};
async function openSemanticMetadataViewer() {
closeSemanticSettingsModal();
var modal = document.createElement('div');
modal.id = 'semanticMetadataModal';
modal.className = 'generic-modal-overlay';
modal.onclick = function(e) { if (e.target === modal) closeSemanticMetadataViewer(); };
modal.innerHTML =
'<div class="generic-modal large" onclick="event.stopPropagation()">' +
'<div class="generic-modal-header">' +
'<div class="flex items-center gap-3">' +
'<i data-lucide="database" class="w-5 h-5 text-primary"></i>' +
'<h3 class="generic-modal-title">Semantic Metadata Browser</h3>' +
'<span id="semanticMetadataCount" class="badge bg-muted text-muted-foreground px-2 py-0.5 text-xs rounded">Loading...</span>' +
'</div>' +
'<button class="generic-modal-close" onclick="closeSemanticMetadataViewer()">' +
'<i data-lucide="x" class="w-4 h-4"></i>' +
'</button>' +
'</div>' +
'<div class="generic-modal-body p-0">' +
'<div class="semantic-viewer-toolbar">' +
'<div class="flex items-center gap-3">' +
'<select id="semanticToolFilter" class="cli-setting-select" onchange="filterSemanticByTool(this.value)">' +
'<option value="">All Tools</option>' +
'<option value="gemini">Gemini</option>' +
'<option value="qwen">Qwen</option>' +
'</select>' +
'<button class="btn-sm btn-outline flex items-center gap-1" onclick="refreshSemanticMetadata()">' +
'<i data-lucide="refresh-cw" class="w-3 h-3"></i> Refresh' +
'</button>' +
'</div>' +
'<div class="flex items-center gap-2 text-sm text-muted-foreground">' +
'<span id="semanticPaginationInfo">-</span>' +
'</div>' +
'</div>' +
'<div id="semanticMetadataTableContainer" class="semantic-table-container">' +
'<div class="semantic-loading">' +
'<div class="animate-spin w-6 h-6 border-2 border-primary border-t-transparent rounded-full"></div>' +
'<span>Loading metadata...</span>' +
'</div>' +
'</div>' +
'<div class="semantic-viewer-footer">' +
'<button id="semanticPrevBtn" class="btn-sm btn-outline" onclick="semanticPrevPage()" disabled>' +
'<i data-lucide="chevron-left" class="w-4 h-4"></i> Previous' +
'</button>' +
'<div class="flex items-center gap-2">' +
'<span class="text-sm text-muted-foreground">Page</span>' +
'<select id="semanticPageSelect" class="cli-setting-select" onchange="semanticGoToPage(this.value)">' +
'<option value="0">1</option>' +
'</select>' +
'</div>' +
'<button id="semanticNextBtn" class="btn-sm btn-outline" onclick="semanticNextPage()" disabled>' +
'Next <i data-lucide="chevron-right" class="w-4 h-4"></i>' +
'</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
requestAnimationFrame(function() {
modal.classList.add('active');
});
var handleEscape = function(e) {
if (e.key === 'Escape') {
closeSemanticMetadataViewer();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
if (window.lucide) {
lucide.createIcons();
}
await loadSemanticMetadata();
}
function closeSemanticMetadataViewer() {
var modal = document.getElementById('semanticMetadataModal');
if (modal) {
modal.classList.remove('active');
setTimeout(function() { modal.remove(); }, 200);
}
}
async function loadSemanticMetadata(offset, toolFilter) {
offset = typeof offset === 'number' ? offset : semanticMetadataCache.offset;
toolFilter = toolFilter !== undefined ? toolFilter : (document.getElementById('semanticToolFilter')?.value || '');
semanticMetadataCache.loading = true;
var container = document.getElementById('semanticMetadataTableContainer');
if (container) {
container.innerHTML =
'<div class="semantic-loading">' +
'<div class="animate-spin w-6 h-6 border-2 border-primary border-t-transparent rounded-full"></div>' +
'<span>Loading metadata...</span>' +
'</div>';
}
try {
var url = '/api/codexlens/semantic/metadata?offset=' + offset + '&limit=' + semanticMetadataCache.limit;
if (toolFilter) {
url += '&tool=' + encodeURIComponent(toolFilter);
}
var response = await fetch(url);
var data = await response.json();
if (data.success && data.result) {
semanticMetadataCache.entries = data.result.entries || [];
semanticMetadataCache.total = data.result.total || 0;
semanticMetadataCache.offset = offset;
renderSemanticMetadataTable();
updateSemanticPagination();
} else {
container.innerHTML =
'<div class="semantic-empty">' +
'<i data-lucide="alert-circle" class="w-8 h-8 text-muted-foreground"></i>' +
'<p>Error loading metadata: ' + (data.error || 'Unknown error') + '</p>' +
'</div>';
if (window.lucide) lucide.createIcons();
}
} catch (err) {
container.innerHTML =
'<div class="semantic-empty">' +
'<i data-lucide="alert-circle" class="w-8 h-8 text-muted-foreground"></i>' +
'<p>Error: ' + err.message + '</p>' +
'</div>';
if (window.lucide) lucide.createIcons();
}
semanticMetadataCache.loading = false;
}
function escapeHtmlSemantic(text) {
if (!text) return '';
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function renderSemanticMetadataTable() {
var container = document.getElementById('semanticMetadataTableContainer');
if (!container) return;
var entries = semanticMetadataCache.entries;
if (!entries.length) {
container.innerHTML =
'<div class="semantic-empty">' +
'<i data-lucide="database" class="w-12 h-12 text-muted-foreground mb-3"></i>' +
'<p class="text-lg font-medium">No semantic metadata found</p>' +
'<p class="text-sm text-muted-foreground mt-1">Run \'codex-lens enhance\' to generate metadata for indexed files.</p>' +
'<button class="btn-sm btn-primary mt-4" onclick="closeSemanticMetadataViewer(); runEnhanceCommand();">' +
'<i data-lucide="zap" class="w-3 h-3 mr-1"></i> Run Enhance' +
'</button>' +
'</div>';
if (window.lucide) lucide.createIcons();
return;
}
var rows = entries.map(function(entry, idx) {
var keywordsHtml = (entry.keywords || []).slice(0, 4).map(function(k) {
return '<span class="semantic-keyword">' + escapeHtmlSemantic(k) + '</span>';
}).join('');
if ((entry.keywords || []).length > 4) {
keywordsHtml += '<span class="semantic-keyword-more">+' + (entry.keywords.length - 4) + '</span>';
}
var date = entry.generated_at ? new Date(entry.generated_at * 1000).toLocaleDateString() : '-';
return (
'<tr class="semantic-row" onclick="toggleSemanticDetail(' + idx + ')">' +
'<td class="semantic-cell-file">' +
'<div class="flex items-center gap-2">' +
'<i data-lucide="file-code" class="w-4 h-4 text-muted-foreground"></i>' +
'<span class="font-medium">' + escapeHtmlSemantic(entry.file_name || '-') + '</span>' +
'</div>' +
'<div class="text-xs text-muted-foreground truncate" title="' + escapeHtmlSemantic(entry.full_path || '') + '">' +
escapeHtmlSemantic(entry.full_path || '-') +
'</div>' +
'</td>' +
'<td class="semantic-cell-lang">' + escapeHtmlSemantic(entry.language || '-') + '</td>' +
'<td class="semantic-cell-purpose">' + escapeHtmlSemantic((entry.purpose || '-').substring(0, 50)) +
((entry.purpose || '').length > 50 ? '...' : '') + '</td>' +
'<td class="semantic-cell-keywords">' + (keywordsHtml || '-') + '</td>' +
'<td class="semantic-cell-tool">' +
'<span class="tool-badge tool-' + (entry.llm_tool || 'unknown') + '">' +
escapeHtmlSemantic(entry.llm_tool || '-') +
'</span>' +
'</td>' +
'<td class="semantic-cell-date">' + date + '</td>' +
'</tr>' +
'<tr id="semanticDetail' + idx + '" class="semantic-detail-row hidden">' +
'<td colspan="6">' +
'<div class="semantic-detail-content">' +
'<div class="semantic-detail-section">' +
'<h4><i data-lucide="file-text" class="w-3 h-3"></i> Summary</h4>' +
'<p>' + escapeHtmlSemantic(entry.summary || 'No summary available') + '</p>' +
'</div>' +
'<div class="semantic-detail-section">' +
'<h4><i data-lucide="tag" class="w-3 h-3"></i> All Keywords</h4>' +
'<div class="semantic-keywords-full">' +
(entry.keywords || []).map(function(k) {
return '<span class="semantic-keyword">' + escapeHtmlSemantic(k) + '</span>';
}).join('') +
'</div>' +
'</div>' +
'<div class="semantic-detail-meta">' +
'<span><i data-lucide="hash" class="w-3 h-3"></i> ' + (entry.line_count || 0) + ' lines</span>' +
'<span><i data-lucide="cpu" class="w-3 h-3"></i> ' + escapeHtmlSemantic(entry.llm_tool || 'Unknown') + '</span>' +
'<span><i data-lucide="calendar" class="w-3 h-3"></i> ' + date + '</span>' +
'</div>' +
'</div>' +
'</td>' +
'</tr>'
);
}).join('');
container.innerHTML =
'<table class="semantic-table">' +
'<thead>' +
'<tr>' +
'<th>File</th>' +
'<th>Language</th>' +
'<th>Purpose</th>' +
'<th>Keywords</th>' +
'<th>Tool</th>' +
'<th>Date</th>' +
'</tr>' +
'</thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
if (window.lucide) lucide.createIcons();
}
function toggleSemanticDetail(idx) {
var detailRow = document.getElementById('semanticDetail' + idx);
if (detailRow) {
detailRow.classList.toggle('hidden');
if (window.lucide) lucide.createIcons();
}
}
function updateSemanticPagination() {
var total = semanticMetadataCache.total;
var offset = semanticMetadataCache.offset;
var limit = semanticMetadataCache.limit;
var entries = semanticMetadataCache.entries;
var countBadge = document.getElementById('semanticMetadataCount');
if (countBadge) {
countBadge.textContent = total + ' entries';
}
var paginationInfo = document.getElementById('semanticPaginationInfo');
if (paginationInfo) {
if (total > 0) {
paginationInfo.textContent = (offset + 1) + '-' + (offset + entries.length) + ' of ' + total;
} else {
paginationInfo.textContent = 'No entries';
}
}
var pageSelect = document.getElementById('semanticPageSelect');
if (pageSelect) {
var totalPages = Math.ceil(total / limit) || 1;
var currentPage = Math.floor(offset / limit);
pageSelect.innerHTML = '';
for (var i = 0; i < totalPages; i++) {
var opt = document.createElement('option');
opt.value = i;
opt.textContent = i + 1;
if (i === currentPage) opt.selected = true;
pageSelect.appendChild(opt);
}
}
var prevBtn = document.getElementById('semanticPrevBtn');
var nextBtn = document.getElementById('semanticNextBtn');
if (prevBtn) prevBtn.disabled = offset === 0;
if (nextBtn) nextBtn.disabled = offset + limit >= total;
}
function semanticPrevPage() {
if (semanticMetadataCache.offset > 0) {
loadSemanticMetadata(Math.max(0, semanticMetadataCache.offset - semanticMetadataCache.limit));
}
}
function semanticNextPage() {
if (semanticMetadataCache.offset + semanticMetadataCache.limit < semanticMetadataCache.total) {
loadSemanticMetadata(semanticMetadataCache.offset + semanticMetadataCache.limit);
}
}
function semanticGoToPage(pageIndex) {
var offset = parseInt(pageIndex, 10) * semanticMetadataCache.limit;
loadSemanticMetadata(offset);
}
function filterSemanticByTool(tool) {
loadSemanticMetadata(0, tool);
}
function refreshSemanticMetadata() {
loadSemanticMetadata(semanticMetadataCache.offset);
}
function getLlmEnhancementSettings() {
return Object.assign({}, llmEnhancementSettings);
}

View File

@@ -194,6 +194,50 @@ function handleNotification(data) {
}
break;
// CLI Review Events
case 'CLI_REVIEW_UPDATED':
if (typeof handleCliReviewUpdated === 'function') {
handleCliReviewUpdated(payload);
}
// Also refresh CLI history to show review status
if (typeof refreshCliHistory === 'function') {
refreshCliHistory();
}
break;
// System Notify Events (from CLI commands)
case 'REFRESH_REQUIRED':
handleRefreshRequired(payload);
break;
case 'MEMORY_UPDATED':
if (typeof handleMemoryUpdated === 'function') {
handleMemoryUpdated(payload);
}
// Force refresh of memory view if active
if (getCurrentView && getCurrentView() === 'memory') {
if (typeof loadMemoryStats === 'function') {
loadMemoryStats().then(function() {
if (typeof renderHotspotsColumn === 'function') renderHotspotsColumn();
});
}
}
break;
case 'HISTORY_UPDATED':
// Refresh CLI history when updated externally
if (typeof refreshCliHistory === 'function') {
refreshCliHistory();
}
break;
case 'INSIGHT_GENERATED':
// Refresh insights when new insight is generated
if (typeof loadInsightsHistory === 'function') {
loadInsightsHistory();
}
break;
default:
console.log('[WS] Unknown notification type:', type);
}
@@ -427,6 +471,60 @@ async function refreshWorkspaceData(newData) {
lastDataHash = calculateDataHash();
}
/**
* Handle REFRESH_REQUIRED events from CLI commands
* @param {Object} payload - Contains scope (memory|history|insights|all)
*/
function handleRefreshRequired(payload) {
const scope = payload?.scope || 'all';
console.log('[WS] Refresh required for scope:', scope);
switch (scope) {
case 'memory':
// Refresh memory stats and graph
if (typeof loadMemoryStats === 'function') {
loadMemoryStats().then(function() {
if (typeof renderHotspotsColumn === 'function') renderHotspotsColumn();
});
}
if (typeof loadMemoryGraph === 'function') {
loadMemoryGraph();
}
break;
case 'history':
// Refresh CLI history
if (typeof refreshCliHistory === 'function') {
refreshCliHistory();
}
break;
case 'insights':
// Refresh insights history
if (typeof loadInsightsHistory === 'function') {
loadInsightsHistory();
}
break;
case 'all':
default:
// Refresh everything
refreshIfNeeded();
if (typeof loadMemoryStats === 'function') {
loadMemoryStats().then(function() {
if (typeof renderHotspotsColumn === 'function') renderHotspotsColumn();
});
}
if (typeof refreshCliHistory === 'function') {
refreshCliHistory();
}
if (typeof loadInsightsHistory === 'function') {
loadInsightsHistory();
}
break;
}
}
// ========== Cleanup ==========
function stopAutoRefresh() {
if (autoRefreshInterval) {

View File

@@ -6,6 +6,8 @@ var currentCliExecution = null;
var cliExecutionOutput = '';
var ccwInstallations = [];
var ccwEndpointTools = [];
var cliToolConfig = null; // Store loaded CLI config
var predefinedModels = {}; // Store predefined models per tool
// ========== CCW Installations ==========
async function loadCcwInstallations() {
@@ -37,6 +39,271 @@ async function loadCcwEndpointTools() {
}
}
// ========== CLI Tool Configuration ==========
async function loadCliToolConfig() {
try {
var response = await fetch('/api/cli/config');
if (!response.ok) throw new Error('Failed to load CLI config');
var data = await response.json();
cliToolConfig = data.config || null;
predefinedModels = data.predefinedModels || {};
return data;
} catch (err) {
console.error('Failed to load CLI config:', err);
cliToolConfig = null;
predefinedModels = {};
return null;
}
}
async function updateCliToolConfig(tool, updates) {
try {
var response = await fetch('/api/cli/config/' + tool, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!response.ok) throw new Error('Failed to update CLI config');
var data = await response.json();
if (data.success && cliToolConfig && cliToolConfig.tools) {
cliToolConfig.tools[tool] = data.config;
}
return data;
} catch (err) {
console.error('Failed to update CLI config:', err);
throw err;
}
}
// ========== Tool Configuration Modal ==========
async function showToolConfigModal(toolName) {
// Load config if not already loaded
if (!cliToolConfig) {
await loadCliToolConfig();
}
var toolConfig = cliToolConfig && cliToolConfig.tools ? cliToolConfig.tools[toolName] : null;
var models = predefinedModels[toolName] || [];
var status = cliToolStatus[toolName] || {};
if (!toolConfig) {
toolConfig = { enabled: true, primaryModel: '', secondaryModel: '' };
}
var content = buildToolConfigModalContent(toolName, toolConfig, models, status);
showModal('Configure ' + toolName.charAt(0).toUpperCase() + toolName.slice(1), content, { size: 'md' });
// Initialize event handlers after modal is shown
setTimeout(function() {
initToolConfigModalEvents(toolName, toolConfig, models);
}, 100);
}
function buildToolConfigModalContent(tool, config, models, status) {
var isAvailable = status.available;
var isEnabled = config.enabled;
// Check if model is custom (not in predefined list or empty)
var isPrimaryCustom = !config.primaryModel || models.indexOf(config.primaryModel) === -1;
var isSecondaryCustom = !config.secondaryModel || models.indexOf(config.secondaryModel) === -1;
var modelsOptionsHtml = function(selected, isCustom) {
var html = '';
for (var i = 0; i < models.length; i++) {
var m = models[i];
html += '<option value="' + escapeHtml(m) + '"' + (m === selected && !isCustom ? ' selected' : '') + '>' + escapeHtml(m) + '</option>';
}
html += '<option value="__custom__"' + (isCustom ? ' selected' : '') + '>Custom...</option>';
return html;
};
return '<div class="tool-config-modal">' +
// Status Section
'<div class="tool-config-section">' +
'<h4>Status</h4>' +
'<div class="tool-config-badges">' +
'<span class="badge ' + (isAvailable ? 'badge-success' : 'badge-muted') + '">' +
'<i data-lucide="' + (isAvailable ? 'check-circle' : 'circle-dashed') + '" class="w-3 h-3"></i> ' +
(isAvailable ? 'Installed' : 'Not Installed') +
'</span>' +
'<span class="badge ' + (isEnabled ? 'badge-primary' : 'badge-muted') + '">' +
'<i data-lucide="' + (isEnabled ? 'toggle-right' : 'toggle-left') + '" class="w-3 h-3"></i> ' +
(isEnabled ? 'Enabled' : 'Disabled') +
'</span>' +
'</div>' +
'</div>' +
// Actions Section
'<div class="tool-config-section">' +
'<h4>Actions</h4>' +
'<div class="tool-config-actions">' +
'<button class="btn-sm ' + (isEnabled ? 'btn-outline' : 'btn-primary') + '" id="toggleEnableBtn" ' + (!isAvailable ? 'disabled' : '') + '>' +
'<i data-lucide="' + (isEnabled ? 'toggle-left' : 'toggle-right') + '" class="w-3 h-3"></i> ' +
(isEnabled ? 'Disable' : 'Enable') +
'</button>' +
'<button class="btn-sm ' + (isAvailable ? 'btn-outline btn-danger-outline' : 'btn-primary') + '" id="installBtn">' +
'<i data-lucide="' + (isAvailable ? 'trash-2' : 'download') + '" class="w-3 h-3"></i> ' +
(isAvailable ? 'Uninstall' : 'Install') +
'</button>' +
'</div>' +
'</div>' +
// Primary Model Section
'<div class="tool-config-section">' +
'<h4>Primary Model <span class="text-muted">(CLI endpoint calls)</span></h4>' +
'<div class="model-select-group">' +
'<select id="primaryModelSelect" class="tool-config-select">' +
modelsOptionsHtml(config.primaryModel, isPrimaryCustom) +
'</select>' +
'<input type="text" id="primaryModelCustom" class="tool-config-input" ' +
'style="display: ' + (isPrimaryCustom ? 'block' : 'none') + ';" ' +
'placeholder="Enter model name (e.g., gemini-2.5-pro)" ' +
'value="' + (isPrimaryCustom && config.primaryModel ? escapeHtml(config.primaryModel) : '') + '" />' +
'</div>' +
'</div>' +
// Secondary Model Section
'<div class="tool-config-section">' +
'<h4>Secondary Model <span class="text-muted">(internal tools)</span></h4>' +
'<div class="model-select-group">' +
'<select id="secondaryModelSelect" class="tool-config-select">' +
modelsOptionsHtml(config.secondaryModel, isSecondaryCustom) +
'</select>' +
'<input type="text" id="secondaryModelCustom" class="tool-config-input" ' +
'style="display: ' + (isSecondaryCustom ? 'block' : 'none') + ';" ' +
'placeholder="Enter model name (e.g., gemini-2.5-flash)" ' +
'value="' + (isSecondaryCustom && config.secondaryModel ? escapeHtml(config.secondaryModel) : '') + '" />' +
'</div>' +
'</div>' +
// Footer
'<div class="tool-config-footer">' +
'<button class="btn btn-outline" onclick="closeModal()">' + t('common.cancel') + '</button>' +
'<button class="btn btn-primary" id="saveConfigBtn">' +
'<i data-lucide="save" class="w-3.5 h-3.5"></i> ' + t('common.save') +
'</button>' +
'</div>' +
'</div>';
}
function initToolConfigModalEvents(tool, currentConfig, models) {
// Toggle Enable/Disable
var toggleBtn = document.getElementById('toggleEnableBtn');
if (toggleBtn) {
toggleBtn.onclick = async function() {
var newEnabled = !currentConfig.enabled;
try {
await updateCliToolConfig(tool, { enabled: newEnabled });
showRefreshToast(tool + ' ' + (newEnabled ? 'enabled' : 'disabled'), 'success');
closeModal();
renderToolsSection();
if (window.lucide) lucide.createIcons();
} catch (err) {
showRefreshToast('Failed to update: ' + err.message, 'error');
}
};
}
// Install/Uninstall
var installBtn = document.getElementById('installBtn');
if (installBtn) {
installBtn.onclick = async function() {
var status = cliToolStatus[tool] || {};
var endpoint = status.available ? '/api/cli/uninstall' : '/api/cli/install';
var action = status.available ? 'uninstalling' : 'installing';
showRefreshToast(tool.charAt(0).toUpperCase() + tool.slice(1) + ' ' + action + '...', 'info');
closeModal();
try {
var response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tool: tool })
});
var result = await response.json();
if (result.success) {
showRefreshToast(result.message || (tool + ' ' + (status.available ? 'uninstalled' : 'installed')), 'success');
await loadCliToolStatus();
renderToolsSection();
if (window.lucide) lucide.createIcons();
} else {
showRefreshToast(result.error || 'Operation failed', 'error');
}
} catch (err) {
showRefreshToast('Failed: ' + err.message, 'error');
}
};
}
// Model select handlers
var primarySelect = document.getElementById('primaryModelSelect');
var primaryCustom = document.getElementById('primaryModelCustom');
var secondarySelect = document.getElementById('secondaryModelSelect');
var secondaryCustom = document.getElementById('secondaryModelCustom');
if (primarySelect && primaryCustom) {
primarySelect.onchange = function() {
if (this.value === '__custom__') {
primaryCustom.style.display = 'block';
primaryCustom.focus();
} else {
primaryCustom.style.display = 'none';
primaryCustom.value = '';
}
};
}
if (secondarySelect && secondaryCustom) {
secondarySelect.onchange = function() {
if (this.value === '__custom__') {
secondaryCustom.style.display = 'block';
secondaryCustom.focus();
} else {
secondaryCustom.style.display = 'none';
secondaryCustom.value = '';
}
};
}
// Save button
var saveBtn = document.getElementById('saveConfigBtn');
if (saveBtn) {
saveBtn.onclick = async function() {
var primaryModel = primarySelect.value === '__custom__'
? primaryCustom.value.trim()
: primarySelect.value;
var secondaryModel = secondarySelect.value === '__custom__'
? secondaryCustom.value.trim()
: secondarySelect.value;
if (!primaryModel) {
showRefreshToast('Primary model is required', 'error');
return;
}
if (!secondaryModel) {
showRefreshToast('Secondary model is required', 'error');
return;
}
try {
await updateCliToolConfig(tool, {
primaryModel: primaryModel,
secondaryModel: secondaryModel
});
showRefreshToast('Configuration saved', 'success');
closeModal();
} catch (err) {
showRefreshToast('Failed to save: ' + err.message, 'error');
}
};
}
// Initialize lucide icons in modal
if (window.lucide) lucide.createIcons();
}
// ========== Rendering ==========
async function renderCliManager() {
var container = document.getElementById('mainContent');
@@ -94,12 +361,13 @@ function renderToolsSection() {
var isAvailable = status.available;
var isDefault = defaultCliTool === tool;
return '<div class="tool-item ' + (isAvailable ? 'available' : 'unavailable') + '">' +
return '<div class="tool-item clickable ' + (isAvailable ? 'available' : 'unavailable') + '" onclick="showToolConfigModal(\'' + tool + '\')">' +
'<div class="tool-item-left">' +
'<span class="tool-status-dot ' + (isAvailable ? 'status-available' : 'status-unavailable') + '"></span>' +
'<div class="tool-item-info">' +
'<div class="tool-item-name">' + tool.charAt(0).toUpperCase() + tool.slice(1) +
(isDefault ? '<span class="tool-default-badge">' + t('cli.default') + '</span>' : '') +
'<i data-lucide="settings" class="w-3 h-3 tool-config-icon"></i>' +
'</div>' +
'<div class="tool-item-desc">' + toolDescriptions[tool] + '</div>' +
'</div>' +
@@ -109,7 +377,7 @@ function renderToolsSection() {
? '<span class="tool-status-text success"><i data-lucide="check-circle" class="w-3.5 h-3.5"></i> ' + t('cli.ready') + '</span>'
: '<span class="tool-status-text muted"><i data-lucide="circle-dashed" class="w-3.5 h-3.5"></i> ' + t('cli.notInstalled') + '</span>') +
(isAvailable && !isDefault
? '<button class="btn-sm btn-outline" onclick="setDefaultCliTool(\'' + tool + '\')"><i data-lucide="star" class="w-3 h-3"></i> ' + t('cli.setDefault') + '</button>'
? '<button class="btn-sm btn-outline" onclick="event.stopPropagation(); setDefaultCliTool(\'' + tool + '\')"><i data-lucide="star" class="w-3 h-3"></i> ' + t('cli.setDefault') + '</button>'
: '') +
'</div>' +
'</div>';
@@ -136,11 +404,13 @@ function renderToolsSection() {
// Semantic Search item (only show if CodexLens is installed)
var semanticHtml = '';
if (codexLensStatus.ready) {
semanticHtml = '<div class="tool-item ' + (semanticStatus.available ? 'available' : 'unavailable') + '">' +
semanticHtml = '<div class="tool-item clickable ' + (semanticStatus.available ? 'available' : 'unavailable') + '" onclick="openSemanticSettingsModal()">' +
'<div class="tool-item-left">' +
'<span class="tool-status-dot ' + (semanticStatus.available ? 'status-available' : 'status-unavailable') + '"></span>' +
'<div class="tool-item-info">' +
'<div class="tool-item-name">Semantic Search <span class="tool-type-badge ai">AI</span></div>' +
'<div class="tool-item-name">Semantic Search <span class="tool-type-badge ai">AI</span>' +
(llmEnhancementSettings.enabled ? '<span class="tool-type-badge llm">LLM</span>' : '') +
'<i data-lucide="settings" class="w-3 h-3 tool-config-icon"></i></div>' +
'<div class="tool-item-desc">' + (semanticStatus.available ? 'AI-powered code understanding' : 'Natural language code search') + '</div>' +
'</div>' +
'</div>' +
@@ -148,7 +418,7 @@ function renderToolsSection() {
(semanticStatus.available
? '<span class="tool-status-text success"><i data-lucide="sparkles" class="w-3.5 h-3.5"></i> ' + (semanticStatus.backend || 'Ready') + '</span>'
: '<span class="tool-status-text muted"><i data-lucide="circle-dashed" class="w-3.5 h-3.5"></i> Not Installed</span>' +
'<button class="btn-sm btn-primary" onclick="openSemanticInstallWizard()"><i data-lucide="brain" class="w-3 h-3"></i> Install</button>') +
'<button class="btn-sm btn-primary" onclick="event.stopPropagation(); openSemanticInstallWizard()"><i data-lucide="brain" class="w-3 h-3"></i> Install</button>') +
'</div>' +
'</div>';
}

View File

@@ -44,7 +44,7 @@ async function loadPromptInsights() {
async function loadPromptInsightsHistory() {
try {
var response = await fetch('/api/memory/insights?limit=20');
var response = await fetch('/api/memory/insights?limit=20&path=' + encodeURIComponent(projectPath));
if (!response.ok) throw new Error('Failed to load insights history');
var data = await response.json();
promptInsightsHistory = data.insights || [];
@@ -699,6 +699,9 @@ async function triggerCliInsightsAnalysis() {
console.log('[PromptHistory] Insights parsed:', promptInsights);
}
// Reload insights history to show the new analysis result
await loadPromptInsightsHistory();
showRefreshToast(t('toast.completed') + ' (' + tool + ')', 'success');
} catch (err) {
console.error('CLI insights analysis failed:', err);

View File

@@ -0,0 +1,272 @@
/**
* CLI Configuration Manager
* Handles loading, saving, and managing CLI tool configurations
* Stores config in .workflow/cli-config.json
*/
import * as fs from 'fs';
import * as path from 'path';
// ========== Types ==========
export interface CliToolConfig {
enabled: boolean;
primaryModel: string; // For CLI endpoint calls (ccw cli exec)
secondaryModel: string; // For internal calls (llm_enhancer, generate_module_docs)
}
export interface CliConfig {
version: number;
tools: Record<string, CliToolConfig>;
}
export type CliToolName = 'gemini' | 'qwen' | 'codex';
// ========== Constants ==========
export const PREDEFINED_MODELS: Record<CliToolName, string[]> = {
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-pro', 'gemini-1.5-flash'],
qwen: ['coder-model', 'vision-model', 'qwen2.5-coder-32b'],
codex: ['gpt5-codex', 'gpt-4.1', 'o4-mini', 'o3']
};
export const DEFAULT_CONFIG: CliConfig = {
version: 1,
tools: {
gemini: {
enabled: true,
primaryModel: 'gemini-2.5-pro',
secondaryModel: 'gemini-2.5-flash'
},
qwen: {
enabled: true,
primaryModel: 'coder-model',
secondaryModel: 'coder-model'
},
codex: {
enabled: true,
primaryModel: 'gpt5-codex',
secondaryModel: 'gpt5-codex'
}
}
};
const CONFIG_DIR = '.workflow';
const CONFIG_FILE = 'cli-config.json';
// ========== Helper Functions ==========
function getConfigPath(baseDir: string): string {
return path.join(baseDir, CONFIG_DIR, CONFIG_FILE);
}
function ensureConfigDir(baseDir: string): void {
const configDir = path.join(baseDir, CONFIG_DIR);
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
}
function isValidToolName(tool: string): tool is CliToolName {
return ['gemini', 'qwen', 'codex'].includes(tool);
}
function validateConfig(config: unknown): config is CliConfig {
if (!config || typeof config !== 'object') return false;
const c = config as Record<string, unknown>;
if (typeof c.version !== 'number') return false;
if (!c.tools || typeof c.tools !== 'object') return false;
const tools = c.tools as Record<string, unknown>;
for (const toolName of ['gemini', 'qwen', 'codex']) {
const tool = tools[toolName];
if (!tool || typeof tool !== 'object') return false;
const t = tool as Record<string, unknown>;
if (typeof t.enabled !== 'boolean') return false;
if (typeof t.primaryModel !== 'string') return false;
if (typeof t.secondaryModel !== 'string') return false;
}
return true;
}
function mergeWithDefaults(config: Partial<CliConfig>): CliConfig {
const result: CliConfig = {
version: config.version ?? DEFAULT_CONFIG.version,
tools: { ...DEFAULT_CONFIG.tools }
};
if (config.tools) {
for (const toolName of Object.keys(config.tools)) {
if (isValidToolName(toolName) && config.tools[toolName]) {
result.tools[toolName] = {
...DEFAULT_CONFIG.tools[toolName],
...config.tools[toolName]
};
}
}
}
return result;
}
// ========== Main Functions ==========
/**
* Load CLI configuration from .workflow/cli-config.json
* Returns default config if file doesn't exist or is invalid
*/
export function loadCliConfig(baseDir: string): CliConfig {
const configPath = getConfigPath(baseDir);
try {
if (!fs.existsSync(configPath)) {
return { ...DEFAULT_CONFIG };
}
const content = fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(content);
if (validateConfig(parsed)) {
return mergeWithDefaults(parsed);
}
// Invalid config, return defaults
console.warn('[cli-config] Invalid config file, using defaults');
return { ...DEFAULT_CONFIG };
} catch (err) {
console.error('[cli-config] Error loading config:', err);
return { ...DEFAULT_CONFIG };
}
}
/**
* Save CLI configuration to .workflow/cli-config.json
*/
export function saveCliConfig(baseDir: string, config: CliConfig): void {
ensureConfigDir(baseDir);
const configPath = getConfigPath(baseDir);
try {
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
} catch (err) {
console.error('[cli-config] Error saving config:', err);
throw new Error(`Failed to save CLI config: ${err}`);
}
}
/**
* Get configuration for a specific tool
*/
export function getToolConfig(baseDir: string, tool: string): CliToolConfig {
if (!isValidToolName(tool)) {
throw new Error(`Invalid tool name: ${tool}`);
}
const config = loadCliConfig(baseDir);
return config.tools[tool] || DEFAULT_CONFIG.tools[tool];
}
/**
* Update configuration for a specific tool
* Returns the updated tool config
*/
export function updateToolConfig(
baseDir: string,
tool: string,
updates: Partial<CliToolConfig>
): CliToolConfig {
if (!isValidToolName(tool)) {
throw new Error(`Invalid tool name: ${tool}`);
}
const config = loadCliConfig(baseDir);
const currentToolConfig = config.tools[tool] || DEFAULT_CONFIG.tools[tool];
// Apply updates
const updatedToolConfig: CliToolConfig = {
enabled: updates.enabled !== undefined ? updates.enabled : currentToolConfig.enabled,
primaryModel: updates.primaryModel || currentToolConfig.primaryModel,
secondaryModel: updates.secondaryModel || currentToolConfig.secondaryModel
};
// Save updated config
config.tools[tool] = updatedToolConfig;
saveCliConfig(baseDir, config);
return updatedToolConfig;
}
/**
* Enable a CLI tool
*/
export function enableTool(baseDir: string, tool: string): CliToolConfig {
return updateToolConfig(baseDir, tool, { enabled: true });
}
/**
* Disable a CLI tool
*/
export function disableTool(baseDir: string, tool: string): CliToolConfig {
return updateToolConfig(baseDir, tool, { enabled: false });
}
/**
* Check if a tool is enabled
*/
export function isToolEnabled(baseDir: string, tool: string): boolean {
try {
const config = getToolConfig(baseDir, tool);
return config.enabled;
} catch {
return true; // Default to enabled if error
}
}
/**
* Get primary model for a tool
*/
export function getPrimaryModel(baseDir: string, tool: string): string {
try {
const config = getToolConfig(baseDir, tool);
return config.primaryModel;
} catch {
return isValidToolName(tool) ? DEFAULT_CONFIG.tools[tool].primaryModel : 'gemini-2.5-pro';
}
}
/**
* Get secondary model for a tool (used for internal calls)
*/
export function getSecondaryModel(baseDir: string, tool: string): string {
try {
const config = getToolConfig(baseDir, tool);
return config.secondaryModel;
} catch {
return isValidToolName(tool) ? DEFAULT_CONFIG.tools[tool].secondaryModel : 'gemini-2.5-flash';
}
}
/**
* Get all predefined models for a tool
*/
export function getPredefinedModels(tool: string): string[] {
if (!isValidToolName(tool)) {
return [];
}
return [...PREDEFINED_MODELS[tool]];
}
/**
* Get full config response for API (includes predefined models)
*/
export function getFullConfigResponse(baseDir: string): {
config: CliConfig;
predefinedModels: Record<string, string[]>;
} {
return {
config: loadCliConfig(baseDir),
predefinedModels: { ...PREDEFINED_MODELS }
};
}

View File

@@ -22,6 +22,12 @@ import {
getResumeModeDescription,
type ResumeDecision
} from './resume-strategy.js';
import {
isToolEnabled as isToolEnabledFromConfig,
enableTool as enableToolFromConfig,
disableTool as disableToolFromConfig,
getPrimaryModel
} from './cli-config-manager.js';
// CLI History storage path
const CLI_HISTORY_DIR = join(process.cwd(), '.workflow', '.cli-history');
@@ -720,12 +726,23 @@ async function executeCliTool(
}
}
// Determine effective model (use config's primaryModel if not explicitly provided)
let effectiveModel = model;
if (!effectiveModel) {
try {
effectiveModel = getPrimaryModel(workingDir, tool);
} catch {
// Config not available, use default (let the CLI tool use its own default)
effectiveModel = undefined;
}
}
// Build command
const { command, args, useStdin } = buildCommand({
tool,
prompt: finalPrompt,
mode,
model,
model: effectiveModel,
dir: cd,
include: includeDirs,
nativeResume: nativeResumeConfig
@@ -1203,6 +1220,19 @@ export function getConversationDetail(baseDir: string, conversationId: string):
return loadConversation(historyDir, conversationId);
}
/**
* Get conversation detail with native session info
*/
export function getConversationDetailWithNativeInfo(baseDir: string, conversationId: string) {
try {
const store = getSqliteStoreSync(baseDir);
return store.getConversationWithNativeInfo(conversationId);
} catch {
// SQLite not initialized, return null
return null;
}
}
/**
* Get execution detail by ID (legacy, returns ExecutionRecord for backward compatibility)
*/
@@ -1274,6 +1304,181 @@ export async function getCliToolsStatus(): Promise<Record<string, ToolAvailabili
return results;
}
// CLI tool package mapping
const CLI_TOOL_PACKAGES: Record<string, string> = {
gemini: '@google/gemini-cli',
qwen: '@qwen-code/qwen-code',
codex: '@openai/codex',
claude: '@anthropic-ai/claude-code'
};
// Disabled tools storage (in-memory fallback, main storage is in cli-config.json)
const disabledTools = new Set<string>();
// Default working directory for config operations
let configBaseDir = process.cwd();
/**
* Set the base directory for config operations
*/
export function setConfigBaseDir(dir: string): void {
configBaseDir = dir;
}
/**
* Install a CLI tool via npm
*/
export async function installCliTool(tool: string): Promise<{ success: boolean; error?: string }> {
const packageName = CLI_TOOL_PACKAGES[tool];
if (!packageName) {
return { success: false, error: `Unknown tool: ${tool}` };
}
return new Promise((resolve) => {
const child = spawn('npm', ['install', '-g', packageName], {
shell: true,
stdio: ['ignore', 'pipe', 'pipe']
});
let stderr = '';
child.stderr?.on('data', (data) => { stderr += data.toString(); });
child.on('close', (code) => {
// Clear cache to force re-check
toolAvailabilityCache.delete(tool);
if (code === 0) {
resolve({ success: true });
} else {
resolve({ success: false, error: stderr || `npm install failed with code ${code}` });
}
});
child.on('error', (err) => {
resolve({ success: false, error: err.message });
});
// Timeout after 2 minutes
setTimeout(() => {
child.kill();
resolve({ success: false, error: 'Installation timed out' });
}, 120000);
});
}
/**
* Uninstall a CLI tool via npm
*/
export async function uninstallCliTool(tool: string): Promise<{ success: boolean; error?: string }> {
const packageName = CLI_TOOL_PACKAGES[tool];
if (!packageName) {
return { success: false, error: `Unknown tool: ${tool}` };
}
return new Promise((resolve) => {
const child = spawn('npm', ['uninstall', '-g', packageName], {
shell: true,
stdio: ['ignore', 'pipe', 'pipe']
});
let stderr = '';
child.stderr?.on('data', (data) => { stderr += data.toString(); });
child.on('close', (code) => {
// Clear cache to force re-check
toolAvailabilityCache.delete(tool);
if (code === 0) {
resolve({ success: true });
} else {
resolve({ success: false, error: stderr || `npm uninstall failed with code ${code}` });
}
});
child.on('error', (err) => {
resolve({ success: false, error: err.message });
});
// Timeout after 1 minute
setTimeout(() => {
child.kill();
resolve({ success: false, error: 'Uninstallation timed out' });
}, 60000);
});
}
/**
* Enable a CLI tool (updates config file)
*/
export function enableCliTool(tool: string): { success: boolean } {
try {
enableToolFromConfig(configBaseDir, tool);
disabledTools.delete(tool); // Also update in-memory fallback
return { success: true };
} catch (err) {
console.error('[cli-executor] Error enabling tool:', err);
disabledTools.delete(tool); // Fallback to in-memory
return { success: true };
}
}
/**
* Disable a CLI tool (updates config file)
*/
export function disableCliTool(tool: string): { success: boolean } {
try {
disableToolFromConfig(configBaseDir, tool);
disabledTools.add(tool); // Also update in-memory fallback
return { success: true };
} catch (err) {
console.error('[cli-executor] Error disabling tool:', err);
disabledTools.add(tool); // Fallback to in-memory
return { success: true };
}
}
/**
* Check if a tool is enabled (reads from config file)
*/
export function isToolEnabled(tool: string): boolean {
try {
return isToolEnabledFromConfig(configBaseDir, tool);
} catch {
// Fallback to in-memory check
return !disabledTools.has(tool);
}
}
/**
* Get full status of all CLI tools including enabled state
*/
export async function getCliToolsFullStatus(): Promise<Record<string, {
available: boolean;
enabled: boolean;
path: string | null;
packageName: string;
}>> {
const tools = Object.keys(CLI_TOOL_PACKAGES);
const results: Record<string, {
available: boolean;
enabled: boolean;
path: string | null;
packageName: string;
}> = {};
await Promise.all(tools.map(async (tool) => {
const availability = await checkToolAvailability(tool);
results[tool] = {
available: availability.available,
enabled: isToolEnabled(tool),
path: availability.path,
packageName: CLI_TOOL_PACKAGES[tool]
};
}));
return results;
}
// ========== Prompt Concatenation System ==========
/**

View File

@@ -463,6 +463,26 @@ export class CliHistoryStore {
};
}
/**
* Get conversation with native session info
*/
getConversationWithNativeInfo(id: string): (ConversationRecord & {
hasNativeSession: boolean;
nativeSessionId?: string;
nativeSessionPath?: string;
}) | null {
const conv = this.getConversation(id);
if (!conv) return null;
const mapping = this.getNativeSessionMapping(id);
return {
...conv,
hasNativeSession: !!mapping,
nativeSessionId: mapping?.native_session_id,
nativeSessionPath: mapping?.native_session_path
};
}
/**
* Query execution history
*/

View File

@@ -9,6 +9,7 @@ import { readdirSync, statSync, existsSync, readFileSync, mkdirSync, writeFileSy
import { join, resolve, basename, extname, relative } from 'path';
import { execSync } from 'child_process';
import { tmpdir } from 'os';
import { getSecondaryModel } from './cli-config-manager.js';
// Directories to exclude
const EXCLUDE_DIRS = [
@@ -266,8 +267,15 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
return { success: false, error: `Not a directory: ${targetPath}` };
}
// Set model
const actualModel = model || DEFAULT_MODELS[tool] || DEFAULT_MODELS.gemini;
// Set model (use secondaryModel from config for internal calls)
let actualModel = model;
if (!actualModel) {
try {
actualModel = getSecondaryModel(process.cwd(), tool);
} catch {
actualModel = DEFAULT_MODELS[tool] || DEFAULT_MODELS.gemini;
}
}
// Scan directory
const { info: structureInfo, folderType } = scanDirectoryStructure(targetPath);