mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-22 19:18:47 +08:00
feat: add MCP server for semantic code search with FastMCP integration
This commit is contained in:
@@ -1,37 +1,13 @@
|
||||
/**
|
||||
* Memory Embedder Bridge - TypeScript interface to Python memory embedder
|
||||
* Memory Embedder Bridge - STUB (v1 Python bridge removed)
|
||||
*
|
||||
* This module provides a TypeScript bridge to the Python memory_embedder.py script,
|
||||
* which generates and searches embeddings for memory chunks using CodexLens's embedder.
|
||||
*
|
||||
* Features:
|
||||
* - Reuses CodexLens venv at ~/.codexlens/venv
|
||||
* - JSON protocol communication
|
||||
* - Three commands: embed, search, status
|
||||
* - Automatic availability checking
|
||||
* - Stage1 output embedding for V2 pipeline
|
||||
* The Python memory_embedder.py bridge has been removed. This module provides
|
||||
* no-op stubs so that existing consumers compile without errors.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getCodexLensHiddenPython } from '../utils/codexlens-path.js';
|
||||
import { getCoreMemoryStore } from './core-memory-store.js';
|
||||
import type { Stage1Output } from './core-memory-store.js';
|
||||
import { StoragePaths } from '../config/storage-paths.js';
|
||||
const V1_REMOVED = 'Memory embedder Python bridge has been removed (v1 cleanup).';
|
||||
|
||||
// Get directory of this module
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Venv paths (reuse CodexLens venv)
|
||||
const VENV_PYTHON = getCodexLensHiddenPython();
|
||||
|
||||
// Script path
|
||||
const EMBEDDER_SCRIPT = join(__dirname, '..', '..', 'scripts', 'memory_embedder.py');
|
||||
|
||||
// Types
|
||||
// Types (kept for backward compatibility)
|
||||
export interface EmbedResult {
|
||||
success: boolean;
|
||||
chunks_processed: number;
|
||||
@@ -78,197 +54,6 @@ export interface SearchOptions {
|
||||
sourceType?: 'core_memory' | 'workflow' | 'cli_history';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if embedder is available (venv and script exist)
|
||||
* @returns True if embedder is available
|
||||
*/
|
||||
export function isEmbedderAvailable(): boolean {
|
||||
// Check venv python exists
|
||||
if (!existsSync(VENV_PYTHON)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check script exists
|
||||
if (!existsSync(EMBEDDER_SCRIPT)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Python script with arguments
|
||||
* @param args - Command line arguments
|
||||
* @param timeout - Timeout in milliseconds
|
||||
* @returns JSON output from script
|
||||
*/
|
||||
function runPython(args: string[], timeout: number = 300000): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Check availability
|
||||
if (!isEmbedderAvailable()) {
|
||||
reject(
|
||||
new Error(
|
||||
'Memory embedder not available. Ensure CodexLens venv exists at ~/.codexlens/venv'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Spawn Python process
|
||||
const child = spawn(VENV_PYTHON, [EMBEDDER_SCRIPT, ...args], {
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim());
|
||||
} else {
|
||||
reject(new Error(`Python script failed (exit code ${code}): ${stderr || stdout}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
||||
reject(new Error('Python script timed out'));
|
||||
} else {
|
||||
reject(new Error(`Failed to spawn Python: ${err.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings for memory chunks
|
||||
* @param dbPath - Path to SQLite database
|
||||
* @param options - Embedding options
|
||||
* @returns Embedding result
|
||||
*/
|
||||
export async function generateEmbeddings(
|
||||
dbPath: string,
|
||||
options: EmbedOptions = {}
|
||||
): Promise<EmbedResult> {
|
||||
const { sourceId, batchSize = 8, force = false } = options;
|
||||
|
||||
// Build arguments
|
||||
const args = ['embed', dbPath];
|
||||
|
||||
if (sourceId) {
|
||||
args.push('--source-id', sourceId);
|
||||
}
|
||||
|
||||
if (batchSize !== 8) {
|
||||
args.push('--batch-size', batchSize.toString());
|
||||
}
|
||||
|
||||
if (force) {
|
||||
args.push('--force');
|
||||
}
|
||||
|
||||
try {
|
||||
// Default timeout: 5 minutes
|
||||
const output = await runPython(args, 300000);
|
||||
const result = JSON.parse(output) as EmbedResult;
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
chunks_processed: 0,
|
||||
chunks_failed: 0,
|
||||
elapsed_time: 0,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search memory chunks using semantic search
|
||||
* @param dbPath - Path to SQLite database
|
||||
* @param query - Search query text
|
||||
* @param options - Search options
|
||||
* @returns Search results
|
||||
*/
|
||||
export async function searchMemories(
|
||||
dbPath: string,
|
||||
query: string,
|
||||
options: SearchOptions = {}
|
||||
): Promise<SearchResult> {
|
||||
const { topK = 10, minScore = 0.3, sourceType } = options;
|
||||
|
||||
// Build arguments
|
||||
const args = ['search', dbPath, query];
|
||||
|
||||
if (topK !== 10) {
|
||||
args.push('--top-k', topK.toString());
|
||||
}
|
||||
|
||||
if (minScore !== 0.3) {
|
||||
args.push('--min-score', minScore.toString());
|
||||
}
|
||||
|
||||
if (sourceType) {
|
||||
args.push('--type', sourceType);
|
||||
}
|
||||
|
||||
try {
|
||||
// Default timeout: 30 seconds
|
||||
const output = await runPython(args, 30000);
|
||||
const result = JSON.parse(output) as SearchResult;
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
matches: [],
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get embedding status statistics
|
||||
* @param dbPath - Path to SQLite database
|
||||
* @returns Embedding status
|
||||
*/
|
||||
export async function getEmbeddingStatus(dbPath: string): Promise<EmbeddingStatus> {
|
||||
// Build arguments
|
||||
const args = ['status', dbPath];
|
||||
|
||||
try {
|
||||
// Default timeout: 30 seconds
|
||||
const output = await runPython(args, 30000);
|
||||
const result = JSON.parse(output) as EmbeddingStatus;
|
||||
return { ...result, success: true };
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
total_chunks: 0,
|
||||
embedded_chunks: 0,
|
||||
pending_chunks: 0,
|
||||
by_type: {},
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Memory V2: Stage1 Output Embedding
|
||||
// ============================================================================
|
||||
|
||||
/** Result of stage1 embedding operation */
|
||||
export interface Stage1EmbedResult {
|
||||
success: boolean;
|
||||
chunksCreated: number;
|
||||
@@ -276,98 +61,54 @@ export interface Stage1EmbedResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunk and embed stage1_outputs (raw_memory + rollout_summary) for semantic search.
|
||||
*
|
||||
* Reads all stage1_outputs from the DB, chunks their raw_memory and rollout_summary
|
||||
* content, inserts chunks into memory_chunks with source_type='cli_history' and
|
||||
* metadata indicating the V2 origin, then triggers embedding generation.
|
||||
*
|
||||
* Uses source_id format: "s1:{thread_id}" to differentiate from regular cli_history chunks.
|
||||
*
|
||||
* @param projectPath - Project root path
|
||||
* @param force - Force re-chunking even if chunks exist
|
||||
* @returns Embedding result
|
||||
*/
|
||||
export async function embedStage1Outputs(
|
||||
projectPath: string,
|
||||
force: boolean = false
|
||||
): Promise<Stage1EmbedResult> {
|
||||
try {
|
||||
const store = getCoreMemoryStore(projectPath);
|
||||
const stage1Outputs = store.listStage1Outputs();
|
||||
|
||||
if (stage1Outputs.length === 0) {
|
||||
return { success: true, chunksCreated: 0, chunksEmbedded: 0 };
|
||||
}
|
||||
|
||||
let totalChunksCreated = 0;
|
||||
|
||||
for (const output of stage1Outputs) {
|
||||
const sourceId = `s1:${output.thread_id}`;
|
||||
|
||||
// Check if already chunked
|
||||
const existingChunks = store.getChunks(sourceId);
|
||||
if (existingChunks.length > 0 && !force) continue;
|
||||
|
||||
// Delete old chunks if force
|
||||
if (force && existingChunks.length > 0) {
|
||||
store.deleteChunks(sourceId);
|
||||
}
|
||||
|
||||
// Combine raw_memory and rollout_summary for richer semantic content
|
||||
const combinedContent = [
|
||||
output.rollout_summary ? `## Summary\n${output.rollout_summary}` : '',
|
||||
output.raw_memory ? `## Raw Memory\n${output.raw_memory}` : '',
|
||||
].filter(Boolean).join('\n\n');
|
||||
|
||||
if (!combinedContent.trim()) continue;
|
||||
|
||||
// Chunk using the store's built-in chunking
|
||||
const chunks = store.chunkContent(combinedContent, sourceId, 'cli_history');
|
||||
|
||||
// Insert chunks with V2 metadata
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
store.insertChunk({
|
||||
source_id: sourceId,
|
||||
source_type: 'cli_history',
|
||||
chunk_index: i,
|
||||
content: chunks[i],
|
||||
metadata: JSON.stringify({
|
||||
v2_source: 'stage1_output',
|
||||
thread_id: output.thread_id,
|
||||
generated_at: output.generated_at,
|
||||
}),
|
||||
created_at: new Date().toISOString(),
|
||||
});
|
||||
totalChunksCreated++;
|
||||
}
|
||||
}
|
||||
|
||||
// If we created chunks, generate embeddings
|
||||
let chunksEmbedded = 0;
|
||||
if (totalChunksCreated > 0) {
|
||||
const paths = StoragePaths.project(projectPath);
|
||||
const dbPath = join(paths.root, 'core-memory', 'core_memory.db');
|
||||
|
||||
const embedResult = await generateEmbeddings(dbPath, { force: false });
|
||||
if (embedResult.success) {
|
||||
chunksEmbedded = embedResult.chunks_processed;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
chunksCreated: totalChunksCreated,
|
||||
chunksEmbedded,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
chunksCreated: 0,
|
||||
chunksEmbedded: 0,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
export function isEmbedderAvailable(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function generateEmbeddings(
|
||||
_dbPath: string,
|
||||
_options: EmbedOptions = {}
|
||||
): Promise<EmbedResult> {
|
||||
return {
|
||||
success: false,
|
||||
chunks_processed: 0,
|
||||
chunks_failed: 0,
|
||||
elapsed_time: 0,
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchMemories(
|
||||
_dbPath: string,
|
||||
_query: string,
|
||||
_options: SearchOptions = {}
|
||||
): Promise<SearchResult> {
|
||||
return {
|
||||
success: false,
|
||||
matches: [],
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEmbeddingStatus(_dbPath: string): Promise<EmbeddingStatus> {
|
||||
return {
|
||||
success: false,
|
||||
total_chunks: 0,
|
||||
embedded_chunks: 0,
|
||||
pending_chunks: 0,
|
||||
by_type: {},
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
}
|
||||
|
||||
export async function embedStage1Outputs(
|
||||
_projectPath: string,
|
||||
_force: boolean = false
|
||||
): Promise<Stage1EmbedResult> {
|
||||
return {
|
||||
success: false,
|
||||
chunksCreated: 0,
|
||||
chunksEmbedded: 0,
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* CodexLens Routes Module
|
||||
* Handles all CodexLens-related API endpoints.
|
||||
*/
|
||||
|
||||
import type { RouteContext } from './types.js';
|
||||
import { handleCodexLensConfigRoutes } from './codexlens/config-handlers.js';
|
||||
import { handleCodexLensIndexRoutes } from './codexlens/index-handlers.js';
|
||||
import { handleCodexLensSemanticRoutes } from './codexlens/semantic-handlers.js';
|
||||
import { handleCodexLensWatcherRoutes } from './codexlens/watcher-handlers.js';
|
||||
|
||||
/**
|
||||
* Handle CodexLens routes
|
||||
* @returns true if route was handled, false otherwise
|
||||
*/
|
||||
export async function handleCodexLensRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
if (await handleCodexLensIndexRoutes(ctx)) return true;
|
||||
if (await handleCodexLensConfigRoutes(ctx)) return true;
|
||||
if (await handleCodexLensSemanticRoutes(ctx)) return true;
|
||||
if (await handleCodexLensWatcherRoutes(ctx)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# CodexLens Routes
|
||||
|
||||
CodexLens-related HTTP endpoints are handled by `ccw/src/core/routes/codexlens-routes.ts`, which delegates to handler modules in this directory. Each handler returns `true` when it handles the current request.
|
||||
|
||||
## File Map
|
||||
|
||||
- `ccw/src/core/routes/codexlens/utils.ts` – shared helpers (ANSI stripping + robust JSON extraction from CLI output).
|
||||
- `ccw/src/core/routes/codexlens/index-handlers.ts` – index/project management endpoints:
|
||||
- `GET /api/codexlens/indexes`
|
||||
- `POST /api/codexlens/clean`
|
||||
- `POST /api/codexlens/init`
|
||||
- `POST /api/codexlens/cancel`
|
||||
- `GET /api/codexlens/indexing-status`
|
||||
- `ccw/src/core/routes/codexlens/config-handlers.ts` – install/config/environment endpoints:
|
||||
- `GET /api/codexlens/status`
|
||||
- `GET /api/codexlens/dashboard-init`
|
||||
- `POST /api/codexlens/bootstrap`
|
||||
- `POST /api/codexlens/uninstall`
|
||||
- `GET /api/codexlens/config`
|
||||
- `POST /api/codexlens/config`
|
||||
- GPU: `GET /api/codexlens/gpu/detect`, `GET /api/codexlens/gpu/list`, `POST /api/codexlens/gpu/select`, `POST /api/codexlens/gpu/reset`
|
||||
- Models: `GET /api/codexlens/models`, `POST /api/codexlens/models/download`, `POST /api/codexlens/models/delete`, `GET /api/codexlens/models/info`
|
||||
- Env: `GET /api/codexlens/env`, `POST /api/codexlens/env`
|
||||
- `ccw/src/core/routes/codexlens/semantic-handlers.ts` – semantic search + reranker + SPLADE endpoints:
|
||||
- Semantic: `GET /api/codexlens/semantic/status`, `GET /api/codexlens/semantic/metadata`, `POST /api/codexlens/semantic/install`
|
||||
- Search: `GET /api/codexlens/search`, `GET /api/codexlens/search_files`, `GET /api/codexlens/symbol`, `POST /api/codexlens/enhance`
|
||||
- Reranker: `GET /api/codexlens/reranker/config`, `POST /api/codexlens/reranker/config`, `GET /api/codexlens/reranker/models`, `POST /api/codexlens/reranker/models/download`, `POST /api/codexlens/reranker/models/delete`, `GET /api/codexlens/reranker/models/info`
|
||||
- SPLADE: `GET /api/codexlens/splade/status`, `POST /api/codexlens/splade/install`, `GET /api/codexlens/splade/index-status`, `POST /api/codexlens/splade/rebuild`
|
||||
- `ccw/src/core/routes/codexlens/watcher-handlers.ts` – file watcher endpoints:
|
||||
- `GET /api/codexlens/watch/status`
|
||||
- `POST /api/codexlens/watch/start`
|
||||
- `POST /api/codexlens/watch/stop`
|
||||
- Also exports `stopWatcherForUninstall()` used during uninstall flow.
|
||||
|
||||
## Notes
|
||||
|
||||
- CodexLens CLI output may include logging + ANSI escapes even with `--json`; handlers use `extractJSON()` from `utils.ts` to parse reliably.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,459 +0,0 @@
|
||||
/**
|
||||
* CodexLens index management handlers.
|
||||
*/
|
||||
|
||||
import {
|
||||
cancelIndexing,
|
||||
checkVenvStatus,
|
||||
checkSemanticStatus,
|
||||
ensureLiteLLMEmbedderReady,
|
||||
executeCodexLens,
|
||||
isIndexingInProgress,
|
||||
} from '../../../tools/codex-lens.js';
|
||||
import type { ProgressInfo } from '../../../tools/codex-lens.js';
|
||||
import type { RouteContext } from '../types.js';
|
||||
import { extractJSON, formatSize } from './utils.js';
|
||||
|
||||
/**
|
||||
* Handle CodexLens index routes
|
||||
* @returns true if route was handled, false otherwise
|
||||
*/
|
||||
export async function handleCodexLensIndexRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
|
||||
|
||||
// API: CodexLens Index List - Get all indexed projects with details
|
||||
if (pathname === '/api/codexlens/indexes') {
|
||||
try {
|
||||
// Check if CodexLens is installed first (without auto-installing)
|
||||
const venvStatus = await checkVenvStatus();
|
||||
if (!venvStatus.ready) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, indexes: [], totalSize: 0, totalSizeFormatted: '0 B' }));
|
||||
return true;
|
||||
}
|
||||
|
||||
// Execute all CLI commands in parallel
|
||||
const [configResult, projectsResult, statusResult] = await Promise.all([
|
||||
executeCodexLens(['config', '--json']),
|
||||
executeCodexLens(['projects', 'list', '--json']),
|
||||
executeCodexLens(['status', '--json'])
|
||||
]);
|
||||
|
||||
let indexDir = '';
|
||||
if (configResult.success) {
|
||||
try {
|
||||
const config = extractJSON(configResult.output ?? '');
|
||||
if (config.success && config.result) {
|
||||
// CLI returns index_dir (not index_root)
|
||||
indexDir = config.result.index_dir || config.result.index_root || '';
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error('[CodexLens] Failed to parse config for index list:', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
let indexes: any[] = [];
|
||||
let totalSize = 0;
|
||||
let vectorIndexCount = 0;
|
||||
let normalIndexCount = 0;
|
||||
|
||||
if (projectsResult.success) {
|
||||
try {
|
||||
const projectsData = extractJSON(projectsResult.output ?? '');
|
||||
if (projectsData.success && Array.isArray(projectsData.result)) {
|
||||
const { stat, readdir } = await import('fs/promises');
|
||||
const { existsSync } = await import('fs');
|
||||
const { basename, join } = await import('path');
|
||||
|
||||
for (const project of projectsData.result) {
|
||||
// Skip test/temp projects
|
||||
if (project.source_root && (
|
||||
project.source_root.includes('\\Temp\\') ||
|
||||
project.source_root.includes('/tmp/') ||
|
||||
project.total_files === 0
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let projectSize = 0;
|
||||
let hasVectorIndex = false;
|
||||
let hasNormalIndex = true; // All projects have FTS index
|
||||
let lastModified = null;
|
||||
|
||||
// Try to get actual index size from index_root
|
||||
if (project.index_root && existsSync(project.index_root)) {
|
||||
try {
|
||||
const files = await readdir(project.index_root);
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = join(project.index_root, file);
|
||||
const fileStat = await stat(filePath);
|
||||
projectSize += fileStat.size;
|
||||
if (!lastModified || fileStat.mtime > lastModified) {
|
||||
lastModified = fileStat.mtime;
|
||||
}
|
||||
// Check for vector/embedding files
|
||||
if (file.includes('vector') || file.includes('embedding') ||
|
||||
file.endsWith('.faiss') || file.endsWith('.npy') ||
|
||||
file.includes('semantic_chunks')) {
|
||||
hasVectorIndex = true;
|
||||
}
|
||||
} catch {
|
||||
// Skip files we can't stat
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Can't read index directory
|
||||
}
|
||||
}
|
||||
|
||||
if (hasVectorIndex) vectorIndexCount++;
|
||||
if (hasNormalIndex) normalIndexCount++;
|
||||
totalSize += projectSize;
|
||||
|
||||
// Use source_root as the display name
|
||||
const displayName = project.source_root ? basename(project.source_root) : `project_${project.id}`;
|
||||
|
||||
indexes.push({
|
||||
id: displayName,
|
||||
path: project.source_root || '',
|
||||
indexPath: project.index_root || '',
|
||||
size: projectSize,
|
||||
sizeFormatted: formatSize(projectSize),
|
||||
fileCount: project.total_files || 0,
|
||||
dirCount: project.total_dirs || 0,
|
||||
hasVectorIndex,
|
||||
hasNormalIndex,
|
||||
status: project.status || 'active',
|
||||
lastModified: lastModified ? lastModified.toISOString() : null
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by file count (most files first), then by name
|
||||
indexes.sort((a, b) => {
|
||||
if (b.fileCount !== a.fileCount) return b.fileCount - a.fileCount;
|
||||
return a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error('[CodexLens] Failed to parse projects list:', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
// Parse summary stats from status command (already fetched in parallel)
|
||||
let statusSummary: any = {};
|
||||
|
||||
if (statusResult.success) {
|
||||
try {
|
||||
const status = extractJSON(statusResult.output ?? '');
|
||||
if (status.success && status.result) {
|
||||
statusSummary = {
|
||||
totalProjects: status.result.projects_count || indexes.length,
|
||||
totalFiles: status.result.total_files || 0,
|
||||
totalDirs: status.result.total_dirs || 0,
|
||||
// Keep calculated totalSize for consistency with per-project sizes
|
||||
// status.index_size_bytes includes shared resources (models, cache)
|
||||
indexSizeBytes: totalSize,
|
||||
indexSizeMb: totalSize / (1024 * 1024),
|
||||
embeddings: status.result.embeddings || {},
|
||||
// Store full index dir size separately for reference
|
||||
fullIndexDirSize: status.result.index_size_bytes || 0,
|
||||
fullIndexDirSizeFormatted: formatSize(status.result.index_size_bytes || 0)
|
||||
};
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
console.error('[CodexLens] Failed to parse status:', e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
indexDir,
|
||||
indexes,
|
||||
summary: {
|
||||
totalProjects: indexes.length,
|
||||
totalSize,
|
||||
totalSizeFormatted: formatSize(totalSize),
|
||||
vectorIndexCount,
|
||||
normalIndexCount,
|
||||
...statusSummary
|
||||
}
|
||||
}));
|
||||
} catch (err: unknown) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: CodexLens Clean (Clean indexes)
|
||||
if (pathname === '/api/codexlens/clean' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { all = false, path } = body as { all?: unknown; path?: unknown };
|
||||
|
||||
try {
|
||||
const args = ['clean'];
|
||||
if (all === true) {
|
||||
args.push('--all');
|
||||
} else if (typeof path === 'string' && path.trim().length > 0) {
|
||||
// Path is passed as a positional argument, not as a flag
|
||||
args.push(path);
|
||||
}
|
||||
args.push('--json');
|
||||
|
||||
const result = await executeCodexLens(args);
|
||||
if (result.success) {
|
||||
return { success: true, message: 'Indexes cleaned successfully' };
|
||||
} else {
|
||||
return { success: false, error: result.error || 'Failed to clean indexes', status: 500 };
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: CodexLens Init (Initialize workspace index)
|
||||
if (pathname === '/api/codexlens/init' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { path: projectPath, indexType = 'vector', embeddingModel = 'code', embeddingBackend = 'fastembed', maxWorkers = 1 } = body as {
|
||||
path?: unknown;
|
||||
indexType?: unknown;
|
||||
embeddingModel?: unknown;
|
||||
embeddingBackend?: unknown;
|
||||
maxWorkers?: unknown;
|
||||
};
|
||||
const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
|
||||
const resolvedIndexType = indexType === 'normal' ? 'normal' : 'vector';
|
||||
const resolvedEmbeddingModel = typeof embeddingModel === 'string' && embeddingModel.trim().length > 0 ? embeddingModel : 'code';
|
||||
const resolvedEmbeddingBackend = typeof embeddingBackend === 'string' && embeddingBackend.trim().length > 0 ? embeddingBackend : 'fastembed';
|
||||
const resolvedMaxWorkers = typeof maxWorkers === 'number' ? maxWorkers : Number(maxWorkers);
|
||||
|
||||
// Pre-check: Verify embedding backend availability before proceeding with vector indexing
|
||||
// This prevents silent degradation where vector indexing is skipped without error
|
||||
if (resolvedIndexType !== 'normal') {
|
||||
if (resolvedEmbeddingBackend === 'litellm') {
|
||||
// For litellm backend, ensure ccw-litellm is installed
|
||||
const installResult = await ensureLiteLLMEmbedderReady();
|
||||
if (!installResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: installResult.error || 'LiteLLM embedding backend is not available. Please install ccw-litellm first.',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// For fastembed backend (default), check semantic dependencies
|
||||
const semanticStatus = await checkSemanticStatus();
|
||||
if (!semanticStatus.available) {
|
||||
return {
|
||||
success: false,
|
||||
error: semanticStatus.error || 'FastEmbed semantic backend is not available. Please install semantic dependencies first (CodeLens Settings → Install Semantic).',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build CLI arguments based on index type
|
||||
// Use 'index init' subcommand (new CLI structure)
|
||||
// --force flag ensures full reindex (not incremental)
|
||||
const args = ['index', 'init', targetPath, '--force', '--json'];
|
||||
if (resolvedIndexType === 'normal') {
|
||||
args.push('--no-embeddings');
|
||||
} else {
|
||||
// Add embedding model selection for vector index (use --model, not --embedding-model)
|
||||
args.push('--model', resolvedEmbeddingModel);
|
||||
// Add embedding backend if not using default fastembed (use --backend, not --embedding-backend)
|
||||
if (resolvedEmbeddingBackend && resolvedEmbeddingBackend !== 'fastembed') {
|
||||
args.push('--backend', resolvedEmbeddingBackend);
|
||||
}
|
||||
// Add max workers for concurrent API calls (useful for litellm backend)
|
||||
if (!Number.isNaN(resolvedMaxWorkers) && resolvedMaxWorkers > 1) {
|
||||
args.push('--max-workers', String(resolvedMaxWorkers));
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast start event
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { stage: 'start', message: 'Starting index...', percent: 0, path: targetPath, indexType: resolvedIndexType }
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await executeCodexLens(args, {
|
||||
cwd: targetPath,
|
||||
timeout: 1800000, // 30 minutes for large codebases
|
||||
onProgress: (progress: ProgressInfo) => {
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { ...progress, path: targetPath }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { stage: 'complete', message: 'Index complete', percent: 100, path: targetPath }
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = extractJSON(result.output ?? '');
|
||||
return { success: true, result: parsed };
|
||||
} catch {
|
||||
return { success: true, output: result.output ?? '' };
|
||||
}
|
||||
} else {
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { stage: 'error', message: result.error || 'Unknown error', percent: 0, path: targetPath }
|
||||
});
|
||||
return { success: false, error: result.error, status: 500 };
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { stage: 'error', message, percent: 0, path: targetPath }
|
||||
});
|
||||
return { success: false, error: message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Cancel CodexLens Indexing
|
||||
if (pathname === '/api/codexlens/cancel' && req.method === 'POST') {
|
||||
const result = cancelIndexing();
|
||||
|
||||
// Broadcast cancellation event
|
||||
if (result.success) {
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { stage: 'cancelled', message: 'Indexing cancelled by user', percent: 0 }
|
||||
});
|
||||
}
|
||||
|
||||
res.writeHead(result.success ? 200 : 400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: CodexLens Update (Incremental index update)
|
||||
if (pathname === '/api/codexlens/update' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { path: projectPath, indexType = 'vector', embeddingModel = 'code', embeddingBackend = 'fastembed', maxWorkers = 1 } = body as {
|
||||
path?: unknown;
|
||||
indexType?: unknown;
|
||||
embeddingModel?: unknown;
|
||||
embeddingBackend?: unknown;
|
||||
maxWorkers?: unknown;
|
||||
};
|
||||
const targetPath = typeof projectPath === 'string' && projectPath.trim().length > 0 ? projectPath : initialPath;
|
||||
const resolvedIndexType = indexType === 'normal' ? 'normal' : 'vector';
|
||||
const resolvedEmbeddingModel = typeof embeddingModel === 'string' && embeddingModel.trim().length > 0 ? embeddingModel : 'code';
|
||||
const resolvedEmbeddingBackend = typeof embeddingBackend === 'string' && embeddingBackend.trim().length > 0 ? embeddingBackend : 'fastembed';
|
||||
const resolvedMaxWorkers = typeof maxWorkers === 'number' ? maxWorkers : Number(maxWorkers);
|
||||
|
||||
// Pre-check: Verify embedding backend availability before proceeding with vector indexing
|
||||
if (resolvedIndexType !== 'normal') {
|
||||
if (resolvedEmbeddingBackend === 'litellm') {
|
||||
const installResult = await ensureLiteLLMEmbedderReady();
|
||||
if (!installResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: installResult.error || 'LiteLLM embedding backend is not available. Please install ccw-litellm first.',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const semanticStatus = await checkSemanticStatus();
|
||||
if (!semanticStatus.available) {
|
||||
return {
|
||||
success: false,
|
||||
error: semanticStatus.error || 'FastEmbed semantic backend is not available. Please install semantic dependencies first.',
|
||||
status: 500
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build CLI arguments for incremental update using 'index init' without --force
|
||||
// 'index init' defaults to incremental mode (skip unchanged files)
|
||||
// 'index update' is only for single-file updates in hooks
|
||||
const args = ['index', 'init', targetPath, '--json'];
|
||||
if (resolvedIndexType === 'normal') {
|
||||
args.push('--no-embeddings');
|
||||
} else {
|
||||
args.push('--model', resolvedEmbeddingModel);
|
||||
if (resolvedEmbeddingBackend && resolvedEmbeddingBackend !== 'fastembed') {
|
||||
args.push('--backend', resolvedEmbeddingBackend);
|
||||
}
|
||||
if (!Number.isNaN(resolvedMaxWorkers) && resolvedMaxWorkers > 1) {
|
||||
args.push('--max-workers', String(resolvedMaxWorkers));
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast start event
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { stage: 'start', message: 'Starting incremental index update...', percent: 0, path: targetPath, indexType: resolvedIndexType }
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await executeCodexLens(args, {
|
||||
cwd: targetPath,
|
||||
timeout: 1800000,
|
||||
onProgress: (progress: ProgressInfo) => {
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { ...progress, path: targetPath }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { stage: 'complete', message: 'Incremental update complete', percent: 100, path: targetPath }
|
||||
});
|
||||
|
||||
try {
|
||||
const parsed = extractJSON(result.output ?? '');
|
||||
return { success: true, result: parsed };
|
||||
} catch {
|
||||
return { success: true, output: result.output ?? '' };
|
||||
}
|
||||
} else {
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { stage: 'error', message: result.error || 'Unknown error', percent: 0, path: targetPath }
|
||||
});
|
||||
return { success: false, error: result.error, status: 500 };
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_INDEX_PROGRESS',
|
||||
payload: { stage: 'error', message, percent: 0, path: targetPath }
|
||||
});
|
||||
return { success: false, error: message, status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Check if indexing is in progress
|
||||
if (pathname === '/api/codexlens/indexing-status') {
|
||||
const inProgress = isIndexingInProgress();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: true, inProgress }));
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* CodexLens route utilities.
|
||||
*
|
||||
* CodexLens CLI can emit logging + ANSI escapes even with --json, so helpers
|
||||
* here normalize output for reliable JSON parsing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Strip ANSI color codes from string.
|
||||
* Rich library adds color codes even with --json flag.
|
||||
*/
|
||||
export function stripAnsiCodes(str: string): string {
|
||||
// ANSI escape code pattern: \x1b[...m or \x1b]...
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, '')
|
||||
.replace(/\x1b\][0-9;]*\x07/g, '')
|
||||
.replace(/\x1b\][^\x07]*\x07/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size to human readable string.
|
||||
*/
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const size = parseFloat((bytes / Math.pow(k, i)).toFixed(i < 2 ? 0 : 1));
|
||||
return size + ' ' + units[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON from CLI output that may contain logging messages.
|
||||
* CodexLens CLI outputs logs like "INFO ..." before the JSON.
|
||||
* Also strips ANSI color codes that Rich library adds.
|
||||
* Handles trailing content after JSON (e.g., "INFO: Done" messages).
|
||||
*/
|
||||
export function extractJSON(output: string): any {
|
||||
// Strip ANSI color codes first
|
||||
const cleanOutput = stripAnsiCodes(output);
|
||||
|
||||
// Find the first { or [ character (start of JSON)
|
||||
const jsonStart = cleanOutput.search(/[{\[]/);
|
||||
if (jsonStart === -1) {
|
||||
throw new Error('No JSON found in output');
|
||||
}
|
||||
|
||||
const startChar = cleanOutput[jsonStart];
|
||||
const endChar = startChar === '{' ? '}' : ']';
|
||||
|
||||
// Find matching closing brace/bracket using a simple counter
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escapeNext = false;
|
||||
let jsonEnd = -1;
|
||||
|
||||
for (let i = jsonStart; i < cleanOutput.length; i++) {
|
||||
const char = cleanOutput[i];
|
||||
|
||||
if (escapeNext) {
|
||||
escapeNext = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '\\' && inString) {
|
||||
escapeNext = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
inString = !inString;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inString) {
|
||||
if (char === startChar) {
|
||||
depth++;
|
||||
} else if (char === endChar) {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
jsonEnd = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (jsonEnd === -1) {
|
||||
// Fallback: try to parse from start to end (original behavior)
|
||||
const jsonString = cleanOutput.substring(jsonStart);
|
||||
return JSON.parse(jsonString);
|
||||
}
|
||||
|
||||
const jsonString = cleanOutput.substring(jsonStart, jsonEnd);
|
||||
return JSON.parse(jsonString);
|
||||
}
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
/**
|
||||
* CodexLens file watcher handlers.
|
||||
*
|
||||
* Maintains watcher process state across requests to support dashboard controls.
|
||||
*/
|
||||
|
||||
import {
|
||||
checkVenvStatus,
|
||||
executeCodexLens,
|
||||
getVenvPythonPath,
|
||||
useCodexLensV2,
|
||||
} from '../../../tools/codex-lens.js';
|
||||
import type { RouteContext } from '../types.js';
|
||||
import { extractJSON, stripAnsiCodes } from './utils.js';
|
||||
import type { ChildProcess } from 'child_process';
|
||||
|
||||
// File watcher state (persisted across requests)
|
||||
let watcherProcess: any = null;
|
||||
let watcherStats = {
|
||||
running: false,
|
||||
root_path: '',
|
||||
events_processed: 0,
|
||||
start_time: null as Date | null
|
||||
};
|
||||
|
||||
export async function stopWatcherForUninstall(): Promise<void> {
|
||||
if (!watcherStats.running || !watcherProcess) return;
|
||||
|
||||
try {
|
||||
watcherProcess.kill('SIGTERM');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
if (watcherProcess && !watcherProcess.killed) {
|
||||
watcherProcess.kill('SIGKILL');
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors stopping watcher
|
||||
}
|
||||
|
||||
watcherStats = {
|
||||
running: false,
|
||||
root_path: '',
|
||||
events_processed: 0,
|
||||
start_time: null
|
||||
};
|
||||
watcherProcess = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn v2 bridge watcher subprocess.
|
||||
* Runs 'codexlens-search watch --root X --debounce-ms Y' and reads JSONL stdout.
|
||||
* @param root - Root directory to watch
|
||||
* @param debounceMs - Debounce interval in milliseconds
|
||||
* @returns Spawned child process
|
||||
*/
|
||||
function spawnV2Watcher(root: string, debounceMs: number): ChildProcess {
|
||||
const { spawn } = require('child_process') as typeof import('child_process');
|
||||
return spawn('codexlens-search', [
|
||||
'watch',
|
||||
'--root', root,
|
||||
'--debounce-ms', String(debounceMs),
|
||||
'--db-path', require('path').join(root, '.codexlens'),
|
||||
], {
|
||||
cwd: root,
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle CodexLens watcher routes
|
||||
* @returns true if route was handled, false otherwise
|
||||
*/
|
||||
export async function handleCodexLensWatcherRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
|
||||
|
||||
// API: Get File Watcher Status
|
||||
if (pathname === '/api/codexlens/watch/status') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
running: watcherStats.running,
|
||||
root_path: watcherStats.root_path,
|
||||
events_processed: watcherStats.events_processed,
|
||||
start_time: watcherStats.start_time?.toISOString() || null,
|
||||
uptime_seconds: watcherStats.start_time
|
||||
? Math.floor((Date.now() - watcherStats.start_time.getTime()) / 1000)
|
||||
: 0
|
||||
}));
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Start File Watcher
|
||||
if (pathname === '/api/codexlens/watch/start' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async (body) => {
|
||||
const { path: watchPath, debounce_ms = 1000 } = body as { path?: unknown; debounce_ms?: unknown };
|
||||
const targetPath = typeof watchPath === 'string' && watchPath.trim().length > 0 ? watchPath : initialPath;
|
||||
const resolvedDebounceMs = typeof debounce_ms === 'number' ? debounce_ms : Number(debounce_ms);
|
||||
const debounceMs = !Number.isNaN(resolvedDebounceMs) && resolvedDebounceMs > 0 ? resolvedDebounceMs : 1000;
|
||||
|
||||
if (watcherStats.running) {
|
||||
return { success: false, error: 'Watcher already running', status: 400 };
|
||||
}
|
||||
|
||||
try {
|
||||
const { spawn } = await import('child_process');
|
||||
const { existsSync, statSync } = await import('fs');
|
||||
|
||||
// Validate path exists and is a directory
|
||||
if (!existsSync(targetPath)) {
|
||||
return { success: false, error: `Path does not exist: ${targetPath}`, status: 400 };
|
||||
}
|
||||
const pathStat = statSync(targetPath);
|
||||
if (!pathStat.isDirectory()) {
|
||||
return { success: false, error: `Path is not a directory: ${targetPath}`, status: 400 };
|
||||
}
|
||||
|
||||
// Route to v2 or v1 watcher based on feature flag
|
||||
if (useCodexLensV2()) {
|
||||
// v2 bridge watcher: codexlens-search watch
|
||||
console.log('[CodexLens] Using v2 bridge watcher');
|
||||
watcherProcess = spawnV2Watcher(targetPath, debounceMs);
|
||||
} else {
|
||||
// v1 watcher: python -m codexlens watch
|
||||
const venvStatus = await checkVenvStatus();
|
||||
if (!venvStatus.ready) {
|
||||
return { success: false, error: 'CodexLens not installed', status: 400 };
|
||||
}
|
||||
|
||||
// Verify directory is indexed before starting watcher
|
||||
try {
|
||||
const statusResult = await executeCodexLens(['projects', 'list', '--json']);
|
||||
if (statusResult.success && statusResult.output) {
|
||||
const parsed = extractJSON(statusResult.output);
|
||||
const projects = parsed.result || parsed || [];
|
||||
const normalizedTarget = targetPath.toLowerCase().replace(/\\/g, '/');
|
||||
const isIndexed = Array.isArray(projects) && projects.some((p: { source_root?: string }) =>
|
||||
p.source_root && p.source_root.toLowerCase().replace(/\\/g, '/') === normalizedTarget
|
||||
);
|
||||
if (!isIndexed) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Directory is not indexed: ${targetPath}. Run 'codexlens init' first.`,
|
||||
status: 400
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[CodexLens] Could not verify index status:', err);
|
||||
// Continue anyway - watcher will fail with proper error if not indexed
|
||||
}
|
||||
|
||||
// Spawn watch process using Python (no shell: true for security)
|
||||
const pythonPath = getVenvPythonPath();
|
||||
const args = ['-m', 'codexlens', 'watch', targetPath, '--debounce', String(debounceMs)];
|
||||
watcherProcess = spawn(pythonPath, args, {
|
||||
cwd: targetPath,
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' }
|
||||
});
|
||||
}
|
||||
|
||||
watcherStats = {
|
||||
running: true,
|
||||
root_path: targetPath,
|
||||
events_processed: 0,
|
||||
start_time: new Date()
|
||||
};
|
||||
|
||||
// Capture stderr for error messages (capped at 4KB to prevent memory leak)
|
||||
const MAX_STDERR_SIZE = 4096;
|
||||
let stderrBuffer = '';
|
||||
if (watcherProcess.stderr) {
|
||||
watcherProcess.stderr.on('data', (data: Buffer) => {
|
||||
stderrBuffer += data.toString();
|
||||
// Cap buffer size to prevent memory leak in long-running watchers
|
||||
if (stderrBuffer.length > MAX_STDERR_SIZE) {
|
||||
stderrBuffer = stderrBuffer.slice(-MAX_STDERR_SIZE);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle process output for event counting
|
||||
const isV2Watcher = useCodexLensV2();
|
||||
let stdoutLineBuffer = '';
|
||||
if (watcherProcess.stdout) {
|
||||
watcherProcess.stdout.on('data', (data: Buffer) => {
|
||||
const output = data.toString();
|
||||
|
||||
if (isV2Watcher) {
|
||||
// v2 bridge outputs JSONL - parse line by line
|
||||
stdoutLineBuffer += output;
|
||||
const lines = stdoutLineBuffer.split('\n');
|
||||
// Keep incomplete last line in buffer
|
||||
stdoutLineBuffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const event = JSON.parse(trimmed);
|
||||
// Count file change events (created, modified, deleted, moved)
|
||||
if (event.event && event.event !== 'watching') {
|
||||
watcherStats.events_processed += 1;
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, skip
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// v1 watcher: count text-based event messages
|
||||
const matches = output.match(/Processed \d+ events?/g);
|
||||
if (matches) {
|
||||
watcherStats.events_processed += matches.length;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle spawn errors (e.g., ENOENT)
|
||||
watcherProcess.on('error', (err: Error) => {
|
||||
console.error(`[CodexLens] Watcher spawn error: ${err.message}`);
|
||||
watcherStats.running = false;
|
||||
watcherProcess = null;
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_WATCHER_STATUS',
|
||||
payload: { running: false, error: `Spawn error: ${err.message}` }
|
||||
});
|
||||
});
|
||||
|
||||
// Handle process exit
|
||||
watcherProcess.on('exit', (code: number) => {
|
||||
watcherStats.running = false;
|
||||
watcherProcess = null;
|
||||
console.log(`[CodexLens] Watcher exited with code ${code}`);
|
||||
|
||||
// Broadcast error if exited with non-zero code
|
||||
if (code !== 0) {
|
||||
const errorMsg = stderrBuffer.trim() || `Exited with code ${code}`;
|
||||
const cleanError = stripAnsiCodes(errorMsg);
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_WATCHER_STATUS',
|
||||
payload: { running: false, error: cleanError }
|
||||
});
|
||||
} else {
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_WATCHER_STATUS',
|
||||
payload: { running: false }
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Broadcast watcher started
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_WATCHER_STATUS',
|
||||
payload: { running: true, path: targetPath }
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Watcher started',
|
||||
path: targetPath,
|
||||
pid: watcherProcess.pid
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// API: Stop File Watcher
|
||||
if (pathname === '/api/codexlens/watch/stop' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async () => {
|
||||
if (!watcherStats.running || !watcherProcess) {
|
||||
return { success: false, error: 'Watcher not running', status: 400 };
|
||||
}
|
||||
|
||||
try {
|
||||
watcherProcess.kill('SIGTERM');
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
if (watcherProcess && !watcherProcess.killed) {
|
||||
watcherProcess.kill('SIGKILL');
|
||||
}
|
||||
|
||||
const finalStats = {
|
||||
events_processed: watcherStats.events_processed,
|
||||
uptime_seconds: watcherStats.start_time
|
||||
? Math.floor((Date.now() - watcherStats.start_time.getTime()) / 1000)
|
||||
: 0
|
||||
};
|
||||
|
||||
watcherStats = {
|
||||
running: false,
|
||||
root_path: '',
|
||||
events_processed: 0,
|
||||
start_time: null
|
||||
};
|
||||
watcherProcess = null;
|
||||
|
||||
broadcastToClients({
|
||||
type: 'CODEXLENS_WATCHER_STATUS',
|
||||
payload: { running: false }
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Watcher stopped',
|
||||
...finalStats
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
return { success: false, error: err instanceof Error ? err.message : String(err), status: 500 };
|
||||
}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -3,17 +3,6 @@
|
||||
* Handles LiteLLM provider management, endpoint configuration, and cache management
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import { spawn } from 'child_process';
|
||||
import {
|
||||
getSystemPythonCommand,
|
||||
parsePythonCommandSpec,
|
||||
type PythonCommandSpec,
|
||||
} from '../../utils/python-utils.js';
|
||||
import {
|
||||
isUvAvailable,
|
||||
createCodexLensUvManager
|
||||
} from '../../utils/uv-manager.js';
|
||||
import { ensureLiteLLMEmbedderReady } from '../../tools/codex-lens.js';
|
||||
import type { RouteContext } from './types.js';
|
||||
|
||||
// ========== Input Validation Schemas ==========
|
||||
@@ -81,106 +70,13 @@ import {
|
||||
type EmbeddingPoolConfig,
|
||||
} from '../../config/litellm-api-config-manager.js';
|
||||
import { getContextCacheStore } from '../../tools/context-cache-store.js';
|
||||
import { getLiteLLMClient } from '../../tools/litellm-client.js';
|
||||
import { testApiKeyConnection, getDefaultApiBase } from '../services/api-key-tester.js';
|
||||
|
||||
interface CcwLitellmEnvCheck {
|
||||
python: string;
|
||||
installed: boolean;
|
||||
version?: string;
|
||||
error?: string;
|
||||
}
|
||||
const V1_REMOVED = 'Python bridge has been removed (v1 cleanup).';
|
||||
|
||||
interface CcwLitellmStatusResponse {
|
||||
/**
|
||||
* Whether ccw-litellm is installed in the CodexLens venv.
|
||||
* This is the environment used for the LiteLLM embedding backend.
|
||||
*/
|
||||
installed: boolean;
|
||||
version?: string;
|
||||
error?: string;
|
||||
checks?: {
|
||||
codexLensVenv: CcwLitellmEnvCheck;
|
||||
systemPython?: CcwLitellmEnvCheck;
|
||||
};
|
||||
}
|
||||
|
||||
function checkCcwLitellmImport(
|
||||
pythonCmd: string | PythonCommandSpec,
|
||||
options: { timeout: number }
|
||||
): Promise<CcwLitellmEnvCheck> {
|
||||
const { timeout } = options;
|
||||
const pythonSpec = typeof pythonCmd === 'string' ? parsePythonCommandSpec(pythonCmd) : pythonCmd;
|
||||
|
||||
const sanitizePythonError = (stderrText: string): string | undefined => {
|
||||
const trimmed = stderrText.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const lines = trimmed
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
// Prefer the final exception line (avoids leaking full traceback + file paths)
|
||||
return lines[lines.length - 1] || undefined;
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(pythonSpec.command, [...pythonSpec.args, '-c', 'import ccw_litellm; print(ccw_litellm.__version__)'], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout,
|
||||
windowsHide: true,
|
||||
shell: false,
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
const version = stdout.trim();
|
||||
const error = sanitizePythonError(stderr);
|
||||
|
||||
if (code === 0 && version) {
|
||||
resolve({ python: pythonSpec.display, installed: true, version });
|
||||
return;
|
||||
}
|
||||
|
||||
if (code === null) {
|
||||
resolve({ python: pythonSpec.display, installed: false, error: `Timed out after ${timeout}ms` });
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({ python: pythonSpec.display, installed: false, error: error || undefined });
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
resolve({ python: pythonSpec.display, installed: false, error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Cache for ccw-litellm status check
|
||||
let ccwLitellmStatusCache: {
|
||||
data: CcwLitellmStatusResponse | null;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
} = {
|
||||
data: null,
|
||||
timestamp: 0,
|
||||
ttl: 5 * 60 * 1000, // 5 minutes
|
||||
};
|
||||
|
||||
// Clear cache (call after install)
|
||||
// Clear cache (no-op stub, kept for backward compatibility)
|
||||
export function clearCcwLitellmStatusCache() {
|
||||
ccwLitellmStatusCache.data = null;
|
||||
ccwLitellmStatusCache.timestamp = 0;
|
||||
// no-op: Python bridge removed
|
||||
}
|
||||
|
||||
function sanitizeProviderForResponse(provider: any): any {
|
||||
@@ -922,57 +818,10 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
|
||||
// CCW-LiteLLM Package Management
|
||||
// ===========================
|
||||
|
||||
// GET /api/litellm-api/ccw-litellm/status - Check ccw-litellm installation status
|
||||
// Supports ?refresh=true to bypass cache
|
||||
// GET /api/litellm-api/ccw-litellm/status - Stub (v1 Python bridge removed)
|
||||
if (pathname === '/api/litellm-api/ccw-litellm/status' && req.method === 'GET') {
|
||||
const forceRefresh = url.searchParams.get('refresh') === 'true';
|
||||
|
||||
// Check cache first (unless force refresh)
|
||||
if (!forceRefresh && ccwLitellmStatusCache.data &&
|
||||
Date.now() - ccwLitellmStatusCache.timestamp < ccwLitellmStatusCache.ttl) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(ccwLitellmStatusCache.data));
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const uv = createCodexLensUvManager();
|
||||
const venvPython = uv.getVenvPython();
|
||||
const statusTimeout = process.platform === 'win32' ? 15000 : 10000;
|
||||
const codexLensVenv = uv.isVenvValid()
|
||||
? await checkCcwLitellmImport(venvPython, { timeout: statusTimeout })
|
||||
: { python: venvPython, installed: false, error: 'CodexLens venv not valid' };
|
||||
|
||||
// Diagnostics only: if not installed in venv, also check system python so users understand mismatches.
|
||||
// NOTE: `installed` flag remains the CodexLens venv status (we want isolated venv dependencies).
|
||||
const systemPython = !codexLensVenv.installed
|
||||
? await checkCcwLitellmImport(getSystemPythonCommand(), { timeout: statusTimeout })
|
||||
: undefined;
|
||||
|
||||
const result: CcwLitellmStatusResponse = {
|
||||
installed: codexLensVenv.installed,
|
||||
version: codexLensVenv.version,
|
||||
error: codexLensVenv.error,
|
||||
checks: {
|
||||
codexLensVenv,
|
||||
...(systemPython ? { systemPython } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
// Update cache
|
||||
ccwLitellmStatusCache = {
|
||||
data: result,
|
||||
timestamp: Date.now(),
|
||||
ttl: 5 * 60 * 1000,
|
||||
};
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(result));
|
||||
} catch (err) {
|
||||
const errorResult = { installed: false, error: (err as Error).message };
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(errorResult));
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ installed: false, error: V1_REMOVED }));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1367,96 +1216,18 @@ export async function handleLiteLLMApiRoutes(ctx: RouteContext): Promise<boolean
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/litellm-api/ccw-litellm/install - Install ccw-litellm package
|
||||
// POST /api/litellm-api/ccw-litellm/install - Stub (v1 Python bridge removed)
|
||||
if (pathname === '/api/litellm-api/ccw-litellm/install' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async () => {
|
||||
try {
|
||||
// Delegate entirely to ensureLiteLLMEmbedderReady for consistent installation
|
||||
// This uses unified package discovery and handles UV → pip fallback
|
||||
const result = await ensureLiteLLMEmbedderReady();
|
||||
|
||||
if (result.success) {
|
||||
clearCcwLitellmStatusCache();
|
||||
broadcastToClients({
|
||||
type: 'CCW_LITELLM_INSTALLED',
|
||||
payload: { timestamp: new Date().toISOString(), method: 'unified' }
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
return { success: false, error: V1_REMOVED };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// POST /api/litellm-api/ccw-litellm/uninstall - Uninstall ccw-litellm package
|
||||
// POST /api/litellm-api/ccw-litellm/uninstall - Stub (v1 Python bridge removed)
|
||||
if (pathname === '/api/litellm-api/ccw-litellm/uninstall' && req.method === 'POST') {
|
||||
handlePostRequest(req, res, async () => {
|
||||
try {
|
||||
// Priority 1: Use UV to uninstall from CodexLens venv
|
||||
if (await isUvAvailable()) {
|
||||
const uv = createCodexLensUvManager();
|
||||
if (uv.isVenvValid()) {
|
||||
console.log('[ccw-litellm uninstall] Using UV to uninstall from CodexLens venv...');
|
||||
const uvResult = await uv.uninstall(['ccw-litellm']);
|
||||
clearCcwLitellmStatusCache();
|
||||
|
||||
if (uvResult.success) {
|
||||
broadcastToClients({
|
||||
type: 'CCW_LITELLM_UNINSTALLED',
|
||||
payload: { timestamp: new Date().toISOString() }
|
||||
});
|
||||
return { success: true, message: 'ccw-litellm uninstalled successfully via UV' };
|
||||
}
|
||||
console.log('[ccw-litellm uninstall] UV uninstall failed, falling back to pip:', uvResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Fallback to system pip uninstall
|
||||
console.log('[ccw-litellm uninstall] Using pip fallback...');
|
||||
const pythonCmd = getSystemPythonCommand();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(
|
||||
pythonCmd.command,
|
||||
[...pythonCmd.args, '-m', 'pip', 'uninstall', '-y', 'ccw-litellm'],
|
||||
{
|
||||
shell: false,
|
||||
timeout: 120000,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
||||
},
|
||||
);
|
||||
let output = '';
|
||||
let error = '';
|
||||
proc.stdout?.on('data', (data) => { output += data.toString(); });
|
||||
proc.stderr?.on('data', (data) => { error += data.toString(); });
|
||||
proc.on('close', (code) => {
|
||||
// Clear status cache after uninstallation attempt
|
||||
clearCcwLitellmStatusCache();
|
||||
|
||||
if (code === 0) {
|
||||
broadcastToClients({
|
||||
type: 'CCW_LITELLM_UNINSTALLED',
|
||||
payload: { timestamp: new Date().toISOString() }
|
||||
});
|
||||
resolve({ success: true, message: 'ccw-litellm uninstalled successfully' });
|
||||
} else {
|
||||
// Check if package was not installed
|
||||
if (error.includes('not installed') || output.includes('not installed')) {
|
||||
resolve({ success: true, message: 'ccw-litellm was not installed' });
|
||||
} else {
|
||||
resolve({ success: false, error: error || output || 'Uninstallation failed' });
|
||||
}
|
||||
}
|
||||
});
|
||||
proc.on('error', (err) => resolve({ success: false, error: err.message }));
|
||||
});
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message };
|
||||
}
|
||||
return { success: false, error: V1_REMOVED };
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { getCliToolsStatus } from '../../tools/cli-executor.js';
|
||||
import { checkVenvStatus, checkSemanticStatus } from '../../tools/codex-lens.js';
|
||||
import type { RouteContext } from './types.js';
|
||||
|
||||
// Performance logging helper
|
||||
@@ -80,36 +79,14 @@ export async function handleStatusRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const ccwInstallStatus = checkCcwInstallStatus();
|
||||
perfLog('checkCcwInstallStatus', ccwStart);
|
||||
|
||||
// Execute all status checks in parallel with individual timing
|
||||
// Execute async status checks
|
||||
const cliStart = Date.now();
|
||||
const codexStart = Date.now();
|
||||
const semanticStart = Date.now();
|
||||
|
||||
const [cliStatus, codexLensStatus, semanticStatus] = await Promise.all([
|
||||
getCliToolsStatus().then(result => {
|
||||
perfLog('getCliToolsStatus', cliStart, { toolCount: Object.keys(result).length });
|
||||
return result;
|
||||
}),
|
||||
checkVenvStatus().then(result => {
|
||||
perfLog('checkVenvStatus', codexStart, { ready: result.ready });
|
||||
return result;
|
||||
}),
|
||||
// Always check semantic status (will return available: false if CodexLens not ready)
|
||||
checkSemanticStatus()
|
||||
.then(result => {
|
||||
perfLog('checkSemanticStatus', semanticStart, { available: result.available });
|
||||
return result;
|
||||
})
|
||||
.catch(() => {
|
||||
perfLog('checkSemanticStatus (error)', semanticStart);
|
||||
return { available: false, backend: null };
|
||||
})
|
||||
]);
|
||||
const cliStatus = await getCliToolsStatus();
|
||||
perfLog('getCliToolsStatus', cliStart, { toolCount: Object.keys(cliStatus).length });
|
||||
|
||||
const response = {
|
||||
cli: cliStatus,
|
||||
codexLens: codexLensStatus,
|
||||
semantic: semanticStatus,
|
||||
ccwInstall: ccwInstallStatus,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
@@ -16,7 +16,6 @@ import { handleUnifiedMemoryRoutes } from './routes/unified-memory-routes.js';
|
||||
import { handleMcpRoutes } from './routes/mcp-routes.js';
|
||||
import { handleHooksRoutes } from './routes/hooks-routes.js';
|
||||
import { handleUnsplashRoutes, handleBackgroundRoutes } from './routes/unsplash-routes.js';
|
||||
import { handleCodexLensRoutes } from './routes/codexlens-routes.js';
|
||||
import { handleGraphRoutes } from './routes/graph-routes.js';
|
||||
import { handleSystemRoutes } from './routes/system-routes.js';
|
||||
import { handleFilesRoutes } from './routes/files-routes.js';
|
||||
@@ -66,7 +65,6 @@ import { getCliSessionManager } from './services/cli-session-manager.js';
|
||||
import { QueueSchedulerService } from './services/queue-scheduler-service.js';
|
||||
|
||||
// Import status check functions for warmup
|
||||
import { checkSemanticStatus, checkVenvStatus } from '../tools/codex-lens.js';
|
||||
import { getCliToolsStatus } from '../tools/cli-executor.js';
|
||||
|
||||
import type { ServerConfig } from '../types/config.js';
|
||||
@@ -302,28 +300,6 @@ async function warmupCaches(initialPath: string): Promise<void> {
|
||||
|
||||
// Run all warmup tasks in parallel for faster startup
|
||||
const warmupTasks = [
|
||||
// Warmup semantic status cache (Python process startup - can be slow first time)
|
||||
(async () => {
|
||||
const taskStart = Date.now();
|
||||
try {
|
||||
const semanticStatus = await checkSemanticStatus();
|
||||
console.log(`[WARMUP] Semantic status: ${semanticStatus.available ? 'available' : 'not available'} (${Date.now() - taskStart}ms)`);
|
||||
} catch (err) {
|
||||
console.warn(`[WARMUP] Semantic status check failed: ${(err as Error).message}`);
|
||||
}
|
||||
})(),
|
||||
|
||||
// Warmup venv status cache
|
||||
(async () => {
|
||||
const taskStart = Date.now();
|
||||
try {
|
||||
const venvStatus = await checkVenvStatus();
|
||||
console.log(`[WARMUP] Venv status: ${venvStatus.ready ? 'ready' : 'not ready'} (${Date.now() - taskStart}ms)`);
|
||||
} catch (err) {
|
||||
console.warn(`[WARMUP] Venv status check failed: ${(err as Error).message}`);
|
||||
}
|
||||
})(),
|
||||
|
||||
// Warmup CLI tools status cache
|
||||
(async () => {
|
||||
const taskStart = Date.now();
|
||||
@@ -598,11 +574,6 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleUnsplashRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// CodexLens routes (/api/codexlens/*)
|
||||
if (pathname.startsWith('/api/codexlens/')) {
|
||||
if (await handleCodexLensRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// LiteLLM routes (/api/litellm/*)
|
||||
if (pathname.startsWith('/api/litellm/')) {
|
||||
if (await handleLiteLLMRoutes(routeContext)) return;
|
||||
|
||||
@@ -1,79 +1,37 @@
|
||||
/**
|
||||
* Unified Vector Index - TypeScript bridge to unified_memory_embedder.py
|
||||
* Unified Vector Index - STUB (v1 Python bridge removed)
|
||||
*
|
||||
* Provides HNSW-backed vector indexing and search for all memory content
|
||||
* (core_memory, cli_history, workflow, entity, pattern) via CodexLens VectorStore.
|
||||
*
|
||||
* Features:
|
||||
* - JSON stdin/stdout protocol to Python embedder
|
||||
* - Content chunking (paragraph -> sentence splitting, CHUNK_SIZE=1500, OVERLAP=200)
|
||||
* - Batch embedding via CodexLens EmbedderFactory
|
||||
* - HNSW approximate nearest neighbor search (sub-10ms for 1000 chunks)
|
||||
* - Category-based filtering
|
||||
* The Python unified_memory_embedder.py bridge has been removed. This module
|
||||
* provides no-op stubs so that existing consumers compile without errors.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getCodexLensHiddenPython } from '../utils/codexlens-path.js';
|
||||
import { StoragePaths, ensureStorageDir } from '../config/storage-paths.js';
|
||||
const V1_REMOVED = 'Unified vector index Python bridge has been removed (v1 cleanup).';
|
||||
|
||||
// Get directory of this module
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types (kept for backward compatibility)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Venv python path (reuse CodexLens venv)
|
||||
const VENV_PYTHON = getCodexLensHiddenPython();
|
||||
|
||||
// Script path
|
||||
const EMBEDDER_SCRIPT = join(__dirname, '..', '..', 'scripts', 'unified_memory_embedder.py');
|
||||
|
||||
// Chunking constants (match existing core-memory-store.ts)
|
||||
const CHUNK_SIZE = 1500;
|
||||
const OVERLAP = 200;
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
/** Valid source types for vector content */
|
||||
export type SourceType = 'core_memory' | 'workflow' | 'cli_history';
|
||||
|
||||
/** Valid category values for vector filtering */
|
||||
export type VectorCategory = 'core_memory' | 'cli_history' | 'workflow' | 'entity' | 'pattern';
|
||||
|
||||
/** Metadata attached to each chunk in the vector store */
|
||||
export interface ChunkMetadata {
|
||||
/** Source identifier (e.g., memory ID, session ID) */
|
||||
source_id: string;
|
||||
/** Source type */
|
||||
source_type: SourceType;
|
||||
/** Category for filtering */
|
||||
category: VectorCategory;
|
||||
/** Chunk index within the source */
|
||||
chunk_index?: number;
|
||||
/** Additional metadata */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** A chunk to be embedded and indexed */
|
||||
export interface VectorChunk {
|
||||
/** Text content */
|
||||
content: string;
|
||||
/** Source identifier */
|
||||
source_id: string;
|
||||
/** Source type */
|
||||
source_type: SourceType;
|
||||
/** Category for filtering */
|
||||
category: VectorCategory;
|
||||
/** Chunk index */
|
||||
chunk_index: number;
|
||||
/** Additional metadata */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Result of an embed operation */
|
||||
export interface EmbedResult {
|
||||
success: boolean;
|
||||
chunks_processed: number;
|
||||
@@ -82,7 +40,6 @@ export interface EmbedResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A single search match */
|
||||
export interface VectorSearchMatch {
|
||||
content: string;
|
||||
score: number;
|
||||
@@ -93,7 +50,6 @@ export interface VectorSearchMatch {
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Result of a search operation */
|
||||
export interface VectorSearchResult {
|
||||
success: boolean;
|
||||
matches: VectorSearchMatch[];
|
||||
@@ -102,14 +58,12 @@ export interface VectorSearchResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Search options */
|
||||
export interface VectorSearchOptions {
|
||||
topK?: number;
|
||||
minScore?: number;
|
||||
category?: VectorCategory;
|
||||
}
|
||||
|
||||
/** Index status information */
|
||||
export interface VectorIndexStatus {
|
||||
success: boolean;
|
||||
total_chunks: number;
|
||||
@@ -126,7 +80,6 @@ export interface VectorIndexStatus {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Reindex result */
|
||||
export interface ReindexResult {
|
||||
success: boolean;
|
||||
hnsw_count?: number;
|
||||
@@ -134,344 +87,73 @@ export interface ReindexResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Python Bridge
|
||||
// =============================================================================
|
||||
// ---------------------------------------------------------------------------
|
||||
// No-op implementations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Check if the unified embedder is available (venv and script exist)
|
||||
*/
|
||||
export function isUnifiedEmbedderAvailable(): boolean {
|
||||
if (!existsSync(VENV_PYTHON)) {
|
||||
return false;
|
||||
}
|
||||
if (!existsSync(EMBEDDER_SCRIPT)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run Python script with JSON stdin/stdout protocol.
|
||||
*
|
||||
* @param request - JSON request object to send via stdin
|
||||
* @param timeout - Timeout in milliseconds (default: 5 minutes)
|
||||
* @returns Parsed JSON response
|
||||
*/
|
||||
function runPython<T>(request: Record<string, unknown>, timeout: number = 300000): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isUnifiedEmbedderAvailable()) {
|
||||
reject(
|
||||
new Error(
|
||||
'Unified embedder not available. Ensure CodexLens venv exists at ~/.codexlens/venv'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const child = spawn(VENV_PYTHON, [EMBEDDER_SCRIPT], {
|
||||
shell: false,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0 && stdout.trim()) {
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim()) as T);
|
||||
} catch {
|
||||
reject(new Error(`Failed to parse Python output: ${stdout.substring(0, 500)}`));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Python script failed (exit code ${code}): ${stderr || stdout}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ETIMEDOUT') {
|
||||
reject(new Error('Python script timed out'));
|
||||
} else {
|
||||
reject(new Error(`Failed to spawn Python: ${err.message}`));
|
||||
}
|
||||
});
|
||||
|
||||
// Write JSON request to stdin and close
|
||||
const jsonInput = JSON.stringify(request);
|
||||
child.stdin.write(jsonInput);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Content Chunking
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Chunk content into smaller pieces for embedding.
|
||||
* Uses paragraph-first, sentence-fallback strategy with overlap.
|
||||
*
|
||||
* Matches the chunking logic in core-memory-store.ts:
|
||||
* - CHUNK_SIZE = 1500 characters
|
||||
* - OVERLAP = 200 characters
|
||||
* - Split by paragraph boundaries (\n\n) first
|
||||
* - Fall back to sentence boundaries (. ) for oversized paragraphs
|
||||
*
|
||||
* @param content - Text content to chunk
|
||||
* @returns Array of chunk strings
|
||||
*/
|
||||
export function chunkContent(content: string): string[] {
|
||||
const chunks: string[] = [];
|
||||
|
||||
// Split by paragraph boundaries first
|
||||
const paragraphs = content.split(/\n\n+/);
|
||||
let currentChunk = '';
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
// If adding this paragraph would exceed chunk size
|
||||
if (currentChunk.length + paragraph.length > CHUNK_SIZE && currentChunk.length > 0) {
|
||||
chunks.push(currentChunk.trim());
|
||||
|
||||
// Start new chunk with overlap
|
||||
const overlapText = currentChunk.slice(-OVERLAP);
|
||||
currentChunk = overlapText + '\n\n' + paragraph;
|
||||
} else {
|
||||
currentChunk += (currentChunk ? '\n\n' : '') + paragraph;
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining chunk
|
||||
if (currentChunk.trim()) {
|
||||
chunks.push(currentChunk.trim());
|
||||
}
|
||||
|
||||
// If chunks are still too large, split by sentences
|
||||
const finalChunks: string[] = [];
|
||||
for (const chunk of chunks) {
|
||||
if (chunk.length <= CHUNK_SIZE) {
|
||||
finalChunks.push(chunk);
|
||||
} else {
|
||||
// Split by sentence boundaries
|
||||
const sentences = chunk.split(/\. +/);
|
||||
let sentenceChunk = '';
|
||||
|
||||
for (const sentence of sentences) {
|
||||
const sentenceWithPeriod = sentence + '. ';
|
||||
if (
|
||||
sentenceChunk.length + sentenceWithPeriod.length > CHUNK_SIZE &&
|
||||
sentenceChunk.length > 0
|
||||
) {
|
||||
finalChunks.push(sentenceChunk.trim());
|
||||
const overlapText = sentenceChunk.slice(-OVERLAP);
|
||||
sentenceChunk = overlapText + sentenceWithPeriod;
|
||||
} else {
|
||||
sentenceChunk += sentenceWithPeriod;
|
||||
}
|
||||
}
|
||||
|
||||
if (sentenceChunk.trim()) {
|
||||
finalChunks.push(sentenceChunk.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return finalChunks.length > 0 ? finalChunks : [content];
|
||||
// Minimal chunking for backward compat - just return the content as-is
|
||||
if (!content.trim()) return [];
|
||||
return [content];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// UnifiedVectorIndex Class
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Unified vector index backed by CodexLens VectorStore (HNSW).
|
||||
*
|
||||
* Provides content chunking, embedding, storage, and search for all
|
||||
* memory content types through a single interface.
|
||||
*/
|
||||
export class UnifiedVectorIndex {
|
||||
private storePath: string;
|
||||
constructor(_projectPath: string) {}
|
||||
|
||||
/**
|
||||
* Create a UnifiedVectorIndex for a project.
|
||||
*
|
||||
* @param projectPath - Project root path (used to resolve storage location)
|
||||
*/
|
||||
constructor(projectPath: string) {
|
||||
const paths = StoragePaths.project(projectPath);
|
||||
this.storePath = paths.unifiedVectors.root;
|
||||
ensureStorageDir(this.storePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Index content by chunking, embedding, and storing in VectorStore.
|
||||
*
|
||||
* @param content - Text content to index
|
||||
* @param metadata - Metadata for all chunks (source_id, source_type, category)
|
||||
* @returns Embed result
|
||||
*/
|
||||
async indexContent(
|
||||
content: string,
|
||||
metadata: ChunkMetadata
|
||||
_content: string,
|
||||
_metadata: ChunkMetadata
|
||||
): Promise<EmbedResult> {
|
||||
if (!content.trim()) {
|
||||
return {
|
||||
success: true,
|
||||
chunks_processed: 0,
|
||||
chunks_failed: 0,
|
||||
elapsed_time: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Chunk content
|
||||
const textChunks = chunkContent(content);
|
||||
|
||||
// Build chunk objects for Python
|
||||
const chunks: VectorChunk[] = textChunks.map((text, index) => ({
|
||||
content: text,
|
||||
source_id: metadata.source_id,
|
||||
source_type: metadata.source_type,
|
||||
category: metadata.category,
|
||||
chunk_index: metadata.chunk_index != null ? metadata.chunk_index + index : index,
|
||||
metadata: { ...metadata },
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await runPython<EmbedResult>({
|
||||
operation: 'embed',
|
||||
store_path: this.storePath,
|
||||
chunks,
|
||||
batch_size: 8,
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
chunks_processed: 0,
|
||||
chunks_failed: textChunks.length,
|
||||
elapsed_time: 0,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
chunks_processed: 0,
|
||||
chunks_failed: 0,
|
||||
elapsed_time: 0,
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the vector index using semantic similarity.
|
||||
*
|
||||
* @param query - Natural language search query
|
||||
* @param options - Search options (topK, minScore, category)
|
||||
* @returns Search results sorted by relevance
|
||||
*/
|
||||
async search(
|
||||
query: string,
|
||||
options: VectorSearchOptions = {}
|
||||
_query: string,
|
||||
_options: VectorSearchOptions = {}
|
||||
): Promise<VectorSearchResult> {
|
||||
const { topK = 10, minScore = 0.3, category } = options;
|
||||
|
||||
try {
|
||||
const result = await runPython<VectorSearchResult>({
|
||||
operation: 'search',
|
||||
store_path: this.storePath,
|
||||
query,
|
||||
top_k: topK,
|
||||
min_score: minScore,
|
||||
category: category || null,
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
matches: [],
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
matches: [],
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the vector index using a pre-computed embedding vector.
|
||||
* Bypasses text embedding, directly querying HNSW with a raw vector.
|
||||
*
|
||||
* @param vector - Pre-computed embedding vector (array of floats)
|
||||
* @param options - Search options (topK, minScore, category)
|
||||
* @returns Search results sorted by relevance
|
||||
*/
|
||||
async searchByVector(
|
||||
vector: number[],
|
||||
options: VectorSearchOptions = {}
|
||||
_vector: number[],
|
||||
_options: VectorSearchOptions = {}
|
||||
): Promise<VectorSearchResult> {
|
||||
const { topK = 10, minScore = 0.3, category } = options;
|
||||
|
||||
try {
|
||||
const result = await runPython<VectorSearchResult>({
|
||||
operation: 'search_by_vector',
|
||||
store_path: this.storePath,
|
||||
vector,
|
||||
top_k: topK,
|
||||
min_score: minScore,
|
||||
category: category || null,
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
matches: [],
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
matches: [],
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the HNSW index from scratch.
|
||||
*
|
||||
* @returns Reindex result
|
||||
*/
|
||||
async reindexAll(): Promise<ReindexResult> {
|
||||
try {
|
||||
const result = await runPython<ReindexResult>({
|
||||
operation: 'reindex',
|
||||
store_path: this.storePath,
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current status of the vector index.
|
||||
*
|
||||
* @returns Index status including chunk counts, HNSW availability, dimension
|
||||
*/
|
||||
async getStatus(): Promise<VectorIndexStatus> {
|
||||
try {
|
||||
const result = await runPython<VectorIndexStatus>({
|
||||
operation: 'status',
|
||||
store_path: this.storePath,
|
||||
});
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
total_chunks: 0,
|
||||
hnsw_available: false,
|
||||
hnsw_count: 0,
|
||||
dimension: 0,
|
||||
error: (err as Error).message,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
total_chunks: 0,
|
||||
hnsw_available: false,
|
||||
hnsw_count: 0,
|
||||
dimension: 0,
|
||||
error: V1_REMOVED,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,405 +0,0 @@
|
||||
/**
|
||||
* CodexLens LSP Tool - Provides LSP-like code intelligence via CodexLens Python API
|
||||
*
|
||||
* Features:
|
||||
* - symbol_search: Search symbols across workspace
|
||||
* - find_definition: Go to symbol definition
|
||||
* - find_references: Find all symbol references
|
||||
* - get_hover: Get hover information for symbols
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
||||
import { spawn } from 'child_process';
|
||||
import { join } from 'path';
|
||||
import { getProjectRoot } from '../utils/path-validator.js';
|
||||
import { getCodexLensHiddenPython } from '../utils/codexlens-path.js';
|
||||
|
||||
// CodexLens venv configuration
|
||||
const CODEXLENS_VENV = getCodexLensHiddenPython();
|
||||
|
||||
// Define Zod schema for validation
|
||||
const ParamsSchema = z.object({
|
||||
action: z.enum(['symbol_search', 'find_definition', 'find_references', 'get_hover']),
|
||||
project_root: z.string().optional().describe('Project root directory (auto-detected if not provided)'),
|
||||
symbol_name: z.string().describe('Symbol name to search/query'),
|
||||
symbol_kind: z.string().optional().describe('Symbol kind filter (class, function, method, etc.)'),
|
||||
file_context: z.string().optional().describe('Current file path for proximity ranking'),
|
||||
limit: z.number().default(50).describe('Maximum number of results to return'),
|
||||
kind_filter: z.array(z.string()).optional().describe('List of symbol kinds to filter (for symbol_search)'),
|
||||
file_pattern: z.string().optional().describe('Glob pattern to filter files (for symbol_search)'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof ParamsSchema>;
|
||||
|
||||
/**
|
||||
* Result types
|
||||
*/
|
||||
interface SymbolInfo {
|
||||
name: string;
|
||||
kind: string;
|
||||
file_path: string;
|
||||
range: {
|
||||
start_line: number;
|
||||
end_line: number;
|
||||
};
|
||||
score?: number;
|
||||
}
|
||||
|
||||
interface DefinitionResult {
|
||||
name: string;
|
||||
kind: string;
|
||||
file_path: string;
|
||||
range: {
|
||||
start_line: number;
|
||||
end_line: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface ReferenceResult {
|
||||
file_path: string;
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
|
||||
interface HoverInfo {
|
||||
name: string;
|
||||
kind: string;
|
||||
signature: string;
|
||||
file_path: string;
|
||||
start_line: number;
|
||||
}
|
||||
|
||||
type LSPResult = {
|
||||
success: boolean;
|
||||
results?: SymbolInfo[] | DefinitionResult[] | ReferenceResult[] | HoverInfo;
|
||||
error?: string;
|
||||
action: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Execute CodexLens Python API call
|
||||
*/
|
||||
async function executeCodexLensAPI(
|
||||
apiFunction: string,
|
||||
args: Record<string, unknown>,
|
||||
timeout: number = 30000
|
||||
): Promise<LSPResult> {
|
||||
return new Promise((resolve) => {
|
||||
// Build Python script to call API function
|
||||
const pythonScript = `
|
||||
import json
|
||||
import sys
|
||||
from dataclasses import is_dataclass, asdict
|
||||
from codexlens.api import ${apiFunction}
|
||||
|
||||
def to_serializable(obj):
|
||||
"""Recursively convert dataclasses to dicts for JSON serialization."""
|
||||
if obj is None:
|
||||
return None
|
||||
if is_dataclass(obj) and not isinstance(obj, type):
|
||||
return asdict(obj)
|
||||
if isinstance(obj, list):
|
||||
return [to_serializable(item) for item in obj]
|
||||
if isinstance(obj, dict):
|
||||
return {key: to_serializable(value) for key, value in obj.items()}
|
||||
if isinstance(obj, tuple):
|
||||
return tuple(to_serializable(item) for item in obj)
|
||||
return obj
|
||||
|
||||
try:
|
||||
args = ${JSON.stringify(args)}
|
||||
result = ${apiFunction}(**args)
|
||||
|
||||
# Convert result to JSON-serializable format
|
||||
output = to_serializable(result)
|
||||
|
||||
print(json.dumps({"success": True, "result": output}))
|
||||
except Exception as e:
|
||||
print(json.dumps({"success": False, "error": str(e)}), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
`;
|
||||
|
||||
const child = spawn(CODEXLENS_VENV, ['-c', pythonScript], {
|
||||
shell: false,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' },
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
try {
|
||||
const errorData = JSON.parse(stderr);
|
||||
resolve({
|
||||
success: false,
|
||||
error: errorData.error || 'Unknown error',
|
||||
action: apiFunction,
|
||||
});
|
||||
} catch {
|
||||
resolve({
|
||||
success: false,
|
||||
error: stderr || `Process exited with code ${code}`,
|
||||
action: apiFunction,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
resolve({
|
||||
success: data.success,
|
||||
results: data.result,
|
||||
action: apiFunction,
|
||||
});
|
||||
} catch (err) {
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Failed to parse output: ${(err as Error).message}`,
|
||||
action: apiFunction,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
resolve({
|
||||
success: false,
|
||||
error: `Failed to execute: ${err.message}`,
|
||||
action: apiFunction,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler: symbol_search
|
||||
*/
|
||||
async function handleSymbolSearch(params: Params): Promise<LSPResult> {
|
||||
const projectRoot = params.project_root || getProjectRoot();
|
||||
|
||||
const args: Record<string, unknown> = {
|
||||
project_root: projectRoot,
|
||||
query: params.symbol_name,
|
||||
limit: params.limit,
|
||||
};
|
||||
|
||||
if (params.kind_filter) {
|
||||
args.kind_filter = params.kind_filter;
|
||||
}
|
||||
|
||||
if (params.file_pattern) {
|
||||
args.file_pattern = params.file_pattern;
|
||||
}
|
||||
|
||||
return executeCodexLensAPI('workspace_symbols', args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler: find_definition
|
||||
*/
|
||||
async function handleFindDefinition(params: Params): Promise<LSPResult> {
|
||||
const projectRoot = params.project_root || getProjectRoot();
|
||||
|
||||
const args: Record<string, unknown> = {
|
||||
project_root: projectRoot,
|
||||
symbol_name: params.symbol_name,
|
||||
limit: params.limit,
|
||||
};
|
||||
|
||||
if (params.symbol_kind) {
|
||||
args.symbol_kind = params.symbol_kind;
|
||||
}
|
||||
|
||||
if (params.file_context) {
|
||||
args.file_context = params.file_context;
|
||||
}
|
||||
|
||||
return executeCodexLensAPI('find_definition', args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler: find_references
|
||||
*/
|
||||
async function handleFindReferences(params: Params): Promise<LSPResult> {
|
||||
const projectRoot = params.project_root || getProjectRoot();
|
||||
|
||||
const args: Record<string, unknown> = {
|
||||
project_root: projectRoot,
|
||||
symbol_name: params.symbol_name,
|
||||
limit: params.limit,
|
||||
};
|
||||
|
||||
if (params.symbol_kind) {
|
||||
args.symbol_kind = params.symbol_kind;
|
||||
}
|
||||
|
||||
return executeCodexLensAPI('find_references', args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler: get_hover
|
||||
*/
|
||||
async function handleGetHover(params: Params): Promise<LSPResult> {
|
||||
const projectRoot = params.project_root || getProjectRoot();
|
||||
|
||||
const args: Record<string, unknown> = {
|
||||
project_root: projectRoot,
|
||||
symbol_name: params.symbol_name,
|
||||
};
|
||||
|
||||
if (params.file_context) {
|
||||
args.file_path = params.file_context;
|
||||
}
|
||||
|
||||
return executeCodexLensAPI('get_hover', args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main handler function
|
||||
*/
|
||||
export async function handler(params: Record<string, unknown>): Promise<ToolResult<LSPResult>> {
|
||||
try {
|
||||
// Validate parameters
|
||||
const validatedParams = ParamsSchema.parse(params);
|
||||
|
||||
// Route to appropriate handler based on action
|
||||
let result: LSPResult;
|
||||
switch (validatedParams.action) {
|
||||
case 'symbol_search':
|
||||
result = await handleSymbolSearch(validatedParams);
|
||||
break;
|
||||
case 'find_definition':
|
||||
result = await handleFindDefinition(validatedParams);
|
||||
break;
|
||||
case 'find_references':
|
||||
result = await handleFindReferences(validatedParams);
|
||||
break;
|
||||
case 'get_hover':
|
||||
result = await handleGetHover(validatedParams);
|
||||
break;
|
||||
default:
|
||||
return {
|
||||
success: false,
|
||||
error: `Unknown action: ${(validatedParams as any).action}`,
|
||||
result: null as any,
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error || 'Unknown error',
|
||||
result: null as any,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Parameter validation failed: ${err.issues.map((e: z.ZodIssue) => `${e.path.join('.')}: ${e.message}`).join(', ')}`,
|
||||
result: null as any,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: `Execution failed: ${(err as Error).message}`,
|
||||
result: null as any,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool schema for MCP
|
||||
*/
|
||||
export const schema: ToolSchema = {
|
||||
name: 'codex_lens_lsp',
|
||||
description: `LSP-like code intelligence tool powered by CodexLens indexing.
|
||||
|
||||
**Actions:**
|
||||
- symbol_search: Search for symbols across the workspace
|
||||
- find_definition: Find the definition of a symbol
|
||||
- find_references: Find all references to a symbol
|
||||
- get_hover: Get hover information for a symbol
|
||||
|
||||
**Usage Examples:**
|
||||
|
||||
Search symbols:
|
||||
codex_lens_lsp(action="symbol_search", symbol_name="MyClass")
|
||||
codex_lens_lsp(action="symbol_search", symbol_name="auth", kind_filter=["function", "method"])
|
||||
codex_lens_lsp(action="symbol_search", symbol_name="User", file_pattern="*.py")
|
||||
|
||||
Find definition:
|
||||
codex_lens_lsp(action="find_definition", symbol_name="authenticate")
|
||||
codex_lens_lsp(action="find_definition", symbol_name="User", symbol_kind="class")
|
||||
|
||||
Find references:
|
||||
codex_lens_lsp(action="find_references", symbol_name="login")
|
||||
|
||||
Get hover info:
|
||||
codex_lens_lsp(action="get_hover", symbol_name="processPayment")
|
||||
|
||||
**Requirements:**
|
||||
- CodexLens must be installed and indexed: run smart_search(action="init") first
|
||||
- Python environment with codex-lens package available`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
action: {
|
||||
type: 'string',
|
||||
enum: ['symbol_search', 'find_definition', 'find_references', 'get_hover'],
|
||||
description: 'LSP action to perform',
|
||||
},
|
||||
symbol_name: {
|
||||
type: 'string',
|
||||
description: 'Symbol name to search/query (required)',
|
||||
},
|
||||
project_root: {
|
||||
type: 'string',
|
||||
description: 'Project root directory (auto-detected if not provided)',
|
||||
},
|
||||
symbol_kind: {
|
||||
type: 'string',
|
||||
description: 'Symbol kind filter: class, function, method, variable, etc. (optional)',
|
||||
},
|
||||
file_context: {
|
||||
type: 'string',
|
||||
description: 'Current file path for proximity ranking (optional)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results to return (default: 50)',
|
||||
default: 50,
|
||||
},
|
||||
kind_filter: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'List of symbol kinds to include (for symbol_search)',
|
||||
},
|
||||
file_pattern: {
|
||||
type: 'string',
|
||||
description: 'Glob pattern to filter files (for symbol_search)',
|
||||
},
|
||||
},
|
||||
required: ['action', 'symbol_name'],
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ import * as cliExecutorMod from './cli-executor.js';
|
||||
import * as smartSearchMod from './smart-search.js';
|
||||
import { executeInitWithProgress } from './smart-search.js';
|
||||
// codex_lens removed - functionality integrated into smart_search
|
||||
import * as codexLensLspMod from './codex-lens-lsp.js';
|
||||
// codex_lens_lsp removed - v1 LSP bridge removed
|
||||
import * as readFileMod from './read-file.js';
|
||||
import * as readManyFilesMod from './read-many-files.js';
|
||||
import * as readOutlineMod from './read-outline.js';
|
||||
@@ -365,7 +365,7 @@ registerTool(toLegacyTool(sessionManagerMod));
|
||||
registerTool(toLegacyTool(cliExecutorMod));
|
||||
registerTool(toLegacyTool(smartSearchMod));
|
||||
// codex_lens removed - functionality integrated into smart_search
|
||||
registerTool(toLegacyTool(codexLensLspMod));
|
||||
// codex_lens_lsp removed - v1 LSP bridge removed
|
||||
registerTool(toLegacyTool(readFileMod));
|
||||
registerTool(toLegacyTool(readManyFilesMod));
|
||||
registerTool(toLegacyTool(readOutlineMod));
|
||||
|
||||
@@ -1,64 +1,23 @@
|
||||
/**
|
||||
* LiteLLM Client - Bridge between CCW and ccw-litellm Python package
|
||||
* Provides LLM chat and embedding capabilities via spawned Python process
|
||||
* LiteLLM Client - STUB (v1 Python bridge removed)
|
||||
*
|
||||
* Features:
|
||||
* - Chat completions with multiple models
|
||||
* - Text embeddings generation
|
||||
* - Configuration management
|
||||
* - JSON protocol communication
|
||||
* The Python ccw-litellm bridge has been removed. This module provides
|
||||
* no-op stubs so that existing consumers compile without errors.
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { getCodexLensPython, getCodexLensHiddenPython, getCodexLensVenvDir } from '../utils/codexlens-path.js';
|
||||
const V1_REMOVED = 'LiteLLM Python bridge has been removed (v1 cleanup).';
|
||||
|
||||
export interface LiteLLMConfig {
|
||||
pythonPath?: string; // Default: CodexLens venv Python
|
||||
configPath?: string; // Configuration file path
|
||||
timeout?: number; // Default 60000ms
|
||||
pythonPath?: string;
|
||||
configPath?: string;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// Platform-specific constants for CodexLens venv
|
||||
const IS_WINDOWS = process.platform === 'win32';
|
||||
const CODEXLENS_VENV = getCodexLensVenvDir();
|
||||
const VENV_BIN_DIR = IS_WINDOWS ? 'Scripts' : 'bin';
|
||||
const PYTHON_EXECUTABLE = IS_WINDOWS ? 'pythonw.exe' : 'python';
|
||||
|
||||
/**
|
||||
* Get the Python path from CodexLens venv
|
||||
* Falls back to system 'python' if venv doesn't exist
|
||||
* @returns Path to Python executable
|
||||
*/
|
||||
export function getCodexLensVenvPython(): string {
|
||||
const venvPython = join(CODEXLENS_VENV, VENV_BIN_DIR, PYTHON_EXECUTABLE);
|
||||
if (existsSync(venvPython)) {
|
||||
return venvPython;
|
||||
}
|
||||
const hiddenPython = getCodexLensHiddenPython();
|
||||
if (existsSync(hiddenPython)) {
|
||||
return hiddenPython;
|
||||
}
|
||||
// Fallback to system Python if venv not available
|
||||
return 'python';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Python path from CodexLens venv using centralized path utility
|
||||
* Falls back to system 'python' if venv doesn't exist
|
||||
* @returns Path to Python executable
|
||||
*/
|
||||
export function getCodexLensPythonPath(): string {
|
||||
const codexLensPython = getCodexLensHiddenPython();
|
||||
if (existsSync(codexLensPython)) {
|
||||
return codexLensPython;
|
||||
}
|
||||
const fallbackPython = getCodexLensPython();
|
||||
if (existsSync(fallbackPython)) {
|
||||
return fallbackPython;
|
||||
}
|
||||
// Fallback to system Python if venv not available
|
||||
return 'python';
|
||||
}
|
||||
|
||||
@@ -90,179 +49,35 @@ export interface LiteLLMStatus {
|
||||
}
|
||||
|
||||
export class LiteLLMClient {
|
||||
private pythonPath: string;
|
||||
private configPath?: string;
|
||||
private timeout: number;
|
||||
constructor(_config: LiteLLMConfig = {}) {}
|
||||
|
||||
constructor(config: LiteLLMConfig = {}) {
|
||||
this.pythonPath = config.pythonPath || getCodexLensVenvPython();
|
||||
this.configPath = config.configPath;
|
||||
this.timeout = config.timeout || 60000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute Python ccw-litellm command
|
||||
*/
|
||||
private async executePython(args: string[], options: { timeout?: number } = {}): Promise<string> {
|
||||
const timeout = options.timeout || this.timeout;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(this.pythonPath, ['-m', 'ccw_litellm.cli', ...args], {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8' }
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let timedOut = false;
|
||||
|
||||
// Set up timeout
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
proc.kill('SIGTERM');
|
||||
reject(new Error(`Command timed out after ${timeout}ms`));
|
||||
}, timeout);
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
clearTimeout(timeoutId);
|
||||
reject(new Error(`Failed to spawn Python process: ${error.message}`));
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (timedOut) {
|
||||
return; // Already rejected
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim());
|
||||
} else {
|
||||
const errorMsg = stderr.trim() || `Process exited with code ${code}`;
|
||||
reject(new Error(errorMsg));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ccw-litellm is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
// Increased timeout to 15s for Python cold start
|
||||
await this.executePython(['version'], { timeout: 15000 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status information
|
||||
*/
|
||||
async getStatus(): Promise<LiteLLMStatus> {
|
||||
try {
|
||||
// Increased timeout to 15s for Python cold start
|
||||
const output = await this.executePython(['version'], { timeout: 15000 });
|
||||
// Parse "ccw-litellm 0.1.0" format
|
||||
const versionMatch = output.trim().match(/ccw-litellm\s+([\d.]+)/);
|
||||
const version = versionMatch ? versionMatch[1] : output.trim();
|
||||
return {
|
||||
available: true,
|
||||
version
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
available: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
return { available: false, error: V1_REMOVED };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
async getConfig(): Promise<any> {
|
||||
// config command outputs JSON by default, no --json flag needed
|
||||
const output = await this.executePython(['config']);
|
||||
return JSON.parse(output);
|
||||
async getConfig(): Promise<unknown> {
|
||||
return { error: V1_REMOVED };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate embeddings for texts
|
||||
*/
|
||||
async embed(texts: string[], model: string = 'default'): Promise<EmbedResponse> {
|
||||
if (!texts || texts.length === 0) {
|
||||
throw new Error('texts array cannot be empty');
|
||||
}
|
||||
|
||||
const args = ['embed', '--model', model, '--output', 'json'];
|
||||
|
||||
// Add texts as arguments
|
||||
for (const text of texts) {
|
||||
args.push(text);
|
||||
}
|
||||
|
||||
const output = await this.executePython(args, { timeout: this.timeout * 2 });
|
||||
const vectors = JSON.parse(output);
|
||||
|
||||
return {
|
||||
vectors,
|
||||
dimensions: vectors[0]?.length || 0,
|
||||
model
|
||||
};
|
||||
async embed(_texts: string[], _model?: string): Promise<EmbedResponse> {
|
||||
throw new Error(V1_REMOVED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat with LLM
|
||||
*/
|
||||
async chat(message: string, model: string = 'default'): Promise<string> {
|
||||
if (!message) {
|
||||
throw new Error('message cannot be empty');
|
||||
}
|
||||
|
||||
const args = ['chat', '--model', model, message];
|
||||
return this.executePython(args, { timeout: this.timeout * 2 });
|
||||
async chat(_message: string, _model?: string): Promise<string> {
|
||||
throw new Error(V1_REMOVED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-turn chat with messages array
|
||||
*/
|
||||
async chatMessages(messages: ChatMessage[], model: string = 'default'): Promise<ChatResponse> {
|
||||
if (!messages || messages.length === 0) {
|
||||
throw new Error('messages array cannot be empty');
|
||||
}
|
||||
|
||||
// For now, just use the last user message
|
||||
// TODO: Implement full message history support in ccw-litellm
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const content = await this.chat(lastMessage.content, model);
|
||||
|
||||
return {
|
||||
content,
|
||||
model,
|
||||
usage: undefined // TODO: Add usage tracking
|
||||
};
|
||||
async chatMessages(_messages: ChatMessage[], _model?: string): Promise<ChatResponse> {
|
||||
throw new Error(V1_REMOVED);
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let _client: LiteLLMClient | null = null;
|
||||
|
||||
/**
|
||||
* Get or create singleton LiteLLM client
|
||||
*/
|
||||
export function getLiteLLMClient(config?: LiteLLMConfig): LiteLLMClient {
|
||||
if (!_client) {
|
||||
_client = new LiteLLMClient(config);
|
||||
@@ -270,29 +85,10 @@ export function getLiteLLMClient(config?: LiteLLMConfig): LiteLLMClient {
|
||||
return _client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if LiteLLM is available
|
||||
*/
|
||||
export async function checkLiteLLMAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const client = getLiteLLMClient();
|
||||
return await client.isAvailable();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LiteLLM status
|
||||
*/
|
||||
export async function getLiteLLMStatus(): Promise<LiteLLMStatus> {
|
||||
try {
|
||||
const client = getLiteLLMClient();
|
||||
return await client.getStatus();
|
||||
} catch (error: any) {
|
||||
return {
|
||||
available: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
return { available: false, error: V1_REMOVED };
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
* 2. Default: ~/.codexlens
|
||||
*/
|
||||
|
||||
import { existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
@@ -26,56 +25,3 @@ export function getCodexLensDataDir(): string {
|
||||
}
|
||||
return join(homedir(), '.codexlens');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CodexLens virtual environment path.
|
||||
*
|
||||
* @returns Path to CodexLens venv directory
|
||||
*/
|
||||
export function getCodexLensVenvDir(): string {
|
||||
return join(getCodexLensDataDir(), 'venv');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Python executable path in the CodexLens venv.
|
||||
*
|
||||
* @returns Path to python executable
|
||||
*/
|
||||
export function getCodexLensPython(): string {
|
||||
const venvDir = getCodexLensVenvDir();
|
||||
return process.platform === 'win32'
|
||||
? join(venvDir, 'Scripts', 'python.exe')
|
||||
: join(venvDir, 'bin', 'python');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the preferred Python executable for hidden/windowless CodexLens subprocesses.
|
||||
* On Windows this prefers pythonw.exe when available to avoid transient console windows.
|
||||
*
|
||||
* @returns Path to the preferred hidden-subprocess Python executable
|
||||
*/
|
||||
export function getCodexLensHiddenPython(): string {
|
||||
if (process.platform !== 'win32') {
|
||||
return getCodexLensPython();
|
||||
}
|
||||
|
||||
const venvDir = getCodexLensVenvDir();
|
||||
const pythonwPath = join(venvDir, 'Scripts', 'pythonw.exe');
|
||||
if (existsSync(pythonwPath)) {
|
||||
return pythonwPath;
|
||||
}
|
||||
|
||||
return getCodexLensPython();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pip executable path in the CodexLens venv.
|
||||
*
|
||||
* @returns Path to pip executable
|
||||
*/
|
||||
export function getCodexLensPip(): string {
|
||||
const venvDir = getCodexLensVenvDir();
|
||||
return process.platform === 'win32'
|
||||
? join(venvDir, 'Scripts', 'pip.exe')
|
||||
: join(venvDir, 'bin', 'pip');
|
||||
}
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
/**
|
||||
* Unified Package Discovery for local Python packages (codex-lens, ccw-litellm)
|
||||
*
|
||||
* Provides a single, transparent path discovery mechanism with:
|
||||
* - Environment variable overrides (highest priority)
|
||||
* - ~/.codexlens/config.json configuration
|
||||
* - Extended search paths (npm global, PACKAGE_ROOT, siblings, etc.)
|
||||
* - Full search result transparency for diagnostics
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { join, dirname, resolve } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import { execSync } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { getCodexLensDataDir } from './codexlens-path.js';
|
||||
import { EXEC_TIMEOUTS } from './exec-constants.js';
|
||||
|
||||
// Get directory of this module (src/utils/)
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// ========================================
|
||||
// Types
|
||||
// ========================================
|
||||
|
||||
/** Source that found the package path */
|
||||
export type PackageSource =
|
||||
| 'env' // Environment variable override
|
||||
| 'config' // ~/.codexlens/config.json
|
||||
| 'sibling' // Sibling directory to ccw project root
|
||||
| 'npm-global' // npm global prefix
|
||||
| 'cwd' // Current working directory
|
||||
| 'cwd-parent' // Parent of current working directory
|
||||
| 'homedir' // User home directory
|
||||
| 'package-root'; // npm package internal path
|
||||
|
||||
/** A single search attempt result */
|
||||
export interface SearchAttempt {
|
||||
path: string;
|
||||
source: PackageSource;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
/** Result of package discovery */
|
||||
export interface PackageDiscoveryResult {
|
||||
/** Resolved package path, or null if not found */
|
||||
path: string | null;
|
||||
/** Source that found the package */
|
||||
source: PackageSource | null;
|
||||
/** All paths searched (for diagnostics) */
|
||||
searchedPaths: SearchAttempt[];
|
||||
/** Whether the found path is inside node_modules */
|
||||
insideNodeModules: boolean;
|
||||
}
|
||||
|
||||
/** Known local package names */
|
||||
export type LocalPackageName = 'codex-lens' | 'ccw-litellm' | 'codexlens-search';
|
||||
|
||||
/** Environment variable mapping for each package */
|
||||
const PACKAGE_ENV_VARS: Record<LocalPackageName, string> = {
|
||||
'codex-lens': 'CODEXLENS_PACKAGE_PATH',
|
||||
'ccw-litellm': 'CCW_LITELLM_PATH',
|
||||
'codexlens-search': 'CODEXLENS_SEARCH_PATH',
|
||||
};
|
||||
|
||||
/** Config key mapping for each package */
|
||||
const PACKAGE_CONFIG_KEYS: Record<LocalPackageName, string> = {
|
||||
'codex-lens': 'codexLensPath',
|
||||
'ccw-litellm': 'ccwLitellmPath',
|
||||
'codexlens-search': 'codexlensSearchPath',
|
||||
};
|
||||
|
||||
// ========================================
|
||||
// Helpers
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Check if a path is inside node_modules
|
||||
*/
|
||||
export function isInsideNodeModules(pathToCheck: string): boolean {
|
||||
const normalized = pathToCheck.replace(/\\/g, '/').toLowerCase();
|
||||
return normalized.includes('/node_modules/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if running in a development environment (not from node_modules)
|
||||
*/
|
||||
export function isDevEnvironment(): boolean {
|
||||
// Yarn PnP detection
|
||||
if ((process.versions as Record<string, unknown>).pnp) {
|
||||
return false;
|
||||
}
|
||||
return !isInsideNodeModules(__dirname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read package paths from ~/.codexlens/config.json
|
||||
*/
|
||||
function readConfigPath(packageName: LocalPackageName): string | null {
|
||||
try {
|
||||
const configPath = join(getCodexLensDataDir(), 'config.json');
|
||||
if (!existsSync(configPath)) return null;
|
||||
|
||||
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
||||
const key = PACKAGE_CONFIG_KEYS[packageName];
|
||||
const value = config?.packagePaths?.[key];
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get npm global prefix directory
|
||||
*/
|
||||
let _npmGlobalPrefix: string | null | undefined;
|
||||
function getNpmGlobalPrefix(): string | null {
|
||||
if (_npmGlobalPrefix !== undefined) return _npmGlobalPrefix;
|
||||
|
||||
try {
|
||||
const result = execSync('npm prefix -g', {
|
||||
encoding: 'utf-8',
|
||||
timeout: EXEC_TIMEOUTS.SYSTEM_INFO,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
_npmGlobalPrefix = result.trim() || null;
|
||||
} catch {
|
||||
_npmGlobalPrefix = null;
|
||||
}
|
||||
return _npmGlobalPrefix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory contains a valid Python package (has pyproject.toml)
|
||||
*/
|
||||
function isValidPackageDir(dir: string): boolean {
|
||||
return existsSync(join(dir, 'pyproject.toml'));
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Main Discovery Function
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* Find a local Python package path with unified search logic.
|
||||
*
|
||||
* Search priority:
|
||||
* 1. Environment variable (CODEXLENS_PACKAGE_PATH / CCW_LITELLM_PATH)
|
||||
* 2. ~/.codexlens/config.json packagePaths
|
||||
* 3. Sibling directory to ccw project root (src/utils -> ../../..)
|
||||
* 4. npm global prefix node_modules path
|
||||
* 5. Current working directory
|
||||
* 6. Parent of current working directory
|
||||
* 7. Home directory
|
||||
*
|
||||
* Two-pass search: first pass skips node_modules paths, second pass allows them.
|
||||
*
|
||||
* @param packageName - Package to find ('codex-lens' or 'ccw-litellm')
|
||||
* @returns Discovery result with path, source, and all searched paths
|
||||
*/
|
||||
export function findPackagePath(packageName: LocalPackageName): PackageDiscoveryResult {
|
||||
const searched: SearchAttempt[] = [];
|
||||
|
||||
// Helper to check and record a path
|
||||
const check = (path: string, source: PackageSource): boolean => {
|
||||
const resolvedPath = resolve(path);
|
||||
const exists = isValidPackageDir(resolvedPath);
|
||||
searched.push({ path: resolvedPath, source, exists });
|
||||
return exists;
|
||||
};
|
||||
|
||||
// 1. Environment variable (highest priority, skip two-pass)
|
||||
const envKey = PACKAGE_ENV_VARS[packageName];
|
||||
const envPath = process.env[envKey];
|
||||
if (envPath) {
|
||||
if (check(envPath, 'env')) {
|
||||
return {
|
||||
path: resolve(envPath),
|
||||
source: 'env',
|
||||
searchedPaths: searched,
|
||||
insideNodeModules: isInsideNodeModules(envPath),
|
||||
};
|
||||
}
|
||||
// Env var set but path invalid — continue searching but warn
|
||||
console.warn(`[PackageDiscovery] ${envKey}="${envPath}" set but pyproject.toml not found, continuing search...`);
|
||||
}
|
||||
|
||||
// 2. Config file
|
||||
const configPath = readConfigPath(packageName);
|
||||
if (configPath) {
|
||||
if (check(configPath, 'config')) {
|
||||
return {
|
||||
path: resolve(configPath),
|
||||
source: 'config',
|
||||
searchedPaths: searched,
|
||||
insideNodeModules: isInsideNodeModules(configPath),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Build candidate paths for two-pass search
|
||||
const candidates: { path: string; source: PackageSource }[] = [];
|
||||
|
||||
// 3. Sibling directory to ccw project root
|
||||
// __dirname = src/utils/ → project root = ../../..
|
||||
// Also try one more level up for nested structures
|
||||
const projectRoot = join(__dirname, '..', '..', '..');
|
||||
candidates.push({ path: join(projectRoot, packageName), source: 'sibling' });
|
||||
candidates.push({ path: join(projectRoot, '..', packageName), source: 'sibling' });
|
||||
|
||||
// 4. npm global prefix
|
||||
const npmPrefix = getNpmGlobalPrefix();
|
||||
if (npmPrefix) {
|
||||
// npm global: prefix/node_modules/claude-code-workflow/<packageName>
|
||||
candidates.push({
|
||||
path: join(npmPrefix, 'node_modules', 'claude-code-workflow', packageName),
|
||||
source: 'npm-global',
|
||||
});
|
||||
// npm global: prefix/lib/node_modules/claude-code-workflow/<packageName> (Linux/Mac)
|
||||
candidates.push({
|
||||
path: join(npmPrefix, 'lib', 'node_modules', 'claude-code-workflow', packageName),
|
||||
source: 'npm-global',
|
||||
});
|
||||
// npm global sibling: prefix/node_modules/<packageName>
|
||||
candidates.push({
|
||||
path: join(npmPrefix, 'node_modules', packageName),
|
||||
source: 'npm-global',
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Current working directory
|
||||
const cwd = process.cwd();
|
||||
candidates.push({ path: join(cwd, packageName), source: 'cwd' });
|
||||
|
||||
// 6. Parent of cwd (common workspace layout)
|
||||
const cwdParent = dirname(cwd);
|
||||
if (cwdParent !== cwd) {
|
||||
candidates.push({ path: join(cwdParent, packageName), source: 'cwd-parent' });
|
||||
}
|
||||
|
||||
// 7. Home directory
|
||||
candidates.push({ path: join(homedir(), packageName), source: 'homedir' });
|
||||
|
||||
// Two-pass search: prefer non-node_modules paths first
|
||||
// First pass: skip node_modules
|
||||
for (const candidate of candidates) {
|
||||
const resolvedPath = resolve(candidate.path);
|
||||
if (isInsideNodeModules(resolvedPath)) continue;
|
||||
if (check(resolvedPath, candidate.source)) {
|
||||
console.log(`[PackageDiscovery] Found ${packageName} at: ${resolvedPath} (source: ${candidate.source})`);
|
||||
return {
|
||||
path: resolvedPath,
|
||||
source: candidate.source,
|
||||
searchedPaths: searched,
|
||||
insideNodeModules: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: allow node_modules paths
|
||||
for (const candidate of candidates) {
|
||||
const resolvedPath = resolve(candidate.path);
|
||||
if (!isInsideNodeModules(resolvedPath)) continue;
|
||||
// Skip if already checked in first pass
|
||||
if (searched.some(s => s.path === resolvedPath)) continue;
|
||||
if (check(resolvedPath, candidate.source)) {
|
||||
console.log(`[PackageDiscovery] Found ${packageName} in node_modules at: ${resolvedPath} (source: ${candidate.source})`);
|
||||
return {
|
||||
path: resolvedPath,
|
||||
source: candidate.source,
|
||||
searchedPaths: searched,
|
||||
insideNodeModules: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Not found
|
||||
return {
|
||||
path: null,
|
||||
source: null,
|
||||
searchedPaths: searched,
|
||||
insideNodeModules: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find codex-lens package path (convenience wrapper)
|
||||
*/
|
||||
export function findCodexLensPath(): PackageDiscoveryResult {
|
||||
return findPackagePath('codex-lens');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ccw-litellm package path (convenience wrapper)
|
||||
*/
|
||||
export function findCcwLitellmPath(): PackageDiscoveryResult {
|
||||
return findPackagePath('ccw-litellm');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find codexlens-search (v2) package path (convenience wrapper)
|
||||
*/
|
||||
export function findCodexLensSearchPath(): PackageDiscoveryResult {
|
||||
return findPackagePath('codexlens-search');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format search results for error messages
|
||||
*/
|
||||
export function formatSearchResults(result: PackageDiscoveryResult, packageName: string): string {
|
||||
const lines = [`Cannot find '${packageName}' package directory.\n`];
|
||||
lines.push('Searched locations:');
|
||||
for (const attempt of result.searchedPaths) {
|
||||
const status = attempt.exists ? '✓' : '✗';
|
||||
lines.push(` ${status} [${attempt.source}] ${attempt.path}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('To fix this:');
|
||||
|
||||
const envKey = PACKAGE_ENV_VARS[packageName as LocalPackageName] || `${packageName.toUpperCase().replace(/-/g, '_')}_PATH`;
|
||||
lines.push(` 1. Set environment variable: ${envKey}=/path/to/${packageName}`);
|
||||
lines.push(` 2. Or add to ~/.codexlens/config.json: { "packagePaths": { "${PACKAGE_CONFIG_KEYS[packageName as LocalPackageName] || packageName}": "/path/to/${packageName}" } }`);
|
||||
lines.push(` 3. Or ensure '${packageName}' directory exists as a sibling to the ccw project`);
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Python detection and version compatibility utilities
|
||||
* Shared module for consistent Python discovery across the application
|
||||
*/
|
||||
|
||||
import { spawnSync, type SpawnSyncOptionsWithStringEncoding } from 'child_process';
|
||||
import { EXEC_TIMEOUTS } from './exec-constants.js';
|
||||
|
||||
export interface PythonCommandSpec {
|
||||
command: string;
|
||||
args: string[];
|
||||
display: string;
|
||||
}
|
||||
|
||||
type HiddenPythonProbeOptions = Omit<SpawnSyncOptionsWithStringEncoding, 'encoding'> & {
|
||||
encoding?: BufferEncoding;
|
||||
};
|
||||
|
||||
function isExecTimeoutError(error: unknown): boolean {
|
||||
const err = error as { code?: unknown; errno?: unknown; message?: unknown } | null;
|
||||
const code = err?.code ?? err?.errno;
|
||||
if (code === 'ETIMEDOUT') return true;
|
||||
const message = typeof err?.message === 'string' ? err.message : '';
|
||||
return message.includes('ETIMEDOUT');
|
||||
}
|
||||
|
||||
function quoteCommandPart(value: string): string {
|
||||
if (!/[\s"]/.test(value)) {
|
||||
return value;
|
||||
}
|
||||
return `"${value.replaceAll('"', '\\"')}"`;
|
||||
}
|
||||
|
||||
function formatPythonCommandDisplay(command: string, args: string[]): string {
|
||||
return [quoteCommandPart(command), ...args.map(quoteCommandPart)].join(' ');
|
||||
}
|
||||
|
||||
function buildPythonCommandSpec(command: string, args: string[] = []): PythonCommandSpec {
|
||||
return {
|
||||
command,
|
||||
args: [...args],
|
||||
display: formatPythonCommandDisplay(command, args),
|
||||
};
|
||||
}
|
||||
|
||||
function tokenizeCommandSpec(raw: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
const tokenPattern = /"((?:\\"|[^"])*)"|(\S+)/g;
|
||||
|
||||
for (const match of raw.matchAll(tokenPattern)) {
|
||||
const quoted = match[1];
|
||||
const plain = match[2];
|
||||
if (quoted !== undefined) {
|
||||
tokens.push(quoted.replaceAll('\\"', '"'));
|
||||
} else if (plain !== undefined) {
|
||||
tokens.push(plain);
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
export function parsePythonCommandSpec(raw: string): PythonCommandSpec {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('Python command cannot be empty');
|
||||
}
|
||||
|
||||
// Unquoted executable paths on Windows commonly contain spaces.
|
||||
if (!trimmed.includes('"') && /[\\/]/.test(trimmed)) {
|
||||
return buildPythonCommandSpec(trimmed);
|
||||
}
|
||||
|
||||
const tokens = tokenizeCommandSpec(trimmed);
|
||||
if (tokens.length === 0) {
|
||||
return buildPythonCommandSpec(trimmed);
|
||||
}
|
||||
|
||||
return buildPythonCommandSpec(tokens[0], tokens.slice(1));
|
||||
}
|
||||
|
||||
function buildPythonProbeOptions(
|
||||
overrides: HiddenPythonProbeOptions = {},
|
||||
): SpawnSyncOptionsWithStringEncoding {
|
||||
const { env, encoding, ...rest } = overrides;
|
||||
return {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
timeout: EXEC_TIMEOUTS.PYTHON_VERSION,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8', ...env },
|
||||
...rest,
|
||||
encoding: encoding ?? 'utf8',
|
||||
};
|
||||
}
|
||||
|
||||
export function probePythonCommandVersion(
|
||||
pythonCommand: PythonCommandSpec,
|
||||
runner: typeof spawnSync = spawnSync,
|
||||
): string {
|
||||
const result = runner(
|
||||
pythonCommand.command,
|
||||
[...pythonCommand.args, '--version'],
|
||||
buildPythonProbeOptions(),
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
const versionOutput = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
|
||||
if (result.status !== 0) {
|
||||
throw new Error(versionOutput || `Python version probe exited with code ${String(result.status)}`);
|
||||
}
|
||||
|
||||
return versionOutput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Python version string to major.minor numbers
|
||||
* @param versionStr - Version string like "Python 3.11.5"
|
||||
* @returns Object with major and minor version numbers, or null if parsing fails
|
||||
*/
|
||||
export function parsePythonVersion(versionStr: string): { major: number; minor: number } | null {
|
||||
const match = versionStr.match(/Python\s+(\d+)\.(\d+)/);
|
||||
if (match) {
|
||||
return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Python version is compatible with onnxruntime (3.9-3.12)
|
||||
* @param major - Major version number
|
||||
* @param minor - Minor version number
|
||||
* @returns true if compatible
|
||||
*/
|
||||
export function isPythonVersionCompatible(major: number, minor: number): boolean {
|
||||
// onnxruntime currently supports Python 3.9-3.12
|
||||
return major === 3 && minor >= 9 && minor <= 12;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect available Python 3 executable
|
||||
* Supports CCW_PYTHON environment variable for custom Python path
|
||||
* On Windows, uses py launcher to find compatible versions
|
||||
* @returns Python executable command spec
|
||||
*/
|
||||
export function getSystemPythonCommand(runner: typeof spawnSync = spawnSync): PythonCommandSpec {
|
||||
const customPython = process.env.CCW_PYTHON?.trim();
|
||||
if (customPython) {
|
||||
const customSpec = parsePythonCommandSpec(customPython);
|
||||
try {
|
||||
const version = probePythonCommandVersion(customSpec, runner);
|
||||
if (version.includes('Python 3')) {
|
||||
const parsed = parsePythonVersion(version);
|
||||
if (parsed && !isPythonVersionCompatible(parsed.major, parsed.minor)) {
|
||||
console.warn(
|
||||
`[Python] Warning: CCW_PYTHON points to Python ${parsed.major}.${parsed.minor}, which may not be compatible with onnxruntime (requires 3.9-3.12)`,
|
||||
);
|
||||
}
|
||||
return customSpec;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (isExecTimeoutError(err)) {
|
||||
console.warn(
|
||||
`[Python] Warning: CCW_PYTHON version check timed out after ${EXEC_TIMEOUTS.PYTHON_VERSION}ms, falling back to system Python`,
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[Python] Warning: CCW_PYTHON="${customPython}" is not a valid Python executable, falling back to system Python`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
const compatibleVersions = ['3.12', '3.11', '3.10', '3.9'];
|
||||
for (const ver of compatibleVersions) {
|
||||
const launcherSpec = buildPythonCommandSpec('py', [`-${ver}`]);
|
||||
try {
|
||||
const version = probePythonCommandVersion(launcherSpec, runner);
|
||||
if (version.includes(`Python ${ver}`)) {
|
||||
console.log(`[Python] Found compatible Python ${ver} via py launcher`);
|
||||
return launcherSpec;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (isExecTimeoutError(err)) {
|
||||
console.warn(
|
||||
`[Python] Warning: py -${ver} version check timed out after ${EXEC_TIMEOUTS.PYTHON_VERSION}ms`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commands = process.platform === 'win32' ? ['python', 'py', 'python3'] : ['python3', 'python'];
|
||||
let fallbackCmd: PythonCommandSpec | null = null;
|
||||
let fallbackVersion: { major: number; minor: number } | null = null;
|
||||
|
||||
for (const cmd of commands) {
|
||||
const pythonSpec = buildPythonCommandSpec(cmd);
|
||||
try {
|
||||
const version = probePythonCommandVersion(pythonSpec, runner);
|
||||
if (version.includes('Python 3')) {
|
||||
const parsed = parsePythonVersion(version);
|
||||
if (parsed) {
|
||||
if (isPythonVersionCompatible(parsed.major, parsed.minor)) {
|
||||
return pythonSpec;
|
||||
}
|
||||
if (!fallbackCmd) {
|
||||
fallbackCmd = pythonSpec;
|
||||
fallbackVersion = parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (isExecTimeoutError(err)) {
|
||||
console.warn(`[Python] Warning: ${cmd} --version timed out after ${EXEC_TIMEOUTS.PYTHON_VERSION}ms`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackCmd && fallbackVersion) {
|
||||
console.warn(
|
||||
`[Python] Warning: Only Python ${fallbackVersion.major}.${fallbackVersion.minor} found, which may not be compatible with onnxruntime (requires 3.9-3.12).`,
|
||||
);
|
||||
console.warn('[Python] Semantic search may fail with ImportError for onnxruntime.');
|
||||
console.warn('[Python] To use a specific Python version, set CCW_PYTHON environment variable:');
|
||||
console.warn(' Windows: set CCW_PYTHON=C:\\path\\to\\python.exe');
|
||||
console.warn(' Unix: export CCW_PYTHON=/path/to/python3.11');
|
||||
console.warn('[Python] Alternatively, use LiteLLM embedding backend which has no Python version restrictions.');
|
||||
return fallbackCmd;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Python 3 not found. Please install Python 3.9-3.12 and ensure it is in PATH, or set CCW_PYTHON environment variable.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect available Python 3 executable
|
||||
* Supports CCW_PYTHON environment variable for custom Python path
|
||||
* On Windows, uses py launcher to find compatible versions
|
||||
* @returns Python executable command
|
||||
*/
|
||||
export function getSystemPython(): string {
|
||||
return getSystemPythonCommand().display;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Python command for pip operations (uses -m pip for reliability)
|
||||
* @returns Array of command arguments for spawn
|
||||
*/
|
||||
export function getPipCommand(): { pythonCmd: string; pipArgs: string[] } {
|
||||
const pythonCmd = getSystemPython();
|
||||
return {
|
||||
pythonCmd,
|
||||
pipArgs: ['-m', 'pip'],
|
||||
};
|
||||
}
|
||||
|
||||
export const __testables = {
|
||||
buildPythonCommandSpec,
|
||||
buildPythonProbeOptions,
|
||||
formatPythonCommandDisplay,
|
||||
parsePythonCommandSpec,
|
||||
probePythonCommandVersion,
|
||||
};
|
||||
@@ -1,902 +0,0 @@
|
||||
/**
|
||||
* UV Package Manager Tool
|
||||
* Provides unified UV (https://github.com/astral-sh/uv) tool management capabilities
|
||||
*
|
||||
* Features:
|
||||
* - Cross-platform UV binary discovery and installation
|
||||
* - Virtual environment creation and management
|
||||
* - Python dependency installation with UV's fast resolver
|
||||
* - Support for local project installs with extras
|
||||
*/
|
||||
|
||||
import { spawn, spawnSync, type SpawnOptions, type SpawnSyncOptionsWithStringEncoding } from 'child_process';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { homedir, platform, arch } from 'os';
|
||||
import { EXEC_TIMEOUTS } from './exec-constants.js';
|
||||
import { getCodexLensDataDir, getCodexLensVenvDir } from './codexlens-path.js';
|
||||
|
||||
/**
|
||||
* Configuration for UvManager
|
||||
*/
|
||||
export interface UvManagerConfig {
|
||||
/** Path to the virtual environment directory */
|
||||
venvPath: string;
|
||||
/** Python version requirement (e.g., ">=3.10", "3.11") */
|
||||
pythonVersion?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of UV operations
|
||||
*/
|
||||
export interface UvInstallResult {
|
||||
/** Whether the operation succeeded */
|
||||
success: boolean;
|
||||
/** Error message if operation failed */
|
||||
error?: string;
|
||||
/** Duration of the operation in milliseconds */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* UV binary search locations in priority order
|
||||
*/
|
||||
interface UvSearchLocation {
|
||||
path: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
// Platform-specific constants
|
||||
const IS_WINDOWS = platform() === 'win32';
|
||||
const UV_BINARY_NAME = IS_WINDOWS ? 'uv.exe' : 'uv';
|
||||
const VENV_BIN_DIR = IS_WINDOWS ? 'Scripts' : 'bin';
|
||||
const PYTHON_EXECUTABLE = IS_WINDOWS ? 'python.exe' : 'python';
|
||||
|
||||
type HiddenUvSpawnSyncOptions = Omit<SpawnSyncOptionsWithStringEncoding, 'encoding'> & {
|
||||
encoding?: BufferEncoding;
|
||||
};
|
||||
|
||||
function buildUvSpawnOptions(overrides: SpawnOptions = {}): SpawnOptions {
|
||||
const { env, ...rest } = overrides;
|
||||
return {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8', ...env },
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUvSpawnSyncOptions(
|
||||
overrides: HiddenUvSpawnSyncOptions = {},
|
||||
): SpawnSyncOptionsWithStringEncoding {
|
||||
const { env, encoding, ...rest } = overrides;
|
||||
return {
|
||||
shell: false,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, PYTHONIOENCODING: 'utf-8', ...env },
|
||||
...rest,
|
||||
encoding: encoding ?? 'utf-8',
|
||||
};
|
||||
}
|
||||
|
||||
function findExecutableOnPath(executable: string, runner: typeof spawnSync = spawnSync): string | null {
|
||||
const lookupCommand = IS_WINDOWS ? 'where' : 'which';
|
||||
const result = runner(
|
||||
lookupCommand,
|
||||
[executable],
|
||||
buildUvSpawnSyncOptions({
|
||||
timeout: EXEC_TIMEOUTS.SYSTEM_INFO,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}),
|
||||
);
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output = `${result.stdout ?? ''}`.trim();
|
||||
if (!output) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return output.split(/\r?\n/)[0] || null;
|
||||
}
|
||||
|
||||
function hasWindowsPythonLauncherVersion(version: string, runner: typeof spawnSync = spawnSync): boolean {
|
||||
const result = runner(
|
||||
'py',
|
||||
[`-${version}`, '--version'],
|
||||
buildUvSpawnSyncOptions({
|
||||
timeout: EXEC_TIMEOUTS.PYTHON_VERSION,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}),
|
||||
);
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`;
|
||||
return output.includes(`Python ${version}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the UV binary
|
||||
* Search order:
|
||||
* 1. CCW_UV_PATH environment variable
|
||||
* 2. Project vendor/uv/ directory
|
||||
* 3. User local directories (~/.local/bin, ~/.cargo/bin)
|
||||
* 4. System PATH
|
||||
*
|
||||
* @returns Path to the UV binary
|
||||
*/
|
||||
export function getUvBinaryPath(): string {
|
||||
const searchLocations: UvSearchLocation[] = [];
|
||||
|
||||
// 1. Environment variable (highest priority)
|
||||
const envPath = process.env.CCW_UV_PATH;
|
||||
if (envPath) {
|
||||
searchLocations.push({ path: envPath, description: 'CCW_UV_PATH environment variable' });
|
||||
}
|
||||
|
||||
// 2. Project vendor directory
|
||||
const vendorPaths = [
|
||||
join(process.cwd(), 'vendor', 'uv', UV_BINARY_NAME),
|
||||
join(dirname(process.cwd()), 'vendor', 'uv', UV_BINARY_NAME),
|
||||
];
|
||||
for (const vendorPath of vendorPaths) {
|
||||
searchLocations.push({ path: vendorPath, description: 'Project vendor directory' });
|
||||
}
|
||||
|
||||
// 3. User local directories
|
||||
const home = homedir();
|
||||
if (IS_WINDOWS) {
|
||||
// Windows: AppData\Local\uv and .cargo\bin
|
||||
searchLocations.push(
|
||||
{ path: join(home, 'AppData', 'Local', 'uv', 'bin', UV_BINARY_NAME), description: 'UV AppData' },
|
||||
{ path: join(home, '.cargo', 'bin', UV_BINARY_NAME), description: 'Cargo bin' },
|
||||
{ path: join(home, '.local', 'bin', UV_BINARY_NAME), description: 'Local bin' },
|
||||
);
|
||||
} else {
|
||||
// Unix: ~/.local/bin and ~/.cargo/bin
|
||||
searchLocations.push(
|
||||
{ path: join(home, '.local', 'bin', UV_BINARY_NAME), description: 'Local bin' },
|
||||
{ path: join(home, '.cargo', 'bin', UV_BINARY_NAME), description: 'Cargo bin' },
|
||||
);
|
||||
}
|
||||
|
||||
// Check each location
|
||||
for (const location of searchLocations) {
|
||||
if (existsSync(location.path)) {
|
||||
return location.path;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try system PATH using 'which' or 'where'
|
||||
const foundPath = findExecutableOnPath('uv');
|
||||
if (foundPath && existsSync(foundPath)) {
|
||||
return foundPath;
|
||||
}
|
||||
|
||||
// Return default path (may not exist)
|
||||
if (IS_WINDOWS) {
|
||||
return join(home, 'AppData', 'Local', 'uv', 'bin', UV_BINARY_NAME);
|
||||
}
|
||||
return join(home, '.local', 'bin', UV_BINARY_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if UV is available and working
|
||||
* @returns True if UV is installed and functional
|
||||
*/
|
||||
export async function isUvAvailable(): Promise<boolean> {
|
||||
const uvPath = getUvBinaryPath();
|
||||
|
||||
if (!existsSync(uvPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(uvPath, ['--version'], buildUvSpawnOptions({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: EXEC_TIMEOUTS.PYTHON_VERSION,
|
||||
}));
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve(code === 0);
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get UV version string
|
||||
* @returns UV version or null if not available
|
||||
*/
|
||||
export async function getUvVersion(): Promise<string | null> {
|
||||
const uvPath = getUvBinaryPath();
|
||||
|
||||
if (!existsSync(uvPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(uvPath, ['--version'], buildUvSpawnOptions({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: EXEC_TIMEOUTS.PYTHON_VERSION,
|
||||
}));
|
||||
|
||||
let stdout = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
// Parse "uv 0.4.0" -> "0.4.0"
|
||||
const match = stdout.match(/uv\s+(\S+)/);
|
||||
resolve(match ? match[1] : stdout.trim());
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Download and install UV using the official installation script
|
||||
* @returns True if installation succeeded
|
||||
*/
|
||||
export async function ensureUvInstalled(): Promise<boolean> {
|
||||
// Check if already installed
|
||||
if (await isUvAvailable()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log('[UV] Installing UV package manager...');
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
|
||||
if (IS_WINDOWS) {
|
||||
// Windows: Use PowerShell to run the install script
|
||||
const installCmd = 'irm https://astral.sh/uv/install.ps1 | iex';
|
||||
child = spawn('powershell', ['-ExecutionPolicy', 'ByPass', '-Command', installCmd], buildUvSpawnOptions({
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
|
||||
}));
|
||||
} else {
|
||||
// Unix: Use curl and sh
|
||||
const installCmd = 'curl -LsSf https://astral.sh/uv/install.sh | sh';
|
||||
child = spawn('sh', ['-c', installCmd], buildUvSpawnOptions({
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
|
||||
}));
|
||||
}
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) console.log(`[UV] ${line}`);
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) console.log(`[UV] ${line}`);
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
console.log('[UV] UV installed successfully');
|
||||
resolve(true);
|
||||
} else {
|
||||
console.error(`[UV] Installation failed with code ${code}`);
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
console.error(`[UV] Installation failed: ${err.message}`);
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* UvManager class for virtual environment and package management
|
||||
*/
|
||||
export class UvManager {
|
||||
private readonly venvPath: string;
|
||||
private readonly pythonVersion?: string;
|
||||
|
||||
/**
|
||||
* Create a new UvManager instance
|
||||
* @param config - Configuration options
|
||||
*/
|
||||
constructor(config: UvManagerConfig) {
|
||||
this.venvPath = config.venvPath;
|
||||
this.pythonVersion = config.pythonVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the Python executable inside the virtual environment
|
||||
* @returns Path to the Python executable
|
||||
*/
|
||||
getVenvPython(): string {
|
||||
return join(this.venvPath, VENV_BIN_DIR, PYTHON_EXECUTABLE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to pip inside the virtual environment
|
||||
* @returns Path to the pip executable
|
||||
*/
|
||||
getVenvPip(): string {
|
||||
const pipName = IS_WINDOWS ? 'pip.exe' : 'pip';
|
||||
return join(this.venvPath, VENV_BIN_DIR, pipName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the virtual environment exists and is valid
|
||||
* @returns True if the venv exists and has a working Python
|
||||
*/
|
||||
isVenvValid(): boolean {
|
||||
const pythonPath = this.getVenvPython();
|
||||
return existsSync(pythonPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a virtual environment using UV
|
||||
* @returns Installation result
|
||||
*/
|
||||
async createVenv(): Promise<UvInstallResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Ensure UV is available
|
||||
if (!(await isUvAvailable())) {
|
||||
const installed = await ensureUvInstalled();
|
||||
if (!installed) {
|
||||
return { success: false, error: 'Failed to install UV' };
|
||||
}
|
||||
}
|
||||
|
||||
const uvPath = getUvBinaryPath();
|
||||
|
||||
// Ensure parent directory exists
|
||||
const parentDir = dirname(this.venvPath);
|
||||
if (!existsSync(parentDir)) {
|
||||
mkdirSync(parentDir, { recursive: true });
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const args = ['venv', this.venvPath];
|
||||
|
||||
// Add Python version constraint if specified
|
||||
if (this.pythonVersion) {
|
||||
args.push('--python', this.pythonVersion);
|
||||
}
|
||||
|
||||
console.log(`[UV] Creating virtual environment at ${this.venvPath}`);
|
||||
if (this.pythonVersion) {
|
||||
console.log(`[UV] Python version: ${this.pythonVersion}`);
|
||||
}
|
||||
|
||||
const child = spawn(uvPath, args, buildUvSpawnOptions({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: EXEC_TIMEOUTS.PROCESS_SPAWN,
|
||||
}));
|
||||
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) {
|
||||
console.log(`[UV] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
const line = data.toString().trim();
|
||||
if (line) {
|
||||
console.log(`[UV] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const duration = Date.now() - startTime;
|
||||
if (code === 0) {
|
||||
console.log(`[UV] Virtual environment created successfully (${duration}ms)`);
|
||||
resolve({ success: true, duration });
|
||||
} else {
|
||||
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
const duration = Date.now() - startTime;
|
||||
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Install packages from a local project with optional extras
|
||||
* Uses `uv pip install` for standard installs, or `-e` for editable installs
|
||||
* @param projectPath - Path to the project directory (must contain pyproject.toml or setup.py)
|
||||
* @param extras - Optional array of extras to install (e.g., ['semantic', 'dev'])
|
||||
* @param editable - Whether to install in editable mode (default: false for stability)
|
||||
* @returns Installation result
|
||||
*/
|
||||
async installFromProject(projectPath: string, extras?: string[], editable = false): Promise<UvInstallResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Ensure UV is available
|
||||
if (!(await isUvAvailable())) {
|
||||
return { success: false, error: 'UV is not available' };
|
||||
}
|
||||
|
||||
// Ensure venv exists
|
||||
if (!this.isVenvValid()) {
|
||||
return { success: false, error: 'Virtual environment does not exist. Call createVenv() first.' };
|
||||
}
|
||||
|
||||
const uvPath = getUvBinaryPath();
|
||||
|
||||
// Build the install specifier
|
||||
let installSpec = projectPath;
|
||||
if (extras && extras.length > 0) {
|
||||
installSpec = `${projectPath}[${extras.join(',')}]`;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const args = editable
|
||||
? ['pip', 'install', '-e', installSpec, '--python', this.getVenvPython()]
|
||||
: ['pip', 'install', installSpec, '--python', this.getVenvPython()];
|
||||
|
||||
console.log(`[UV] Installing from project: ${installSpec} (editable: ${editable})`);
|
||||
|
||||
const child = spawn(uvPath, args, buildUvSpawnOptions({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
|
||||
cwd: projectPath,
|
||||
}));
|
||||
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) {
|
||||
console.log(`[UV] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
const line = data.toString().trim();
|
||||
if (line && !line.startsWith('Resolved') && !line.startsWith('Prepared') && !line.startsWith('Installed')) {
|
||||
// Only log non-progress lines to stderr
|
||||
console.log(`[UV] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const duration = Date.now() - startTime;
|
||||
if (code === 0) {
|
||||
console.log(`[UV] Project installation successful (${duration}ms)`);
|
||||
resolve({ success: true, duration });
|
||||
} else {
|
||||
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
const duration = Date.now() - startTime;
|
||||
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a list of packages
|
||||
* @param packages - Array of package specifiers (e.g., ['numpy>=1.24', 'requests'])
|
||||
* @returns Installation result
|
||||
*/
|
||||
async install(packages: string[]): Promise<UvInstallResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (packages.length === 0) {
|
||||
return { success: true, duration: 0 };
|
||||
}
|
||||
|
||||
// Ensure UV is available
|
||||
if (!(await isUvAvailable())) {
|
||||
return { success: false, error: 'UV is not available' };
|
||||
}
|
||||
|
||||
// Ensure venv exists
|
||||
if (!this.isVenvValid()) {
|
||||
return { success: false, error: 'Virtual environment does not exist. Call createVenv() first.' };
|
||||
}
|
||||
|
||||
const uvPath = getUvBinaryPath();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const args = ['pip', 'install', ...packages, '--python', this.getVenvPython()];
|
||||
|
||||
console.log(`[UV] Installing packages: ${packages.join(', ')}`);
|
||||
|
||||
const child = spawn(uvPath, args, buildUvSpawnOptions({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
|
||||
}));
|
||||
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) {
|
||||
console.log(`[UV] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const duration = Date.now() - startTime;
|
||||
if (code === 0) {
|
||||
console.log(`[UV] Package installation successful (${duration}ms)`);
|
||||
resolve({ success: true, duration });
|
||||
} else {
|
||||
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
const duration = Date.now() - startTime;
|
||||
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall packages
|
||||
* @param packages - Array of package names to uninstall
|
||||
* @returns Uninstall result
|
||||
*/
|
||||
async uninstall(packages: string[]): Promise<UvInstallResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
if (packages.length === 0) {
|
||||
return { success: true, duration: 0 };
|
||||
}
|
||||
|
||||
// Ensure UV is available
|
||||
if (!(await isUvAvailable())) {
|
||||
return { success: false, error: 'UV is not available' };
|
||||
}
|
||||
|
||||
// Ensure venv exists
|
||||
if (!this.isVenvValid()) {
|
||||
return { success: false, error: 'Virtual environment does not exist.' };
|
||||
}
|
||||
|
||||
const uvPath = getUvBinaryPath();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const args = ['pip', 'uninstall', ...packages, '--python', this.getVenvPython()];
|
||||
|
||||
console.log(`[UV] Uninstalling packages: ${packages.join(', ')}`);
|
||||
|
||||
const child = spawn(uvPath, args, buildUvSpawnOptions({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
|
||||
}));
|
||||
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) {
|
||||
console.log(`[UV] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const duration = Date.now() - startTime;
|
||||
if (code === 0) {
|
||||
console.log(`[UV] Package uninstallation successful (${duration}ms)`);
|
||||
resolve({ success: true, duration });
|
||||
} else {
|
||||
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
const duration = Date.now() - startTime;
|
||||
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync dependencies from a requirements file or pyproject.toml
|
||||
* Uses `uv pip sync` for deterministic installs
|
||||
* @param requirementsPath - Path to requirements.txt or pyproject.toml
|
||||
* @returns Sync result
|
||||
*/
|
||||
async sync(requirementsPath: string): Promise<UvInstallResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Ensure UV is available
|
||||
if (!(await isUvAvailable())) {
|
||||
return { success: false, error: 'UV is not available' };
|
||||
}
|
||||
|
||||
// Ensure venv exists
|
||||
if (!this.isVenvValid()) {
|
||||
return { success: false, error: 'Virtual environment does not exist. Call createVenv() first.' };
|
||||
}
|
||||
|
||||
const uvPath = getUvBinaryPath();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const args = ['pip', 'sync', requirementsPath, '--python', this.getVenvPython()];
|
||||
|
||||
console.log(`[UV] Syncing dependencies from: ${requirementsPath}`);
|
||||
|
||||
const child = spawn(uvPath, args, buildUvSpawnOptions({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: EXEC_TIMEOUTS.PACKAGE_INSTALL,
|
||||
}));
|
||||
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
const line = data.toString().trim();
|
||||
if (line) {
|
||||
console.log(`[UV] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
const duration = Date.now() - startTime;
|
||||
if (code === 0) {
|
||||
console.log(`[UV] Sync successful (${duration}ms)`);
|
||||
resolve({ success: true, duration });
|
||||
} else {
|
||||
resolve({ success: false, error: stderr || `Process exited with code ${code}`, duration });
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
const duration = Date.now() - startTime;
|
||||
resolve({ success: false, error: `Failed to spawn UV: ${err.message}`, duration });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List installed packages in the virtual environment
|
||||
* @returns List of installed packages or null on error
|
||||
*/
|
||||
async list(): Promise<{ name: string; version: string }[] | null> {
|
||||
// Ensure UV is available
|
||||
if (!(await isUvAvailable())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure venv exists
|
||||
if (!this.isVenvValid()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const uvPath = getUvBinaryPath();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const args = ['pip', 'list', '--format', 'json', '--python', this.getVenvPython()];
|
||||
|
||||
const child = spawn(uvPath, args, buildUvSpawnOptions({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: EXEC_TIMEOUTS.PROCESS_SPAWN,
|
||||
}));
|
||||
|
||||
let stdout = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
try {
|
||||
const packages = JSON.parse(stdout);
|
||||
resolve(packages);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', () => {
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific package is installed
|
||||
* @param packageName - Name of the package to check
|
||||
* @returns True if the package is installed
|
||||
*/
|
||||
async isPackageInstalled(packageName: string): Promise<boolean> {
|
||||
const packages = await this.list();
|
||||
if (!packages) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedName = packageName.toLowerCase().replace(/-/g, '_');
|
||||
return packages.some(
|
||||
(pkg) => pkg.name.toLowerCase().replace(/-/g, '_') === normalizedName
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a Python command in the virtual environment
|
||||
* @param args - Arguments to pass to Python
|
||||
* @param options - Spawn options
|
||||
* @returns Result with stdout/stderr
|
||||
*/
|
||||
async runPython(
|
||||
args: string[],
|
||||
options: { timeout?: number; cwd?: string } = {}
|
||||
): Promise<{ success: boolean; stdout: string; stderr: string }> {
|
||||
const pythonPath = this.getVenvPython();
|
||||
|
||||
if (!existsSync(pythonPath)) {
|
||||
return { success: false, stdout: '', stderr: 'Virtual environment does not exist' };
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(pythonPath, args, buildUvSpawnOptions({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: options.timeout ?? EXEC_TIMEOUTS.PROCESS_SPAWN,
|
||||
cwd: options.cwd,
|
||||
}));
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout?.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
child.stderr?.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
resolve({ success: code === 0, stdout: stdout.trim(), stderr: stderr.trim() });
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
resolve({ success: false, stdout: '', stderr: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Python version in the virtual environment
|
||||
* @returns Python version string or null
|
||||
*/
|
||||
async getPythonVersion(): Promise<string | null> {
|
||||
const result = await this.runPython(['--version']);
|
||||
if (result.success) {
|
||||
const match = result.stdout.match(/Python\s+(\S+)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the virtual environment
|
||||
* @returns True if deletion succeeded
|
||||
*/
|
||||
async deleteVenv(): Promise<boolean> {
|
||||
if (!existsSync(this.venvPath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
fs.rmSync(this.venvPath, { recursive: true, force: true });
|
||||
console.log(`[UV] Virtual environment deleted: ${this.venvPath}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[UV] Failed to delete venv: ${(err as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getPreferredCodexLensPythonSpec(): string {
|
||||
const override = process.env.CCW_PYTHON?.trim();
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
|
||||
if (!IS_WINDOWS) {
|
||||
return '>=3.10,<3.13';
|
||||
}
|
||||
|
||||
// Prefer 3.11/3.10 on Windows because current CodexLens semantic GPU extras
|
||||
// depend on onnxruntime 1.15.x wheels, which are not consistently available for cp312.
|
||||
const preferredVersions = ['3.11', '3.10', '3.12'];
|
||||
for (const version of preferredVersions) {
|
||||
if (hasWindowsPythonLauncherVersion(version)) {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
|
||||
return '>=3.10,<3.13';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a UvManager with default settings for CodexLens
|
||||
* @param dataDir - Base data directory (defaults to ~/.codexlens)
|
||||
* @returns Configured UvManager instance
|
||||
*/
|
||||
export function createCodexLensUvManager(dataDir?: string): UvManager {
|
||||
const baseDir = dataDir ?? getCodexLensDataDir();
|
||||
void baseDir;
|
||||
return new UvManager({
|
||||
venvPath: getCodexLensVenvDir(),
|
||||
pythonVersion: getPreferredCodexLensPythonSpec(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick bootstrap function: ensure UV is installed and create a venv
|
||||
* @param venvPath - Path to the virtual environment
|
||||
* @param pythonVersion - Optional Python version constraint
|
||||
* @returns Installation result
|
||||
*/
|
||||
export async function bootstrapUvVenv(
|
||||
venvPath: string,
|
||||
pythonVersion?: string
|
||||
): Promise<UvInstallResult> {
|
||||
// Ensure UV is installed first
|
||||
const uvInstalled = await ensureUvInstalled();
|
||||
if (!uvInstalled) {
|
||||
return { success: false, error: 'Failed to install UV' };
|
||||
}
|
||||
|
||||
// Create the venv
|
||||
const manager = new UvManager({ venvPath, pythonVersion });
|
||||
return manager.createVenv();
|
||||
}
|
||||
|
||||
export const __testables = {
|
||||
buildUvSpawnOptions,
|
||||
buildUvSpawnSyncOptions,
|
||||
findExecutableOnPath,
|
||||
hasWindowsPythonLauncherVersion,
|
||||
};
|
||||
Reference in New Issue
Block a user