Refactor search modes and optimize embedding generation

- Updated the dashboard template to hide the Code Graph Explorer feature.
- Enhanced the `executeCodexLens` function to use `exec` for better cross-platform compatibility and improved command execution.
- Changed the default `maxResults` and `limit` parameters in the smart search tool to 10 for better performance.
- Introduced a new `priority` search mode in the smart search tool, replacing the previous `parallel` mode, which now follows a fallback strategy: hybrid -> exact -> ripgrep.
- Optimized the embedding generation process in the embedding manager by batching operations and using a cached embedder instance to reduce model loading overhead.
- Implemented a thread-safe singleton pattern for the embedder to improve performance across multiple searches.
This commit is contained in:
catlog22
2025-12-20 11:08:34 +08:00
parent 7adde91e9f
commit e1cac5dd50
16 changed files with 852 additions and 284 deletions

View File

@@ -173,6 +173,10 @@ function generateFromUnifiedTemplate(data: unknown): string {
jsContent = jsContent.replace(/\{\{PROJECT_PATH\}\}/g, projectPath.replace(/\\/g, '/'));
jsContent = jsContent.replace('{{RECENT_PATHS}}', JSON.stringify(recentPaths));
// Inject platform information for cross-platform MCP command generation
// 'win32' for Windows, 'darwin' for macOS, 'linux' for Linux
jsContent = jsContent.replace(/\{\{SERVER_PLATFORM\}\}/g, process.platform);
// Inject JS and CSS into HTML template
html = html.replace('{{JS_CONTENT}}', jsContent);
html = html.replace('{{CSS_CONTENT}}', cssContent);

View File

@@ -224,6 +224,13 @@
border-radius: 8px;
padding: 1.25rem;
transition: all 0.2s ease;
cursor: pointer;
/* Fixed card dimensions */
display: flex;
flex-direction: column;
height: 280px;
min-height: 280px;
max-height: 280px;
}
.memory-card:hover {
@@ -239,8 +246,9 @@
.memory-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
align-items: center;
margin-bottom: 0.75rem;
flex-shrink: 0;
}
.memory-id {
@@ -248,7 +256,8 @@
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: hsl(var(--muted-foreground));
color: hsl(var(--foreground));
font-weight: 600;
flex-wrap: wrap;
}
@@ -257,6 +266,10 @@
height: 16px;
}
.memory-id i[data-lucide="star"] {
color: hsl(38 92% 50%);
}
.memory-actions {
display: flex;
gap: 0.5rem;
@@ -282,34 +295,83 @@
color: hsl(var(--destructive));
}
.icon-btn.favorite-active {
color: hsl(38 92% 50%);
}
.icon-btn.favorite-active:hover {
color: hsl(38 92% 40%);
}
.icon-btn i {
width: 18px;
height: 18px;
}
/* Copy ID Button */
.copy-id-btn {
background: none;
border: none;
padding: 0.125rem 0.25rem;
cursor: pointer;
color: hsl(var(--muted-foreground));
transition: color 0.2s;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 0.25rem;
border-radius: 4px;
}
.copy-id-btn:hover {
color: hsl(var(--primary));
background: hsl(var(--muted));
}
.copy-id-btn i {
width: 14px;
height: 14px;
}
.copy-id-btn.copied {
color: hsl(var(--success, 142 76% 36%));
}
/* Memory Content */
.memory-content {
margin-bottom: 1rem;
}
.memory-summary {
font-size: 0.9375rem;
line-height: 1.6;
color: hsl(var(--foreground));
flex: 1;
min-height: 0;
overflow: hidden;
margin-bottom: 0.75rem;
}
.memory-summary,
.memory-preview {
font-size: 0.875rem;
line-height: 1.5;
color: hsl(var(--muted-foreground));
/* Fixed height with ellipsis */
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
max-height: 6em;
}
.memory-summary {
color: hsl(var(--foreground));
}
/* Memory Tags */
.memory-tags {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 1rem;
gap: 0.375rem;
margin-bottom: 0.5rem;
flex-shrink: 0;
max-height: 2rem;
overflow: hidden;
}
.tag {
@@ -321,13 +383,14 @@
font-weight: 500;
}
/* Memory Footer */
/* Memory Footer - Fixed at bottom */
.memory-footer {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding-top: 1rem;
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid hsl(var(--border));
margin-top: auto;
}
.memory-meta {
@@ -850,6 +913,89 @@
margin: 0;
}
/* Relations Detail Styles */
.relations-detail h4 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 1rem;
color: hsl(var(--foreground));
}
.clusters-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.relation-cluster-item {
padding: 1rem;
background: hsl(var(--muted) / 0.3);
border: 1px solid hsl(var(--border));
border-radius: 8px;
}
.relation-cluster-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.relation-cluster-header i {
width: 18px;
height: 18px;
color: hsl(var(--primary));
}
.relation-cluster-header .cluster-name {
font-weight: 600;
color: hsl(var(--foreground));
}
.cluster-relations-list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid hsl(var(--border));
}
.relations-label {
font-size: 0.8125rem;
color: hsl(var(--muted-foreground));
}
.relation-tag {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.625rem;
background: hsl(var(--accent));
border-radius: 4px;
font-size: 0.8125rem;
}
.relation-tag i {
width: 12px;
height: 12px;
color: hsl(var(--muted-foreground));
}
.relation-tag .relation-type {
font-size: 0.6875rem;
padding: 0.125rem 0.375rem;
background: hsl(var(--primary) / 0.15);
color: hsl(var(--primary));
border-radius: 3px;
margin-left: 0.25rem;
}
.text-muted {
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
}
/* Dark Mode Adjustments */
[data-theme="dark"] .memory-card {
background: #1e293b;

View File

@@ -928,11 +928,19 @@ function selectCcwTools(type) {
}
// Build CCW Tools config with selected tools
// Uses isWindowsPlatform from state.js to generate platform-appropriate commands
function buildCcwToolsConfig(selectedTools) {
const config = {
command: "cmd",
args: ["/c", "npx", "-y", "ccw-mcp"]
};
// Windows requires 'cmd /c' wrapper to execute npx
// Other platforms (macOS, Linux) can run npx directly
const config = isWindowsPlatform
? {
command: "cmd",
args: ["/c", "npx", "-y", "ccw-mcp"]
}
: {
command: "npx",
args: ["-y", "ccw-mcp"]
};
// Add env if not all tools or not default 4 core tools
const coreTools = ['write_file', 'edit_file', 'codex_lens', 'smart_search'];

View File

@@ -1291,8 +1291,27 @@ const i18n = {
'coreMemory.clusterUpdateError': 'Failed to update cluster',
'coreMemory.memberRemoved': 'Member removed',
'coreMemory.memberRemoveError': 'Failed to remove member',
'coreMemory.favorites': 'Favorites',
'coreMemory.totalFavorites': 'Total Favorites',
'coreMemory.noFavorites': 'No favorites yet',
'coreMemory.toggleFavorite': 'Toggle Favorite',
'coreMemory.addedToFavorites': 'Added to favorites',
'coreMemory.removedFromFavorites': 'Removed from favorites',
'coreMemory.favoriteError': 'Failed to update favorite',
'coreMemory.relations': 'Relations',
'coreMemory.showRelations': 'Show Relations',
'coreMemory.relationsFor': 'Relations',
'coreMemory.noRelations': 'No cluster relations found',
'coreMemory.noRelationsHint': 'Use Auto Cluster in the Clusters tab to create relations',
'coreMemory.belongsToClusters': 'Belongs to Clusters',
'coreMemory.relationsError': 'Failed to load relations',
// Common additions
'common.copyId': 'Copy ID',
'common.copied': 'Copied!',
'common.copyError': 'Failed to copy',
},
zh: {
// App title and brand
'app.title': 'CCW 控制面板',
@@ -1589,13 +1608,13 @@ const i18n = {
'index.projects': '项目数',
'index.totalSize': '总大小',
'index.vectorIndexes': '向量',
'index.ftsIndexes': '全文',
'index.ftsIndexes': 'FTS',
'index.projectId': '项目 ID',
'index.size': '大小',
'index.type': '类型',
'index.lastModified': '修改时间',
'index.vector': '向量',
'index.fts': '全文',
'index.fts': 'FTS',
'index.noIndexes': '暂无索引',
'index.notConfigured': '未配置',
'index.initCurrent': '索引当前项目',
@@ -1608,7 +1627,7 @@ const i18n = {
'index.cleanAllConfirm': '确定要清理所有索引吗?此操作无法撤销。',
'index.cleanAllSuccess': '所有索引已清理',
'index.vectorIndex': '向量索引',
'index.normalIndex': '全文索引',
'index.normalIndex': 'FTS索引',
'index.vectorDesc': '语义搜索(含嵌入向量)',
'index.normalDesc': '快速全文搜索',
@@ -2585,6 +2604,25 @@ const i18n = {
'coreMemory.clusterUpdateError': '更新聚类失败',
'coreMemory.memberRemoved': '成员已移除',
'coreMemory.memberRemoveError': '移除成员失败',
'coreMemory.favorites': '收藏',
'coreMemory.totalFavorites': '收藏总数',
'coreMemory.noFavorites': '暂无收藏',
'coreMemory.toggleFavorite': '切换收藏',
'coreMemory.addedToFavorites': '已添加到收藏',
'coreMemory.removedFromFavorites': '已从收藏移除',
'coreMemory.favoriteError': '更新收藏失败',
'coreMemory.relations': '关联',
'coreMemory.showRelations': '显示关联',
'coreMemory.relationsFor': '关联关系',
'coreMemory.noRelations': '未找到聚类关联',
'coreMemory.noRelationsHint': '在聚类 Tab 中使用自动聚类来创建关联',
'coreMemory.belongsToClusters': '所属聚类',
'coreMemory.relationsError': '加载关联失败',
// Common additions
'common.copyId': '复制 ID',
'common.copied': '已复制!',
'common.copyError': '复制失败',
}
};

View File

@@ -10,6 +10,11 @@ let workflowData = {{WORKFLOW_DATA}};
let projectPath = '{{PROJECT_PATH}}';
let recentPaths = {{RECENT_PATHS}};
// Platform detection for cross-platform MCP command generation
// 'win32' for Windows, 'darwin' for macOS, 'linux' for Linux
const serverPlatform = '{{SERVER_PLATFORM}}';
const isWindowsPlatform = serverPlatform === 'win32';
// ========== Application State ==========
// Current filter for session list view ('all', 'active', 'archived')
let currentFilter = 'all';

View File

@@ -5,6 +5,37 @@
// ========== HTML/Text Processing ==========
/**
* Encode JSON config data to Base64 for safe HTML attribute storage
* @param {Object} config - Configuration object to encode
* @returns {string} Base64 encoded JSON string
*/
function encodeConfigData(config) {
try {
const jsonStr = JSON.stringify(config);
return btoa(encodeURIComponent(jsonStr));
} catch (err) {
console.error('[Utils] Error encoding config data:', err);
return '';
}
}
/**
* Decode Base64 config data back to JSON object
* @param {string} encoded - Base64 encoded string
* @returns {Object|null} Decoded configuration object or null on error
*/
function decodeConfigData(encoded) {
try {
if (!encoded) return null;
const jsonStr = decodeURIComponent(atob(encoded));
return JSON.parse(jsonStr);
} catch (err) {
console.error('[Utils] Error decoding config data:', err);
return null;
}
}
/**
* Escape HTML special characters to prevent XSS attacks
* @param {string} str - String to escape

View File

@@ -392,7 +392,8 @@ function renderToolsSection() {
'<div class="tool-item-right">' +
(codexLensStatus.ready
? '<span class="tool-status-text success"><i data-lucide="check-circle" class="w-3.5 h-3.5"></i> v' + (codexLensStatus.version || 'installed') + '</span>' +
'<button class="btn-sm btn-outline" onclick="event.stopPropagation(); initCodexLensIndex()"><i data-lucide="database" class="w-3 h-3"></i> ' + t('cli.initIndex') + '</button>' +
'<button class="btn-sm btn-outline" onclick="event.stopPropagation(); initCodexLensIndex(\'vector\')" title="' + (t('index.vectorDesc') || 'Semantic search with embeddings') + '"><i data-lucide="sparkles" class="w-3 h-3"></i> ' + (t('index.vectorIndex') || 'Vector') + '</button>' +
'<button class="btn-sm btn-outline" onclick="event.stopPropagation(); initCodexLensIndex(\'normal\')" title="' + (t('index.normalDesc') || 'Fast full-text search only') + '"><i data-lucide="file-text" class="w-3 h-3"></i> ' + (t('index.normalIndex') || 'FTS') + '</button>' +
'<button class="btn-sm btn-outline btn-danger" onclick="event.stopPropagation(); uninstallCodexLens()"><i data-lucide="trash-2" class="w-3 h-3"></i> ' + t('cli.uninstall') + '</button>'
: '<span class="tool-status-text muted"><i data-lucide="circle-dashed" class="w-3.5 h-3.5"></i> ' + t('cli.notInstalled') + '</span>' +
'<button class="btn-sm btn-primary" onclick="event.stopPropagation(); installCodexLens()"><i data-lucide="download" class="w-3 h-3"></i> ' + t('cli.install') + '</button>') +

View File

@@ -66,6 +66,10 @@ async function renderCoreMemoryView() {
<i data-lucide="brain"></i>
${t('coreMemory.memories')}
</button>
<button class="tab-btn" id="favoritesViewBtn" onclick="showFavoritesView()">
<i data-lucide="star"></i>
${t('coreMemory.favorites') || 'Favorites'}
</button>
<button class="tab-btn" id="clustersViewBtn" onclick="showClustersView()">
<i data-lucide="folder-tree"></i>
${t('coreMemory.clusters')}
@@ -106,6 +110,22 @@ async function renderCoreMemoryView() {
</div>
</div>
<!-- Favorites Tab Content (hidden by default) -->
<div class="cm-tab-panel" id="favoritesGrid" style="display: none;">
<div class="memory-stats">
<div class="stat-item">
<span class="stat-label">${t('coreMemory.totalFavorites') || 'Total Favorites'}</span>
<span class="stat-value" id="totalFavoritesCount">0</span>
</div>
</div>
<div class="memories-grid" id="favoritesGridContent">
<div class="empty-state">
<i data-lucide="star"></i>
<p>${t('coreMemory.noFavorites') || 'No favorites yet'}</p>
</div>
</div>
</div>
<!-- Clusters Tab Content (hidden by default) -->
<div class="cm-tab-panel clusters-container" id="clustersContainer" style="display: none;">
<div class="clusters-layout">
@@ -213,21 +233,21 @@ function renderMemoryCard(memory) {
const priority = metadata.priority || 'medium';
return `
<div class="memory-card ${isArchived ? 'archived' : ''}" data-memory-id="${memory.id}">
<div class="memory-card ${isArchived ? 'archived' : ''}" data-memory-id="${memory.id}" onclick="viewMemoryDetail('${memory.id}')">
<div class="memory-card-header">
<div class="memory-id">
<i data-lucide="bookmark"></i>
${metadata.favorite ? '<i data-lucide="star"></i>' : ''}
<span>${memory.id}</span>
${isArchived ? `<span class="badge badge-archived">${t('common.archived')}</span>` : ''}
${priority !== 'medium' ? `<span class="badge badge-priority-${priority}">${priority}</span>` : ''}
</div>
<div class="memory-actions">
<button class="icon-btn" onclick="viewMemoryDetail('${memory.id}')" title="${t('common.view')}">
<i data-lucide="eye"></i>
</button>
<div class="memory-actions" onclick="event.stopPropagation()">
<button class="icon-btn" onclick="editMemory('${memory.id}')" title="${t('common.edit')}">
<i data-lucide="edit"></i>
</button>
<button class="icon-btn ${metadata.favorite ? 'favorite-active' : ''}" onclick="toggleFavorite('${memory.id}')" title="${t('coreMemory.toggleFavorite') || 'Toggle Favorite'}">
<i data-lucide="star"></i>
</button>
${!isArchived
? `<button class="icon-btn" onclick="archiveMemory('${memory.id}')" title="${t('common.archive')}">
<i data-lucide="archive"></i>
@@ -270,11 +290,19 @@ function renderMemoryCard(memory) {
: ''
}
</div>
<div class="memory-features">
<div class="memory-features" onclick="event.stopPropagation()">
<button class="feature-btn" onclick="generateMemorySummary('${memory.id}')" title="${t('coreMemory.generateSummary')}">
<i data-lucide="sparkles"></i>
${t('coreMemory.summary')}
</button>
<button class="feature-btn" onclick="copyMemoryId('${memory.id}')" title="${t('common.copyId') || 'Copy ID'}">
<i data-lucide="copy"></i>
${t('common.copyId') || 'Copy ID'}
</button>
<button class="feature-btn" onclick="showMemoryRelations('${memory.id}')" title="${t('coreMemory.showRelations') || 'Show Relations'}">
<i data-lucide="git-branch"></i>
${t('coreMemory.relations') || 'Relations'}
</button>
</div>
</div>
</div>
@@ -565,18 +593,44 @@ function escapeHtml(text) {
return div.innerHTML;
}
async function copyMemoryId(memoryId) {
try {
await navigator.clipboard.writeText(memoryId);
showNotification(t('common.copied') || 'Copied!', 'success');
} catch (error) {
console.error('Failed to copy:', error);
showNotification(t('common.copyError') || 'Failed to copy', 'error');
}
}
// View Toggle Functions
function showMemoriesView() {
document.getElementById('memoriesGrid').style.display = '';
document.getElementById('favoritesGrid').style.display = 'none';
document.getElementById('clustersContainer').style.display = 'none';
document.getElementById('memoriesViewBtn').classList.add('active');
document.getElementById('favoritesViewBtn').classList.remove('active');
document.getElementById('clustersViewBtn').classList.remove('active');
}
async function showFavoritesView() {
document.getElementById('memoriesGrid').style.display = 'none';
document.getElementById('favoritesGrid').style.display = '';
document.getElementById('clustersContainer').style.display = 'none';
document.getElementById('memoriesViewBtn').classList.remove('active');
document.getElementById('favoritesViewBtn').classList.add('active');
document.getElementById('clustersViewBtn').classList.remove('active');
// Load favorites
await refreshFavorites();
}
function showClustersView() {
document.getElementById('memoriesGrid').style.display = 'none';
document.getElementById('favoritesGrid').style.display = 'none';
document.getElementById('clustersContainer').style.display = '';
document.getElementById('memoriesViewBtn').classList.remove('active');
document.getElementById('favoritesViewBtn').classList.remove('active');
document.getElementById('clustersViewBtn').classList.add('active');
// Load clusters from core-memory-clusters.js
@@ -586,3 +640,143 @@ function showClustersView() {
console.error('loadClusters is not available. Make sure core-memory-clusters.js is loaded.');
}
}
// Favorites Functions
async function refreshFavorites() {
const allMemories = await fetchCoreMemories(false);
const favorites = allMemories.filter(m => m.metadata && m.metadata.favorite);
const countEl = document.getElementById('totalFavoritesCount');
const gridEl = document.getElementById('favoritesGridContent');
if (countEl) countEl.textContent = favorites.length;
if (gridEl) {
if (favorites.length === 0) {
gridEl.innerHTML = `
<div class="empty-state">
<i data-lucide="star"></i>
<p>${t('coreMemory.noFavorites') || 'No favorites yet'}</p>
</div>
`;
} else {
gridEl.innerHTML = favorites.map(memory => renderMemoryCard(memory)).join('');
}
}
lucide.createIcons();
}
async function showMemoryRelations(memoryId) {
try {
// Fetch all clusters
const response = await fetch(`/api/core-memory/clusters?path=${encodeURIComponent(projectPath)}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
const clusters = result.clusters || [];
// Find clusters containing this memory
const relatedClusters = [];
for (const cluster of clusters) {
const detailRes = await fetch(`/api/core-memory/clusters/${cluster.id}?path=${encodeURIComponent(projectPath)}`);
if (detailRes.ok) {
const detail = await detailRes.json();
const members = detail.members || [];
if (members.some(m => m.session_id === memoryId || m.id === memoryId)) {
relatedClusters.push({
...cluster,
relations: detail.relations || []
});
}
}
}
// Show in modal
const modal = document.getElementById('memoryDetailModal');
document.getElementById('memoryDetailTitle').textContent = t('coreMemory.relationsFor') || `Relations: ${memoryId}`;
const body = document.getElementById('memoryDetailBody');
if (relatedClusters.length === 0) {
body.innerHTML = `
<div class="empty-state">
<i data-lucide="git-branch"></i>
<p>${t('coreMemory.noRelations') || 'No cluster relations found'}</p>
<p class="text-muted">${t('coreMemory.noRelationsHint') || 'Use Auto Cluster in the Clusters tab to create relations'}</p>
</div>
`;
} else {
body.innerHTML = `
<div class="relations-detail">
<h4>${t('coreMemory.belongsToClusters') || 'Belongs to Clusters'}</h4>
<div class="clusters-list">
${relatedClusters.map(cluster => `
<div class="relation-cluster-item">
<div class="relation-cluster-header">
<i data-lucide="folder"></i>
<span class="cluster-name">${escapeHtml(cluster.name)}</span>
<span class="badge badge-${cluster.status}">${cluster.status}</span>
</div>
${cluster.relations && cluster.relations.length > 0 ? `
<div class="cluster-relations-list">
<span class="relations-label">${t('coreMemory.relatedClusters')}:</span>
${cluster.relations.map(rel => `
<span class="relation-tag">
<i data-lucide="link"></i>
${escapeHtml(rel.target_name || rel.target_id)}
<span class="relation-type">${rel.relation_type}</span>
</span>
`).join('')}
</div>
` : ''}
</div>
`).join('')}
</div>
</div>
`;
}
modal.style.display = 'flex';
lucide.createIcons();
} catch (error) {
console.error('Failed to load relations:', error);
showNotification(t('coreMemory.relationsError') || 'Failed to load relations', 'error');
}
}
async function toggleFavorite(memoryId) {
try {
const memory = await fetchMemoryById(memoryId);
if (!memory) return;
const metadata = memory.metadata || {};
metadata.favorite = !metadata.favorite;
const response = await fetch('/api/core-memory/memories', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...memory, metadata, path: projectPath })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
showNotification(
metadata.favorite
? (t('coreMemory.addedToFavorites') || 'Added to favorites')
: (t('coreMemory.removedFromFavorites') || 'Removed from favorites'),
'success'
);
// Refresh current view
await refreshCoreMemories();
// Also refresh favorites if visible
const favoritesGrid = document.getElementById('favoritesGrid');
if (favoritesGrid && favoritesGrid.style.display !== 'none') {
await refreshFavorites();
}
} catch (error) {
console.error('Failed to toggle favorite:', error);
showNotification(t('coreMemory.favoriteError') || 'Failed to update favorite', 'error');
}
}

View File

@@ -324,7 +324,7 @@ async function renderMcpManager() {
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
data-action="copy-to-codex"
data-server-name="${escapeHtml(serverName)}"
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
data-server-config="${encodeConfigData(serverConfig)}"
title="${t('mcp.codex.copyToCodex')}">
<i data-lucide="arrow-right" class="w-3.5 h-3.5 inline"></i> Codex
</button>
@@ -684,7 +684,7 @@ async function renderMcpManager() {
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
data-action="copy-codex-to-claude"
data-server-name="${escapeHtml(serverName)}"
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "&#39;")}'
data-server-config="${encodeConfigData(serverConfig)}"
title="${t('mcp.claude.copyToClaude')}">
<i data-lucide="arrow-right" class="w-3.5 h-3.5 inline"></i> Claude
</button>
@@ -823,7 +823,7 @@ function renderProjectAvailableServerCard(entry) {
return `
<div class="mcp-server-card bg-card border border-border rounded-lg p-4 hover:shadow-md transition-all cursor-pointer ${canToggle && !isEnabled ? 'opacity-60' : ''}"
data-server-name="${escapeHtml(name)}"
data-server-config="${escapeHtml(JSON.stringify(config))}"
data-server-config="${encodeConfigData(config)}"
data-server-source="${source}"
data-action="view-details"
title="${t('mcp.clickToViewDetails')}">
@@ -867,7 +867,7 @@ function renderProjectAvailableServerCard(entry) {
<div class="flex items-center gap-2">
<button class="text-xs text-success hover:text-success/80 transition-colors flex items-center gap-1"
data-server-name="${escapeHtml(name)}"
data-server-config="${escapeHtml(JSON.stringify(config))}"
data-server-config="${encodeConfigData(config)}"
data-action="save-as-template"
onclick="event.stopPropagation()"
title="${t('mcp.saveAsTemplate')}">
@@ -898,7 +898,7 @@ function renderGlobalManagementCard(serverName, serverConfig) {
return `
<div class="mcp-server-card mcp-server-global bg-card border border-success/30 rounded-lg p-4 hover:shadow-md transition-all cursor-pointer"
data-server-name="${escapeHtml(serverName)}"
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
data-server-config="${encodeConfigData(serverConfig)}"
data-server-source="global"
data-action="view-details"
title="${t('mcp.clickToEdit')}">
@@ -976,7 +976,7 @@ function renderAvailableServerCard(serverName, serverInfo) {
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
data-server-name="${escapeHtml(originalName)}"
data-server-key="${escapeHtml(serverName)}"
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "&#39;")}'
data-server-config="${encodeConfigData(serverConfig)}"
data-scope="project"
data-action="add-from-other"
title="${t('mcp.installToProject')}">
@@ -985,7 +985,7 @@ function renderAvailableServerCard(serverName, serverInfo) {
<button class="px-3 py-1 text-xs bg-success text-success-foreground rounded hover:opacity-90 transition-opacity"
data-server-name="${escapeHtml(originalName)}"
data-server-key="${escapeHtml(serverName)}"
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "&#39;")}'
data-server-config="${encodeConfigData(serverConfig)}"
data-scope="global"
data-action="add-from-other"
title="${t('mcp.installToGlobal')}">
@@ -1014,7 +1014,7 @@ function renderAvailableServerCard(serverName, serverInfo) {
<div class="mt-3 pt-3 border-t border-border flex items-center gap-2">
<button class="text-xs text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
data-server-name="${escapeHtml(originalName)}"
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
data-server-config="${encodeConfigData(serverConfig)}"
data-action="install-to-project"
title="${t('mcp.installToProject')}">
<i data-lucide="download" class="w-3 h-3"></i>
@@ -1022,7 +1022,7 @@ function renderAvailableServerCard(serverName, serverInfo) {
</button>
<button class="text-xs text-success hover:text-success/80 transition-colors flex items-center gap-1"
data-server-name="${escapeHtml(originalName)}"
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
data-server-config="${encodeConfigData(serverConfig)}"
data-action="install-to-global"
title="${t('mcp.installToGlobal')}">
<i data-lucide="globe" class="w-3 h-3"></i>
@@ -1072,7 +1072,7 @@ function renderAvailableServerCardForCodex(serverName, serverInfo) {
<button class="px-3 py-1 text-xs bg-primary text-primary-foreground rounded hover:opacity-90 transition-opacity"
data-action="copy-to-codex"
data-server-name="${escapeHtml(originalName)}"
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
data-server-config="${encodeConfigData(serverConfig)}"
title="${t('mcp.codex.copyToCodex')}">
<i data-lucide="arrow-right" class="w-3.5 h-3.5 inline"></i> Codex
</button>
@@ -1100,7 +1100,7 @@ function renderAvailableServerCardForCodex(serverName, serverInfo) {
<button class="text-xs text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
data-action="copy-to-codex"
data-server-name="${escapeHtml(originalName)}"
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
data-server-config="${encodeConfigData(serverConfig)}"
title="${t('mcp.codex.copyToCodex')}">
<i data-lucide="download" class="w-3 h-3"></i>
${t('mcp.codex.install')}
@@ -1130,7 +1130,7 @@ function renderCodexServerCard(serverName, serverConfig) {
return `
<div class="mcp-server-card bg-card border border-primary/20 rounded-lg p-4 hover:shadow-md transition-all cursor-pointer ${!isEnabled ? 'opacity-60' : ''}"
data-server-name="${escapeHtml(serverName)}"
data-server-config="${escapeHtml(JSON.stringify(serverConfig))}"
data-server-config="${encodeConfigData(serverConfig)}"
data-cli-type="codex"
data-action="view-details-codex"
title="${t('mcp.clickToEdit')}">
@@ -1180,7 +1180,7 @@ function renderCodexServerCard(serverName, serverConfig) {
<button class="text-xs text-primary hover:text-primary/80 transition-colors flex items-center gap-1"
data-action="copy-codex-to-claude"
data-server-name="${escapeHtml(serverName)}"
data-server-config='${JSON.stringify(serverConfig).replace(/'/g, "&#39;")}'
data-server-config="${encodeConfigData(serverConfig)}"
title="${t('mcp.codex.copyToClaude')}">
<i data-lucide="copy" class="w-3 h-3"></i>
${t('mcp.codex.copyToClaude')}
@@ -1250,7 +1250,7 @@ function renderCrossCliServerCard(server, isClaude) {
<button class="w-full px-3 py-2 text-sm font-medium bg-primary hover:bg-primary/90 text-primary-foreground rounded-lg transition-colors flex items-center justify-center gap-1.5"
data-action="copy-cross-cli"
data-server-name="${escapeHtml(name)}"
data-server-config='${JSON.stringify(config).replace(/'/g, "&#39;")}'
data-server-config="${encodeConfigData(config)}"
data-from-cli="${fromCli}"
data-target-cli="${targetCli}">
<i data-lucide="copy" class="w-4 h-4"></i>
@@ -1357,14 +1357,18 @@ function attachMcpEventListeners() {
// Add from other projects (with scope selection)
document.querySelectorAll('.mcp-server-card button[data-action="add-from-other"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const serverName = btn.dataset.serverName;
const serverConfig = JSON.parse(btn.dataset.serverConfig);
const scope = btn.dataset.scope; // 'project' or 'global'
try {
const serverName = btn.dataset.serverName;
const serverConfig = decodeConfigData(btn.dataset.serverConfig);
const scope = btn.dataset.scope; // 'project' or 'global'
if (scope === 'global') {
await addGlobalMcpServer(serverName, serverConfig);
} else {
await copyMcpServerToProject(serverName, serverConfig);
if (scope === 'global') {
await addGlobalMcpServer(serverName, serverConfig);
} else {
await copyMcpServerToProject(serverName, serverConfig);
}
} catch (err) {
console.error('[MCP] Error adding server from other project:', err);
}
});
});
@@ -1392,27 +1396,39 @@ function attachMcpEventListeners() {
// Install to project buttons
document.querySelectorAll('.mcp-server-card button[data-action="install-to-project"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const serverName = btn.dataset.serverName;
const serverConfig = JSON.parse(btn.dataset.serverConfig);
await copyMcpServerToProject(serverName, serverConfig);
try {
const serverName = btn.dataset.serverName;
const serverConfig = decodeConfigData(btn.dataset.serverConfig);
await copyMcpServerToProject(serverName, serverConfig);
} catch (err) {
console.error('[MCP] Error installing to project:', err);
}
});
});
// Install to global buttons
document.querySelectorAll('.mcp-server-card button[data-action="install-to-global"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const serverName = btn.dataset.serverName;
const serverConfig = JSON.parse(btn.dataset.serverConfig);
await addGlobalMcpServer(serverName, serverConfig);
try {
const serverName = btn.dataset.serverName;
const serverConfig = decodeConfigData(btn.dataset.serverConfig);
await addGlobalMcpServer(serverName, serverConfig);
} catch (err) {
console.error('[MCP] Error installing to global:', err);
}
});
});
// Save as template buttons
document.querySelectorAll('.mcp-server-card button[data-action="save-as-template"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
const serverName = btn.dataset.serverName;
const serverConfig = JSON.parse(btn.dataset.serverConfig);
await saveMcpAsTemplate(serverName, serverConfig);
try {
const serverName = btn.dataset.serverName;
const serverConfig = decodeConfigData(btn.dataset.serverConfig);
await saveMcpAsTemplate(serverName, serverConfig);
} catch (err) {
console.error('[MCP] Error saving as template:', err);
}
});
});
@@ -1507,10 +1523,14 @@ function attachMcpEventListeners() {
document.querySelectorAll('button[data-action="copy-to-codex"]').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const serverName = btn.dataset.serverName;
const serverConfig = JSON.parse(btn.dataset.serverConfig);
console.log('[MCP] Copying to Codex:', serverName);
await copyClaudeServerToCodex(serverName, serverConfig);
try {
const serverName = btn.dataset.serverName;
const serverConfig = decodeConfigData(btn.dataset.serverConfig);
console.log('[MCP] Copying to Codex:', serverName);
await copyClaudeServerToCodex(serverName, serverConfig);
} catch (err) {
console.error('[MCP] Error copying to Codex:', err);
}
});
});
@@ -1522,7 +1542,7 @@ function attachMcpEventListeners() {
const serverName = btn.dataset.serverName;
let serverConfig;
try {
serverConfig = JSON.parse(btn.dataset.serverConfig);
serverConfig = decodeConfigData(btn.dataset.serverConfig);
} catch (err) {
console.error('[MCP] JSON Parse Error:', err);
if (typeof showRefreshToast === 'function') {
@@ -1543,7 +1563,7 @@ function attachMcpEventListeners() {
const serverName = btn.dataset.serverName;
let serverConfig;
try {
serverConfig = JSON.parse(btn.dataset.serverConfig);
serverConfig = decodeConfigData(btn.dataset.serverConfig);
} catch (err) {
console.error('[MCP] JSON Parse Error:', err);
if (typeof showRefreshToast === 'function') {
@@ -1567,14 +1587,21 @@ function attachMcpEventListeners() {
}
try {
const serverName = card.dataset.serverName;
// Decode HTML entities before parsing JSON
const configStr = unescapeHtml(card.dataset.serverConfig);
const serverConfig = JSON.parse(configStr);
const configData = card.dataset.serverConfig;
if (!configData) {
console.error('[MCP] Missing server config for:', serverName);
return;
}
const serverConfig = decodeConfigData(configData);
if (!serverConfig) {
console.error('[MCP] Failed to decode server config for:', serverName);
return;
}
const serverSource = card.dataset.serverSource;
console.log('[MCP] Card clicked:', serverName, serverSource);
showMcpEditModal(serverName, serverConfig, serverSource, 'claude');
} catch (err) {
console.error('[MCP] Error handling card click:', err, card.dataset.serverConfig);
console.error('[MCP] Error handling card click:', err);
}
});
});
@@ -1588,13 +1615,20 @@ function attachMcpEventListeners() {
}
try {
const serverName = card.dataset.serverName;
// Decode HTML entities before parsing JSON
const configStr = unescapeHtml(card.dataset.serverConfig);
const serverConfig = JSON.parse(configStr);
const configData = card.dataset.serverConfig;
if (!configData) {
console.error('[MCP] Missing server config for:', serverName);
return;
}
const serverConfig = decodeConfigData(configData);
if (!serverConfig) {
console.error('[MCP] Failed to decode server config for:', serverName);
return;
}
console.log('[MCP] Codex card clicked:', serverName);
showMcpEditModal(serverName, serverConfig, 'codex', 'codex');
} catch (err) {
console.error('[MCP] Error handling Codex card click:', err, card.dataset.serverConfig);
console.error('[MCP] Error handling Codex card click:', err);
}
});
});

View File

@@ -331,10 +331,12 @@
<i data-lucide="history" class="nav-icon"></i>
<span class="nav-text flex-1" data-i18n="nav.history">History</span>
</li>
<!-- Hidden: Code Graph Explorer (feature disabled)
<li class="nav-item flex items-center gap-2 mx-2 px-3 py-2.5 text-sm text-muted-foreground hover:bg-hover hover:text-foreground rounded cursor-pointer transition-colors" data-view="graph-explorer" data-tooltip="Code Graph Explorer">
<i data-lucide="git-branch" class="nav-icon"></i>
<span class="nav-text flex-1" data-i18n="nav.graphExplorer">Graph</span>
</li>
-->
</ul>
</div>

View File

@@ -11,7 +11,7 @@
import { z } from 'zod';
import type { ToolSchema, ToolResult } from '../types/tool.js';
import { spawn, execSync } from 'child_process';
import { spawn, execSync, exec } from 'child_process';
import { existsSync, mkdirSync } from 'fs';
import { join, dirname } from 'path';
import { homedir } from 'os';
@@ -443,22 +443,44 @@ async function executeCodexLens(args: string[], options: ExecuteOptions = {}): P
}
return new Promise((resolve) => {
const child = spawn(VENV_PYTHON, ['-m', 'codexlens', ...args], {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
// Build command string - quote paths for shell execution
const quotedPython = `"${VENV_PYTHON}"`;
const cmdArgs = args.map(arg => {
// Quote arguments that contain spaces or special characters
if (arg.includes(' ') || arg.includes('\\')) {
return `"${arg}"`;
}
return arg;
});
let stdout = '';
let stderr = '';
let timedOut = false;
// Build full command - on Windows, prepend cd to handle different drives
let fullCmd: string;
if (process.platform === 'win32' && cwd) {
// Use cd /d to change drive and directory, then run command
fullCmd = `cd /d "${cwd}" && ${quotedPython} -m codexlens ${cmdArgs.join(' ')}`;
} else {
fullCmd = `${quotedPython} -m codexlens ${cmdArgs.join(' ')}`;
}
child.stdout.on('data', (data) => {
const chunk = data.toString();
stdout += chunk;
// Use exec with shell option for cross-platform compatibility
exec(fullCmd, {
cwd: process.platform === 'win32' ? undefined : cwd, // Don't use cwd on Windows, use cd command instead
timeout,
maxBuffer: 50 * 1024 * 1024, // 50MB buffer for large outputs
shell: process.platform === 'win32' ? process.env.ComSpec : undefined,
}, (error, stdout, stderr) => {
if (error) {
if (error.killed) {
resolve({ success: false, error: 'Command timed out' });
} else {
resolve({ success: false, error: stderr || error.message });
}
return;
}
// Report progress if callback provided
if (onProgress) {
const lines = chunk.split('\n');
// Report final progress if callback provided
if (onProgress && stdout) {
const lines = stdout.split('\n');
for (const line of lines) {
const progress = parseProgressLine(line.trim());
if (progress) {
@@ -466,44 +488,8 @@ async function executeCodexLens(args: string[], options: ExecuteOptions = {}): P
}
}
}
});
child.stderr.on('data', (data) => {
const chunk = data.toString();
stderr += chunk;
// Also check stderr for progress (some tools output there)
if (onProgress) {
const lines = chunk.split('\n');
for (const line of lines) {
const progress = parseProgressLine(line.trim());
if (progress) {
onProgress(progress);
}
}
}
});
const timeoutId = setTimeout(() => {
timedOut = true;
child.kill('SIGTERM');
}, timeout);
child.on('close', (code) => {
clearTimeout(timeoutId);
if (timedOut) {
resolve({ success: false, error: 'Command timed out' });
} else if (code === 0) {
resolve({ success: true, output: stdout.trim() });
} else {
resolve({ success: false, error: stderr || `Exit code: ${code}` });
}
});
child.on('error', (err) => {
clearTimeout(timeoutId);
resolve({ success: false, error: `Spawn failed: ${err.message}` });
resolve({ success: true, output: stdout.trim() });
});
});
}

View File

@@ -27,26 +27,21 @@ import type { ProgressInfo } from './codex-lens.js';
const ParamsSchema = z.object({
action: z.enum(['init', 'search', 'search_files', 'status']).default('search'),
query: z.string().optional(),
mode: z.enum(['auto', 'hybrid', 'exact', 'ripgrep', 'parallel']).default('auto'),
mode: z.enum(['auto', 'hybrid', 'exact', 'ripgrep', 'priority']).default('auto'),
output_mode: z.enum(['full', 'files_only', 'count']).default('full'),
path: z.string().optional(),
paths: z.array(z.string()).default([]),
contextLines: z.number().default(0),
maxResults: z.number().default(100),
maxResults: z.number().default(10),
includeHidden: z.boolean().default(false),
languages: z.array(z.string()).optional(),
limit: z.number().default(100),
parallelWeights: z.object({
hybrid: z.number().default(0.5),
exact: z.number().default(0.3),
ripgrep: z.number().default(0.2),
}).optional(),
limit: z.number().default(10),
});
type Params = z.infer<typeof ParamsSchema>;
// Search mode constants
const SEARCH_MODES = ['auto', 'hybrid', 'exact', 'ripgrep', 'parallel'] as const;
const SEARCH_MODES = ['auto', 'hybrid', 'exact', 'ripgrep', 'priority'] as const;
// Classification confidence threshold
const CONFIDENCE_THRESHOLD = 0.7;
@@ -89,6 +84,7 @@ interface SearchMetadata {
warning?: string;
note?: string;
index_status?: 'indexed' | 'not_indexed' | 'partial';
fallback_history?: string[];
// Init action specific
action?: string;
path?: string;
@@ -120,6 +116,13 @@ interface IndexStatus {
warning?: string;
}
/**
* Strip ANSI color codes from string (for JSON parsing)
*/
function stripAnsi(str: string): string {
return str.replace(/\x1b\[[0-9;]*m/g, '');
}
/**
* Check if CodexLens index exists for current directory
* @param path - Directory path to check
@@ -140,7 +143,7 @@ async function checkIndexStatus(path: string = '.'): Promise<IndexStatus> {
// Parse status output
try {
// Strip ANSI color codes from JSON output
const cleanOutput = (result.output || '{}').replace(/\x1b\[[0-9;]*m/g, '');
const cleanOutput = stripAnsi(result.output || '{}');
const parsed = JSON.parse(cleanOutput);
// Handle both direct and nested response formats (status returns {success, result: {...}})
const status = parsed.result || parsed;
@@ -293,7 +296,7 @@ function buildRipgrepCommand(params: {
maxResults: number;
includeHidden: boolean;
}): { command: string; args: string[] } {
const { query, paths = ['.'], contextLines = 0, maxResults = 100, includeHidden = false } = params;
const { query, paths = ['.'], contextLines = 0, maxResults = 10, includeHidden = false } = params;
const args = [
'-n', // Show line numbers
@@ -478,7 +481,7 @@ async function executeAutoMode(params: Params): Promise<SearchResult> {
* No index required, fallback to CodexLens if ripgrep unavailable
*/
async function executeRipgrepMode(params: Params): Promise<SearchResult> {
const { query, paths = [], contextLines = 0, maxResults = 100, includeHidden = false, path = '.' } = params;
const { query, paths = [], contextLines = 0, maxResults = 10, includeHidden = false, path = '.' } = params;
if (!query) {
return {
@@ -520,8 +523,8 @@ async function executeRipgrepMode(params: Params): Promise<SearchResult> {
// Parse results
let results: SemanticMatch[] = [];
try {
const parsed = JSON.parse(result.output || '{}');
const data = parsed.results || parsed;
const parsed = JSON.parse(stripAnsi(result.output || '{}'));
const data = parsed.result?.results || parsed.results || parsed;
results = (Array.isArray(data) ? data : []).map((item: any) => ({
file: item.path || item.file,
score: item.score || 0,
@@ -632,7 +635,7 @@ async function executeRipgrepMode(params: Params): Promise<SearchResult> {
* Requires index
*/
async function executeCodexLensExactMode(params: Params): Promise<SearchResult> {
const { query, path = '.', limit = 100 } = params;
const { query, path = '.', maxResults = 10 } = params;
if (!query) {
return {
@@ -653,7 +656,7 @@ async function executeCodexLensExactMode(params: Params): Promise<SearchResult>
// Check index status
const indexStatus = await checkIndexStatus(path);
const args = ['search', query, '--limit', limit.toString(), '--mode', 'exact', '--json'];
const args = ['search', query, '--limit', maxResults.toString(), '--mode', 'exact', '--json'];
const result = await executeCodexLens(args, { cwd: path });
if (!result.success) {
@@ -673,8 +676,8 @@ async function executeCodexLensExactMode(params: Params): Promise<SearchResult>
// Parse results
let results: SemanticMatch[] = [];
try {
const parsed = JSON.parse(result.output || '{}');
const data = parsed.results || parsed;
const parsed = JSON.parse(stripAnsi(result.output || '{}'));
const data = parsed.result?.results || parsed.results || parsed;
results = (Array.isArray(data) ? data : []).map((item: any) => ({
file: item.path || item.file,
score: item.score || 0,
@@ -704,7 +707,7 @@ async function executeCodexLensExactMode(params: Params): Promise<SearchResult>
* Requires index with embeddings
*/
async function executeHybridMode(params: Params): Promise<SearchResult> {
const { query, path = '.', limit = 100 } = params;
const { query, path = '.', maxResults = 10 } = params;
if (!query) {
return {
@@ -725,7 +728,7 @@ async function executeHybridMode(params: Params): Promise<SearchResult> {
// Check index status
const indexStatus = await checkIndexStatus(path);
const args = ['search', query, '--limit', limit.toString(), '--mode', 'hybrid', '--json'];
const args = ['search', query, '--limit', maxResults.toString(), '--mode', 'hybrid', '--json'];
const result = await executeCodexLens(args, { cwd: path });
if (!result.success) {
@@ -745,8 +748,8 @@ async function executeHybridMode(params: Params): Promise<SearchResult> {
// Parse results
let results: SemanticMatch[] = [];
try {
const parsed = JSON.parse(result.output || '{}');
const data = parsed.results || parsed;
const parsed = JSON.parse(stripAnsi(result.output || '{}'));
const data = parsed.result?.results || parsed.results || parsed;
results = (Array.isArray(data) ? data : []).map((item: any) => ({
file: item.path || item.file,
score: item.score || 0,
@@ -828,94 +831,114 @@ function applyRRFFusion(
}
/**
* Mode: parallel - Run all backends simultaneously with RRF fusion
* Returns best results from hybrid + exact + ripgrep combined
* Promise wrapper with timeout support
* @param promise - The promise to wrap
* @param ms - Timeout in milliseconds
* @param modeName - Name of the mode for error message
* @returns A new promise that rejects on timeout
*/
async function executeParallelMode(params: Params): Promise<SearchResult> {
const { query, path = '.', limit = 100, parallelWeights } = params;
function withTimeout<T>(promise: Promise<T>, ms: number, modeName: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`'${modeName}' search timed out after ${ms}ms`));
}, ms);
promise
.then(resolve)
.catch(reject)
.finally(() => clearTimeout(timer));
});
}
/**
* Mode: priority - Fallback search strategy: hybrid -> exact -> ripgrep
* Returns results from the first backend that succeeds and provides results.
* More efficient than parallel mode - stops as soon as valid results are found.
*/
async function executePriorityFallbackMode(params: Params): Promise<SearchResult> {
const { query, path = '.' } = params;
const fallbackHistory: string[] = [];
if (!query) {
return { success: false, error: 'Query is required for search' };
}
// Check index status first
const indexStatus = await checkIndexStatus(path);
// 1. Try Hybrid search (highest priority) - 90s timeout for large indexes
if (indexStatus.indexed && indexStatus.has_embeddings) {
try {
const hybridResult = await withTimeout(executeHybridMode(params), 90000, 'hybrid');
if (hybridResult.success && hybridResult.results && (hybridResult.results as any[]).length > 0) {
fallbackHistory.push('hybrid: success');
return {
...hybridResult,
metadata: {
...hybridResult.metadata,
mode: 'priority',
note: 'Result from hybrid search (semantic + vector).',
fallback_history: fallbackHistory,
},
};
}
fallbackHistory.push('hybrid: no results');
} catch (error) {
fallbackHistory.push(`hybrid: ${(error as Error).message}`);
}
} else {
fallbackHistory.push(`hybrid: skipped (${!indexStatus.indexed ? 'no index' : 'no embeddings'})`);
}
// 2. Fallback to Exact search - 10s timeout
if (indexStatus.indexed) {
try {
const exactResult = await withTimeout(executeCodexLensExactMode(params), 10000, 'exact');
if (exactResult.success && exactResult.results && (exactResult.results as any[]).length > 0) {
fallbackHistory.push('exact: success');
return {
...exactResult,
metadata: {
...exactResult.metadata,
mode: 'priority',
note: 'Result from exact/FTS search (fallback from hybrid).',
fallback_history: fallbackHistory,
},
};
}
fallbackHistory.push('exact: no results');
} catch (error) {
fallbackHistory.push(`exact: ${(error as Error).message}`);
}
} else {
fallbackHistory.push('exact: skipped (no index)');
}
// 3. Final fallback to Ripgrep - 5s timeout
try {
const ripgrepResult = await withTimeout(executeRipgrepMode(params), 5000, 'ripgrep');
fallbackHistory.push(ripgrepResult.success ? 'ripgrep: success' : 'ripgrep: failed');
return {
success: false,
error: 'Query is required for search',
};
}
// Default weights if not provided
const weights = parallelWeights || {
hybrid: 0.5,
exact: 0.3,
ripgrep: 0.2,
};
// Run all backends in parallel
const [hybridResult, exactResult, ripgrepResult] = await Promise.allSettled([
executeHybridMode(params),
executeCodexLensExactMode(params),
executeRipgrepMode(params),
]);
// Collect successful results
const resultsMap = new Map<string, any[]>();
const backendStatus: Record<string, string> = {};
if (hybridResult.status === 'fulfilled' && hybridResult.value.success) {
resultsMap.set('hybrid', hybridResult.value.results as any[]);
backendStatus.hybrid = 'success';
} else {
backendStatus.hybrid = hybridResult.status === 'rejected'
? `error: ${hybridResult.reason}`
: `failed: ${(hybridResult as PromiseFulfilledResult<SearchResult>).value.error}`;
}
if (exactResult.status === 'fulfilled' && exactResult.value.success) {
resultsMap.set('exact', exactResult.value.results as any[]);
backendStatus.exact = 'success';
} else {
backendStatus.exact = exactResult.status === 'rejected'
? `error: ${exactResult.reason}`
: `failed: ${(exactResult as PromiseFulfilledResult<SearchResult>).value.error}`;
}
if (ripgrepResult.status === 'fulfilled' && ripgrepResult.value.success) {
resultsMap.set('ripgrep', ripgrepResult.value.results as any[]);
backendStatus.ripgrep = 'success';
} else {
backendStatus.ripgrep = ripgrepResult.status === 'rejected'
? `error: ${ripgrepResult.reason}`
: `failed: ${(ripgrepResult as PromiseFulfilledResult<SearchResult>).value.error}`;
}
// If no results from any backend
if (resultsMap.size === 0) {
return {
success: false,
error: 'All search backends failed',
...ripgrepResult,
metadata: {
mode: 'parallel',
backend: 'multi-backend',
count: 0,
query,
backend_status: backendStatus,
} as any,
...ripgrepResult.metadata,
mode: 'priority',
note: 'Result from ripgrep search (final fallback).',
fallback_history: fallbackHistory,
},
};
} catch (error) {
fallbackHistory.push(`ripgrep: ${(error as Error).message}`);
}
// Apply RRF fusion
const fusedResults = applyRRFFusion(resultsMap, weights, limit);
// All modes failed
return {
success: true,
results: fusedResults,
success: false,
error: 'All search backends in priority mode failed or returned no results.',
metadata: {
mode: 'parallel',
backend: 'multi-backend',
count: fusedResults.length,
mode: 'priority',
query,
backends_used: Array.from(resultsMap.keys()),
backend_status: backendStatus,
weights,
note: 'Parallel mode runs hybrid + exact + ripgrep simultaneously with RRF fusion',
fallback_history: fallbackHistory,
} as any,
};
}
@@ -923,11 +946,11 @@ async function executeParallelMode(params: Params): Promise<SearchResult> {
// Tool schema for MCP
export const schema: ToolSchema = {
name: 'smart_search',
description: `Intelligent code search with five modes: auto, hybrid, exact, ripgrep, parallel.
description: `Intelligent code search with five modes: auto, hybrid, exact, ripgrep, priority.
**Quick Start:**
smart_search(query="authentication logic") # Auto mode (intelligent routing)
smart_search(action="init", path=".") # Initialize FTS index (fast, no embeddings)
smart_search(action="init", path=".") # Initialize index (required for hybrid)
smart_search(action="status") # Check index status
**Five Modes:**
@@ -938,7 +961,7 @@ export const schema: ToolSchema = {
2. hybrid: CodexLens RRF fusion (exact + fuzzy + vector)
- Best quality, semantic understanding
- Requires index with embeddings (create via "ccw view" dashboard)
- Requires index with embeddings
3. exact: CodexLens FTS (full-text search)
- Precise keyword matching
@@ -948,20 +971,21 @@ export const schema: ToolSchema = {
- Fast, no index required
- Literal string matching
5. parallel: Run all backends simultaneously
- Highest recall, runs hybrid + exact + ripgrep in parallel
- Results merged using RRF fusion with configurable weights
5. priority: Fallback strategy for best balance of speed and recall
- Tries searches in order: hybrid -> exact -> ripgrep
- Returns results from the first successful search with results
- More efficient than running all backends in parallel
**Actions:**
- search (default): Intelligent search with auto routing
- init: Create FTS index only (no embeddings, faster). For vector/semantic search, use "ccw view" dashboard
- init: Create CodexLens index
- status: Check index and embedding availability
- search_files: Return file paths only
**Workflow:**
1. Run action="init" to create FTS index (fast)
2. For semantic search: create vector index via "ccw view" dashboard or "codexlens init <path>"
3. Use auto mode - it routes to hybrid for NL queries, exact for simple queries`,
1. Run action="init" to create index
2. Use auto mode - it routes to hybrid for NL queries, exact for simple queries
3. Use priority mode for comprehensive fallback search`,
inputSchema: {
type: 'object',
properties: {
@@ -978,7 +1002,7 @@ export const schema: ToolSchema = {
mode: {
type: 'string',
enum: SEARCH_MODES,
description: 'Search mode: auto (default), hybrid (best quality), exact (CodexLens FTS), ripgrep (fast, no index), parallel (all backends with RRF fusion)',
description: 'Search mode: auto (default), hybrid (best quality), exact (CodexLens FTS), ripgrep (fast, no index), priority (fallback: hybrid->exact->ripgrep)',
default: 'auto',
},
output_mode: {
@@ -1006,13 +1030,13 @@ export const schema: ToolSchema = {
},
maxResults: {
type: 'number',
description: 'Maximum number of results (default: 100)',
default: 100,
description: 'Maximum number of results (default: 10)',
default: 10,
},
limit: {
type: 'number',
description: 'Alias for maxResults',
default: 100,
default: 10,
},
includeHidden: {
type: 'boolean',
@@ -1024,15 +1048,6 @@ export const schema: ToolSchema = {
items: { type: 'string' },
description: 'Languages to index (for init action). Example: ["javascript", "typescript"]',
},
parallelWeights: {
type: 'object',
properties: {
hybrid: { type: 'number', default: 0.5 },
exact: { type: 'number', default: 0.3 },
ripgrep: { type: 'number', default: 0.2 },
},
description: 'RRF weights for parallel mode. Weights should sum to 1.0. Default: {hybrid: 0.5, exact: 0.3, ripgrep: 0.2}',
},
},
required: [],
},
@@ -1082,12 +1097,13 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
return { success: false, error: `Invalid params: ${parsed.error.message}` };
}
const { action, mode, output_mode, limit, maxResults } = parsed.data;
const { action, mode, output_mode } = parsed.data;
// Use limit if maxResults not provided
if (limit && !maxResults) {
parsed.data.maxResults = limit;
}
// Sync limit and maxResults - use the larger of the two if both provided
// This ensures user-provided values take precedence over defaults
const effectiveLimit = Math.max(parsed.data.limit || 10, parsed.data.maxResults || 10);
parsed.data.maxResults = effectiveLimit;
parsed.data.limit = effectiveLimit;
try {
let result: SearchResult;
@@ -1109,7 +1125,7 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
case 'search':
default:
// Handle search modes: auto | hybrid | exact | ripgrep | parallel
// Handle search modes: auto | hybrid | exact | ripgrep | priority
switch (mode) {
case 'auto':
result = await executeAutoMode(parsed.data);
@@ -1123,11 +1139,11 @@ export async function handler(params: Record<string, unknown>): Promise<ToolResu
case 'ripgrep':
result = await executeRipgrepMode(parsed.data);
break;
case 'parallel':
result = await executeParallelMode(parsed.data);
case 'priority':
result = await executePriorityFallbackMode(parsed.data);
break;
default:
throw new Error(`Unsupported mode: ${mode}. Use: auto, hybrid, exact, ripgrep, or parallel`);
throw new Error(`Unsupported mode: ${mode}. Use: auto, hybrid, exact, ripgrep, or priority`);
}
break;
}