diff --git a/.ccw-cache/fix-smart-search.py b/.ccw-cache/fix-smart-search.py deleted file mode 100644 index 01359a48..00000000 --- a/.ccw-cache/fix-smart-search.py +++ /dev/null @@ -1,429 +0,0 @@ -#!/usr/bin/env python3 -import re - -# Read the file -with open('ccw/src/tools/smart-search.js', 'r', encoding='utf-8') as f: - content = f.read() - -# Fix 1: Update imports -content = content.replace( - "import { existsSync, readdirSync, statSync } from 'fs';", - "import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'fs';" -) - -# Fix 2: Remove duplicate query declaration in buildRipgrepCommand (keep fuzzy version) -content = re.sub( - r'(function buildRipgrepCommand\(params\) \{\s*const \{ query, paths = \[.*?\], contextLines = 0, maxResults = 100, includeHidden = false \} = params;\s*)', - '', - content, - count=1 -) - -# Fix 3: Remove errant 'n' character -content = re.sub(r'\nn/\*\*', r'\n/**', content) - -# Fix 4: Remove duplicated lines in buildRipgrepCommand -lines = content.split('\n') -fixed_lines = [] -skip_next = False -for i, line in enumerate(lines): - if skip_next: - skip_next = False - continue - - # Skip duplicate ripgrep command logic - if '// Use literal/fixed string matching for exact mode' in line: - # Skip old version - if i + 3 < len(lines) and 'args.push(...paths)' in lines[i + 3]: - skip_next = False - continue - - if '// Use fuzzy regex or literal matching based on mode' in line: - # Keep fuzzy version - fixed_lines.append(line) - continue - - fixed_lines.append(line) - -content = '\n'.join(fixed_lines) - -# Fix 5: Replace executeGraphMode implementation -graph_impl = '''/** - * Parse import statements from file content - * @param {string} fileContent - File content to parse - * @returns {Array<{source: string, specifiers: string[]}>} - */ -function parseImports(fileContent) { - const imports = []; - - // Pattern 1: ES6 import statements - const es6ImportPattern = /import\\s+(?:(?:(\\*\\s+as\\s+\\w+)|(\\w+)|(?:\\{([^}]+)\\}))\\s+from\\s+)?['\"]([^'\"]+)['\"]/g; - let match; - - while ((match = es6ImportPattern.exec(fileContent)) !== null) { - const source = match[4]; - const specifiers = []; - - if (match[1]) specifiers.push(match[1]); - else if (match[2]) specifiers.push(match[2]); - else if (match[3]) { - const named = match[3].split(',').map(s => s.trim()); - specifiers.push(...named); - } - - imports.push({ source, specifiers }); - } - - // Pattern 2: CommonJS require() - const requirePattern = /require\\(['\"]([^'\"]+)['\"]\\)/g; - while ((match = requirePattern.exec(fileContent)) !== null) { - imports.push({ source: match[1], specifiers: [] }); - } - - // Pattern 3: Dynamic import() - const dynamicImportPattern = /import\\(['\"]([^'\"]+)['\"]\\)/g; - while ((match = dynamicImportPattern)) !== null) { - imports.push({ source: match[1], specifiers: [] }); - } - - // Pattern 4: TypeScript import type - const typeImportPattern = /import\\s+type\\s+(?:\\{([^}]+)\\})\\s+from\\s+['\"]([^'\"]+)['\"]/g; - while ((match = typeImportPattern.exec(fileContent)) !== null) { - const source = match[2]; - const specifiers = match[1].split(',').map(s => s.trim()); - imports.push({ source, specifiers }); - } - - return imports; -} - -/** - * Parse export statements from file content - * @param {string} fileContent - File content to parse - * @returns {Array<{name: string, type: string}>} - */ -function parseExports(fileContent) { - const exports = []; - - // Pattern 1: export default - const defaultExportPattern = /export\\s+default\\s+(?:class|function|const|let|var)?\\s*(\\w+)?/g; - let match; - - while ((match = defaultExportPattern.exec(fileContent)) !== null) { - exports.push({ name: match[1] || 'default', type: 'default' }); - } - - // Pattern 2: export named declarations - const namedDeclPattern = /export\\s+(?:const|let|var|function|class)\\s+(\\w+)/g; - while ((match = namedDeclPattern.exec(fileContent)) !== null) { - exports.push({ name: match[1], type: 'named' }); - } - - // Pattern 3: export { ... } - const namedExportPattern = /export\\s+\\{([^}]+)\\}/g; - while ((match = namedExportPattern.exec(fileContent)) !== null) { - const names = match[1].split(',').map(s => { - const parts = s.trim().split(/\\s+as\\s+/); - return parts[parts.length - 1]; - }); - - names.forEach(name => { - exports.push({ name: name.trim(), type: 'named' }); - }); - } - - return exports; -} - -/** - * Build dependency graph by scanning project files - * @param {string} rootPath - Root directory to scan - * @param {string[]} gitignorePatterns - Patterns to exclude - * @returns {{nodes: Array, edges: Array, metadata: Object}} - */ -function buildDependencyGraph(rootPath, gitignorePatterns = []) { - const nodes = []; - const edges = []; - const processedFiles = new Set(); - - const SYSTEM_EXCLUDES = [ - '.git', 'node_modules', '.npm', '.yarn', '.pnpm', - 'dist', 'build', 'out', 'coverage', '.cache', - '.next', '.nuxt', '.vite', '__pycache__', 'venv' - ]; - - function shouldExclude(name) { - if (SYSTEM_EXCLUDES.includes(name)) return true; - for (const pattern of gitignorePatterns) { - if (name === pattern) return true; - if (pattern.includes('*')) { - const regex = new RegExp('^' + pattern.replace(/\\*/g, '.*') + '$'); - if (regex.test(name)) return true; - } - } - return false; - } - - function scanDirectory(dirPath) { - if (!existsSync(dirPath)) return; - - try { - const entries = readdirSync(dirPath, { withFileTypes: true }); - - for (const entry of entries) { - if (shouldExclude(entry.name)) continue; - - const fullPath = join(dirPath, entry.name); - - if (entry.isDirectory()) { - scanDirectory(fullPath); - } else if (entry.isFile()) { - const ext = entry.name.split('.').pop(); - if (['js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx'].includes(ext)) { - processFile(fullPath); - } - } - } - } catch (err) { - // Skip directories we can't read - } - } - - function processFile(filePath) { - if (processedFiles.has(filePath)) return; - processedFiles.add(filePath); - - try { - const content = readFileSync(filePath, 'utf8'); - const relativePath = './' + filePath.replace(rootPath, '').replace(/\\\\/g, '/').replace(/^\\//, ''); - - const fileExports = parseExports(content); - - nodes.push({ - id: relativePath, - path: filePath, - exports: fileExports - }); - - const imports = parseImports(content); - - imports.forEach(imp => { - let targetPath = imp.source; - - if (!targetPath.startsWith('.') && !targetPath.startsWith('/')) { - return; - } - - const targetRelative = './' + targetPath.replace(/^\\.\\//, ''); - - edges.push({ - from: relativePath, - to: targetRelative, - imports: imp.specifiers - }); - }); - } catch (err) { - // Skip files we can't read or parse - } - } - - scanDirectory(rootPath); - - const circularDeps = detectCircularDependencies(edges); - - return { - nodes, - edges, - metadata: { - timestamp: Date.now(), - rootPath, - nodeCount: nodes.length, - edgeCount: edges.length, - circular_deps_detected: circularDeps.length > 0, - circular_deps: circularDeps - } - }; -} - -/** - * Detect circular dependencies in the graph - * @param {Array} edges - Graph edges - * @returns {Array} List of circular dependency chains - */ -function detectCircularDependencies(edges) { - const cycles = []; - const visited = new Set(); - const recStack = new Set(); - - const graph = {}; - edges.forEach(edge => { - if (!graph[edge.from]) graph[edge.from] = []; - graph[edge.from].push(edge.to); - }); - - function dfs(node, path = []) { - if (recStack.has(node)) { - const cycleStart = path.indexOf(node); - if (cycleStart !== -1) { - cycles.push(path.slice(cycleStart).concat(node)); - } - return; - } - - if (visited.has(node)) return; - - visited.add(node); - recStack.add(node); - path.push(node); - - const neighbors = graph[node] || []; - for (const neighbor of neighbors) { - dfs(neighbor, [...path]); - } - - recStack.delete(node); - } - - Object.keys(graph).forEach(node => { - if (!visited.has(node)) { - dfs(node); - } - }); - - return cycles; -} - -/** - * Mode: graph - Dependency and relationship traversal - * Analyzes code relationships (imports, exports, dependencies) - */ -async function executeGraphMode(params) { - const { query, paths = [], maxResults = 100 } = params; - - const rootPath = resolve(process.cwd(), paths[0] || '.'); - const cacheDir = join(process.cwd(), '.ccw-cache'); - const cacheFile = join(cacheDir, 'dependency-graph.json'); - const CACHE_TTL = 5 * 60 * 1000; - - let graph; - - if (existsSync(cacheFile)) { - try { - const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); - const age = Date.now() - cached.metadata.timestamp; - - if (age < CACHE_TTL) { - graph = cached; - } - } catch (err) { - // Cache invalid, will rebuild - } - } - - if (!graph) { - const gitignorePatterns = []; - const gitignorePath = join(rootPath, '.gitignore'); - - if (existsSync(gitignorePath)) { - const content = readFileSync(gitignorePath, 'utf8'); - content.split('\\n').forEach(line => { - line = line.trim(); - if (!line || line.startsWith('#')) return; - gitignorePatterns.push(line.replace(/\\/$/, '')); - }); - } - - graph = buildDependencyGraph(rootPath, gitignorePatterns); - - try { - mkdirSync(cacheDir, { recursive: true }); - writeFileSync(cacheFile, JSON.stringify(graph, null, 2), 'utf8'); - } catch (err) { - // Cache write failed, continue - } - } - - const queryLower = query.toLowerCase(); - let queryType = 'unknown'; - let filteredNodes = []; - let filteredEdges = []; - let queryPaths = []; - - if (queryLower.match(/imports?\\s+(\\S+)/)) { - queryType = 'imports'; - const target = queryLower.match(/imports?\\s+(\\S+)/)[1]; - - filteredEdges = graph.edges.filter(edge => - edge.to.includes(target) || edge.imports.some(imp => imp.toLowerCase().includes(target)) - ); - - const nodeIds = new Set(filteredEdges.map(e => e.from)); - filteredNodes = graph.nodes.filter(n => nodeIds.has(n.id)); - } else if (queryLower.match(/exports?\\s+(\\S+)/)) { - queryType = 'exports'; - const target = queryLower.match(/exports?\\s+(\\S+)/)[1]; - - filteredNodes = graph.nodes.filter(node => - node.exports.some(exp => exp.name.toLowerCase().includes(target)) - ); - - const nodeIds = new Set(filteredNodes.map(n => n.id)); - filteredEdges = graph.edges.filter(e => nodeIds.has(e.from) || nodeIds.has(e.to)); - } else if (queryLower.includes('dependency') || queryLower.includes('chain') || queryLower.includes('depends')) { - queryType = 'dependency_chain'; - - filteredNodes = graph.nodes.slice(0, maxResults); - filteredEdges = graph.edges; - - if (graph.metadata.circular_deps && graph.metadata.circular_deps.length > 0) { - queryPaths = graph.metadata.circular_deps.slice(0, 10); - } - } else { - queryType = 'module_search'; - - filteredNodes = graph.nodes.filter(node => - node.id.toLowerCase().includes(queryLower) || - node.path.toLowerCase().includes(queryLower) - ); - - const nodeIds = new Set(filteredNodes.map(n => n.id)); - filteredEdges = graph.edges.filter(e => nodeIds.has(e.from) || nodeIds.has(e.to)); - } - - if (filteredNodes.length > maxResults) { - filteredNodes = filteredNodes.slice(0, maxResults); - } - - return { - success: true, - graph: { - nodes: filteredNodes, - edges: filteredEdges, - paths: queryPaths - }, - metadata: { - mode: 'graph', - storage: 'json', - query_type: queryType, - total_nodes: graph.metadata.nodeCount, - total_edges: graph.metadata.edgeCount, - filtered_nodes: filteredNodes.length, - filtered_edges: filteredEdges.length, - circular_deps_detected: graph.metadata.circular_deps_detected, - cached: existsSync(cacheFile), - query - } - }; -} -''' - -# Find and replace executeGraphMode -pattern = r'/\*\*\s*\* Mode: graph.*?\* Analyzes code relationships.*?\*/\s*async function executeGraphMode\(params\) \{.*?error: \'Graph mode not implemented - dependency analysis pending\'\s*\};?\s*\}' - -content = re.sub(pattern, graph_impl, content, flags=re.DOTALL) - -# Write the file -with open('ccw/src/tools/smart-search.js', 'w', encoding='utf-8') as f: - f.write(content) - -print('File updated successfully') diff --git a/.ccw-cache/graph-impl.js b/.ccw-cache/graph-impl.js deleted file mode 100644 index 3b71aa50..00000000 --- a/.ccw-cache/graph-impl.js +++ /dev/null @@ -1,378 +0,0 @@ -/** - * Parse import statements from file content - * @param {string} fileContent - File content to parse - * @returns {Array<{source: string, specifiers: string[]}>} - */ -function parseImports(fileContent) { - const imports = []; - - // Pattern 1: ES6 import statements - const es6ImportPattern = /import\s+(?:(?:(\*\s+as\s+\w+)|(\w+)|(?:\{([^}]+)\}))\s+from\s+)?['"]([^'"]+)['"]/g; - let match; - - while ((match = es6ImportPattern.exec(fileContent)) !== null) { - const source = match[4]; - const specifiers = []; - - if (match[1]) specifiers.push(match[1]); - else if (match[2]) specifiers.push(match[2]); - else if (match[3]) { - const named = match[3].split(',').map(s => s.trim()); - specifiers.push(...named); - } - - imports.push({ source, specifiers }); - } - - // Pattern 2: CommonJS require() - const requirePattern = /require\(['"]([^'"]+)['"]\)/g; - while ((match = requirePattern.exec(fileContent)) !== null) { - imports.push({ source: match[1], specifiers: [] }); - } - - // Pattern 3: Dynamic import() - const dynamicImportPattern = /import\(['"]([^'"]+)['"]\)/g; - while ((match = dynamicImportPattern.exec(fileContent)) !== null) { - imports.push({ source: match[1], specifiers: [] }); - } - - // Pattern 4: TypeScript import type - const typeImportPattern = /import\s+type\s+(?:\{([^}]+)\})\s+from\s+['"]([^'"]+)['"]/g; - while ((match = typeImportPattern.exec(fileContent)) !== null) { - const source = match[2]; - const specifiers = match[1].split(',').map(s => s.trim()); - imports.push({ source, specifiers }); - } - - return imports; -} - -/** - * Parse export statements from file content - * @param {string} fileContent - File content to parse - * @returns {Array<{name: string, type: string}>} - */ -function parseExports(fileContent) { - const exports = []; - - // Pattern 1: export default - const defaultExportPattern = /export\s+default\s+(?:class|function|const|let|var)?\s*(\w+)?/g; - let match; - - while ((match = defaultExportPattern.exec(fileContent)) !== null) { - exports.push({ name: match[1] || 'default', type: 'default' }); - } - - // Pattern 2: export named declarations - const namedDeclPattern = /export\s+(?:const|let|var|function|class)\s+(\w+)/g; - while ((match = namedDeclPattern.exec(fileContent)) !== null) { - exports.push({ name: match[1], type: 'named' }); - } - - // Pattern 3: export { ... } - const namedExportPattern = /export\s+\{([^}]+)\}/g; - while ((match = namedExportPattern.exec(fileContent)) !== null) { - const names = match[1].split(',').map(s => { - const parts = s.trim().split(/\s+as\s+/); - return parts[parts.length - 1]; - }); - - names.forEach(name => { - exports.push({ name: name.trim(), type: 'named' }); - }); - } - - return exports; -} - -/** - * Build dependency graph by scanning project files - * @param {string} rootPath - Root directory to scan - * @param {string[]} gitignorePatterns - Patterns to exclude - * @returns {{nodes: Array, edges: Array, metadata: Object}} - */ -function buildDependencyGraph(rootPath, gitignorePatterns = []) { - const { readFileSync, readdirSync, existsSync } = require('fs'); - const { join, relative, resolve: resolvePath } = require('path'); - - const nodes = []; - const edges = []; - const processedFiles = new Set(); - - const SYSTEM_EXCLUDES = [ - '.git', 'node_modules', '.npm', '.yarn', '.pnpm', - 'dist', 'build', 'out', 'coverage', '.cache', - '.next', '.nuxt', '.vite', '__pycache__', 'venv' - ]; - - function shouldExclude(name) { - if (SYSTEM_EXCLUDES.includes(name)) return true; - for (const pattern of gitignorePatterns) { - if (name === pattern) return true; - if (pattern.includes('*')) { - const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); - if (regex.test(name)) return true; - } - } - return false; - } - - function scanDirectory(dirPath) { - if (!existsSync(dirPath)) return; - - try { - const entries = readdirSync(dirPath, { withFileTypes: true }); - - for (const entry of entries) { - if (shouldExclude(entry.name)) continue; - - const fullPath = join(dirPath, entry.name); - - if (entry.isDirectory()) { - scanDirectory(fullPath); - } else if (entry.isFile()) { - const ext = entry.name.split('.').pop(); - if (['js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx'].includes(ext)) { - processFile(fullPath); - } - } - } - } catch (err) { - // Skip directories we can't read - } - } - - function processFile(filePath) { - if (processedFiles.has(filePath)) return; - processedFiles.add(filePath); - - try { - const content = readFileSync(filePath, 'utf8'); - const relativePath = './' + relative(rootPath, filePath).replace(/\\/g, '/'); - - const fileExports = parseExports(content); - - nodes.push({ - id: relativePath, - path: filePath, - exports: fileExports - }); - - const imports = parseImports(content); - - imports.forEach(imp => { - let targetPath = imp.source; - - if (!targetPath.startsWith('.') && !targetPath.startsWith('/')) { - return; - } - - try { - targetPath = resolvePath(join(filePath, '..', targetPath)); - const targetRelative = './' + relative(rootPath, targetPath).replace(/\\/g, '/'); - - edges.push({ - from: relativePath, - to: targetRelative, - imports: imp.specifiers - }); - } catch (err) { - // Skip invalid paths - } - }); - } catch (err) { - // Skip files we can't read or parse - } - } - - scanDirectory(rootPath); - - const circularDeps = detectCircularDependencies(edges); - - return { - nodes, - edges, - metadata: { - timestamp: Date.now(), - rootPath, - nodeCount: nodes.length, - edgeCount: edges.length, - circular_deps_detected: circularDeps.length > 0, - circular_deps: circularDeps - } - }; -} - -/** - * Detect circular dependencies in the graph - * @param {Array} edges - Graph edges - * @returns {Array} List of circular dependency chains - */ -function detectCircularDependencies(edges) { - const cycles = []; - const visited = new Set(); - const recStack = new Set(); - - const graph = {}; - edges.forEach(edge => { - if (!graph[edge.from]) graph[edge.from] = []; - graph[edge.from].push(edge.to); - }); - - function dfs(node, path = []) { - if (recStack.has(node)) { - const cycleStart = path.indexOf(node); - if (cycleStart !== -1) { - cycles.push(path.slice(cycleStart).concat(node)); - } - return; - } - - if (visited.has(node)) return; - - visited.add(node); - recStack.add(node); - path.push(node); - - const neighbors = graph[node] || []; - for (const neighbor of neighbors) { - dfs(neighbor, [...path]); - } - - recStack.delete(node); - } - - Object.keys(graph).forEach(node => { - if (!visited.has(node)) { - dfs(node); - } - }); - - return cycles; -} - -/** - * Mode: graph - Dependency and relationship traversal - * Analyzes code relationships (imports, exports, dependencies) - */ -async function executeGraphMode(params) { - const { readFileSync, writeFileSync, mkdirSync, existsSync } = await import('fs'); - const { join, resolve: resolvePath } = await import('path'); - - const { query, paths = [], maxResults = 100 } = params; - - const rootPath = resolvePath(process.cwd(), paths[0] || '.'); - const cacheDir = join(process.cwd(), '.ccw-cache'); - const cacheFile = join(cacheDir, 'dependency-graph.json'); - const CACHE_TTL = 5 * 60 * 1000; - - let graph; - - if (existsSync(cacheFile)) { - try { - const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); - const age = Date.now() - cached.metadata.timestamp; - - if (age < CACHE_TTL) { - graph = cached; - } - } catch (err) { - // Cache invalid, will rebuild - } - } - - if (!graph) { - const gitignorePatterns = []; - const gitignorePath = join(rootPath, '.gitignore'); - - if (existsSync(gitignorePath)) { - const content = readFileSync(gitignorePath, 'utf8'); - content.split('\n').forEach(line => { - line = line.trim(); - if (!line || line.startsWith('#')) return; - gitignorePatterns.push(line.replace(/\/$/, '')); - }); - } - - graph = buildDependencyGraph(rootPath, gitignorePatterns); - - try { - mkdirSync(cacheDir, { recursive: true }); - writeFileSync(cacheFile, JSON.stringify(graph, null, 2), 'utf8'); - } catch (err) { - // Cache write failed, continue - } - } - - const queryLower = query.toLowerCase(); - let queryType = 'unknown'; - let filteredNodes = []; - let filteredEdges = []; - let paths = []; - - if (queryLower.match(/imports?\s+(\S+)/)) { - queryType = 'imports'; - const target = queryLower.match(/imports?\s+(\S+)/)[1]; - - filteredEdges = graph.edges.filter(edge => - edge.to.includes(target) || edge.imports.some(imp => imp.toLowerCase().includes(target)) - ); - - const nodeIds = new Set(filteredEdges.map(e => e.from)); - filteredNodes = graph.nodes.filter(n => nodeIds.has(n.id)); - } else if (queryLower.match(/exports?\s+(\S+)/)) { - queryType = 'exports'; - const target = queryLower.match(/exports?\s+(\S+)/)[1]; - - filteredNodes = graph.nodes.filter(node => - node.exports.some(exp => exp.name.toLowerCase().includes(target)) - ); - - const nodeIds = new Set(filteredNodes.map(n => n.id)); - filteredEdges = graph.edges.filter(e => nodeIds.has(e.from) || nodeIds.has(e.to)); - } else if (queryLower.includes('dependency') || queryLower.includes('chain') || queryLower.includes('depends')) { - queryType = 'dependency_chain'; - - filteredNodes = graph.nodes.slice(0, maxResults); - filteredEdges = graph.edges; - - if (graph.metadata.circular_deps && graph.metadata.circular_deps.length > 0) { - paths = graph.metadata.circular_deps.slice(0, 10); - } - } else { - queryType = 'module_search'; - - filteredNodes = graph.nodes.filter(node => - node.id.toLowerCase().includes(queryLower) || - node.path.toLowerCase().includes(queryLower) - ); - - const nodeIds = new Set(filteredNodes.map(n => n.id)); - filteredEdges = graph.edges.filter(e => nodeIds.has(e.from) || nodeIds.has(e.to)); - } - - if (filteredNodes.length > maxResults) { - filteredNodes = filteredNodes.slice(0, maxResults); - } - - return { - success: true, - graph: { - nodes: filteredNodes, - edges: filteredEdges, - paths - }, - metadata: { - mode: 'graph', - storage: 'json', - query_type: queryType, - total_nodes: graph.metadata.nodeCount, - total_edges: graph.metadata.edgeCount, - filtered_nodes: filteredNodes.length, - filtered_edges: filteredEdges.length, - circular_deps_detected: graph.metadata.circular_deps_detected, - cached: existsSync(cacheFile), - query - } - }; -} \ No newline at end of file diff --git a/.ccw-cache/graph-mode-full.js b/.ccw-cache/graph-mode-full.js deleted file mode 100644 index ab93d86e..00000000 --- a/.ccw-cache/graph-mode-full.js +++ /dev/null @@ -1,367 +0,0 @@ -/** - * Parse import statements from file content - * @param {string} fileContent - File content to parse - * @returns {Array<{source: string, specifiers: string[]}>} - */ -function parseImports(fileContent) { - const imports = []; - - // Pattern 1: ES6 import statements - const es6ImportPattern = /import\s+(?:(?:(\*\s+as\s+\w+)|(\w+)|(?:\{([^}]+)\}))\s+from\s+)?['"]([^'"]+)['"]/g; - let match; - - while ((match = es6ImportPattern.exec(fileContent)) !== null) { - const source = match[4]; - const specifiers = []; - - if (match[1]) specifiers.push(match[1]); - else if (match[2]) specifiers.push(match[2]); - else if (match[3]) { - const named = match[3].split(',').map(s => s.trim()); - specifiers.push(...named); - } - - imports.push({ source, specifiers }); - } - - // Pattern 2: CommonJS require() - const requirePattern = /require\(['"]([^'"]+)['"]\)/g; - while ((match = requirePattern.exec(fileContent)) !== null) { - imports.push({ source: match[1], specifiers: [] }); - } - - // Pattern 3: Dynamic import() - const dynamicImportPattern = /import\(['"]([^'"]+)['"]\)/g; - while ((match = dynamicImportPattern.exec(fileContent)) !== null) { - imports.push({ source: match[1], specifiers: [] }); - } - - // Pattern 4: TypeScript import type - const typeImportPattern = /import\s+type\s+(?:\{([^}]+)\})\s+from\s+['"]([^'"]+)['"]/g; - while ((match = typeImportPattern.exec(fileContent)) !== null) { - const source = match[2]; - const specifiers = match[1].split(',').map(s => s.trim()); - imports.push({ source, specifiers }); - } - - return imports; -} - -/** - * Parse export statements from file content - * @param {string} fileContent - File content to parse - * @returns {Array<{name: string, type: string}>} - */ -function parseExports(fileContent) { - const exports = []; - - // Pattern 1: export default - const defaultExportPattern = /export\s+default\s+(?:class|function|const|let|var)?\s*(\w+)?/g; - let match; - - while ((match = defaultExportPattern.exec(fileContent)) !== null) { - exports.push({ name: match[1] || 'default', type: 'default' }); - } - - // Pattern 2: export named declarations - const namedDeclPattern = /export\s+(?:const|let|var|function|class)\s+(\w+)/g; - while ((match = namedDeclPattern.exec(fileContent)) !== null) { - exports.push({ name: match[1], type: 'named' }); - } - - // Pattern 3: export { ... } - const namedExportPattern = /export\s+\{([^}]+)\}/g; - while ((match = namedExportPattern.exec(fileContent)) !== null) { - const names = match[1].split(',').map(s => { - const parts = s.trim().split(/\s+as\s+/); - return parts[parts.length - 1]; - }); - - names.forEach(name => { - exports.push({ name: name.trim(), type: 'named' }); - }); - } - - return exports; -} - -/** - * Build dependency graph by scanning project files - * @param {string} rootPath - Root directory to scan - * @param {string[]} gitignorePatterns - Patterns to exclude - * @returns {{nodes: Array, edges: Array, metadata: Object}} - */ -function buildDependencyGraph(rootPath, gitignorePatterns = []) { - const nodes = []; - const edges = []; - const processedFiles = new Set(); - - const SYSTEM_EXCLUDES = [ - '.git', 'node_modules', '.npm', '.yarn', '.pnpm', - 'dist', 'build', 'out', 'coverage', '.cache', - '.next', '.nuxt', '.vite', '__pycache__', 'venv' - ]; - - function shouldExclude(name) { - if (SYSTEM_EXCLUDES.includes(name)) return true; - for (const pattern of gitignorePatterns) { - if (name === pattern) return true; - if (pattern.includes('*')) { - const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$'); - if (regex.test(name)) return true; - } - } - return false; - } - - function scanDirectory(dirPath) { - if (!existsSync(dirPath)) return; - - try { - const entries = readdirSync(dirPath, { withFileTypes: true }); - - for (const entry of entries) { - if (shouldExclude(entry.name)) continue; - - const fullPath = join(dirPath, entry.name); - - if (entry.isDirectory()) { - scanDirectory(fullPath); - } else if (entry.isFile()) { - const ext = entry.name.split('.').pop(); - if (['js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx'].includes(ext)) { - processFile(fullPath); - } - } - } - } catch (err) { - // Skip directories we can't read - } - } - - function processFile(filePath) { - if (processedFiles.has(filePath)) return; - processedFiles.add(filePath); - - try { - const content = readFileSync(filePath, 'utf8'); - const relativePath = './' + filePath.replace(rootPath, '').replace(/\\/g, '/').replace(/^\//, ''); - - const fileExports = parseExports(content); - - nodes.push({ - id: relativePath, - path: filePath, - exports: fileExports - }); - - const imports = parseImports(content); - - imports.forEach(imp => { - let targetPath = imp.source; - - if (!targetPath.startsWith('.') && !targetPath.startsWith('/')) { - return; - } - - const targetRelative = './' + targetPath.replace(/^\.\//, ''); - - edges.push({ - from: relativePath, - to: targetRelative, - imports: imp.specifiers - }); - }); - } catch (err) { - // Skip files we can't read or parse - } - } - - scanDirectory(rootPath); - - const circularDeps = detectCircularDependencies(edges); - - return { - nodes, - edges, - metadata: { - timestamp: Date.now(), - rootPath, - nodeCount: nodes.length, - edgeCount: edges.length, - circular_deps_detected: circularDeps.length > 0, - circular_deps: circularDeps - } - }; -} - -/** - * Detect circular dependencies in the graph - * @param {Array} edges - Graph edges - * @returns {Array} List of circular dependency chains - */ -function detectCircularDependencies(edges) { - const cycles = []; - const visited = new Set(); - const recStack = new Set(); - - const graph = {}; - edges.forEach(edge => { - if (!graph[edge.from]) graph[edge.from] = []; - graph[edge.from].push(edge.to); - }); - - function dfs(node, path = []) { - if (recStack.has(node)) { - const cycleStart = path.indexOf(node); - if (cycleStart !== -1) { - cycles.push(path.slice(cycleStart).concat(node)); - } - return; - } - - if (visited.has(node)) return; - - visited.add(node); - recStack.add(node); - path.push(node); - - const neighbors = graph[node] || []; - for (const neighbor of neighbors) { - dfs(neighbor, [...path]); - } - - recStack.delete(node); - } - - Object.keys(graph).forEach(node => { - if (!visited.has(node)) { - dfs(node); - } - }); - - return cycles; -} - -/** - * Mode: graph - Dependency and relationship traversal - * Analyzes code relationships (imports, exports, dependencies) - */ -async function executeGraphMode(params) { - const { query, paths = [], maxResults = 100 } = params; - - const rootPath = resolve(process.cwd(), paths[0] || '.'); - const cacheDir = join(process.cwd(), '.ccw-cache'); - const cacheFile = join(cacheDir, 'dependency-graph.json'); - const CACHE_TTL = 5 * 60 * 1000; - - let graph; - - if (existsSync(cacheFile)) { - try { - const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); - const age = Date.now() - cached.metadata.timestamp; - - if (age < CACHE_TTL) { - graph = cached; - } - } catch (err) { - // Cache invalid, will rebuild - } - } - - if (!graph) { - const gitignorePatterns = []; - const gitignorePath = join(rootPath, '.gitignore'); - - if (existsSync(gitignorePath)) { - const content = readFileSync(gitignorePath, 'utf8'); - content.split('\n').forEach(line => { - line = line.trim(); - if (!line || line.startsWith('#')) return; - gitignorePatterns.push(line.replace(/\/$/, '')); - }); - } - - graph = buildDependencyGraph(rootPath, gitignorePatterns); - - try { - mkdirSync(cacheDir, { recursive: true }); - writeFileSync(cacheFile, JSON.stringify(graph, null, 2), 'utf8'); - } catch (err) { - // Cache write failed, continue - } - } - - const queryLower = query.toLowerCase(); - let queryType = 'unknown'; - let filteredNodes = []; - let filteredEdges = []; - let queryPaths = []; - - if (queryLower.match(/imports?\s+(\S+)/)) { - queryType = 'imports'; - const target = queryLower.match(/imports?\s+(\S+)/)[1]; - - filteredEdges = graph.edges.filter(edge => - edge.to.includes(target) || edge.imports.some(imp => imp.toLowerCase().includes(target)) - ); - - const nodeIds = new Set(filteredEdges.map(e => e.from)); - filteredNodes = graph.nodes.filter(n => nodeIds.has(n.id)); - } else if (queryLower.match(/exports?\s+(\S+)/)) { - queryType = 'exports'; - const target = queryLower.match(/exports?\s+(\S+)/)[1]; - - filteredNodes = graph.nodes.filter(node => - node.exports.some(exp => exp.name.toLowerCase().includes(target)) - ); - - const nodeIds = new Set(filteredNodes.map(n => n.id)); - filteredEdges = graph.edges.filter(e => nodeIds.has(e.from) || nodeIds.has(e.to)); - } else if (queryLower.includes('dependency') || queryLower.includes('chain') || queryLower.includes('depends')) { - queryType = 'dependency_chain'; - - filteredNodes = graph.nodes.slice(0, maxResults); - filteredEdges = graph.edges; - - if (graph.metadata.circular_deps && graph.metadata.circular_deps.length > 0) { - queryPaths = graph.metadata.circular_deps.slice(0, 10); - } - } else { - queryType = 'module_search'; - - filteredNodes = graph.nodes.filter(node => - node.id.toLowerCase().includes(queryLower) || - node.path.toLowerCase().includes(queryLower) - ); - - const nodeIds = new Set(filteredNodes.map(n => n.id)); - filteredEdges = graph.edges.filter(e => nodeIds.has(e.from) || nodeIds.has(e.to)); - } - - if (filteredNodes.length > maxResults) { - filteredNodes = filteredNodes.slice(0, maxResults); - } - - return { - success: true, - graph: { - nodes: filteredNodes, - edges: filteredEdges, - paths: queryPaths - }, - metadata: { - mode: 'graph', - storage: 'json', - query_type: queryType, - total_nodes: graph.metadata.nodeCount, - total_edges: graph.metadata.edgeCount, - filtered_nodes: filteredNodes.length, - filtered_edges: filteredEdges.length, - circular_deps_detected: graph.metadata.circular_deps_detected, - cached: existsSync(cacheFile), - query - } - }; -} diff --git a/.ccw-cache/insert-graph-impl.js b/.ccw-cache/insert-graph-impl.js deleted file mode 100644 index 6d36457a..00000000 --- a/.ccw-cache/insert-graph-impl.js +++ /dev/null @@ -1,442 +0,0 @@ -import { readFileSync, writeFileSync } from 'fs'; - -// Read current file -let content = readFileSync('ccw/src/tools/smart-search.js', 'utf8'); - -// Step 1: Fix imports -content = content.replace( - "import { existsSync, readdirSync, statSync } from 'fs';", - "import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'fs';" -); - -// Step 2: Fix duplicate const { query... } lines in buildRipgrepCommand -const lines = content.split('\n'); -const fixedLines = []; -let inBuildRipgrep = false; -let foundQueryDecl = false; - -for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - if (line.includes('function buildRipgrepCommand(params)')) { - inBuildRipgrep = true; - foundQueryDecl = false; - fixedLines.push(line); - continue; - } - - if (inBuildRipgrep && line.includes('const { query,')) { - if (!foundQueryDecl) { - // Keep the first (fuzzy) version - foundQueryDecl = true; - fixedLines.push(line); - } - // Skip duplicate - continue; - } - - if (inBuildRipgrep && line.includes('return { command:')) { - inBuildRipgrep = false; - } - - // Remove old exact-mode-only comment - if (line.includes('// Use literal/fixed string matching for exact mode')) { - continue; - } - - // Skip old args.push('-F', query) - if (line.trim() === "args.push('-F', query);") { - continue; - } - - // Remove errant 'n/**' line - if (line.trim() === 'n/**') { - continue; - } - - fixedLines.push(line); -} - -content = fixedLines.join('\n'); - -// Step 3: Insert helper functions before executeGraphMode -const graphHelpers = ` -/** - * Parse import statements from file content - * @param {string} fileContent - File content to parse - * @returns {Array<{source: string, specifiers: string[]}>} - */ -function parseImports(fileContent) { - const imports = []; - - // Pattern 1: ES6 import statements - const es6ImportPattern = /import\\s+(?:(?:(\\*\\s+as\\s+\\w+)|(\\w+)|(?:\\{([^}]+)\\}))\\s+from\\s+)?['\"]([^'\"]+)['\"]/g; - let match; - - while ((match = es6ImportPattern.exec(fileContent)) !== null) { - const source = match[4]; - const specifiers = []; - - if (match[1]) specifiers.push(match[1]); - else if (match[2]) specifiers.push(match[2]); - else if (match[3]) { - const named = match[3].split(',').map(s => s.trim()); - specifiers.push(...named); - } - - imports.push({ source, specifiers }); - } - - // Pattern 2: CommonJS require() - const requirePattern = /require\\(['\"]([^'\"]+)['\"]\\)/g; - while ((match = requirePattern.exec(fileContent)) !== null) { - imports.push({ source: match[1], specifiers: [] }); - } - - // Pattern 3: Dynamic import() - const dynamicImportPattern = /import\\(['\"]([^'\"]+)['\"]\\)/g; - while ((match = dynamicImportPattern.exec(fileContent)) !== null) { - imports.push({ source: match[1], specifiers: [] }); - } - - // Pattern 4: TypeScript import type - const typeImportPattern = /import\\s+type\\s+(?:\\{([^}]+)\\})\\s+from\\s+['\"]([^'\"]+)['\"]/g; - while ((match = typeImportPattern.exec(fileContent)) !== null) { - const source = match[2]; - const specifiers = match[1].split(',').map(s => s.trim()); - imports.push({ source, specifiers }); - } - - return imports; -} - -/** - * Parse export statements from file content - * @param {string} fileContent - File content to parse - * @returns {Array<{name: string, type: string}>} - */ -function parseExports(fileContent) { - const exports = []; - - // Pattern 1: export default - const defaultExportPattern = /export\\s+default\\s+(?:class|function|const|let|var)?\\s*(\\w+)?/g; - let match; - - while ((match = defaultExportPattern.exec(fileContent)) !== null) { - exports.push({ name: match[1] || 'default', type: 'default' }); - } - - // Pattern 2: export named declarations - const namedDeclPattern = /export\\s+(?:const|let|var|function|class)\\s+(\\w+)/g; - while ((match = namedDeclPattern.exec(fileContent)) !== null) { - exports.push({ name: match[1], type: 'named' }); - } - - // Pattern 3: export { ... } - const namedExportPattern = /export\\s+\\{([^}]+)\\}/g; - while ((match = namedExportPattern.exec(fileContent)) !== null) { - const names = match[1].split(',').map(s => { - const parts = s.trim().split(/\\s+as\\s+/); - return parts[parts.length - 1]; - }); - - names.forEach(name => { - exports.push({ name: name.trim(), type: 'named' }); - }); - } - - return exports; -} - -/** - * Build dependency graph by scanning project files - * @param {string} rootPath - Root directory to scan - * @param {string[]} gitignorePatterns - Patterns to exclude - * @returns {{nodes: Array, edges: Array, metadata: Object}} - */ -function buildDependencyGraph(rootPath, gitignorePatterns = []) { - const nodes = []; - const edges = []; - const processedFiles = new Set(); - - const SYSTEM_EXCLUDES = [ - '.git', 'node_modules', '.npm', '.yarn', '.pnpm', - 'dist', 'build', 'out', 'coverage', '.cache', - '.next', '.nuxt', '.vite', '__pycache__', 'venv' - ]; - - function shouldExclude(name) { - if (SYSTEM_EXCLUDES.includes(name)) return true; - for (const pattern of gitignorePatterns) { - if (name === pattern) return true; - if (pattern.includes('*')) { - const regex = new RegExp('^' + pattern.replace(/\\*/g, '.*') + '$'); - if (regex.test(name)) return true; - } - } - return false; - } - - function scanDirectory(dirPath) { - if (!existsSync(dirPath)) return; - - try { - const entries = readdirSync(dirPath, { withFileTypes: true }); - - for (const entry of entries) { - if (shouldExclude(entry.name)) continue; - - const fullPath = join(dirPath, entry.name); - - if (entry.isDirectory()) { - scanDirectory(fullPath); - } else if (entry.isFile()) { - const ext = entry.name.split('.').pop(); - if (['js', 'mjs', 'cjs', 'ts', 'tsx', 'jsx'].includes(ext)) { - processFile(fullPath); - } - } - } - } catch (err) { - // Skip directories we can't read - } - } - - function processFile(filePath) { - if (processedFiles.has(filePath)) return; - processedFiles.add(filePath); - - try { - const content = readFileSync(filePath, 'utf8'); - const relativePath = './' + filePath.replace(rootPath, '').replace(/\\\\/g, '/').replace(/^\\//, ''); - - const fileExports = parseExports(content); - - nodes.push({ - id: relativePath, - path: filePath, - exports: fileExports - }); - - const imports = parseImports(content); - - imports.forEach(imp => { - let targetPath = imp.source; - - if (!targetPath.startsWith('.') && !targetPath.startsWith('/')) { - return; - } - - const targetRelative = './' + targetPath.replace(/^\\.\\//, ''); - - edges.push({ - from: relativePath, - to: targetRelative, - imports: imp.specifiers - }); - }); - } catch (err) { - // Skip files we can't read or parse - } - } - - scanDirectory(rootPath); - - const circularDeps = detectCircularDependencies(edges); - - return { - nodes, - edges, - metadata: { - timestamp: Date.now(), - rootPath, - nodeCount: nodes.length, - edgeCount: edges.length, - circular_deps_detected: circularDeps.length > 0, - circular_deps: circularDeps - } - }; -} - -/** - * Detect circular dependencies in the graph - * @param {Array} edges - Graph edges - * @returns {Array} List of circular dependency chains - */ -function detectCircularDependencies(edges) { - const cycles = []; - const visited = new Set(); - const recStack = new Set(); - - const graph = {}; - edges.forEach(edge => { - if (!graph[edge.from]) graph[edge.from] = []; - graph[edge.from].push(edge.to); - }); - - function dfs(node, path = []) { - if (recStack.has(node)) { - const cycleStart = path.indexOf(node); - if (cycleStart !== -1) { - cycles.push(path.slice(cycleStart).concat(node)); - } - return; - } - - if (visited.has(node)) return; - - visited.add(node); - recStack.add(node); - path.push(node); - - const neighbors = graph[node] || []; - for (const neighbor of neighbors) { - dfs(neighbor, [...path]); - } - - recStack.delete(node); - } - - Object.keys(graph).forEach(node => { - if (!visited.has(node)) { - dfs(node); - } - }); - - return cycles; -} - -`; - -const newExecuteGraphMode = `/** - * Mode: graph - Dependency and relationship traversal - * Analyzes code relationships (imports, exports, dependencies) - */ -async function executeGraphMode(params) { - const { query, paths = [], maxResults = 100 } = params; - - const rootPath = resolve(process.cwd(), paths[0] || '.'); - const cacheDir = join(process.cwd(), '.ccw-cache'); - const cacheFile = join(cacheDir, 'dependency-graph.json'); - const CACHE_TTL = 5 * 60 * 1000; - - let graph; - - if (existsSync(cacheFile)) { - try { - const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); - const age = Date.now() - cached.metadata.timestamp; - - if (age < CACHE_TTL) { - graph = cached; - } - } catch (err) { - // Cache invalid, will rebuild - } - } - - if (!graph) { - const gitignorePatterns = []; - const gitignorePath = join(rootPath, '.gitignore'); - - if (existsSync(gitignorePath)) { - const content = readFileSync(gitignorePath, 'utf8'); - content.split('\\n').forEach(line => { - line = line.trim(); - if (!line || line.startsWith('#')) return; - gitignorePatterns.push(line.replace(/\\/$/, '')); - }); - } - - graph = buildDependencyGraph(rootPath, gitignorePatterns); - - try { - mkdirSync(cacheDir, { recursive: true }); - writeFileSync(cacheFile, JSON.stringify(graph, null, 2), 'utf8'); - } catch (err) { - // Cache write failed, continue - } - } - - const queryLower = query.toLowerCase(); - let queryType = 'unknown'; - let filteredNodes = []; - let filteredEdges = []; - let queryPaths = []; - - if (queryLower.match(/imports?\\s+(\\S+)/)) { - queryType = 'imports'; - const target = queryLower.match(/imports?\\s+(\\S+)/)[1]; - - filteredEdges = graph.edges.filter(edge => - edge.to.includes(target) || edge.imports.some(imp => imp.toLowerCase().includes(target)) - ); - - const nodeIds = new Set(filteredEdges.map(e => e.from)); - filteredNodes = graph.nodes.filter(n => nodeIds.has(n.id)); - } else if (queryLower.match(/exports?\\s+(\\S+)/)) { - queryType = 'exports'; - const target = queryLower.match(/exports?\\s+(\\S+)/)[1]; - - filteredNodes = graph.nodes.filter(node => - node.exports.some(exp => exp.name.toLowerCase().includes(target)) - ); - - const nodeIds = new Set(filteredNodes.map(n => n.id)); - filteredEdges = graph.edges.filter(e => nodeIds.has(e.from) || nodeIds.has(e.to)); - } else if (queryLower.includes('dependency') || queryLower.includes('chain') || queryLower.includes('depends')) { - queryType = 'dependency_chain'; - - filteredNodes = graph.nodes.slice(0, maxResults); - filteredEdges = graph.edges; - - if (graph.metadata.circular_deps && graph.metadata.circular_deps.length > 0) { - queryPaths = graph.metadata.circular_deps.slice(0, 10); - } - } else { - queryType = 'module_search'; - - filteredNodes = graph.nodes.filter(node => - node.id.toLowerCase().includes(queryLower) || - node.path.toLowerCase().includes(queryLower) - ); - - const nodeIds = new Set(filteredNodes.map(n => n.id)); - filteredEdges = graph.edges.filter(e => nodeIds.has(e.from) || nodeIds.has(e.to)); - } - - if (filteredNodes.length > maxResults) { - filteredNodes = filteredNodes.slice(0, maxResults); - } - - return { - success: true, - graph: { - nodes: filteredNodes, - edges: filteredEdges, - paths: queryPaths - }, - metadata: { - mode: 'graph', - storage: 'json', - query_type: queryType, - total_nodes: graph.metadata.nodeCount, - total_edges: graph.metadata.edgeCount, - filtered_nodes: filteredNodes.length, - filtered_edges: filteredEdges.length, - circular_deps_detected: graph.metadata.circular_deps_detected, - cached: existsSync(cacheFile), - query - } - }; -}`; - -// Replace old executeGraphMode -const oldGraphMode = /\\/\\*\\*[\\s\\S]*?\\* Mode: graph - Dependency and relationship traversal[\\s\\S]*?\\*\\/\\s*async function executeGraphMode\\(params\\) \\{[\\s\\S]*?error: 'Graph mode not implemented - dependency analysis pending'[\\s\\S]*?\\}/; - -content = content.replace(oldGraphMode, graphHelpers + newExecuteGraphMode); - -// Write back -writeFileSync('ccw/src/tools/smart-search.js', content, 'utf8'); - -console.log('Successfully updated smart-search.js'); diff --git a/.claude/rules/active_memory.md b/.claude/rules/active_memory.md deleted file mode 100644 index ad883a3d..00000000 --- a/.claude/rules/active_memory.md +++ /dev/null @@ -1,13 +0,0 @@ -# Active Memory - -> Auto-generated understanding of frequently accessed files using GEMINI. -> Last updated: 2025-12-13T15:15:52.148Z -> Files analyzed: 10 -> CLI Tool: gemini - ---- - -[object Object] - ---- - diff --git a/.claude/rules/active_memory_config.json b/.claude/rules/active_memory_config.json deleted file mode 100644 index f1b9cd57..00000000 --- a/.claude/rules/active_memory_config.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "interval": "manual", - "tool": "gemini" -} \ No newline at end of file diff --git a/.claude/workflows/tool-strategy.md b/.claude/workflows/tool-strategy.md index d707d5ee..e6d6ed76 100644 --- a/.claude/workflows/tool-strategy.md +++ b/.claude/workflows/tool-strategy.md @@ -52,16 +52,22 @@ mcp__ccw-tools__read_file(paths="src/", contentPattern="TODO") # Regex searc ### codex_lens -**When to Use**: Code indexing and semantic search +**When to Use**: Code indexing, semantic search, cache management ``` mcp__ccw-tools__codex_lens(action="init", path=".") mcp__ccw-tools__codex_lens(action="search", query="function main", path=".") mcp__ccw-tools__codex_lens(action="search_files", query="pattern", limit=20) mcp__ccw-tools__codex_lens(action="symbol", file="src/main.py") +mcp__ccw-tools__codex_lens(action="status") +mcp__ccw-tools__codex_lens(action="config_show") +mcp__ccw-tools__codex_lens(action="config_set", key="index_dir", value="/path") +mcp__ccw-tools__codex_lens(action="config_migrate", newPath="/new/path") +mcp__ccw-tools__codex_lens(action="clean", path=".") +mcp__ccw-tools__codex_lens(action="clean", all=true) ``` -**Actions**: `init`, `search`, `search_files`, `symbol`, `status`, `update` +**Actions**: `init`, `search`, `search_files`, `symbol`, `status`, `config_show`, `config_set`, `config_migrate`, `clean` ### smart_search diff --git a/ccw/src/commands/memory.ts b/ccw/src/commands/memory.ts index 4d484eba..be354d9a 100644 --- a/ccw/src/commands/memory.ts +++ b/ccw/src/commands/memory.ts @@ -6,6 +6,7 @@ import chalk from 'chalk'; import { getMemoryStore, type Entity, type HotEntity, type PromptHistory } from '../core/memory-store.js'; import { HistoryImporter } from '../core/history-importer.js'; +import { notifyMemoryUpdate, notifyRefreshRequired } from '../tools/notifier.js'; import { join } from 'path'; import { existsSync, readdirSync } from 'fs'; @@ -190,6 +191,13 @@ async function trackAction(options: TrackOptions): Promise { } } + // Notify server of memory update (best-effort, non-blocking) + notifyMemoryUpdate({ + entityType: type, + entityId: String(entityId), + action: action + }).catch(() => { /* ignore errors - server may not be running */ }); + if (stdin) { // Silent mode for hooks - just exit successfully process.exit(0); @@ -275,6 +283,11 @@ async function importAction(options: ImportOptions): Promise { console.log(chalk.gray(` Total Skipped: ${totalSkipped}`)); console.log(chalk.gray(` Total Errors: ${totalErrors}`)); console.log(chalk.gray(` Database: ${dbPath}\n`)); + + // Notify server to refresh memory data + if (totalImported > 0) { + notifyRefreshRequired('memory').catch(() => { /* ignore */ }); + } } catch (error) { console.error(chalk.red(`\n Error importing: ${(error as Error).message}\n`)); process.exit(1); @@ -612,6 +625,11 @@ async function pruneAction(options: PruneOptions): Promise { console.log(chalk.green(`\n Pruned ${accessResult.changes} access logs`)); console.log(chalk.green(` Pruned ${entitiesResult.changes} entities\n`)); + // Notify server to refresh memory data + if (accessResult.changes > 0 || entitiesResult.changes > 0) { + notifyRefreshRequired('memory').catch(() => { /* ignore */ }); + } + } catch (error) { console.error(chalk.red(`\n Error: ${(error as Error).message}\n`)); process.exit(1); diff --git a/ccw/src/core/server.ts b/ccw/src/core/server.ts index e7bc883e..249c2ee5 100644 --- a/ccw/src/core/server.ts +++ b/ccw/src/core/server.ts @@ -57,7 +57,8 @@ const MODULE_CSS_FILES = [ '09-explorer.css', '10-cli.css', '11-memory.css', - '11-prompt-history.css' + '11-prompt-history.css', + '12-skills-rules.css' ]; /** @@ -126,6 +127,8 @@ const MODULE_FILES = [ 'views/explorer.js', 'views/memory.js', 'views/prompt-history.js', + 'views/skills-manager.js', + 'views/rules-manager.js', 'main.js' ]; /** @@ -420,6 +423,37 @@ export async function startServer(options: ServerOptions = {}): Promise { + const { type, scope, data } = body as { + type: 'REFRESH_REQUIRED' | 'MEMORY_UPDATED' | 'HISTORY_UPDATED' | 'INSIGHT_GENERATED'; + scope: 'memory' | 'history' | 'insights' | 'all'; + data?: Record; + }; + + 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, clientCount: wsClients.size }; + }); + return; + } + // API: Get hooks configuration if (pathname === '/api/hooks' && req.method === 'GET') { const projectPathParam = url.searchParams.get('path'); @@ -462,12 +496,12 @@ export async function startServer(options: ServerOptions = {}): Promise { - const { tool, prompt, mode, format, model, dir, includeDirs, timeout, smartContext } = body; + const { tool, prompt, mode, format, model, dir, includeDirs, timeout, smartContext, parentExecutionId, category } = body; if (!tool || !prompt) { return { error: 'tool and prompt are required', status: 400 }; @@ -857,6 +891,7 @@ export async function startServer(options: ServerOptions = {}): Promise { // Broadcast output chunks via WebSocket @@ -917,6 +954,94 @@ export async function startServer(options: ServerOptions = {}): Promise { + 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)); + + // Verify execution exists + const execution = historyStore.getConversation(executionId); + if (!execution) { + return { error: 'Execution not found', status: 404 }; + } + + // Save review + const review = historyStore.saveReview({ + execution_id: executionId, + status, + rating, + comments, + reviewer + }); + + // Broadcast review update + 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; + } + + // 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; + } + + // 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; + } + // API: Memory Module - Track entity access if (pathname === '/api/memory/track' && req.method === 'POST') { handlePostRequest(req, res, async (body) => { @@ -1967,6 +2092,69 @@ RULES: Be concise. Focus on practical understanding. Include function signatures return; } + // ========== Skills & Rules API Routes ========== + + // 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; + } + + // 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; + } + + // 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; + } + + // 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; + } + + // 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; + } + // Serve dashboard HTML if (pathname === '/' || pathname === '/index.html') { const html = generateServerDashboard(initialPath); @@ -3704,3 +3892,441 @@ function compareVersions(v1, v2) { } return 0; } + +// ========== Skills Helper Functions ========== + +/** + * Parse SKILL.md file to extract frontmatter and content + * @param {string} content - 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 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 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 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 }; + } +} + +// ========== Rules Helper Functions ========== + +/** + * Parse rule file to extract frontmatter (paths) and content + * @param {string} content - File content + * @returns {Object} Parsed frontmatter and content + */ +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; +} + +/** + * 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; +} + +/** + * 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 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 }; + } +} + +/** + * 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; +} + +/** + * 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 }; + } +} diff --git a/ccw/src/templates/dashboard-css/11-prompt-history.css b/ccw/src/templates/dashboard-css/11-prompt-history.css index f0994c24..33371813 100644 --- a/ccw/src/templates/dashboard-css/11-prompt-history.css +++ b/ccw/src/templates/dashboard-css/11-prompt-history.css @@ -115,6 +115,7 @@ border-bottom: 1px solid hsl(var(--border)); background: hsl(var(--muted) / 0.3); gap: 1rem; + flex-wrap: nowrap; } .prompt-timeline-header h3 { @@ -125,6 +126,7 @@ font-weight: 600; margin: 0; flex-shrink: 0; + white-space: nowrap; } .prompt-timeline-filters { @@ -133,12 +135,16 @@ gap: 0.5rem; flex: 1; justify-content: flex-end; + flex-wrap: nowrap; + min-width: 0; } .prompt-search-wrapper { position: relative; + display: flex; + align-items: center; flex: 1; - min-width: 150px; + min-width: 120px; max-width: 280px; } @@ -149,6 +155,7 @@ transform: translateY(-50%); color: hsl(var(--muted-foreground)); pointer-events: none; + z-index: 1; } .prompt-search-input { @@ -225,28 +232,102 @@ .prompt-session-items { display: flex; flex-direction: column; - gap: 0.5rem; + gap: 0; + position: relative; + padding-left: 2rem; } -/* Prompt Items */ +/* Timeline axis - subtle vertical line */ +.prompt-session-items::before { + content: ''; + position: absolute; + left: 0.4375rem; + top: 0; + bottom: 0; + width: 2px; + background: hsl(var(--border)); +} + +/* Prompt Items - Card style matching memory timeline */ .prompt-item { + display: flex; + gap: 0.75rem; padding: 0.875rem; - background: hsl(var(--card)); + margin-bottom: 0.625rem; + background: hsl(var(--background)); border: 1px solid hsl(var(--border)); border-radius: 0.5rem; cursor: pointer; - transition: all 0.2s; + transition: all 0.15s ease; + position: relative; +} + +/* Timeline dot - clean circle */ +.prompt-item::before { + content: ''; + position: absolute; + left: -2rem; + top: 1rem; + width: 12px; + height: 12px; + background: hsl(var(--background)); + border: 2px solid hsl(var(--muted-foreground) / 0.4); + border-radius: 50%; + transform: translateX(50%); + z-index: 1; + transition: all 0.2s ease; +} + +/* Timeline connector line to card */ +.prompt-item::after { + content: ''; + position: absolute; + left: -1.25rem; + top: 1.25rem; + width: 1rem; + height: 2px; + background: hsl(var(--border)); +} + +.prompt-item:last-child { + margin-bottom: 0; } .prompt-item:hover { border-color: hsl(var(--primary) / 0.3); - box-shadow: 0 2px 8px hsl(var(--primary) / 0.1); + background: hsl(var(--hover)); +} + +.prompt-item:hover::before { + border-color: hsl(var(--primary)); + background: hsl(var(--primary) / 0.1); +} + +.prompt-item:hover::after { + background: hsl(var(--primary) / 0.3); } .prompt-item-expanded { + max-height: none; + background: hsl(var(--muted) / 0.3); + border-color: hsl(var(--primary) / 0.5); +} + +.prompt-item-expanded::before { + background: hsl(var(--primary)); border-color: hsl(var(--primary)); } +.prompt-item-expanded::after { + background: hsl(var(--primary) / 0.5); +} + +/* Inner content layout */ +.prompt-item-inner { + flex: 1; + min-width: 0; +} + .prompt-item-header { display: flex; align-items: center; @@ -665,3 +746,322 @@ color: hsl(var(--muted-foreground)); font-size: 0.875rem; } + +/* ========== Insights History Cards ========== */ +.insights-history-container { + padding: 0.75rem; + max-height: calc(100vh - 300px); + overflow-y: auto; +} + +.insights-history-cards { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.insight-history-card { + padding: 0.875rem; + background: hsl(var(--card)); + border: 1px solid hsl(var(--border)); + border-left-width: 3px; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s ease; +} + +.insight-history-card:hover { + background: hsl(var(--muted) / 0.5); + transform: translateY(-1px); + box-shadow: 0 2px 8px hsl(var(--foreground) / 0.08); +} + +.insight-history-card.high { + border-left-color: hsl(0, 84%, 60%); +} + +.insight-history-card.medium { + border-left-color: hsl(48, 96%, 53%); +} + +.insight-history-card.low { + border-left-color: hsl(142, 71%, 45%); +} + +.insight-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.625rem; +} + +.insight-card-tool { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + font-weight: 500; + color: hsl(var(--foreground)); +} + +.insight-card-tool i { + color: hsl(var(--primary)); +} + +.insight-card-time { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); +} + +.insight-card-stats { + display: flex; + gap: 1rem; + margin-bottom: 0.625rem; +} + +.insight-stat { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.125rem; +} + +.insight-stat-value { + font-size: 1rem; + font-weight: 600; + color: hsl(var(--foreground)); +} + +.insight-stat-label { + font-size: 0.625rem; + color: hsl(var(--muted-foreground)); + text-transform: uppercase; + letter-spacing: 0.025em; +} + +.insight-card-preview { + margin-top: 0.5rem; +} + +.pattern-preview { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem; + border-radius: 0.375rem; + font-size: 0.75rem; + overflow: hidden; +} + +.pattern-preview.high { + background: hsl(0, 84%, 95%); +} + +.pattern-preview.medium { + background: hsl(48, 96%, 95%); +} + +.pattern-preview.low { + background: hsl(142, 71%, 95%); +} + +.pattern-preview .pattern-type { + font-weight: 600; + text-transform: capitalize; + white-space: nowrap; +} + +.pattern-preview .pattern-desc { + color: hsl(var(--muted-foreground)); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Insight Detail Panel */ +.insight-detail-panel { + position: fixed; + top: 0; + right: 0; + width: 400px; + max-width: 90vw; + height: 100vh; + background: hsl(var(--card)); + border-left: 1px solid hsl(var(--border)); + box-shadow: -4px 0 16px hsl(var(--foreground) / 0.1); + z-index: 1000; + overflow-y: auto; + animation: slideInRight 0.2s ease; +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } +} + +.insight-detail { + padding: 1.25rem; +} + +.insight-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 1rem; + border-bottom: 1px solid hsl(var(--border)); + margin-bottom: 1rem; +} + +.insight-detail-header h4 { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 1rem; + font-weight: 600; + margin: 0; +} + +.insight-detail-meta { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.25rem; + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.insight-detail-meta span { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.insight-patterns, +.insight-suggestions { + margin-bottom: 1.25rem; +} + +.insight-patterns h5, +.insight-suggestions h5 { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + font-weight: 600; + margin: 0 0 0.75rem 0; +} + +.patterns-list, +.suggestions-list { + display: flex; + flex-direction: column; + gap: 0.625rem; +} + +.pattern-item { + padding: 0.75rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-left-width: 3px; + border-radius: 0.5rem; +} + +.pattern-item.high { + border-left-color: hsl(0, 84%, 60%); +} + +.pattern-item.medium { + border-left-color: hsl(48, 96%, 53%); +} + +.pattern-item.low { + border-left-color: hsl(142, 71%, 45%); +} + +.pattern-item .pattern-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.pattern-type-badge { + padding: 0.125rem 0.5rem; + background: hsl(var(--muted)); + border-radius: 0.25rem; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; +} + +.pattern-severity { + font-size: 0.6875rem; + color: hsl(var(--muted-foreground)); + text-transform: capitalize; +} + +.pattern-occurrences { + margin-left: auto; + font-size: 0.75rem; + font-weight: 600; + color: hsl(var(--muted-foreground)); +} + +.pattern-item .pattern-description { + font-size: 0.8125rem; + line-height: 1.5; + margin-bottom: 0.5rem; +} + +.pattern-item .pattern-suggestion { + display: flex; + align-items: start; + gap: 0.375rem; + padding: 0.5rem; + background: hsl(var(--muted) / 0.5); + border-radius: 0.375rem; + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); +} + +.suggestion-item { + padding: 0.75rem; + background: hsl(var(--background)); + border: 1px solid hsl(var(--border)); + border-radius: 0.5rem; +} + +.suggestion-item .suggestion-title { + font-size: 0.8125rem; + font-weight: 600; + margin-bottom: 0.375rem; +} + +.suggestion-item .suggestion-description { + font-size: 0.75rem; + color: hsl(var(--muted-foreground)); + line-height: 1.5; + margin-bottom: 0.5rem; +} + +.suggestion-item .suggestion-example { + padding: 0.5rem; + background: hsl(var(--muted) / 0.5); + border-radius: 0.375rem; +} + +.suggestion-item .suggestion-example code { + font-size: 0.75rem; + font-family: monospace; + color: hsl(var(--foreground)); +} + +.insight-detail-actions { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid hsl(var(--border)); +} diff --git a/ccw/src/templates/dashboard-css/12-skills-rules.css b/ccw/src/templates/dashboard-css/12-skills-rules.css new file mode 100644 index 00000000..6e0cec77 --- /dev/null +++ b/ccw/src/templates/dashboard-css/12-skills-rules.css @@ -0,0 +1,216 @@ +/* ========================================== + SKILLS & RULES MANAGER STYLES + ========================================== */ + +/* Skills Manager */ +.skills-manager { + width: 100%; +} + +.skills-manager.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + color: hsl(var(--muted-foreground)); +} + +.skills-section { + margin-bottom: 2rem; + width: 100%; +} + +.skills-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; + width: 100%; +} + +.skill-card { + position: relative; + transition: all 0.2s ease; +} + +.skill-card:hover { + border-color: hsl(var(--primary)); + transform: translateY(-2px); +} + +.skills-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 160px; +} + +/* Skill Detail Panel */ +.skill-detail-panel { + animation: slideIn 0.2s ease-out; +} + +.skill-detail-overlay { + animation: fadeIn 0.15s ease-out; +} + +/* Rules Manager */ +.rules-manager { + width: 100%; +} + +.rules-manager.loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + color: hsl(var(--muted-foreground)); +} + +.rules-section { + margin-bottom: 2rem; + width: 100%; +} + +.rules-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; + width: 100%; +} + +.rule-card { + position: relative; + transition: all 0.2s ease; +} + +.rule-card:hover { + border-color: hsl(var(--success)); + transform: translateY(-2px); +} + +.rules-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 160px; +} + +/* Rule Detail Panel */ +.rule-detail-panel { + animation: slideIn 0.2s ease-out; +} + +.rule-detail-overlay { + animation: fadeIn 0.15s ease-out; +} + +/* Shared Animations */ +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Line clamp utility for card descriptions */ +.line-clamp-2 { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .skills-grid, + .rules-grid { + grid-template-columns: 1fr; + } + + .skill-detail-panel, + .rule-detail-panel { + width: 100%; + max-width: 100%; + } +} + +/* Badge styles for skills and rules */ +.skill-card .badge, +.rule-card .badge { + font-size: 0.75rem; + font-weight: 500; +} + +/* Code preview in rule cards */ +.rule-card pre { + font-family: var(--font-mono); + font-size: 0.75rem; + line-height: 1.4; +} + +/* Create modal styles (shared) */ +.skill-modal, +.rule-modal { + animation: fadeIn 0.15s ease-out; +} + +.skill-modal-backdrop, +.rule-modal-backdrop { + animation: fadeIn 0.15s ease-out; +} + +.skill-modal-content, +.rule-modal-content { + animation: slideUp 0.2s ease-out; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.skill-modal.hidden, +.rule-modal.hidden { + display: none; +} + +/* Form group styles */ +.skill-modal .form-group label, +.rule-modal .form-group label { + display: block; + margin-bottom: 0.25rem; +} + +.skill-modal input, +.skill-modal textarea, +.rule-modal input, +.rule-modal textarea { + transition: border-color 0.15s, box-shadow 0.15s; +} + +.skill-modal input:focus, +.skill-modal textarea:focus, +.rule-modal input:focus, +.rule-modal textarea:focus { + border-color: hsl(var(--primary)); + box-shadow: 0 0 0 2px hsl(var(--primary) / 0.2); +} diff --git a/ccw/src/templates/dashboard-js/components/navigation.js b/ccw/src/templates/dashboard-js/components/navigation.js index 5ec9ad1f..9ad921a1 100644 --- a/ccw/src/templates/dashboard-js/components/navigation.js +++ b/ccw/src/templates/dashboard-js/components/navigation.js @@ -108,6 +108,10 @@ function initNavigation() { renderMemoryView(); } else if (currentView === 'prompt-history') { renderPromptHistoryView(); + } else if (currentView === 'skills-manager') { + renderSkillsManager(); + } else if (currentView === 'rules-manager') { + renderRulesManager(); } }); }); @@ -136,6 +140,10 @@ function updateContentTitle() { titleEl.textContent = t('title.memoryModule'); } else if (currentView === 'prompt-history') { titleEl.textContent = t('title.promptHistory'); + } else if (currentView === 'skills-manager') { + titleEl.textContent = t('title.skillsManager'); + } else if (currentView === 'rules-manager') { + titleEl.textContent = t('title.rulesManager'); } else if (currentView === 'liteTasks') { const names = { 'lite-plan': t('title.litePlanSessions'), 'lite-fix': t('title.liteFixSessions') }; titleEl.textContent = names[currentLiteType] || t('title.liteTasks'); diff --git a/ccw/src/templates/dashboard-js/i18n.js b/ccw/src/templates/dashboard-js/i18n.js index 20daf01e..126a1d09 100644 --- a/ccw/src/templates/dashboard-js/i18n.js +++ b/ccw/src/templates/dashboard-js/i18n.js @@ -624,6 +624,62 @@ const i18n = { 'memory.prompts': 'prompts', 'memory.refreshInsights': 'Refresh', + // Skills + 'nav.skills': 'Skills', + 'title.skillsManager': 'Skills Manager', + 'skills.title': 'Skills Manager', + 'skills.description': 'Manage Claude Code skills and capabilities', + 'skills.create': 'Create Skill', + 'skills.projectSkills': 'Project Skills', + 'skills.userSkills': 'User Skills', + 'skills.skillsCount': 'skills', + 'skills.noProjectSkills': 'No project skills found', + 'skills.createHint': 'Create a skill in .claude/skills/ to add capabilities', + 'skills.noUserSkills': 'No user skills found', + 'skills.userSkillsHint': 'User skills apply to all your projects', + 'skills.noDescription': 'No description provided', + 'skills.tools': 'tools', + 'skills.files': 'files', + 'skills.descriptionLabel': 'Description', + 'skills.metadata': 'Metadata', + 'skills.location': 'Location', + 'skills.version': 'Version', + 'skills.allowedTools': 'Allowed Tools', + 'skills.supportingFiles': 'Supporting Files', + 'skills.path': 'Path', + 'skills.loadError': 'Failed to load skill details', + 'skills.deleteConfirm': 'Are you sure you want to delete the skill "{name}"?', + 'skills.deleted': 'Skill deleted successfully', + 'skills.deleteError': 'Failed to delete skill', + 'skills.editNotImplemented': 'Edit feature coming soon', + 'skills.createNotImplemented': 'Create feature coming soon', + + // Rules + 'nav.rules': 'Rules', + 'title.rulesManager': 'Rules Manager', + 'rules.title': 'Rules Manager', + 'rules.description': 'Manage project and user rules for Claude Code', + 'rules.create': 'Create Rule', + 'rules.projectRules': 'Project Rules', + 'rules.userRules': 'User Rules', + 'rules.rulesCount': 'rules', + 'rules.noProjectRules': 'No project rules found', + 'rules.createHint': 'Create rules in .claude/rules/ for project-specific instructions', + 'rules.noUserRules': 'No user rules found', + 'rules.userRulesHint': 'User rules apply to all your projects', + 'rules.typeLabel': 'Type', + 'rules.conditional': 'Conditional', + 'rules.global': 'Global', + 'rules.pathConditions': 'Path Conditions', + 'rules.content': 'Content', + 'rules.filePath': 'File Path', + 'rules.loadError': 'Failed to load rule details', + 'rules.deleteConfirm': 'Are you sure you want to delete the rule "{name}"?', + 'rules.deleted': 'Rule deleted successfully', + 'rules.deleteError': 'Failed to delete rule', + 'rules.editNotImplemented': 'Edit feature coming soon', + 'rules.createNotImplemented': 'Create feature coming soon', + // Common 'common.cancel': 'Cancel', 'common.create': 'Create', @@ -1258,6 +1314,62 @@ const i18n = { 'memory.prompts': '提示', 'memory.refreshInsights': '刷新', + // Skills + 'nav.skills': '技能', + 'title.skillsManager': '技能管理', + 'skills.title': '技能管理', + 'skills.description': '管理 Claude Code 的技能和能力', + 'skills.create': '创建技能', + 'skills.projectSkills': '项目技能', + 'skills.userSkills': '用户技能', + 'skills.skillsCount': '个技能', + 'skills.noProjectSkills': '未找到项目技能', + 'skills.createHint': '在 .claude/skills/ 中创建技能以添加功能', + 'skills.noUserSkills': '未找到用户技能', + 'skills.userSkillsHint': '用户技能适用于所有项目', + 'skills.noDescription': '无描述', + 'skills.tools': '工具', + 'skills.files': '文件', + 'skills.descriptionLabel': '描述', + 'skills.metadata': '元数据', + 'skills.location': '位置', + 'skills.version': '版本', + 'skills.allowedTools': '允许的工具', + 'skills.supportingFiles': '支持文件', + 'skills.path': '路径', + 'skills.loadError': '加载技能详情失败', + 'skills.deleteConfirm': '确定要删除技能 "{name}" 吗?', + 'skills.deleted': '技能删除成功', + 'skills.deleteError': '删除技能失败', + 'skills.editNotImplemented': '编辑功能即将推出', + 'skills.createNotImplemented': '创建功能即将推出', + + // Rules + 'nav.rules': '规则', + 'title.rulesManager': '规则管理', + 'rules.title': '规则管理', + 'rules.description': '管理 Claude Code 的项目和用户规则', + 'rules.create': '创建规则', + 'rules.projectRules': '项目规则', + 'rules.userRules': '用户规则', + 'rules.rulesCount': '条规则', + 'rules.noProjectRules': '未找到项目规则', + 'rules.createHint': '在 .claude/rules/ 中创建规则以设置项目特定指令', + 'rules.noUserRules': '未找到用户规则', + 'rules.userRulesHint': '用户规则适用于所有项目', + 'rules.typeLabel': '类型', + 'rules.conditional': '条件规则', + 'rules.global': '全局规则', + 'rules.pathConditions': '路径条件', + 'rules.content': '内容', + 'rules.filePath': '文件路径', + 'rules.loadError': '加载规则详情失败', + 'rules.deleteConfirm': '确定要删除规则 "{name}" 吗?', + 'rules.deleted': '规则删除成功', + 'rules.deleteError': '删除规则失败', + 'rules.editNotImplemented': '编辑功能即将推出', + 'rules.createNotImplemented': '创建功能即将推出', + // Common 'common.cancel': '取消', 'common.create': '创建', diff --git a/ccw/src/templates/dashboard-js/views/memory.js b/ccw/src/templates/dashboard-js/views/memory.js index 08559c1c..dc6637d9 100644 --- a/ccw/src/templates/dashboard-js/views/memory.js +++ b/ccw/src/templates/dashboard-js/views/memory.js @@ -58,14 +58,12 @@ async function renderMemoryView() { '
' + '
' + '' + - '
' + ''; // Render each column renderHotspotsColumn(); renderGraphColumn(); renderContextColumn(); - renderInsightsSection(); // Initialize Lucide icons if (window.lucide) lucide.createIcons(); diff --git a/ccw/src/templates/dashboard-js/views/prompt-history.js b/ccw/src/templates/dashboard-js/views/prompt-history.js index ee858c7b..13e4c4f4 100644 --- a/ccw/src/templates/dashboard-js/views/prompt-history.js +++ b/ccw/src/templates/dashboard-js/views/prompt-history.js @@ -8,6 +8,8 @@ var promptHistorySearch = ''; var promptHistoryDateFilter = null; var promptHistoryProjectFilter = null; var selectedPromptId = null; +var promptInsightsHistory = []; // Insights analysis history +var selectedPromptInsight = null; // Currently selected insight for detail view // ========== Data Loading ========== async function loadPromptHistory() { @@ -40,6 +42,20 @@ async function loadPromptInsights() { } } +async function loadPromptInsightsHistory() { + try { + var response = await fetch('/api/memory/insights?limit=20'); + if (!response.ok) throw new Error('Failed to load insights history'); + var data = await response.json(); + promptInsightsHistory = data.insights || []; + return promptInsightsHistory; + } catch (err) { + console.error('Failed to load insights history:', err); + promptInsightsHistory = []; + return []; + } +} + // ========== Rendering ========== async function renderPromptHistoryView() { var container = document.getElementById('mainContent'); @@ -52,7 +68,7 @@ async function renderPromptHistoryView() { if (searchInput) searchInput.parentElement.style.display = 'none'; // Load data - await Promise.all([loadPromptHistory(), loadPromptInsights()]); + await Promise.all([loadPromptHistory(), loadPromptInsights(), loadPromptInsightsHistory()]); // Calculate stats var totalPrompts = promptHistoryData.length; @@ -232,51 +248,207 @@ function renderInsightsPanel() { return html; } - if (!promptInsights || !promptInsights.patterns || promptInsights.patterns.length === 0) { - html += '
' + + // Show insights history cards + html += '
' + + renderPromptInsightsHistory() + + '
'; + + // Show detail panel if an insight is selected + if (selectedPromptInsight) { + html += '
' + + renderPromptInsightDetail(selectedPromptInsight) + + '
'; + } + + return html; +} + +function renderPromptInsightsHistory() { + if (!promptInsightsHistory || promptInsightsHistory.length === 0) { + return '
' + '' + '

' + t('prompt.noInsights') + '

' + '

' + t('prompt.noInsightsText') + '

' + '
'; - } else { - html += '
'; - - // Render detected patterns - if (promptInsights.patterns && promptInsights.patterns.length > 0) { - html += '
' + - '

Detected Patterns

'; - for (var i = 0; i < promptInsights.patterns.length; i++) { - html += renderPatternCard(promptInsights.patterns[i]); - } - html += '
'; - } - - // Render suggestions - if (promptInsights.suggestions && promptInsights.suggestions.length > 0) { - html += '
' + - '

Optimization Suggestions

'; - for (var j = 0; j < promptInsights.suggestions.length; j++) { - html += renderSuggestionCard(promptInsights.suggestions[j]); - } - html += '
'; - } - - // Render similar successful prompts - if (promptInsights.similar_prompts && promptInsights.similar_prompts.length > 0) { - html += '
' + - '

Similar Successful Prompts

'; - for (var k = 0; k < promptInsights.similar_prompts.length; k++) { - html += renderSimilarPromptCard(promptInsights.similar_prompts[k]); - } - html += '
'; - } - - html += '
'; } + return '
' + + promptInsightsHistory.map(function(insight) { + var patternCount = (insight.patterns || []).length; + var suggestionCount = (insight.suggestions || []).length; + var severity = getPromptInsightSeverity(insight.patterns); + var timeAgo = formatPromptTimestamp(insight.created_at); + + return '
' + + '
' + + '
' + + '' + + '' + (insight.tool || 'CLI') + '' + + '
' + + '
' + timeAgo + '
' + + '
' + + '
' + + '
' + + '' + patternCount + '' + + '' + (isZh() ? '模式' : 'Patterns') + '' + + '
' + + '
' + + '' + suggestionCount + '' + + '' + (isZh() ? '建议' : 'Suggestions') + '' + + '
' + + '
' + + '' + (insight.prompt_count || 0) + '' + + '' + (isZh() ? '提示' : 'Prompts') + '' + + '
' + + '
' + + (insight.patterns && insight.patterns.length > 0 ? + '
' + + '
' + + '' + escapeHtml(insight.patterns[0].type || 'pattern') + '' + + '' + escapeHtml((insight.patterns[0].description || '').substring(0, 60)) + '...' + + '
' + + '
' : '') + + '
'; + }).join('') + + '
'; +} + +function getPromptInsightSeverity(patterns) { + if (!patterns || patterns.length === 0) return 'low'; + var hasHigh = patterns.some(function(p) { return p.severity === 'high'; }); + var hasMedium = patterns.some(function(p) { return p.severity === 'medium'; }); + return hasHigh ? 'high' : (hasMedium ? 'medium' : 'low'); +} + +function getPromptToolIcon(tool) { + switch(tool) { + case 'gemini': return 'sparkles'; + case 'qwen': return 'bot'; + case 'codex': return 'code-2'; + default: return 'cpu'; + } +} + +function formatPromptTimestamp(timestamp) { + if (!timestamp) return ''; + var date = new Date(timestamp); + var now = new Date(); + var diff = now - date; + var minutes = Math.floor(diff / 60000); + var hours = Math.floor(diff / 3600000); + var days = Math.floor(diff / 86400000); + + if (minutes < 1) return isZh() ? '刚刚' : 'Just now'; + if (minutes < 60) return minutes + (isZh() ? ' 分钟前' : 'm ago'); + if (hours < 24) return hours + (isZh() ? ' 小时前' : 'h ago'); + if (days < 7) return days + (isZh() ? ' 天前' : 'd ago'); + return date.toLocaleDateString(); +} + +async function showPromptInsightDetail(insightId) { + try { + var response = await fetch('/api/memory/insights/' + insightId); + if (!response.ok) throw new Error('Failed to load insight detail'); + var data = await response.json(); + selectedPromptInsight = data.insight; + renderPromptHistoryView(); + } catch (err) { + console.error('Failed to load insight detail:', err); + if (window.showToast) { + showToast(isZh() ? '加载洞察详情失败' : 'Failed to load insight detail', 'error'); + } + } +} + +function closePromptInsightDetail() { + selectedPromptInsight = null; + renderPromptHistoryView(); +} + +function renderPromptInsightDetail(insight) { + if (!insight) return ''; + + var html = '
' + + '
' + + '

' + (isZh() ? '洞察详情' : 'Insight Detail') + '

' + + '' + + '
' + + '
' + + ' ' + (insight.tool || 'CLI') + '' + + ' ' + formatPromptTimestamp(insight.created_at) + '' + + ' ' + (insight.prompt_count || 0) + ' ' + (isZh() ? '个提示已分析' : 'prompts analyzed') + '' + + '
'; + + // Patterns + if (insight.patterns && insight.patterns.length > 0) { + html += '
' + + '
' + (isZh() ? '发现的模式' : 'Patterns Found') + ' (' + insight.patterns.length + ')
' + + '
' + + insight.patterns.map(function(p) { + return '
' + + '
' + + '' + escapeHtml(p.type || 'pattern') + '' + + '' + (p.severity || 'low') + '' + + (p.occurrences ? '' + p.occurrences + 'x' : '') + + '
' + + '
' + escapeHtml(p.description || '') + '
' + + (p.suggestion ? '
' + escapeHtml(p.suggestion) + '
' : '') + + '
'; + }).join('') + + '
' + + '
'; + } + + // Suggestions + if (insight.suggestions && insight.suggestions.length > 0) { + html += '
' + + '
' + (isZh() ? '提供的建议' : 'Suggestions') + ' (' + insight.suggestions.length + ')
' + + '
' + + insight.suggestions.map(function(s) { + return '
' + + '
' + escapeHtml(s.title || '') + '
' + + '
' + escapeHtml(s.description || '') + '
' + + (s.example ? '
' + escapeHtml(s.example) + '
' : '') + + '
'; + }).join('') + + '
' + + '
'; + } + + html += '
' + + '' + + '
' + + '
'; + return html; } +async function deletePromptInsight(insightId) { + if (!confirm(isZh() ? '确定要删除这条洞察记录吗?' : 'Are you sure you want to delete this insight?')) return; + + try { + var response = await fetch('/api/memory/insights/' + insightId, { method: 'DELETE' }); + if (!response.ok) throw new Error('Failed to delete insight'); + + selectedPromptInsight = null; + await loadPromptInsightsHistory(); + renderPromptHistoryView(); + + if (window.showToast) { + showToast(isZh() ? '洞察已删除' : 'Insight deleted', 'success'); + } + } catch (err) { + console.error('Failed to delete insight:', err); + if (window.showToast) { + showToast(isZh() ? '删除洞察失败' : 'Failed to delete insight', 'error'); + } + } +} + function renderPatternCard(pattern) { var iconMap = { 'vague': 'help-circle', diff --git a/ccw/src/templates/dashboard-js/views/rules-manager.js b/ccw/src/templates/dashboard-js/views/rules-manager.js new file mode 100644 index 00000000..19f0fa4b --- /dev/null +++ b/ccw/src/templates/dashboard-js/views/rules-manager.js @@ -0,0 +1,343 @@ +// Rules Manager View +// Manages Claude Code rules (.claude/rules/) + +// ========== Rules State ========== +var rulesData = { + projectRules: [], + userRules: [] +}; +var selectedRule = null; +var rulesLoading = false; + +// ========== Main Render Function ========== +async function renderRulesManager() { + const container = document.getElementById('mainContent'); + if (!container) return; + + // Hide stats grid and search + const statsGrid = document.getElementById('statsGrid'); + const searchInput = document.getElementById('searchInput'); + if (statsGrid) statsGrid.style.display = 'none'; + if (searchInput) searchInput.parentElement.style.display = 'none'; + + // Show loading state + container.innerHTML = '
' + + '
' + + '

' + t('common.loading') + '

' + + '
'; + + // Load rules data + await loadRulesData(); + + // Render the main view + renderRulesView(); +} + +async function loadRulesData() { + rulesLoading = true; + try { + const response = await fetch('/api/rules?path=' + encodeURIComponent(projectPath)); + if (!response.ok) throw new Error('Failed to load rules'); + const data = await response.json(); + rulesData = { + projectRules: data.projectRules || [], + userRules: data.userRules || [] + }; + // Update badge + updateRulesBadge(); + } catch (err) { + console.error('Failed to load rules:', err); + rulesData = { projectRules: [], userRules: [] }; + } finally { + rulesLoading = false; + } +} + +function updateRulesBadge() { + const badge = document.getElementById('badgeRules'); + if (badge) { + const total = rulesData.projectRules.length + rulesData.userRules.length; + badge.textContent = total; + } +} + +function renderRulesView() { + const container = document.getElementById('mainContent'); + if (!container) return; + + const projectRules = rulesData.projectRules || []; + const userRules = rulesData.userRules || []; + + container.innerHTML = ` +
+ +
+
+
+
+ +
+
+

${t('rules.title')}

+

${t('rules.description')}

+
+
+ +
+
+ + +
+
+
+ +

${t('rules.projectRules')}

+ .claude/rules/ +
+ ${projectRules.length} ${t('rules.rulesCount')} +
+ + ${projectRules.length === 0 ? ` +
+
+

${t('rules.noProjectRules')}

+

${t('rules.createHint')}

+
+ ` : ` +
+ ${projectRules.map(rule => renderRuleCard(rule, 'project')).join('')} +
+ `} +
+ + +
+
+
+ +

${t('rules.userRules')}

+ ~/.claude/rules/ +
+ ${userRules.length} ${t('rules.rulesCount')} +
+ + ${userRules.length === 0 ? ` +
+
+

${t('rules.noUserRules')}

+

${t('rules.userRulesHint')}

+
+ ` : ` +
+ ${userRules.map(rule => renderRuleCard(rule, 'user')).join('')} +
+ `} +
+ + + ${selectedRule ? renderRuleDetailPanel(selectedRule) : ''} +
+ `; + + // Initialize Lucide icons + if (typeof lucide !== 'undefined') lucide.createIcons(); +} + +function renderRuleCard(rule, location) { + const hasPathCondition = rule.paths && rule.paths.length > 0; + const isGlobal = !hasPathCondition; + const locationIcon = location === 'project' ? 'folder' : 'user'; + const locationClass = location === 'project' ? 'text-success' : 'text-orange'; + const locationBg = location === 'project' ? 'bg-success/10' : 'bg-orange/10'; + + // Get preview of content (first 100 chars) + const contentPreview = rule.content ? rule.content.substring(0, 100).replace(/\n/g, ' ') + (rule.content.length > 100 ? '...' : '') : ''; + + return ` +
+
+
+
+ +
+
+

${escapeHtml(rule.name)}

+ ${rule.subdirectory ? `${escapeHtml(rule.subdirectory)}/` : ''} +
+
+
+ ${isGlobal ? ` + + + global + + ` : ` + + + conditional + + `} + + + ${location} + +
+
+ + ${contentPreview ? ` +

${escapeHtml(contentPreview)}

+ ` : ''} + + ${hasPathCondition ? ` +
+ + ${escapeHtml(rule.paths.join(', '))} +
+ ` : ''} +
+ `; +} + +function renderRuleDetailPanel(rule) { + const hasPathCondition = rule.paths && rule.paths.length > 0; + + return ` +
+
+

${escapeHtml(rule.name)}

+ +
+
+
+ +
+

${t('rules.typeLabel')}

+
+ ${hasPathCondition ? ` + + + ${t('rules.conditional')} + + ` : ` + + + ${t('rules.global')} + + `} +
+
+ + + ${hasPathCondition ? ` +
+

${t('rules.pathConditions')}

+
+ ${rule.paths.map(path => ` +
+ + ${escapeHtml(path)} +
+ `).join('')} +
+
+ ` : ''} + + +
+

${t('rules.content')}

+
+
${escapeHtml(rule.content || '')}
+
+
+ + +
+

${t('rules.filePath')}

+ ${escapeHtml(rule.path)} +
+
+
+ + +
+ + +
+
+
+ `; +} + +async function showRuleDetail(ruleName, location) { + try { + const response = await fetch('/api/rules/' + encodeURIComponent(ruleName) + '?location=' + location + '&path=' + encodeURIComponent(projectPath)); + if (!response.ok) throw new Error('Failed to load rule detail'); + const data = await response.json(); + selectedRule = data.rule; + renderRulesView(); + } catch (err) { + console.error('Failed to load rule detail:', err); + if (window.showToast) { + showToast(t('rules.loadError'), 'error'); + } + } +} + +function closeRuleDetail() { + selectedRule = null; + renderRulesView(); +} + +async function deleteRule(ruleName, location) { + if (!confirm(t('rules.deleteConfirm', { name: ruleName }))) return; + + try { + const response = await fetch('/api/rules/' + encodeURIComponent(ruleName), { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ location, projectPath }) + }); + if (!response.ok) throw new Error('Failed to delete rule'); + + selectedRule = null; + await loadRulesData(); + renderRulesView(); + + if (window.showToast) { + showToast(t('rules.deleted'), 'success'); + } + } catch (err) { + console.error('Failed to delete rule:', err); + if (window.showToast) { + showToast(t('rules.deleteError'), 'error'); + } + } +} + +function editRule(ruleName, location) { + // Open edit modal (to be implemented with modal) + if (window.showToast) { + showToast(t('rules.editNotImplemented'), 'info'); + } +} + +function openRuleCreateModal() { + // Open create modal (to be implemented with modal) + if (window.showToast) { + showToast(t('rules.createNotImplemented'), 'info'); + } +} diff --git a/ccw/src/templates/dashboard-js/views/skills-manager.js b/ccw/src/templates/dashboard-js/views/skills-manager.js new file mode 100644 index 00000000..4315451d --- /dev/null +++ b/ccw/src/templates/dashboard-js/views/skills-manager.js @@ -0,0 +1,345 @@ +// Skills Manager View +// Manages Claude Code skills (.claude/skills/) + +// ========== Skills State ========== +var skillsData = { + projectSkills: [], + userSkills: [] +}; +var selectedSkill = null; +var skillsLoading = false; + +// ========== Main Render Function ========== +async function renderSkillsManager() { + const container = document.getElementById('mainContent'); + if (!container) return; + + // Hide stats grid and search + const statsGrid = document.getElementById('statsGrid'); + const searchInput = document.getElementById('searchInput'); + if (statsGrid) statsGrid.style.display = 'none'; + if (searchInput) searchInput.parentElement.style.display = 'none'; + + // Show loading state + container.innerHTML = '
' + + '
' + + '

' + t('common.loading') + '

' + + '
'; + + // Load skills data + await loadSkillsData(); + + // Render the main view + renderSkillsView(); +} + +async function loadSkillsData() { + skillsLoading = true; + try { + const response = await fetch('/api/skills?path=' + encodeURIComponent(projectPath)); + if (!response.ok) throw new Error('Failed to load skills'); + const data = await response.json(); + skillsData = { + projectSkills: data.projectSkills || [], + userSkills: data.userSkills || [] + }; + // Update badge + updateSkillsBadge(); + } catch (err) { + console.error('Failed to load skills:', err); + skillsData = { projectSkills: [], userSkills: [] }; + } finally { + skillsLoading = false; + } +} + +function updateSkillsBadge() { + const badge = document.getElementById('badgeSkills'); + if (badge) { + const total = skillsData.projectSkills.length + skillsData.userSkills.length; + badge.textContent = total; + } +} + +function renderSkillsView() { + const container = document.getElementById('mainContent'); + if (!container) return; + + const projectSkills = skillsData.projectSkills || []; + const userSkills = skillsData.userSkills || []; + + container.innerHTML = ` +
+ +
+
+
+
+ +
+
+

${t('skills.title')}

+

${t('skills.description')}

+
+
+ +
+
+ + +
+
+
+ +

${t('skills.projectSkills')}

+ .claude/skills/ +
+ ${projectSkills.length} ${t('skills.skillsCount')} +
+ + ${projectSkills.length === 0 ? ` +
+
+

${t('skills.noProjectSkills')}

+

${t('skills.createHint')}

+
+ ` : ` +
+ ${projectSkills.map(skill => renderSkillCard(skill, 'project')).join('')} +
+ `} +
+ + +
+
+
+ +

${t('skills.userSkills')}

+ ~/.claude/skills/ +
+ ${userSkills.length} ${t('skills.skillsCount')} +
+ + ${userSkills.length === 0 ? ` +
+
+

${t('skills.noUserSkills')}

+

${t('skills.userSkillsHint')}

+
+ ` : ` +
+ ${userSkills.map(skill => renderSkillCard(skill, 'user')).join('')} +
+ `} +
+ + + ${selectedSkill ? renderSkillDetailPanel(selectedSkill) : ''} +
+ `; + + // Initialize Lucide icons + if (typeof lucide !== 'undefined') lucide.createIcons(); +} + +function renderSkillCard(skill, location) { + const hasAllowedTools = skill.allowedTools && skill.allowedTools.length > 0; + const hasSupportingFiles = skill.supportingFiles && skill.supportingFiles.length > 0; + const locationIcon = location === 'project' ? 'folder' : 'user'; + const locationClass = location === 'project' ? 'text-primary' : 'text-indigo'; + const locationBg = location === 'project' ? 'bg-primary/10' : 'bg-indigo/10'; + + return ` +
+
+
+
+ +
+
+

${escapeHtml(skill.name)}

+ ${skill.version ? `v${escapeHtml(skill.version)}` : ''} +
+
+
+ + + ${location} + +
+
+ +

${escapeHtml(skill.description || t('skills.noDescription'))}

+ +
+ ${hasAllowedTools ? ` + + + ${skill.allowedTools.length} ${t('skills.tools')} + + ` : ''} + ${hasSupportingFiles ? ` + + + ${skill.supportingFiles.length} ${t('skills.files')} + + ` : ''} +
+
+ `; +} + +function renderSkillDetailPanel(skill) { + const hasAllowedTools = skill.allowedTools && skill.allowedTools.length > 0; + const hasSupportingFiles = skill.supportingFiles && skill.supportingFiles.length > 0; + + return ` +
+
+

${escapeHtml(skill.name)}

+ +
+
+
+ +
+

${t('skills.descriptionLabel')}

+

${escapeHtml(skill.description || t('skills.noDescription'))}

+
+ + +
+

${t('skills.metadata')}

+
+
+ ${t('skills.location')} +

${escapeHtml(skill.location)}

+
+ ${skill.version ? ` +
+ ${t('skills.version')} +

${escapeHtml(skill.version)}

+
+ ` : ''} +
+
+ + + ${hasAllowedTools ? ` +
+

${t('skills.allowedTools')}

+
+ ${skill.allowedTools.map(tool => ` + ${escapeHtml(tool)} + `).join('')} +
+
+ ` : ''} + + + ${hasSupportingFiles ? ` +
+

${t('skills.supportingFiles')}

+
+ ${skill.supportingFiles.map(file => ` +
+ + ${escapeHtml(file)} +
+ `).join('')} +
+
+ ` : ''} + + +
+

${t('skills.path')}

+ ${escapeHtml(skill.path)} +
+
+
+ + +
+ + +
+
+
+ `; +} + +async function showSkillDetail(skillName, location) { + try { + const response = await fetch('/api/skills/' + encodeURIComponent(skillName) + '?location=' + location + '&path=' + encodeURIComponent(projectPath)); + if (!response.ok) throw new Error('Failed to load skill detail'); + const data = await response.json(); + selectedSkill = data.skill; + renderSkillsView(); + } catch (err) { + console.error('Failed to load skill detail:', err); + if (window.showToast) { + showToast(t('skills.loadError'), 'error'); + } + } +} + +function closeSkillDetail() { + selectedSkill = null; + renderSkillsView(); +} + +async function deleteSkill(skillName, location) { + if (!confirm(t('skills.deleteConfirm', { name: skillName }))) return; + + try { + const response = await fetch('/api/skills/' + encodeURIComponent(skillName), { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ location, projectPath }) + }); + if (!response.ok) throw new Error('Failed to delete skill'); + + selectedSkill = null; + await loadSkillsData(); + renderSkillsView(); + + if (window.showToast) { + showToast(t('skills.deleted'), 'success'); + } + } catch (err) { + console.error('Failed to delete skill:', err); + if (window.showToast) { + showToast(t('skills.deleteError'), 'error'); + } + } +} + +function editSkill(skillName, location) { + // Open edit modal (to be implemented with modal) + if (window.showToast) { + showToast(t('skills.editNotImplemented'), 'info'); + } +} + +function openSkillCreateModal() { + // Open create modal (to be implemented with modal) + if (window.showToast) { + showToast(t('skills.createNotImplemented'), 'info'); + } +} diff --git a/ccw/src/templates/dashboard.html b/ccw/src/templates/dashboard.html index 9585bcfa..43994197 100644 --- a/ccw/src/templates/dashboard.html +++ b/ccw/src/templates/dashboard.html @@ -424,6 +424,16 @@ Prompts + +
diff --git a/ccw/src/tools/cli-executor.ts b/ccw/src/tools/cli-executor.ts index 832a9099..5e674ebb 100644 --- a/ccw/src/tools/cli-executor.ts +++ b/ccw/src/tools/cli-executor.ts @@ -63,6 +63,7 @@ const ParamsSchema = z.object({ id: z.string().optional(), // Custom execution ID (e.g., IMPL-001-step1) noNative: z.boolean().optional(), // Force prompt concatenation instead of native resume category: z.enum(['user', 'internal', 'insight']).default('user'), // Execution category for tracking + parentExecutionId: z.string().optional(), // Parent execution ID for fork/retry scenarios }); // Execution category types diff --git a/ccw/src/tools/cli-history-store.ts b/ccw/src/tools/cli-history-store.ts index 8738a612..e4f53045 100644 --- a/ccw/src/tools/cli-history-store.ts +++ b/ccw/src/tools/cli-history-store.ts @@ -38,6 +38,7 @@ export interface ConversationRecord { turn_count: number; latest_status: 'success' | 'error' | 'timeout'; turns: ConversationTurn[]; + parent_execution_id?: string; // For fork/retry scenarios } export interface HistoryQueryOptions { @@ -74,6 +75,20 @@ export interface NativeSessionMapping { created_at: string; } +// Review record interface +export type ReviewStatus = 'pending' | 'approved' | 'rejected' | 'changes_requested'; + +export interface ReviewRecord { + id?: number; + execution_id: string; + status: ReviewStatus; + rating?: number; + comments?: string; + reviewer?: string; + created_at: string; + updated_at: string; +} + /** * CLI History Store using SQLite */ @@ -113,7 +128,9 @@ export class CliHistoryStore { total_duration_ms INTEGER DEFAULT 0, turn_count INTEGER DEFAULT 0, latest_status TEXT DEFAULT 'success', - prompt_preview TEXT + prompt_preview TEXT, + parent_execution_id TEXT, + FOREIGN KEY (parent_execution_id) REFERENCES conversations(id) ON DELETE SET NULL ); -- Turns table (individual conversation turns) @@ -193,6 +210,23 @@ export class CliHistoryStore { CREATE INDEX IF NOT EXISTS idx_insights_created ON insights(created_at DESC); CREATE INDEX IF NOT EXISTS idx_insights_tool ON insights(tool); + + -- Reviews table for CLI execution reviews + CREATE TABLE IF NOT EXISTS reviews ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + execution_id TEXT NOT NULL UNIQUE, + status TEXT NOT NULL DEFAULT 'pending', + rating INTEGER, + comments TEXT, + reviewer TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (execution_id) REFERENCES conversations(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_reviews_execution ON reviews(execution_id); + CREATE INDEX IF NOT EXISTS idx_reviews_status ON reviews(status); + CREATE INDEX IF NOT EXISTS idx_reviews_created ON reviews(created_at DESC); `); // Migration: Add category column if not exists (for existing databases) @@ -207,6 +241,7 @@ export class CliHistoryStore { // Check if category column exists const tableInfo = this.db.prepare('PRAGMA table_info(conversations)').all() as Array<{ name: string }>; const hasCategory = tableInfo.some(col => col.name === 'category'); + const hasParentExecutionId = tableInfo.some(col => col.name === 'parent_execution_id'); if (!hasCategory) { console.log('[CLI History] Migrating database: adding category column...'); @@ -221,6 +256,19 @@ export class CliHistoryStore { } console.log('[CLI History] Migration complete: category column added'); } + + if (!hasParentExecutionId) { + console.log('[CLI History] Migrating database: adding parent_execution_id column...'); + this.db.exec(` + ALTER TABLE conversations ADD COLUMN parent_execution_id TEXT; + `); + try { + this.db.exec(`CREATE INDEX IF NOT EXISTS idx_conversations_parent ON conversations(parent_execution_id);`); + } catch (indexErr) { + console.warn('[CLI History] Parent execution index creation warning:', (indexErr as Error).message); + } + console.log('[CLI History] Migration complete: parent_execution_id column added'); + } } catch (err) { console.error('[CLI History] Migration error:', (err as Error).message); // Don't throw - allow the store to continue working with existing schema @@ -314,8 +362,8 @@ export class CliHistoryStore { : ''; const upsertConversation = this.db.prepare(` - INSERT INTO conversations (id, created_at, updated_at, tool, model, mode, category, total_duration_ms, turn_count, latest_status, prompt_preview) - VALUES (@id, @created_at, @updated_at, @tool, @model, @mode, @category, @total_duration_ms, @turn_count, @latest_status, @prompt_preview) + INSERT INTO conversations (id, created_at, updated_at, tool, model, mode, category, total_duration_ms, turn_count, latest_status, prompt_preview, parent_execution_id) + VALUES (@id, @created_at, @updated_at, @tool, @model, @mode, @category, @total_duration_ms, @turn_count, @latest_status, @prompt_preview, @parent_execution_id) ON CONFLICT(id) DO UPDATE SET updated_at = @updated_at, total_duration_ms = @total_duration_ms, @@ -350,7 +398,8 @@ export class CliHistoryStore { total_duration_ms: conversation.total_duration_ms, turn_count: conversation.turn_count, latest_status: conversation.latest_status, - prompt_preview: promptPreview + prompt_preview: promptPreview, + parent_execution_id: conversation.parent_execution_id || null }); for (const turn of conversation.turns) { @@ -397,6 +446,7 @@ export class CliHistoryStore { total_duration_ms: conv.total_duration_ms, turn_count: conv.turn_count, latest_status: conv.latest_status, + parent_execution_id: conv.parent_execution_id || undefined, turns: turns.map(t => ({ turn: t.turn_number, timestamp: t.timestamp, @@ -935,6 +985,107 @@ export class CliHistoryStore { return result.changes > 0; } + /** + * Save or update a review for an execution + */ + saveReview(review: Omit & { created_at?: string; updated_at?: string }): ReviewRecord { + const now = new Date().toISOString(); + const created_at = review.created_at || now; + const updated_at = review.updated_at || now; + + const stmt = this.db.prepare(` + INSERT INTO reviews (execution_id, status, rating, comments, reviewer, created_at, updated_at) + VALUES (@execution_id, @status, @rating, @comments, @reviewer, @created_at, @updated_at) + ON CONFLICT(execution_id) DO UPDATE SET + status = @status, + rating = @rating, + comments = @comments, + reviewer = @reviewer, + updated_at = @updated_at + `); + + const result = stmt.run({ + execution_id: review.execution_id, + status: review.status, + rating: review.rating ?? null, + comments: review.comments ?? null, + reviewer: review.reviewer ?? null, + created_at, + updated_at + }); + + return { + id: result.lastInsertRowid as number, + execution_id: review.execution_id, + status: review.status, + rating: review.rating, + comments: review.comments, + reviewer: review.reviewer, + created_at, + updated_at + }; + } + + /** + * Get review for an execution + */ + getReview(executionId: string): ReviewRecord | null { + const row = this.db.prepare( + 'SELECT * FROM reviews WHERE execution_id = ?' + ).get(executionId) as any; + + if (!row) return null; + + return { + id: row.id, + execution_id: row.execution_id, + status: row.status as ReviewStatus, + rating: row.rating, + comments: row.comments, + reviewer: row.reviewer, + created_at: row.created_at, + updated_at: row.updated_at + }; + } + + /** + * Get reviews with optional filtering + */ + getReviews(options: { status?: ReviewStatus; limit?: number } = {}): ReviewRecord[] { + const { status, limit = 50 } = options; + + let sql = 'SELECT * FROM reviews'; + const params: any = { limit }; + + if (status) { + sql += ' WHERE status = @status'; + params.status = status; + } + + sql += ' ORDER BY updated_at DESC LIMIT @limit'; + + const rows = this.db.prepare(sql).all(params) as any[]; + + return rows.map(row => ({ + id: row.id, + execution_id: row.execution_id, + status: row.status as ReviewStatus, + rating: row.rating, + comments: row.comments, + reviewer: row.reviewer, + created_at: row.created_at, + updated_at: row.updated_at + })); + } + + /** + * Delete a review + */ + deleteReview(executionId: string): boolean { + const result = this.db.prepare('DELETE FROM reviews WHERE execution_id = ?').run(executionId); + return result.changes > 0; + } + /** * Close database connection */ diff --git a/ccw/src/tools/codex-lens.ts b/ccw/src/tools/codex-lens.ts index 6eb53cc3..a5c8a270 100644 --- a/ccw/src/tools/codex-lens.ts +++ b/ccw/src/tools/codex-lens.ts @@ -35,12 +35,27 @@ let bootstrapReady = false; // Define Zod schema for validation const ParamsSchema = z.object({ - action: z.enum(['init', 'search', 'search_files', 'symbol', 'status', 'update', 'bootstrap', 'check']), + action: z.enum([ + 'init', + 'search', + 'search_files', + 'symbol', + 'status', + 'config_show', + 'config_set', + 'config_migrate', + 'clean', + 'bootstrap', + 'check', + ]), path: z.string().optional(), query: z.string().optional(), mode: z.enum(['text', 'semantic']).default('text'), file: z.string().optional(), - files: z.array(z.string()).optional(), + key: z.string().optional(), // For config_set action + value: z.string().optional(), // For config_set action + newPath: z.string().optional(), // For config_migrate action + all: z.boolean().optional(), // For clean action languages: z.array(z.string()).optional(), limit: z.number().default(20), format: z.enum(['json', 'table', 'plain']).default('json'), @@ -75,7 +90,8 @@ interface ExecuteResult { files?: unknown; symbols?: unknown; status?: unknown; - updateResult?: unknown; + config?: unknown; + cleanResult?: unknown; ready?: boolean; version?: string; } @@ -534,24 +550,105 @@ async function getStatus(params: Params): Promise { } /** - * Update specific files in the index + * Show configuration * @param params - Parameters * @returns Execution result */ -async function updateFiles(params: Params): Promise { - const { files, path = '.' } = params; - - if (!files || !Array.isArray(files) || files.length === 0) { - return { success: false, error: 'files parameter is required and must be a non-empty array' }; - } - - const args = ['update', ...files, '--json']; - - const result = await executeCodexLens(args, { cwd: path }); +async function configShow(): Promise { + const args = ['config', 'show', '--json']; + const result = await executeCodexLens(args); if (result.success && result.output) { try { - result.updateResult = JSON.parse(result.output); + result.config = JSON.parse(result.output); + delete result.output; + } catch { + // Keep raw output if JSON parse fails + } + } + + return result; +} + +/** + * Set configuration value + * @param params - Parameters + * @returns Execution result + */ +async function configSet(params: Params): Promise { + const { key, value } = params; + + if (!key) { + return { success: false, error: 'key is required for config_set action' }; + } + if (!value) { + return { success: false, error: 'value is required for config_set action' }; + } + + const args = ['config', 'set', key, value, '--json']; + const result = await executeCodexLens(args); + + if (result.success && result.output) { + try { + result.config = JSON.parse(result.output); + delete result.output; + } catch { + // Keep raw output if JSON parse fails + } + } + + return result; +} + +/** + * Migrate indexes to new location + * @param params - Parameters + * @returns Execution result + */ +async function configMigrate(params: Params): Promise { + const { newPath } = params; + + if (!newPath) { + return { success: false, error: 'newPath is required for config_migrate action' }; + } + + const args = ['config', 'migrate', newPath, '--json']; + const result = await executeCodexLens(args, { timeout: 300000 }); // 5 min for migration + + if (result.success && result.output) { + try { + result.config = JSON.parse(result.output); + delete result.output; + } catch { + // Keep raw output if JSON parse fails + } + } + + return result; +} + +/** + * Clean indexes + * @param params - Parameters + * @returns Execution result + */ +async function cleanIndexes(params: Params): Promise { + const { path, all } = params; + + const args = ['clean']; + + if (all) { + args.push('--all'); + } else if (path) { + args.push(path); + } + + args.push('--json'); + const result = await executeCodexLens(args); + + if (result.success && result.output) { + try { + result.cleanResult = JSON.parse(result.output); delete result.output; } catch { // Keep raw output if JSON parse fails @@ -572,18 +669,35 @@ Usage: codex_lens(action="search_files", query="x") # Search, return paths only codex_lens(action="symbol", file="f.py") # Extract symbols codex_lens(action="status") # Index status - codex_lens(action="update", files=["a.js"]) # Update specific files`, + codex_lens(action="config_show") # Show configuration + codex_lens(action="config_set", key="index_dir", value="/path/to/indexes") # Set config + codex_lens(action="config_migrate", newPath="/new/path") # Migrate indexes + codex_lens(action="clean") # Show clean status + codex_lens(action="clean", path=".") # Clean specific project + codex_lens(action="clean", all=true) # Clean all indexes`, inputSchema: { type: 'object', properties: { action: { type: 'string', - enum: ['init', 'search', 'search_files', 'symbol', 'status', 'update', 'bootstrap', 'check'], + enum: [ + 'init', + 'search', + 'search_files', + 'symbol', + 'status', + 'config_show', + 'config_set', + 'config_migrate', + 'clean', + 'bootstrap', + 'check', + ], description: 'Action to perform', }, path: { type: 'string', - description: 'Target path (for init, search, search_files, status, update)', + description: 'Target path (for init, search, search_files, status, clean)', }, query: { type: 'string', @@ -599,10 +713,22 @@ Usage: type: 'string', description: 'File path (for symbol action)', }, - files: { - type: 'array', - items: { type: 'string' }, - description: 'File paths to update (for update action)', + key: { + type: 'string', + description: 'Config key (for config_set action, e.g., "index_dir")', + }, + value: { + type: 'string', + description: 'Config value (for config_set action)', + }, + newPath: { + type: 'string', + description: 'New index path (for config_migrate action)', + }, + all: { + type: 'boolean', + description: 'Clean all indexes (for clean action)', + default: false, }, languages: { type: 'array', @@ -658,8 +784,20 @@ export async function handler(params: Record): Promise): Promise { + const host = options?.host || DEFAULT_HOST; + const port = options?.port || DEFAULT_PORT; + + return new Promise((resolve) => { + const postData = JSON.stringify(payload); + + const req = http.request( + { + hostname: host, + port: port, + path: '/api/system/notify', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(postData), + }, + timeout: NOTIFY_TIMEOUT, + }, + (res) => { + // Success if we get a 2xx response + if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { + resolve({ success: true }); + } else { + resolve({ success: false, error: `HTTP ${res.statusCode}` }); + } + } + ); + + // Handle errors silently - server may not be running + req.on('error', () => { + resolve({ success: false, error: 'Server not reachable' }); + }); + + req.on('timeout', () => { + req.destroy(); + resolve({ success: false, error: 'Timeout' }); + }); + + req.write(postData); + req.end(); + }); +} + +/** + * Convenience: Notify memory update + */ +export async function notifyMemoryUpdate(data?: { + entityType?: string; + entityId?: string | number; + action?: string; +}): Promise { + return notifyServer({ + type: 'MEMORY_UPDATED', + scope: 'memory', + data, + }); +} + +/** + * Convenience: Notify CLI history update + */ +export async function notifyHistoryUpdate(executionId?: string): Promise { + return notifyServer({ + type: 'HISTORY_UPDATED', + scope: 'history', + data: executionId ? { executionId } : undefined, + }); +} + +/** + * Convenience: Notify insight generated + */ +export async function notifyInsightGenerated(executionId?: string): Promise { + return notifyServer({ + type: 'INSIGHT_GENERATED', + scope: 'insights', + data: executionId ? { executionId } : undefined, + }); +} + +/** + * Convenience: Request full refresh + */ +export async function notifyRefreshRequired(scope: NotifyScope = 'all'): Promise { + return notifyServer({ + type: 'REFRESH_REQUIRED', + scope, + }); +} diff --git a/codex-lens/CHAIN_SEARCH_IMPLEMENTATION.md b/codex-lens/CHAIN_SEARCH_IMPLEMENTATION.md new file mode 100644 index 00000000..f0792a39 --- /dev/null +++ b/codex-lens/CHAIN_SEARCH_IMPLEMENTATION.md @@ -0,0 +1,245 @@ +# Chain Search Implementation Summary + +## Files Created + +### 1. `D:\Claude_dms3\codex-lens\src\codexlens\search\__init__.py` +Module initialization file exporting all public classes and functions: +- `ChainSearchEngine` +- `SearchOptions` +- `SearchStats` +- `ChainSearchResult` +- `quick_search` + +### 2. `D:\Claude_dms3\codex-lens\src\codexlens\search\chain_search.py` +Complete implementation of the chain search engine (460+ lines) with: + +#### Classes + +**SearchOptions** +- Configuration dataclass for search behavior +- Controls depth, parallelism, result limits +- Supports files-only and symbol search modes + +**SearchStats** +- Search execution statistics +- Tracks directories searched, files matched, timing, errors + +**ChainSearchResult** +- Comprehensive search result container +- Includes results, symbols, and execution statistics + +**ChainSearchEngine** +- Main parallel search engine +- Thread-safe with ThreadPoolExecutor +- Supports recursive directory traversal +- Implements result aggregation and deduplication + +#### Key Methods + +**Public API:** +- `search()` - Main search with full results +- `search_files_only()` - Fast file path-only search +- `search_symbols()` - Symbol search across hierarchy + +**Internal Methods:** +- `_find_start_index()` - Locate starting index for source path +- `_collect_index_paths()` - Recursive index path collection via subdirs +- `_search_parallel()` - Parallel ThreadPoolExecutor search +- `_search_single_index()` - Single index search with error handling +- `_merge_and_rank()` - Result deduplication and ranking +- `_search_symbols_parallel()` - Parallel symbol search +- `_search_symbols_single()` - Single index symbol search + +**Convenience Function:** +- `quick_search()` - One-line search with auto-initialization + +## Implementation Features + +### 1. Chain Traversal +- Starts from source path, finds nearest index +- Recursively collects subdirectory indexes via `subdirs` table +- Supports depth limiting (-1 = unlimited, 0 = current only) +- Prevents duplicate traversal with visited set + +### 2. Parallel Execution +- Uses ThreadPoolExecutor for concurrent searches +- Configurable worker count (default: 8) +- Error-tolerant: individual index failures don't block overall search +- Collects results as futures complete + +### 3. Result Processing +- **Deduplication**: By file path, keeping highest score +- **Ranking**: BM25 score descending +- **Limiting**: Per-directory and total limits +- **Statistics**: Comprehensive execution metrics + +### 4. Search Modes +- **Full search**: Results with excerpts and scores +- **Files-only**: Fast path-only mode +- **Symbol search**: Cross-directory symbol lookup + +### 5. Error Handling +- Graceful degradation on index errors +- Missing index warnings logged +- Error tracking in SearchStats +- Non-blocking failure mode + +## Search Flow Example + +``` +search("auth", path="D:/project/src", depth=-1) + | + v + [1] _find_start_index + registry.find_index_path("D:/project/src") + -> ~/.codexlens/indexes/D/project/src/_index.db + | + v + [2] _collect_index_paths (chain traversal) + src/_index.db + +-- subdirs: [api, utils] + | + +-- api/_index.db + | +-- subdirs: [] + | + +-- utils/_index.db + +-- subdirs: [] + + Result: [src/_index.db, api/_index.db, utils/_index.db] + | + v + [3] _search_parallel (ThreadPoolExecutor) + Thread1: src/ -> FTS search + Thread2: api/ -> FTS search + Thread3: utils/ -> FTS search + | + v + [4] _merge_and_rank + - Deduplicate by path + - Sort by score descending + - Apply total_limit + | + v + ChainSearchResult +``` + +## Testing + +### Test File: `D:\Claude_dms3\codex-lens\test_chain_search.py` +Comprehensive test suite with four test functions: + +1. **test_basic_search()** - Full search with all options +2. **test_quick_search()** - Convenience function test +3. **test_symbol_search()** - Symbol search across hierarchy +4. **test_files_only_search()** - Fast file-only mode + +### Test Results +- All imports successful +- All tests pass without errors +- Returns empty results (expected - no indexes built yet) +- Logging shows proper "No index found" warnings +- No crashes or exceptions + +## Integration Points + +### Dependencies +- `codexlens.entities`: SearchResult, Symbol +- `codexlens.storage.registry`: RegistryStore, DirMapping +- `codexlens.storage.dir_index`: DirIndexStore, SubdirLink +- `codexlens.storage.path_mapper`: PathMapper + +### Thread Safety +- Uses ThreadPoolExecutor for parallel searches +- Each thread gets own DirIndexStore connection +- SQLite WAL mode supports concurrent reads +- Registry uses thread-local connections + +## Usage Examples + +### Basic Search +```python +from pathlib import Path +from codexlens.search import ChainSearchEngine +from codexlens.storage.registry import RegistryStore +from codexlens.storage.path_mapper import PathMapper + +registry = RegistryStore() +registry.initialize() +mapper = PathMapper() +engine = ChainSearchEngine(registry, mapper) + +result = engine.search("authentication", Path("D:/project/src")) +print(f"Found {len(result.results)} matches in {result.stats.time_ms:.2f}ms") +``` + +### Quick Search +```python +from pathlib import Path +from codexlens.search import quick_search + +results = quick_search("TODO", Path("D:/project"), depth=2) +for r in results[:5]: + print(f"{r.path}: {r.score:.2f}") +``` + +### Symbol Search +```python +symbols = engine.search_symbols("init", Path("D:/project"), kind="function") +for sym in symbols: + print(f"{sym.name} - lines {sym.range[0]}-{sym.range[1]}") +``` + +### Files-Only Mode +```python +paths = engine.search_files_only("config", Path("D:/project")) +print(f"Files with 'config': {len(paths)}") +``` + +## Performance Characteristics + +### Strengths +- **Parallel execution**: Multiple indexes searched concurrently +- **Lazy traversal**: Only loads needed subdirectories +- **Memory efficient**: Streaming results, no full tree in memory +- **Depth limiting**: Can restrict search scope + +### Considerations +- **First search slower**: Needs to traverse subdir links +- **Many small dirs**: Overhead from thread pool +- **Deep hierarchies**: Depth=-1 may be slow on large trees + +### Optimization Tips +- Use `depth` parameter to limit scope +- Use `limit_per_dir` to reduce per-index overhead +- Use `files_only=True` when excerpts not needed +- Reuse ChainSearchEngine instance for multiple searches + +## Code Quality + +### Standards Met +- **Type annotations**: Full typing on all methods +- **Docstrings**: Complete with examples and parameter docs +- **Error handling**: Graceful degradation, no crashes +- **ASCII-only**: Windows GBK compatible +- **No debug spam**: Clean logging at appropriate levels +- **Thread safety**: Proper locking and pooling + +### Design Patterns +- **Dataclasses**: Clean configuration and result objects +- **Context managers**: Proper resource cleanup +- **Dependency injection**: Registry and mapper passed in +- **Builder pattern**: SearchOptions for configuration +- **Template method**: _search_single_index extensible + +## Status: Complete and Tested + +All requirements met: +- [x] Parallel search with ThreadPoolExecutor +- [x] Chain traversal via subdirs links +- [x] Depth limiting +- [x] Error tolerance +- [x] Search statistics +- [x] Complete docstrings and type hints +- [x] Test suite passes +- [x] ASCII-only output (GBK compatible) +- [x] Integration with existing codebase diff --git a/codex-lens/docs/CHAIN_SEARCH_QUICKREF.md b/codex-lens/docs/CHAIN_SEARCH_QUICKREF.md new file mode 100644 index 00000000..dc16e192 --- /dev/null +++ b/codex-lens/docs/CHAIN_SEARCH_QUICKREF.md @@ -0,0 +1,171 @@ +# Chain Search Quick Reference + +## Import + +```python +from pathlib import Path +from codexlens.search import ( + ChainSearchEngine, + SearchOptions, + quick_search +) +from codexlens.storage.registry import RegistryStore +from codexlens.storage.path_mapper import PathMapper +``` + +## One-Line Search + +```python +results = quick_search("query", Path("/path/to/search"), depth=-1) +``` + +## Full Engine Usage + +### 1. Initialize Engine +```python +registry = RegistryStore() +registry.initialize() +mapper = PathMapper() +engine = ChainSearchEngine(registry, mapper) +``` + +### 2. Configure Search +```python +options = SearchOptions( + depth=-1, # -1 = unlimited, 0 = current dir only + max_workers=8, # Parallel threads + limit_per_dir=10, # Max results per directory + total_limit=100, # Total result limit + include_symbols=False, # Include symbol search + files_only=False # Return only paths +) +``` + +### 3. Execute Search +```python +result = engine.search("query", Path("/path"), options) + +# Access results +for r in result.results: + print(f"{r.path}: score={r.score:.2f}") + print(f" {r.excerpt}") + +# Check statistics +print(f"Searched {result.stats.dirs_searched} directories") +print(f"Found {result.stats.files_matched} files") +print(f"Time: {result.stats.time_ms:.2f}ms") +``` + +### 4. Symbol Search +```python +symbols = engine.search_symbols( + "function_name", + Path("/path"), + kind="function" # Optional: 'function', 'class', 'method', etc. +) + +for sym in symbols: + print(f"{sym.name} ({sym.kind}) at lines {sym.range[0]}-{sym.range[1]}") +``` + +### 5. Files-Only Mode +```python +paths = engine.search_files_only("query", Path("/path")) +for path in paths: + print(path) +``` + +## SearchOptions Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `depth` | int | -1 | Search depth (-1 = unlimited) | +| `max_workers` | int | 8 | Parallel worker threads | +| `limit_per_dir` | int | 10 | Max results per directory | +| `total_limit` | int | 100 | Total result limit | +| `include_symbols` | bool | False | Include symbol search | +| `files_only` | bool | False | Return only file paths | + +## SearchResult Fields + +| Field | Type | Description | +|-------|------|-------------| +| `path` | str | File path | +| `score` | float | BM25 relevance score | +| `excerpt` | str | Highlighted text snippet | +| `content` | str | Full matched content (optional) | +| `symbol` | Symbol | Matched symbol (optional) | + +## SearchStats Fields + +| Field | Type | Description | +|-------|------|-------------| +| `dirs_searched` | int | Number of directories searched | +| `files_matched` | int | Number of files with matches | +| `time_ms` | float | Total search time (milliseconds) | +| `errors` | List[str] | Error messages | + +## Common Patterns + +### Search Current Project +```python +result = engine.search("authentication", Path.cwd()) +``` + +### Limit Depth for Speed +```python +options = SearchOptions(depth=2) # Only 2 levels deep +result = engine.search("TODO", Path("/project"), options) +``` + +### Find All Implementations +```python +symbols = engine.search_symbols("__init__", Path("/project"), kind="function") +``` + +### Quick File List +```python +files = engine.search_files_only("config", Path("/project")) +``` + +### Comprehensive Search +```python +options = SearchOptions( + depth=-1, + total_limit=500, + include_symbols=True +) +result = engine.search("api", Path("/project"), options) +print(f"Files: {len(result.results)}") +print(f"Symbols: {len(result.symbols)}") +``` + +## Performance Tips + +1. **Use depth limits** for faster searches in large codebases +2. **Use files_only** when you don't need excerpts +3. **Reuse ChainSearchEngine** instance for multiple searches +4. **Adjust max_workers** based on CPU cores +5. **Use limit_per_dir** to reduce memory usage + +## Error Handling + +```python +result = engine.search("query", Path("/path")) + +if result.stats.errors: + print("Errors occurred:") + for error in result.stats.errors: + print(f" - {error}") + +if not result.results: + print("No results found") +else: + print(f"Found {len(result.results)} results") +``` + +## Cleanup + +```python +registry.close() # Close when done +``` diff --git a/codex-lens/src/codexlens/cli/commands.py b/codex-lens/src/codexlens/cli/commands.py index 820dd39a..1e2ad9b0 100644 --- a/codex-lens/src/codexlens/cli/commands.py +++ b/codex-lens/src/codexlens/cli/commands.py @@ -5,17 +5,22 @@ from __future__ import annotations import json import logging import os +import shutil from pathlib import Path from typing import Any, Dict, Iterable, List, Optional import typer from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn +from rich.table import Table -from codexlens.config import Config, WorkspaceConfig, find_workspace_root +from codexlens.config import Config from codexlens.entities import IndexedFile, SearchResult, Symbol from codexlens.errors import CodexLensError from codexlens.parsers.factory import ParserFactory -from codexlens.storage.sqlite_store import SQLiteStore +from codexlens.storage.path_mapper import PathMapper +from codexlens.storage.registry import RegistryStore, ProjectInfo +from codexlens.storage.index_tree import IndexTreeBuilder +from codexlens.search.chain_search import ChainSearchEngine, SearchOptions from .output import ( console, @@ -46,106 +51,20 @@ def _parse_languages(raw: Optional[List[str]]) -> Optional[List[str]]: return langs or None -def _load_gitignore(base_path: Path) -> List[str]: - gitignore = base_path / ".gitignore" - if not gitignore.exists(): - return [] - try: - return [line.strip() for line in gitignore.read_text(encoding="utf-8").splitlines() if line.strip()] - except OSError: - return [] +def _get_index_root() -> Path: + """Get the index root directory from config or default.""" + env_override = os.getenv("CODEXLENS_INDEX_DIR") + if env_override: + return Path(env_override).expanduser().resolve() + return Path.home() / ".codexlens" / "indexes" -def _iter_source_files( - base_path: Path, - config: Config, - languages: Optional[List[str]] = None, -) -> Iterable[Path]: - ignore_dirs = {".git", ".venv", "venv", "node_modules", "__pycache__", ".codexlens"} - - # Cache for PathSpec objects per directory - pathspec_cache: Dict[Path, Optional[Any]] = {} - - def get_pathspec_for_dir(dir_path: Path) -> Optional[Any]: - """Get PathSpec for a directory, loading .gitignore if present.""" - if dir_path in pathspec_cache: - return pathspec_cache[dir_path] - - ignore_patterns = _load_gitignore(dir_path) - if not ignore_patterns: - pathspec_cache[dir_path] = None - return None - - try: - from pathspec import PathSpec - from pathspec.patterns.gitwildmatch import GitWildMatchPattern - pathspec = PathSpec.from_lines(GitWildMatchPattern, ignore_patterns) - pathspec_cache[dir_path] = pathspec - return pathspec - except Exception: - pathspec_cache[dir_path] = None - return None - - for root, dirs, files in os.walk(base_path): - dirs[:] = [d for d in dirs if d not in ignore_dirs and not d.startswith(".")] - root_path = Path(root) - - # Get pathspec for current directory - pathspec = get_pathspec_for_dir(root_path) - - for file in files: - if file.startswith("."): - continue - full_path = root_path / file - rel = full_path.relative_to(root_path) - if pathspec and pathspec.match_file(str(rel)): - continue - language_id = config.language_for_path(full_path) - if not language_id: - continue - if languages and language_id not in languages: - continue - yield full_path - - -def _get_store_for_path(path: Path, use_global: bool = False) -> tuple[SQLiteStore, Path]: - """Get SQLiteStore for a path, using workspace-local or global database. - - Returns (store, db_path) tuple. - """ - if use_global: - config = Config() - config.ensure_runtime_dirs() - return SQLiteStore(config.db_path), config.db_path - - # Try to find existing workspace - workspace = WorkspaceConfig.from_path(path) - if workspace: - return SQLiteStore(workspace.db_path), workspace.db_path - - # Fall back to global config - config = Config() - config.ensure_runtime_dirs() - return SQLiteStore(config.db_path), config.db_path - - - - -def _is_safe_to_clean(target_dir: Path) -> bool: - """Verify directory is a CodexLens directory before deletion. - - Checks for presence of .codexlens directory or index.db file. - """ - if not target_dir.exists(): - return True - - # Check if it's the .codexlens directory itself - if target_dir.name == ".codexlens": - # Verify it contains index.db or cache directory - return (target_dir / "index.db").exists() or (target_dir / "cache").exists() - - # Check if it contains .codexlens subdirectory - return (target_dir / ".codexlens").exists() +def _get_registry_path() -> Path: + """Get the registry database path.""" + env_override = os.getenv("CODEXLENS_DATA_DIR") + if env_override: + return Path(env_override).expanduser().resolve() / "registry.db" + return Path.home() / ".codexlens" / "registry.db" @app.command() @@ -157,112 +76,98 @@ def init( "-l", help="Limit indexing to specific languages (repeat or comma-separated).", ), - use_global: bool = typer.Option(False, "--global", "-g", help="Use global database instead of workspace-local."), + workers: int = typer.Option(4, "--workers", "-w", min=1, max=16, help="Parallel worker processes."), json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."), ) -> None: """Initialize or rebuild the index for a directory. - Creates a .codexlens/ directory in the project root to store index data. - Use --global to use the global database at ~/.codexlens/ instead. + Indexes are stored in ~/.codexlens/indexes/ with mirrored directory structure. + Set CODEXLENS_INDEX_DIR to customize the index location. """ _configure_logging(verbose) config = Config() - factory = ParserFactory(config) - languages = _parse_languages(language) base_path = path.expanduser().resolve() - store: SQLiteStore | None = None + registry: RegistryStore | None = None try: - # Determine database location - if use_global: - config.ensure_runtime_dirs() - db_path = config.db_path - workspace_root = None - else: - # Create workspace-local .codexlens directory - workspace = WorkspaceConfig.create_at(base_path) - db_path = workspace.db_path - workspace_root = workspace.workspace_root + registry = RegistryStore() + registry.initialize() + mapper = PathMapper() - store = SQLiteStore(db_path) - store.initialize() + builder = IndexTreeBuilder(registry, mapper, config) - files = list(_iter_source_files(base_path, config, languages)) - indexed_count = 0 - symbol_count = 0 + console.print(f"[bold]Building index for:[/bold] {base_path}") - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - BarColumn(), - TextColumn("{task.completed}/{task.total} files"), - TimeElapsedColumn(), - console=console, - ) as progress: - task = progress.add_task("Indexing", total=len(files)) - for file_path in files: - progress.advance(task) - try: - text = file_path.read_text(encoding="utf-8", errors="ignore") - lang_id = config.language_for_path(file_path) or "unknown" - parser = factory.get_parser(lang_id) - indexed_file = parser.parse(text, file_path) - store.add_file(indexed_file, text) - indexed_count += 1 - symbol_count += len(indexed_file.symbols) - except Exception as exc: - logging.debug("Failed to index %s: %s", file_path, exc) - continue + build_result = builder.build( + source_root=base_path, + languages=languages, + workers=workers, + ) result = { "path": str(base_path), - "files_indexed": indexed_count, - "symbols_indexed": symbol_count, + "files_indexed": build_result.total_files, + "dirs_indexed": build_result.total_dirs, + "index_root": str(build_result.index_root), + "project_id": build_result.project_id, "languages": languages or sorted(config.supported_languages.keys()), - "db_path": str(db_path), - "workspace_root": str(workspace_root) if workspace_root else None, + "errors": len(build_result.errors), } if json_mode: print_json(success=True, result=result) else: - render_status(result) + console.print(f"[green]OK[/green] Indexed [bold]{build_result.total_files}[/bold] files in [bold]{build_result.total_dirs}[/bold] directories") + console.print(f" Index root: {build_result.index_root}") + if build_result.errors: + console.print(f" [yellow]Warnings:[/yellow] {len(build_result.errors)} errors") + except Exception as exc: if json_mode: print_json(success=False, error=str(exc)) else: + console.print(f"[red]Init failed:[/red] {exc}") raise typer.Exit(code=1) finally: - if store is not None: - store.close() + if registry is not None: + registry.close() @app.command() def search( query: str = typer.Argument(..., help="FTS query to run."), + path: Path = typer.Option(Path("."), "--path", "-p", help="Directory to search from."), limit: int = typer.Option(20, "--limit", "-n", min=1, max=500, help="Max results."), + depth: int = typer.Option(-1, "--depth", "-d", help="Search depth (-1 = unlimited, 0 = current only)."), files_only: bool = typer.Option(False, "--files-only", "-f", help="Return only file paths without content snippets."), - use_global: bool = typer.Option(False, "--global", "-g", help="Use global database instead of workspace-local."), json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."), ) -> None: """Search indexed file contents using SQLite FTS5. - Searches the workspace-local .codexlens/index.db by default. - Use --global to search the global database at ~/.codexlens/. - Use --files-only to return only matching file paths. + Uses chain search across directory indexes. + Use --depth to limit search recursion (0 = current dir only). """ _configure_logging(verbose) + search_path = path.expanduser().resolve() - store: SQLiteStore | None = None + registry: RegistryStore | None = None try: - store, db_path = _get_store_for_path(Path.cwd(), use_global) - store.initialize() + registry = RegistryStore() + registry.initialize() + mapper = PathMapper() + + engine = ChainSearchEngine(registry, mapper) + options = SearchOptions( + depth=depth, + total_limit=limit, + files_only=files_only, + ) if files_only: - file_paths = store.search_files_only(query, limit=limit) + file_paths = engine.search_files_only(query, search_path, options) payload = {"query": query, "count": len(file_paths), "files": file_paths} if json_mode: print_json(success=True, result=payload) @@ -270,12 +175,24 @@ def search( for fp in file_paths: console.print(fp) else: - results = store.search_fts(query, limit=limit) - payload = {"query": query, "count": len(results), "results": results} + result = engine.search(query, search_path, options) + payload = { + "query": query, + "count": len(result.results), + "results": [{"path": r.path, "score": r.score, "excerpt": r.excerpt} for r in result.results], + "stats": { + "dirs_searched": result.stats.dirs_searched, + "files_matched": result.stats.files_matched, + "time_ms": result.stats.time_ms, + }, + } if json_mode: print_json(success=True, result=payload) else: - render_search_results(results) + render_search_results(result.results) + if verbose: + console.print(f"[dim]Searched {result.stats.dirs_searched} directories in {result.stats.time_ms:.1f}ms[/dim]") + except Exception as exc: if json_mode: print_json(success=False, error=str(exc)) @@ -283,13 +200,14 @@ def search( console.print(f"[red]Search failed:[/red] {exc}") raise typer.Exit(code=1) finally: - if store is not None: - store.close() + if registry is not None: + registry.close() @app.command() def symbol( name: str = typer.Argument(..., help="Symbol name to look up."), + path: Path = typer.Option(Path("."), "--path", "-p", help="Directory to search from."), kind: Optional[str] = typer.Option( None, "--kind", @@ -297,27 +215,31 @@ def symbol( help="Filter by kind (function|class|method).", ), limit: int = typer.Option(50, "--limit", "-n", min=1, max=500, help="Max symbols."), - use_global: bool = typer.Option(False, "--global", "-g", help="Use global database instead of workspace-local."), + depth: int = typer.Option(-1, "--depth", "-d", help="Search depth (-1 = unlimited)."), json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."), ) -> None: - """Look up symbols by name and optional kind. - - Searches the workspace-local .codexlens/index.db by default. - Use --global to search the global database at ~/.codexlens/. - """ + """Look up symbols by name and optional kind.""" _configure_logging(verbose) + search_path = path.expanduser().resolve() - store: SQLiteStore | None = None + registry: RegistryStore | None = None try: - store, db_path = _get_store_for_path(Path.cwd(), use_global) - store.initialize() - syms = store.search_symbols(name, kind=kind, limit=limit) + registry = RegistryStore() + registry.initialize() + mapper = PathMapper() + + engine = ChainSearchEngine(registry, mapper) + options = SearchOptions(depth=depth, total_limit=limit) + + syms = engine.search_symbols(name, search_path, kind=kind, options=options) + payload = {"name": name, "kind": kind, "count": len(syms), "symbols": syms} if json_mode: print_json(success=True, result=payload) else: render_symbols(syms) + except Exception as exc: if json_mode: print_json(success=False, error=str(exc)) @@ -325,8 +247,8 @@ def symbol( console.print(f"[red]Symbol lookup failed:[/red] {exc}") raise typer.Exit(code=1) finally: - if store is not None: - store.close() + if registry is not None: + registry.close() @app.command() @@ -365,26 +287,54 @@ def inspect( @app.command() def status( - use_global: bool = typer.Option(False, "--global", "-g", help="Use global database instead of workspace-local."), json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."), ) -> None: - """Show index statistics. - - Shows statistics for the workspace-local .codexlens/index.db by default. - Use --global to show the global database at ~/.codexlens/. - """ + """Show index status and configuration.""" _configure_logging(verbose) - store: SQLiteStore | None = None + registry: RegistryStore | None = None try: - store, db_path = _get_store_for_path(Path.cwd(), use_global) - store.initialize() - stats = store.stats() + registry = RegistryStore() + registry.initialize() + mapper = PathMapper() + + # Get all projects + projects = registry.list_projects() + + # Calculate total stats + total_files = sum(p.total_files for p in projects) + total_dirs = sum(p.total_dirs for p in projects) + + # Get index root size + index_root = mapper.index_root + index_size = 0 + if index_root.exists(): + for f in index_root.rglob("*"): + if f.is_file(): + index_size += f.stat().st_size + + stats = { + "index_root": str(index_root), + "registry_path": str(_get_registry_path()), + "projects_count": len(projects), + "total_files": total_files, + "total_dirs": total_dirs, + "index_size_bytes": index_size, + "index_size_mb": round(index_size / (1024 * 1024), 2), + } + if json_mode: print_json(success=True, result=stats) else: - render_status(stats) + console.print("[bold]CodexLens Status[/bold]") + console.print(f" Index Root: {stats['index_root']}") + console.print(f" Registry: {stats['registry_path']}") + console.print(f" Projects: {stats['projects_count']}") + console.print(f" Total Files: {stats['total_files']}") + console.print(f" Total Directories: {stats['total_dirs']}") + console.print(f" Index Size: {stats['index_size_mb']} MB") + except Exception as exc: if json_mode: print_json(success=False, error=str(exc)) @@ -392,153 +342,423 @@ def status( console.print(f"[red]Status failed:[/red] {exc}") raise typer.Exit(code=1) finally: - if store is not None: - store.close() + if registry is not None: + registry.close() @app.command() -def update( - files: List[str] = typer.Argument(..., help="File paths to update in the index."), - use_global: bool = typer.Option(False, "--global", "-g", help="Use global database instead of workspace-local."), +def projects( + action: str = typer.Argument("list", help="Action: list, show, remove"), + project_path: Optional[Path] = typer.Argument(None, help="Project path (for show/remove)."), json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."), ) -> None: - """Incrementally update specific files in the index. + """Manage registered projects in the global registry. - Pass one or more file paths to update. Files that no longer exist - will be removed from the index. New or modified files will be re-indexed. - - This is much faster than re-running init for large codebases when - only a few files have changed. + Actions: + - list: Show all registered projects + - show : Show details for a specific project + - remove : Remove a project from the registry """ _configure_logging(verbose) - config = Config() - factory = ParserFactory(config) - store: SQLiteStore | None = None + registry: RegistryStore | None = None try: - store, db_path = _get_store_for_path(Path.cwd(), use_global) - store.initialize() + registry = RegistryStore() + registry.initialize() - updated = 0 - removed = 0 - skipped = 0 - errors = [] - - for file_str in files: - file_path = Path(file_str).resolve() - - # Check if file exists on disk - if not file_path.exists(): - # File was deleted - remove from index - if store.remove_file(file_path): - removed += 1 - logging.debug("Removed deleted file: %s", file_path) + if action == "list": + project_list = registry.list_projects() + if json_mode: + result = [ + { + "id": p.id, + "source_root": str(p.source_root), + "index_root": str(p.index_root), + "total_files": p.total_files, + "total_dirs": p.total_dirs, + "status": p.status, + } + for p in project_list + ] + print_json(success=True, result=result) + else: + if not project_list: + console.print("[yellow]No projects registered.[/yellow]") else: - skipped += 1 - logging.debug("File not in index: %s", file_path) - continue + table = Table(title="Registered Projects") + table.add_column("ID", style="dim") + table.add_column("Source Root") + table.add_column("Files", justify="right") + table.add_column("Dirs", justify="right") + table.add_column("Status") - # Check if file is supported - language_id = config.language_for_path(file_path) - if not language_id: - skipped += 1 - logging.debug("Unsupported file type: %s", file_path) - continue + for p in project_list: + table.add_row( + str(p.id), + str(p.source_root), + str(p.total_files), + str(p.total_dirs), + p.status, + ) + console.print(table) - # Check if file needs update (compare mtime) - current_mtime = file_path.stat().st_mtime - stored_mtime = store.get_file_mtime(file_path) + elif action == "show": + if not project_path: + raise typer.BadParameter("Project path required for 'show' action") - if stored_mtime is not None and abs(current_mtime - stored_mtime) < 0.001: - skipped += 1 - logging.debug("File unchanged: %s", file_path) - continue + project_path = project_path.expanduser().resolve() + project_info = registry.get_project(project_path) - # Re-index the file - try: - text = file_path.read_text(encoding="utf-8", errors="ignore") - parser = factory.get_parser(language_id) - indexed_file = parser.parse(text, file_path) - store.add_file(indexed_file, text) - updated += 1 - logging.debug("Updated file: %s", file_path) - except Exception as exc: - errors.append({"file": str(file_path), "error": str(exc)}) - logging.debug("Failed to update %s: %s", file_path, exc) + if not project_info: + if json_mode: + print_json(success=False, error=f"Project not found: {project_path}") + else: + console.print(f"[red]Project not found:[/red] {project_path}") + raise typer.Exit(code=1) - result = { - "updated": updated, - "removed": removed, - "skipped": skipped, - "errors": errors, - "db_path": str(db_path), - } + if json_mode: + result = { + "id": project_info.id, + "source_root": str(project_info.source_root), + "index_root": str(project_info.index_root), + "total_files": project_info.total_files, + "total_dirs": project_info.total_dirs, + "status": project_info.status, + "created_at": project_info.created_at, + "last_indexed": project_info.last_indexed, + } + print_json(success=True, result=result) + else: + console.print(f"[bold]Project:[/bold] {project_info.source_root}") + console.print(f" ID: {project_info.id}") + console.print(f" Index Root: {project_info.index_root}") + console.print(f" Files: {project_info.total_files}") + console.print(f" Directories: {project_info.total_dirs}") + console.print(f" Status: {project_info.status}") + + # Show directory breakdown + dirs = registry.get_project_dirs(project_info.id) + if dirs: + console.print(f"\n [bold]Indexed Directories:[/bold] {len(dirs)}") + for d in dirs[:10]: + console.print(f" - {d.source_path.name}/ ({d.files_count} files)") + if len(dirs) > 10: + console.print(f" ... and {len(dirs) - 10} more") + + elif action == "remove": + if not project_path: + raise typer.BadParameter("Project path required for 'remove' action") + + project_path = project_path.expanduser().resolve() + removed = registry.unregister_project(project_path) + + if removed: + mapper = PathMapper() + index_root = mapper.source_to_index_dir(project_path) + if index_root.exists(): + shutil.rmtree(index_root) + + if json_mode: + print_json(success=True, result={"removed": str(project_path)}) + else: + console.print(f"[green]Removed:[/green] {project_path}") + else: + if json_mode: + print_json(success=False, error=f"Project not found: {project_path}") + else: + console.print(f"[yellow]Project not found:[/yellow] {project_path}") - if json_mode: - print_json(success=True, result=result) else: - console.print(f"[green]Updated:[/green] {updated} files") - console.print(f"[yellow]Removed:[/yellow] {removed} files") - console.print(f"[dim]Skipped:[/dim] {skipped} files") - if errors: - console.print(f"[red]Errors:[/red] {len(errors)}") - for err in errors[:5]: - console.print(f" - {err['file']}: {err['error']}") + raise typer.BadParameter(f"Unknown action: {action}. Use list, show, or remove.") + except typer.BadParameter: + raise except Exception as exc: if json_mode: print_json(success=False, error=str(exc)) else: - console.print(f"[red]Update failed:[/red] {exc}") + console.print(f"[red]Projects command failed:[/red] {exc}") raise typer.Exit(code=1) finally: - if store is not None: - store.close() + if registry is not None: + registry.close() + + +@app.command() +def config( + action: str = typer.Argument("show", help="Action: show, set, migrate"), + key: Optional[str] = typer.Argument(None, help="Config key (for set action)."), + value: Optional[str] = typer.Argument(None, help="Config value (for set action)."), + json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), + verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."), +) -> None: + """Manage CodexLens configuration. + + Actions: + - show: Display current configuration + - set : Set configuration value + - migrate : Migrate indexes to new location + + Config keys: + - index_dir: Directory to store indexes (default: ~/.codexlens/indexes) + """ + _configure_logging(verbose) + + config_file = Path.home() / ".codexlens" / "config.json" + + def load_config() -> Dict[str, Any]: + if config_file.exists(): + return json.loads(config_file.read_text(encoding="utf-8")) + return {} + + def save_config(cfg: Dict[str, Any]) -> None: + config_file.parent.mkdir(parents=True, exist_ok=True) + config_file.write_text(json.dumps(cfg, indent=2), encoding="utf-8") + + try: + if action == "show": + cfg = load_config() + current_index_dir = os.getenv("CODEXLENS_INDEX_DIR") or cfg.get("index_dir") or str(Path.home() / ".codexlens" / "indexes") + + result = { + "config_file": str(config_file), + "index_dir": current_index_dir, + "env_override": os.getenv("CODEXLENS_INDEX_DIR"), + } + + if json_mode: + print_json(success=True, result=result) + else: + console.print("[bold]CodexLens Configuration[/bold]") + console.print(f" Config File: {result['config_file']}") + console.print(f" Index Directory: {result['index_dir']}") + if result['env_override']: + console.print(f" [dim](Override via CODEXLENS_INDEX_DIR)[/dim]") + + elif action == "set": + if not key: + raise typer.BadParameter("Config key required for 'set' action") + if not value: + raise typer.BadParameter("Config value required for 'set' action") + + cfg = load_config() + + if key == "index_dir": + new_path = Path(value).expanduser().resolve() + cfg["index_dir"] = str(new_path) + save_config(cfg) + + if json_mode: + print_json(success=True, result={"key": key, "value": str(new_path)}) + else: + console.print(f"[green]Set {key}=[/green] {new_path}") + console.print("[yellow]Note: Existing indexes remain at old location. Use 'config migrate' to move them.[/yellow]") + else: + raise typer.BadParameter(f"Unknown config key: {key}") + + elif action == "migrate": + if not key: + raise typer.BadParameter("New path required for 'migrate' action") + + new_path = Path(key).expanduser().resolve() + mapper = PathMapper() + old_path = mapper.index_root + + if not old_path.exists(): + if json_mode: + print_json(success=False, error="No indexes to migrate") + else: + console.print("[yellow]No indexes to migrate.[/yellow]") + return + + # Create new directory + new_path.mkdir(parents=True, exist_ok=True) + + # Count items to migrate + items = list(old_path.iterdir()) + migrated = 0 + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + BarColumn(), + TextColumn("{task.completed}/{task.total}"), + TimeElapsedColumn(), + console=console, + ) as progress: + task = progress.add_task("Migrating indexes", total=len(items)) + + for item in items: + dest = new_path / item.name + if item.is_dir(): + shutil.copytree(item, dest, dirs_exist_ok=True) + else: + shutil.copy2(item, dest) + migrated += 1 + progress.advance(task) + + # Update config + cfg = load_config() + cfg["index_dir"] = str(new_path) + save_config(cfg) + + # Update registry paths + registry = RegistryStore() + registry.initialize() + registry.update_index_paths(old_path, new_path) + registry.close() + + result = { + "migrated_from": str(old_path), + "migrated_to": str(new_path), + "items_migrated": migrated, + } + + if json_mode: + print_json(success=True, result=result) + else: + console.print(f"[green]Migrated {migrated} items to:[/green] {new_path}") + console.print("[dim]Old indexes can be manually deleted after verifying migration.[/dim]") + + else: + raise typer.BadParameter(f"Unknown action: {action}. Use show, set, or migrate.") + + except typer.BadParameter: + raise + except Exception as exc: + if json_mode: + print_json(success=False, error=str(exc)) + else: + console.print(f"[red]Config command failed:[/red] {exc}") + raise typer.Exit(code=1) @app.command() def clean( - path: Path = typer.Argument(Path("."), exists=True, file_okay=False, dir_okay=True, help="Project root to clean."), - use_global: bool = typer.Option(False, "--global", "-g", help="Clean global database instead of workspace-local."), + path: Optional[Path] = typer.Argument(None, help="Project path to clean (removes project index)."), + all_indexes: bool = typer.Option(False, "--all", "-a", help="Remove all indexes."), json_mode: bool = typer.Option(False, "--json", help="Output JSON response."), verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."), ) -> None: """Remove CodexLens index data. - Removes the .codexlens/ directory from the project root. - Use --global to clean the global database at ~/.codexlens/. + Without arguments, shows current index size. + With path, removes that project's indexes. + With --all, removes all indexes (use with caution). """ _configure_logging(verbose) - base_path = path.expanduser().resolve() try: - if use_global: - config = Config() - import shutil - if config.index_dir.exists(): - if not _is_safe_to_clean(config.index_dir): - raise CodexLensError(f"Safety check failed: {config.index_dir} does not appear to be a CodexLens directory") - shutil.rmtree(config.index_dir) - result = {"cleaned": str(config.index_dir), "type": "global"} - else: - workspace = WorkspaceConfig.from_path(base_path) - if workspace and workspace.codexlens_dir.exists(): - import shutil - if not _is_safe_to_clean(workspace.codexlens_dir): - raise CodexLensError(f"Safety check failed: {workspace.codexlens_dir} does not appear to be a CodexLens directory") - shutil.rmtree(workspace.codexlens_dir) - result = {"cleaned": str(workspace.codexlens_dir), "type": "workspace"} - else: - result = {"cleaned": None, "type": "workspace", "message": "No workspace found"} + mapper = PathMapper() + index_root = mapper.index_root - if json_mode: - print_json(success=True, result=result) - else: - if result.get("cleaned"): - console.print(f"[green]Cleaned:[/green] {result['cleaned']}") + if all_indexes: + # Remove everything + if not index_root.exists(): + if json_mode: + print_json(success=True, result={"cleaned": None, "message": "No indexes to clean"}) + else: + console.print("[yellow]No indexes to clean.[/yellow]") + return + + # Calculate size before removal + total_size = 0 + for f in index_root.rglob("*"): + if f.is_file(): + total_size += f.stat().st_size + + # Remove registry first + registry_path = _get_registry_path() + if registry_path.exists(): + registry_path.unlink() + + # Remove all indexes + shutil.rmtree(index_root) + + result = { + "cleaned": str(index_root), + "size_freed_mb": round(total_size / (1024 * 1024), 2), + } + + if json_mode: + print_json(success=True, result=result) else: - console.print("[yellow]No workspace index found to clean.[/yellow]") + console.print(f"[green]Removed all indexes:[/green] {result['size_freed_mb']} MB freed") + + elif path: + # Remove specific project + project_path = path.expanduser().resolve() + project_index = mapper.source_to_index_dir(project_path) + + if not project_index.exists(): + if json_mode: + print_json(success=False, error=f"No index found for: {project_path}") + else: + console.print(f"[yellow]No index found for:[/yellow] {project_path}") + return + + # Calculate size + total_size = 0 + for f in project_index.rglob("*"): + if f.is_file(): + total_size += f.stat().st_size + + # Remove from registry + registry = RegistryStore() + registry.initialize() + registry.unregister_project(project_path) + registry.close() + + # Remove indexes + shutil.rmtree(project_index) + + result = { + "cleaned": str(project_path), + "index_path": str(project_index), + "size_freed_mb": round(total_size / (1024 * 1024), 2), + } + + if json_mode: + print_json(success=True, result=result) + else: + console.print(f"[green]Removed indexes for:[/green] {project_path}") + console.print(f" Freed: {result['size_freed_mb']} MB") + + else: + # Show current status + if not index_root.exists(): + if json_mode: + print_json(success=True, result={"index_root": str(index_root), "exists": False}) + else: + console.print("[yellow]No indexes found.[/yellow]") + return + + total_size = 0 + for f in index_root.rglob("*"): + if f.is_file(): + total_size += f.stat().st_size + + registry = RegistryStore() + registry.initialize() + projects = registry.list_projects() + registry.close() + + result = { + "index_root": str(index_root), + "projects_count": len(projects), + "total_size_mb": round(total_size / (1024 * 1024), 2), + } + + if json_mode: + print_json(success=True, result=result) + else: + console.print("[bold]Index Status[/bold]") + console.print(f" Location: {result['index_root']}") + console.print(f" Projects: {result['projects_count']}") + console.print(f" Total Size: {result['total_size_mb']} MB") + console.print("\n[dim]Use 'clean ' to remove a specific project or 'clean --all' to remove everything.[/dim]") + except Exception as exc: if json_mode: print_json(success=False, error=str(exc)) diff --git a/codex-lens/test_chain_search.py b/codex-lens/test_chain_search.py new file mode 100644 index 00000000..d2ed55c0 --- /dev/null +++ b/codex-lens/test_chain_search.py @@ -0,0 +1,146 @@ +"""Test script for chain search engine functionality.""" + +from pathlib import Path +from codexlens.search import ChainSearchEngine, SearchOptions, quick_search +from codexlens.storage.registry import RegistryStore +from codexlens.storage.path_mapper import PathMapper + + +def test_basic_search(): + """Test basic chain search functionality.""" + print("=== Testing Chain Search Engine ===\n") + + # Initialize components + registry = RegistryStore() + registry.initialize() + mapper = PathMapper() + + # Create engine + engine = ChainSearchEngine(registry, mapper) + print(f"[OK] ChainSearchEngine initialized") + + # Test search options + options = SearchOptions( + depth=-1, + max_workers=4, + limit_per_dir=10, + total_limit=50, + include_symbols=False, + files_only=False + ) + print(f"[OK] SearchOptions configured: depth={options.depth}, workers={options.max_workers}") + + # Test path that exists in the current project + test_path = Path("D:/Claude_dms3/codex-lens/src/codexlens") + + if test_path.exists(): + print(f"\n[OK] Test path exists: {test_path}") + + # Perform search + result = engine.search("search", test_path, options) + + print(f"\n=== Search Results ===") + print(f"Query: '{result.query}'") + print(f"Directories searched: {result.stats.dirs_searched}") + print(f"Files matched: {result.stats.files_matched}") + print(f"Time: {result.stats.time_ms:.2f}ms") + + if result.stats.errors: + print(f"Errors: {len(result.stats.errors)}") + for err in result.stats.errors[:3]: + print(f" - {err}") + + print(f"\nTop Results (showing first 5):") + for i, res in enumerate(result.results[:5], 1): + print(f"{i}. {res.path}") + print(f" Score: {res.score:.2f}") + if res.excerpt: + excerpt = res.excerpt.replace('\n', ' ')[:100] + print(f" Excerpt: {excerpt}...") + else: + print(f"\n[SKIP] Test path does not exist: {test_path}") + print(" (Index may not be built yet)") + + registry.close() + print("\n[OK] Test completed") + + +def test_quick_search(): + """Test quick_search convenience function.""" + print("\n\n=== Testing Quick Search ===\n") + + test_path = Path("D:/Claude_dms3/codex-lens/src") + + if test_path.exists(): + results = quick_search("index", test_path, depth=2) + print(f"[OK] Quick search completed") + print(f" Found {len(results)} results") + if results: + print(f" Top result: {results[0].path}") + else: + print(f"[SKIP] Test path does not exist: {test_path}") + + print("\n[OK] Quick search test completed") + + +def test_symbol_search(): + """Test symbol search functionality.""" + print("\n\n=== Testing Symbol Search ===\n") + + registry = RegistryStore() + registry.initialize() + mapper = PathMapper() + engine = ChainSearchEngine(registry, mapper) + + test_path = Path("D:/Claude_dms3/codex-lens/src/codexlens") + + if test_path.exists(): + symbols = engine.search_symbols("search", test_path, kind=None) + print(f"[OK] Symbol search completed") + print(f" Found {len(symbols)} symbols") + for i, sym in enumerate(symbols[:5], 1): + print(f" {i}. {sym.name} ({sym.kind}) - lines {sym.range[0]}-{sym.range[1]}") + else: + print(f"[SKIP] Test path does not exist: {test_path}") + + registry.close() + print("\n[OK] Symbol search test completed") + + +def test_files_only_search(): + """Test files-only search mode.""" + print("\n\n=== Testing Files-Only Search ===\n") + + registry = RegistryStore() + registry.initialize() + mapper = PathMapper() + engine = ChainSearchEngine(registry, mapper) + + test_path = Path("D:/Claude_dms3/codex-lens/src") + + if test_path.exists(): + file_paths = engine.search_files_only("class", test_path) + print(f"[OK] Files-only search completed") + print(f" Found {len(file_paths)} files") + for i, path in enumerate(file_paths[:5], 1): + print(f" {i}. {path}") + else: + print(f"[SKIP] Test path does not exist: {test_path}") + + registry.close() + print("\n[OK] Files-only search test completed") + + +if __name__ == "__main__": + try: + test_basic_search() + test_quick_search() + test_symbol_search() + test_files_only_search() + print("\n" + "=" * 50) + print("All tests completed successfully!") + print("=" * 50) + except Exception as e: + print(f"\n[ERROR] Test failed with error: {e}") + import traceback + traceback.print_exc()