mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-20 19:03:51 +08:00
Remove outdated tests for CodexLens and LiteLLM client, refactor Smart Search MCP usage tests to use new command structure, and clean up unified vector index tests.
This commit is contained in:
@@ -14,7 +14,6 @@ import { Sparkline } from '@/components/charts/Sparkline';
|
|||||||
import { useWorkflowStatusCounts } from '@/hooks/useWorkflowStatusCounts';
|
import { useWorkflowStatusCounts } from '@/hooks/useWorkflowStatusCounts';
|
||||||
import { useDashboardStats } from '@/hooks/useDashboardStats';
|
import { useDashboardStats } from '@/hooks/useDashboardStats';
|
||||||
import { useProjectOverview } from '@/hooks/useProjectOverview';
|
import { useProjectOverview } from '@/hooks/useProjectOverview';
|
||||||
import { useIndexStatus } from '@/hooks/useIndex';
|
|
||||||
import { useSessions } from '@/hooks/useSessions';
|
import { useSessions } from '@/hooks/useSessions';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { TaskData } from '@/types/store';
|
import type { TaskData } from '@/types/store';
|
||||||
@@ -40,7 +39,6 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
PieChart as PieChartIcon,
|
PieChart as PieChartIcon,
|
||||||
Database,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export interface WorkflowTaskWidgetProps {
|
export interface WorkflowTaskWidgetProps {
|
||||||
@@ -187,8 +185,6 @@ function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) {
|
|||||||
const { data, isLoading } = useWorkflowStatusCounts();
|
const { data, isLoading } = useWorkflowStatusCounts();
|
||||||
const { stats, isLoading: statsLoading } = useDashboardStats({ refetchInterval: 60000 });
|
const { stats, isLoading: statsLoading } = useDashboardStats({ refetchInterval: 60000 });
|
||||||
const { projectOverview, isLoading: projectLoading } = useProjectOverview();
|
const { projectOverview, isLoading: projectLoading } = useProjectOverview();
|
||||||
const { status: indexStatus } = useIndexStatus({ refetchInterval: 30000 });
|
|
||||||
|
|
||||||
// Fetch real sessions data
|
// Fetch real sessions data
|
||||||
const { activeSessions, isLoading: sessionsLoading } = useSessions({
|
const { activeSessions, isLoading: sessionsLoading } = useSessions({
|
||||||
filter: { location: 'active' },
|
filter: { location: 'active' },
|
||||||
@@ -328,34 +324,6 @@ function WorkflowTaskWidgetComponent({ className }: WorkflowTaskWidgetProps) {
|
|||||||
<span className="text-muted-foreground">{formatMessage({ id: 'projectOverview.devIndex.category.enhancements' })}</span>
|
<span className="text-muted-foreground">{formatMessage({ id: 'projectOverview.devIndex.category.enhancements' })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Index Status Indicator */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="relative">
|
|
||||||
<Database className={cn(
|
|
||||||
"h-3.5 w-3.5",
|
|
||||||
indexStatus?.status === 'building' && "text-blue-600 animate-pulse",
|
|
||||||
indexStatus?.status === 'completed' && "text-emerald-600",
|
|
||||||
indexStatus?.status === 'idle' && "text-slate-500",
|
|
||||||
indexStatus?.status === 'failed' && "text-red-600"
|
|
||||||
)} />
|
|
||||||
{indexStatus?.status === 'building' && (
|
|
||||||
<span className="absolute -top-0.5 -right-0.5 flex h-2 w-2">
|
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-400 opacity-75"></span>
|
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-blue-500"></span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className={cn(
|
|
||||||
"font-semibold",
|
|
||||||
indexStatus?.status === 'building' && "text-blue-600",
|
|
||||||
indexStatus?.status === 'completed' && "text-emerald-600",
|
|
||||||
indexStatus?.status === 'idle' && "text-slate-500",
|
|
||||||
indexStatus?.status === 'failed' && "text-red-600"
|
|
||||||
)}>
|
|
||||||
{indexStatus?.totalFiles || 0}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">{formatMessage({ id: 'home.indexStatus.label' })}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date + Expand Button */}
|
{/* Date + Expand Button */}
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ const navGroupDefinitions: NavGroupDef[] = [
|
|||||||
titleKey: 'navigation.groups.configuration',
|
titleKey: 'navigation.groups.configuration',
|
||||||
icon: Cog,
|
icon: Cog,
|
||||||
items: [
|
items: [
|
||||||
{ path: '/settings/codexlens', labelKey: 'navigation.main.codexlens', icon: Sparkles },
|
|
||||||
{ path: '/api-settings', labelKey: 'navigation.main.apiSettings', icon: Server },
|
{ path: '/api-settings', labelKey: 'navigation.main.apiSettings', icon: Server },
|
||||||
{ path: '/settings', labelKey: 'navigation.main.settings', icon: Settings, end: true },
|
{ path: '/settings', labelKey: 'navigation.main.settings', icon: Settings, end: true },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ describe('CcwToolsMcpCard', () => {
|
|||||||
render(
|
render(
|
||||||
<CcwToolsMcpCard
|
<CcwToolsMcpCard
|
||||||
isInstalled={true}
|
isInstalled={true}
|
||||||
enabledTools={['write_file', 'smart_search']}
|
enabledTools={['write_file', 'edit_file']}
|
||||||
onToggleTool={vi.fn()}
|
onToggleTool={vi.fn()}
|
||||||
onUpdateConfig={vi.fn()}
|
onUpdateConfig={vi.fn()}
|
||||||
onInstall={vi.fn()}
|
onInstall={vi.fn()}
|
||||||
@@ -170,7 +170,7 @@ describe('CcwToolsMcpCard', () => {
|
|||||||
const [payload] = updateClaudeMock.mock.calls[0] ?? [];
|
const [payload] = updateClaudeMock.mock.calls[0] ?? [];
|
||||||
expect(payload).toEqual(
|
expect(payload).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
enabledTools: ['write_file', 'smart_search'],
|
enabledTools: ['write_file', 'edit_file'],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
HardDrive,
|
HardDrive,
|
||||||
MessageCircleQuestion,
|
MessageCircleQuestion,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
SearchCode,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Globe,
|
Globe,
|
||||||
@@ -110,7 +109,6 @@ export const CCW_MCP_TOOLS: CcwTool[] = [
|
|||||||
{ name: 'read_many_files', desc: 'Read multiple files/dirs', core: true },
|
{ name: 'read_many_files', desc: 'Read multiple files/dirs', core: true },
|
||||||
{ name: 'core_memory', desc: 'Core memory management', core: true },
|
{ name: 'core_memory', desc: 'Core memory management', core: true },
|
||||||
{ name: 'ask_question', desc: 'Interactive questions (A2UI)', core: false },
|
{ name: 'ask_question', desc: 'Interactive questions (A2UI)', core: false },
|
||||||
{ name: 'smart_search', desc: 'Intelligent code search', core: true },
|
|
||||||
{ name: 'team_msg', desc: 'Agent team message bus', core: false },
|
{ name: 'team_msg', desc: 'Agent team message bus', core: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -572,8 +570,6 @@ function getToolIcon(toolName: string): React.ReactElement {
|
|||||||
return <Settings {...iconProps} />;
|
return <Settings {...iconProps} />;
|
||||||
case 'ask_question':
|
case 'ask_question':
|
||||||
return <MessageCircleQuestion {...iconProps} />;
|
return <MessageCircleQuestion {...iconProps} />;
|
||||||
case 'smart_search':
|
|
||||||
return <SearchCode {...iconProps} />;
|
|
||||||
case 'team_msg':
|
case 'team_msg':
|
||||||
return <MessagesSquare {...iconProps} />;
|
return <MessagesSquare {...iconProps} />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,227 +0,0 @@
|
|||||||
// ========================================
|
|
||||||
// IndexManager Component
|
|
||||||
// ========================================
|
|
||||||
// Component for managing code index with status display and rebuild functionality
|
|
||||||
|
|
||||||
import * as React from 'react';
|
|
||||||
import { useIntl } from 'react-intl';
|
|
||||||
import { Database, RefreshCw, AlertCircle, CheckCircle2, Clock } from 'lucide-react';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { StatCard } from '@/components/shared/StatCard';
|
|
||||||
import { Badge } from '@/components/ui/Badge';
|
|
||||||
import { useIndex } from '@/hooks/useIndex';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
// ========== Types ==========
|
|
||||||
|
|
||||||
export interface IndexManagerProps {
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Helper Components ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Progress bar for index rebuild
|
|
||||||
*/
|
|
||||||
function IndexProgressBar({ progress, status }: { progress?: number; status: string }) {
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
|
|
||||||
if (status !== 'building' || progress === undefined) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-4 space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{formatMessage({ id: 'index.status.building' })}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium text-foreground">{progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 w-full bg-muted rounded-full overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-primary transition-all duration-300 ease-out"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Status badge component
|
|
||||||
*/
|
|
||||||
function IndexStatusBadge({ status }: { status: string }) {
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
|
|
||||||
const config: Record<string, { variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string }> = {
|
|
||||||
idle: { variant: 'secondary', label: formatMessage({ id: 'index.status.idle' }) },
|
|
||||||
building: { variant: 'default', label: formatMessage({ id: 'index.status.building' }) },
|
|
||||||
completed: { variant: 'outline', label: formatMessage({ id: 'index.status.completed' }) },
|
|
||||||
failed: { variant: 'destructive', label: formatMessage({ id: 'index.status.failed' }) },
|
|
||||||
};
|
|
||||||
|
|
||||||
const { variant, label } = config[status] ?? config.idle;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge variant={variant} className="text-xs">
|
|
||||||
{label}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Main Component ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* IndexManager component for displaying index status and managing rebuild operations
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* <IndexManager />
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function IndexManager({ className }: IndexManagerProps) {
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
const { status, isLoading, rebuildIndex, isRebuilding, rebuildError, refetch } = useIndex();
|
|
||||||
|
|
||||||
// Auto-refresh during rebuild
|
|
||||||
const refetchInterval = status?.status === 'building' ? 2000 : 0;
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (status?.status === 'building') {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
refetch();
|
|
||||||
}, refetchInterval);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}
|
|
||||||
}, [status?.status, refetchInterval, refetch]);
|
|
||||||
|
|
||||||
// Handle rebuild button click
|
|
||||||
const handleRebuild = async () => {
|
|
||||||
try {
|
|
||||||
await rebuildIndex({ force: false });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[IndexManager] Rebuild failed:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format build time (ms to human readable)
|
|
||||||
const formatBuildTime = (ms: number): string => {
|
|
||||||
if (ms < 1000) return `${ms}ms`;
|
|
||||||
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
||||||
return `${Math.floor(ms / 60000)}m ${Math.floor((ms % 60000) / 1000)}s`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format last updated time
|
|
||||||
const formatLastUpdated = (isoString: string): string => {
|
|
||||||
const date = new Date(isoString);
|
|
||||||
const now = new Date();
|
|
||||||
const diffMs = now.getTime() - date.getTime();
|
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
|
||||||
const diffHours = Math.floor(diffMs / 3600000);
|
|
||||||
const diffDays = Math.floor(diffMs / 86400000);
|
|
||||||
|
|
||||||
if (diffMins < 1) return formatMessage({ id: 'index.time.justNow' });
|
|
||||||
if (diffMins < 60) return formatMessage({ id: 'index.time.minutesAgo' }, { value: diffMins });
|
|
||||||
if (diffHours < 24) return formatMessage({ id: 'index.time.hoursAgo' }, { value: diffHours });
|
|
||||||
return formatMessage({ id: 'index.time.daysAgo' }, { value: diffDays });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className={cn('p-6', className)}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Database className="w-5 h-5 text-primary" />
|
|
||||||
<h2 className="text-lg font-semibold text-foreground">
|
|
||||||
{formatMessage({ id: 'index.title' })}
|
|
||||||
</h2>
|
|
||||||
{status && <IndexStatusBadge status={status.status} />}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleRebuild}
|
|
||||||
disabled={isRebuilding || status?.status === 'building'}
|
|
||||||
className="h-8"
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn('w-4 h-4 mr-1', isRebuilding && 'animate-spin')} />
|
|
||||||
{formatMessage({ id: 'index.actions.rebuild' })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<p className="text-sm text-muted-foreground mb-4">
|
|
||||||
{formatMessage({ id: 'index.description' })}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Error message */}
|
|
||||||
{rebuildError && (
|
|
||||||
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/30 rounded-lg flex items-start gap-2">
|
|
||||||
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm font-medium text-destructive">
|
|
||||||
{formatMessage({ id: 'index.errors.rebuildFailed' })}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-destructive/80 mt-1">{rebuildError.message}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Status error */}
|
|
||||||
{status?.error && (
|
|
||||||
<div className="mb-4 p-3 bg-destructive/10 border border-destructive/30 rounded-lg flex items-start gap-2">
|
|
||||||
<AlertCircle className="w-4 h-4 text-destructive mt-0.5 flex-shrink-0" />
|
|
||||||
<p className="text-sm text-destructive">{status.error}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
{status && <IndexProgressBar progress={status.progress} status={status.status} />}
|
|
||||||
|
|
||||||
{/* Current file being indexed */}
|
|
||||||
{status?.currentFile && status.status === 'building' && (
|
|
||||||
<div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
|
||||||
<span className="truncate">{status.currentFile}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Stat Cards */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mt-6">
|
|
||||||
{/* Total Files */}
|
|
||||||
<StatCard
|
|
||||||
title={formatMessage({ id: 'index.stats.totalFiles' })}
|
|
||||||
value={status?.totalFiles ?? 0}
|
|
||||||
icon={Database}
|
|
||||||
variant="primary"
|
|
||||||
isLoading={isLoading}
|
|
||||||
description={formatMessage({ id: 'index.stats.totalFilesDesc' })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Last Updated */}
|
|
||||||
<StatCard
|
|
||||||
title={formatMessage({ id: 'index.stats.lastUpdated' })}
|
|
||||||
value={status?.lastUpdated ? formatLastUpdated(status.lastUpdated) : '-'}
|
|
||||||
icon={Clock}
|
|
||||||
variant="info"
|
|
||||||
isLoading={isLoading}
|
|
||||||
description={status?.lastUpdated
|
|
||||||
? new Date(status.lastUpdated).toLocaleString()
|
|
||||||
: formatMessage({ id: 'index.stats.never' })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Build Time */}
|
|
||||||
<StatCard
|
|
||||||
title={formatMessage({ id: 'index.stats.buildTime' })}
|
|
||||||
value={status?.buildTime ? formatBuildTime(status.buildTime) : '-'}
|
|
||||||
icon={status?.status === 'completed' ? CheckCircle2 : AlertCircle}
|
|
||||||
variant={status?.status === 'completed' ? 'success' : 'warning'}
|
|
||||||
isLoading={isLoading}
|
|
||||||
description={formatMessage({ id: 'index.stats.buildTimeDesc' })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default IndexManager;
|
|
||||||
@@ -146,9 +146,6 @@ export type { RuleDialogProps } from './RuleDialog';
|
|||||||
// Tools and utility components
|
// Tools and utility components
|
||||||
export { ThemeSelector } from './ThemeSelector';
|
export { ThemeSelector } from './ThemeSelector';
|
||||||
|
|
||||||
export { IndexManager } from './IndexManager';
|
|
||||||
export type { IndexManagerProps } from './IndexManager';
|
|
||||||
|
|
||||||
export { ExplorerToolbar } from './ExplorerToolbar';
|
export { ExplorerToolbar } from './ExplorerToolbar';
|
||||||
export type { ExplorerToolbarProps } from './ExplorerToolbar';
|
export type { ExplorerToolbarProps } from './ExplorerToolbar';
|
||||||
|
|
||||||
|
|||||||
@@ -290,16 +290,6 @@ export type {
|
|||||||
WorkspaceQueryKeys,
|
WorkspaceQueryKeys,
|
||||||
} from './useWorkspaceQueryKeys';
|
} from './useWorkspaceQueryKeys';
|
||||||
|
|
||||||
// ========== CodexLens (v2) ==========
|
|
||||||
export {
|
|
||||||
useV2SearchManager,
|
|
||||||
} from './useV2SearchManager';
|
|
||||||
export type {
|
|
||||||
V2IndexStatus,
|
|
||||||
V2SearchTestResult,
|
|
||||||
UseV2SearchManagerReturn,
|
|
||||||
} from './useV2SearchManager';
|
|
||||||
|
|
||||||
// ========== Skill Hub ==========
|
// ========== Skill Hub ==========
|
||||||
export {
|
export {
|
||||||
useRemoteSkills,
|
useRemoteSkills,
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
// ========================================
|
|
||||||
// useIndex Hook
|
|
||||||
// ========================================
|
|
||||||
// TanStack Query hooks for index management with real-time updates
|
|
||||||
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import {
|
|
||||||
fetchIndexStatus,
|
|
||||||
rebuildIndex,
|
|
||||||
type IndexStatus,
|
|
||||||
type IndexRebuildRequest,
|
|
||||||
} from '../lib/api';
|
|
||||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
|
||||||
import { workspaceQueryKeys } from '@/lib/queryKeys';
|
|
||||||
|
|
||||||
// ========== Stale Time ==========
|
|
||||||
|
|
||||||
// Default stale time: 30 seconds (index status updates less frequently)
|
|
||||||
const STALE_TIME = 30 * 1000;
|
|
||||||
|
|
||||||
// ========== Query Hook ==========
|
|
||||||
|
|
||||||
export interface UseIndexStatusOptions {
|
|
||||||
enabled?: boolean;
|
|
||||||
staleTime?: number;
|
|
||||||
refetchInterval?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseIndexStatusReturn {
|
|
||||||
status: IndexStatus | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
isFetching: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
refetch: () => Promise<void>;
|
|
||||||
invalidate: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for fetching index status
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const { status, isLoading, refetch } = useIndexStatus();
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useIndexStatus(options: UseIndexStatusOptions = {}): UseIndexStatusReturn {
|
|
||||||
const { staleTime = STALE_TIME, enabled = true, refetchInterval = 0 } = options;
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const projectPath = useWorkflowStore(selectProjectPath);
|
|
||||||
const queryEnabled = enabled && !!projectPath;
|
|
||||||
|
|
||||||
const query = useQuery({
|
|
||||||
queryKey: workspaceQueryKeys.indexStatus(projectPath),
|
|
||||||
queryFn: () => fetchIndexStatus(projectPath),
|
|
||||||
staleTime,
|
|
||||||
enabled: queryEnabled,
|
|
||||||
refetchInterval: refetchInterval > 0 ? refetchInterval : false,
|
|
||||||
retry: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const refetch = async () => {
|
|
||||||
await query.refetch();
|
|
||||||
};
|
|
||||||
|
|
||||||
const invalidate = async () => {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: workspaceQueryKeys.index(projectPath) });
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: query.data ?? null,
|
|
||||||
isLoading: query.isLoading,
|
|
||||||
isFetching: query.isFetching,
|
|
||||||
error: query.error,
|
|
||||||
refetch,
|
|
||||||
invalidate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Mutation Hooks ==========
|
|
||||||
|
|
||||||
export interface UseRebuildIndexReturn {
|
|
||||||
rebuildIndex: (request?: IndexRebuildRequest) => Promise<IndexStatus>;
|
|
||||||
isRebuilding: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for rebuilding index
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const { rebuildIndex, isRebuilding } = useRebuildIndex();
|
|
||||||
*
|
|
||||||
* const handleRebuild = async () => {
|
|
||||||
* await rebuildIndex({ force: true });
|
|
||||||
* };
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useRebuildIndex(): UseRebuildIndexReturn {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const projectPath = useWorkflowStore(selectProjectPath);
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: rebuildIndex,
|
|
||||||
onSuccess: (updatedStatus) => {
|
|
||||||
// Update the status query cache
|
|
||||||
queryClient.setQueryData(workspaceQueryKeys.indexStatus(projectPath), updatedStatus);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
rebuildIndex: mutation.mutateAsync,
|
|
||||||
isRebuilding: mutation.isPending,
|
|
||||||
error: mutation.error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Combined hook for all index operations
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const {
|
|
||||||
* status,
|
|
||||||
* isLoading,
|
|
||||||
* rebuildIndex,
|
|
||||||
* isRebuilding,
|
|
||||||
* } = useIndex();
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useIndex() {
|
|
||||||
const status = useIndexStatus();
|
|
||||||
const rebuild = useRebuildIndex();
|
|
||||||
|
|
||||||
return {
|
|
||||||
...status,
|
|
||||||
rebuildIndex: rebuild.rebuildIndex,
|
|
||||||
isRebuilding: rebuild.isRebuilding,
|
|
||||||
rebuildError: rebuild.error,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
// ========================================
|
|
||||||
// useV2SearchManager Hook
|
|
||||||
// ========================================
|
|
||||||
// React hook for v2 search management via smart_search tool
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
// ========== Types ==========
|
|
||||||
|
|
||||||
export interface V2IndexStatus {
|
|
||||||
indexed: boolean;
|
|
||||||
totalFiles: number;
|
|
||||||
totalChunks: number;
|
|
||||||
lastIndexedAt: string | null;
|
|
||||||
dbSizeBytes: number;
|
|
||||||
vectorDimension: number | null;
|
|
||||||
ftsEnabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface V2SearchTestResult {
|
|
||||||
query: string;
|
|
||||||
results: Array<{
|
|
||||||
file: string;
|
|
||||||
score: number;
|
|
||||||
snippet: string;
|
|
||||||
}>;
|
|
||||||
timingMs: number;
|
|
||||||
totalResults: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseV2SearchManagerReturn {
|
|
||||||
status: V2IndexStatus | null;
|
|
||||||
isLoadingStatus: boolean;
|
|
||||||
statusError: Error | null;
|
|
||||||
refetchStatus: () => void;
|
|
||||||
search: (query: string) => Promise<V2SearchTestResult>;
|
|
||||||
isSearching: boolean;
|
|
||||||
searchResult: V2SearchTestResult | null;
|
|
||||||
reindex: () => Promise<void>;
|
|
||||||
isReindexing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== API helpers ==========
|
|
||||||
|
|
||||||
async function fetchWithJson<T>(url: string, body?: Record<string, unknown>): Promise<T> {
|
|
||||||
const response = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
credentials: 'same-origin',
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Request failed: ${response.status}`);
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchV2Status(): Promise<V2IndexStatus> {
|
|
||||||
const data = await fetchWithJson<{ result?: V2IndexStatus; error?: string }>('/api/tools', {
|
|
||||||
tool_name: 'smart_search',
|
|
||||||
action: 'status',
|
|
||||||
});
|
|
||||||
if (data.error) {
|
|
||||||
throw new Error(data.error);
|
|
||||||
}
|
|
||||||
// Provide defaults for fields that may be missing
|
|
||||||
return {
|
|
||||||
indexed: false,
|
|
||||||
totalFiles: 0,
|
|
||||||
totalChunks: 0,
|
|
||||||
lastIndexedAt: null,
|
|
||||||
dbSizeBytes: 0,
|
|
||||||
vectorDimension: null,
|
|
||||||
ftsEnabled: false,
|
|
||||||
...data.result,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchV2Search(query: string): Promise<V2SearchTestResult> {
|
|
||||||
const data = await fetchWithJson<{ result?: V2SearchTestResult; error?: string }>('/api/tools', {
|
|
||||||
tool_name: 'smart_search',
|
|
||||||
action: 'search',
|
|
||||||
params: { query, limit: 10 },
|
|
||||||
});
|
|
||||||
if (data.error) {
|
|
||||||
throw new Error(data.error);
|
|
||||||
}
|
|
||||||
return data.result ?? { query, results: [], timingMs: 0, totalResults: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchV2Reindex(): Promise<void> {
|
|
||||||
const data = await fetchWithJson<{ error?: string }>('/api/tools', {
|
|
||||||
tool_name: 'smart_search',
|
|
||||||
action: 'reindex',
|
|
||||||
});
|
|
||||||
if (data.error) {
|
|
||||||
throw new Error(data.error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Query Keys ==========
|
|
||||||
|
|
||||||
export const v2SearchKeys = {
|
|
||||||
all: ['v2-search'] as const,
|
|
||||||
status: () => [...v2SearchKeys.all, 'status'] as const,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========== Hook ==========
|
|
||||||
|
|
||||||
export function useV2SearchManager(): UseV2SearchManagerReturn {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [searchResult, setSearchResult] = useState<V2SearchTestResult | null>(null);
|
|
||||||
|
|
||||||
// Status query
|
|
||||||
const statusQuery = useQuery({
|
|
||||||
queryKey: v2SearchKeys.status(),
|
|
||||||
queryFn: fetchV2Status,
|
|
||||||
staleTime: 30_000,
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Search mutation
|
|
||||||
const searchMutation = useMutation({
|
|
||||||
mutationFn: (query: string) => fetchV2Search(query),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
setSearchResult(data);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reindex mutation
|
|
||||||
const reindexMutation = useMutation({
|
|
||||||
mutationFn: fetchV2Reindex,
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: v2SearchKeys.status() });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const search = useCallback(async (query: string) => {
|
|
||||||
const result = await searchMutation.mutateAsync(query);
|
|
||||||
return result;
|
|
||||||
}, [searchMutation]);
|
|
||||||
|
|
||||||
const reindex = useCallback(async () => {
|
|
||||||
await reindexMutation.mutateAsync();
|
|
||||||
}, [reindexMutation]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
status: statusQuery.data ?? null,
|
|
||||||
isLoadingStatus: statusQuery.isLoading,
|
|
||||||
statusError: statusQuery.error as Error | null,
|
|
||||||
refetchStatus: () => statusQuery.refetch(),
|
|
||||||
search,
|
|
||||||
isSearching: searchMutation.isPending,
|
|
||||||
searchResult,
|
|
||||||
reindex,
|
|
||||||
isReindexing: reindexMutation.isPending,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -3,11 +3,11 @@
|
|||||||
// ========================================
|
// ========================================
|
||||||
// Typed fetch functions for API communication with CSRF token handling
|
// Typed fetch functions for API communication with CSRF token handling
|
||||||
|
|
||||||
import type { SessionMetadata, TaskData, IndexStatus, IndexRebuildRequest, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion, McpTemplate, McpTemplateInstallRequest, AllProjectsResponse, OtherProjectsServersResponse, CrossCliCopyRequest, CrossCliCopyResponse } from '../types/store';
|
import type { SessionMetadata, TaskData, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion, McpTemplate, McpTemplateInstallRequest, AllProjectsResponse, OtherProjectsServersResponse, CrossCliCopyRequest, CrossCliCopyResponse } from '../types/store';
|
||||||
import type { TeamArtifactsResponse } from '../types/team';
|
import type { TeamArtifactsResponse } from '../types/team';
|
||||||
|
|
||||||
// Re-export types for backward compatibility
|
// Re-export types for backward compatibility
|
||||||
export type { IndexStatus, IndexRebuildRequest, Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion, McpTemplate, McpTemplateInstallRequest, AllProjectsResponse, OtherProjectsServersResponse, CrossCliCopyRequest, CrossCliCopyResponse };
|
export type { Rule, RuleCreateInput, RulesResponse, Prompt, PromptInsight, Pattern, Suggestion, McpTemplate, McpTemplateInstallRequest, AllProjectsResponse, OtherProjectsServersResponse, CrossCliCopyRequest, CrossCliCopyResponse };
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4648,10 +4648,10 @@ export async function fetchCcwMcpConfig(currentProjectPath?: string): Promise<Cc
|
|||||||
let enabledTools: string[];
|
let enabledTools: string[];
|
||||||
if (enabledToolsStr === undefined || enabledToolsStr === null) {
|
if (enabledToolsStr === undefined || enabledToolsStr === null) {
|
||||||
// No setting = use default tools
|
// No setting = use default tools
|
||||||
enabledTools = ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'];
|
enabledTools = ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question'];
|
||||||
} else if (enabledToolsStr === '' || enabledToolsStr === 'all') {
|
} else if (enabledToolsStr === '' || enabledToolsStr === 'all') {
|
||||||
// Empty string = all tools disabled, 'all' = default set (for backward compatibility)
|
// Empty string = all tools disabled, 'all' = default set (for backward compatibility)
|
||||||
enabledTools = enabledToolsStr === '' ? [] : ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'];
|
enabledTools = enabledToolsStr === '' ? [] : ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question'];
|
||||||
} else {
|
} else {
|
||||||
// Comma-separated list
|
// Comma-separated list
|
||||||
enabledTools = enabledToolsStr.split(',').map((t: string) => t.trim()).filter(Boolean);
|
enabledTools = enabledToolsStr.split(',').map((t: string) => t.trim()).filter(Boolean);
|
||||||
@@ -4710,7 +4710,7 @@ export async function installCcwMcp(
|
|||||||
scope,
|
scope,
|
||||||
projectPath: path,
|
projectPath: path,
|
||||||
env: {
|
env: {
|
||||||
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'],
|
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question'],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -4793,10 +4793,10 @@ export async function fetchCcwMcpConfigForCodex(): Promise<CcwMcpConfig> {
|
|||||||
let enabledTools: string[];
|
let enabledTools: string[];
|
||||||
if (enabledToolsStr === undefined || enabledToolsStr === null) {
|
if (enabledToolsStr === undefined || enabledToolsStr === null) {
|
||||||
// No setting = use default tools
|
// No setting = use default tools
|
||||||
enabledTools = ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'];
|
enabledTools = ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question'];
|
||||||
} else if (enabledToolsStr === '' || enabledToolsStr === 'all') {
|
} else if (enabledToolsStr === '' || enabledToolsStr === 'all') {
|
||||||
// Empty string = all tools disabled, 'all' = default set (for backward compatibility)
|
// Empty string = all tools disabled, 'all' = default set (for backward compatibility)
|
||||||
enabledTools = enabledToolsStr === '' ? [] : ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'];
|
enabledTools = enabledToolsStr === '' ? [] : ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question'];
|
||||||
} else {
|
} else {
|
||||||
// Comma-separated list
|
// Comma-separated list
|
||||||
enabledTools = enabledToolsStr.split(',').map((t: string) => t.trim()).filter(Boolean);
|
enabledTools = enabledToolsStr.split(',').map((t: string) => t.trim()).filter(Boolean);
|
||||||
@@ -4831,7 +4831,7 @@ function buildCcwMcpServerConfigForCodex(config: {
|
|||||||
if (config.enabledTools !== undefined) {
|
if (config.enabledTools !== undefined) {
|
||||||
env.CCW_ENABLED_TOOLS = config.enabledTools.join(',');
|
env.CCW_ENABLED_TOOLS = config.enabledTools.join(',');
|
||||||
} else {
|
} else {
|
||||||
env.CCW_ENABLED_TOOLS = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
|
env.CCW_ENABLED_TOOLS = 'write_file,edit_file,read_file,core_memory,ask_question';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.projectRoot) {
|
if (config.projectRoot) {
|
||||||
@@ -4852,7 +4852,7 @@ function buildCcwMcpServerConfigForCodex(config: {
|
|||||||
*/
|
*/
|
||||||
export async function installCcwMcpToCodex(): Promise<CcwMcpConfig> {
|
export async function installCcwMcpToCodex(): Promise<CcwMcpConfig> {
|
||||||
const serverConfig = buildCcwMcpServerConfigForCodex({
|
const serverConfig = buildCcwMcpServerConfigForCodex({
|
||||||
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question', 'smart_search'],
|
enabledTools: ['write_file', 'edit_file', 'read_file', 'core_memory', 'ask_question'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await addCodexMcpServer('ccw-tools', serverConfig);
|
const result = await addCodexMcpServer('ccw-tools', serverConfig);
|
||||||
@@ -4892,42 +4892,6 @@ export async function updateCcwConfigForCodex(config: {
|
|||||||
return fetchCcwMcpConfigForCodex();
|
return fetchCcwMcpConfigForCodex();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Index Management API ==========
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch current index status for a specific workspace
|
|
||||||
* @param projectPath - Optional project path to filter data by workspace
|
|
||||||
*/
|
|
||||||
export async function fetchIndexStatus(_projectPath?: string): Promise<IndexStatus> {
|
|
||||||
const resp = await fetchApi<{ result?: { indexed?: boolean; totalFiles?: number } }>('/api/tools', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ tool_name: 'smart_search', action: 'status' }),
|
|
||||||
});
|
|
||||||
const result = resp.result ?? {};
|
|
||||||
return {
|
|
||||||
totalFiles: result.totalFiles ?? 0,
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
buildTime: 0,
|
|
||||||
status: result.indexed ? 'completed' : 'idle',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rebuild index
|
|
||||||
*/
|
|
||||||
export async function rebuildIndex(_request: IndexRebuildRequest = {}): Promise<IndexStatus> {
|
|
||||||
await fetchApi<{ error?: string }>('/api/tools', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ tool_name: 'smart_search', action: 'reindex' }),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
totalFiles: 0,
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
buildTime: 0,
|
|
||||||
status: 'building',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ========== Prompt History API ==========
|
// ========== Prompt History API ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -183,10 +183,6 @@
|
|||||||
"name": "ask_question",
|
"name": "ask_question",
|
||||||
"desc": "Ask interactive questions through A2UI interface"
|
"desc": "Ask interactive questions through A2UI interface"
|
||||||
},
|
},
|
||||||
"smart_search": {
|
|
||||||
"name": "smart_search",
|
|
||||||
"desc": "Intelligent code search with fuzzy and semantic modes"
|
|
||||||
},
|
|
||||||
"team_msg": {
|
"team_msg": {
|
||||||
"name": "team_msg",
|
"name": "team_msg",
|
||||||
"desc": "Persistent JSONL message bus for Agent Team communication"
|
"desc": "Persistent JSONL message bus for Agent Team communication"
|
||||||
|
|||||||
@@ -172,10 +172,6 @@
|
|||||||
"name": "ask_question",
|
"name": "ask_question",
|
||||||
"desc": "通过 A2UI 界面发起交互式问答"
|
"desc": "通过 A2UI 界面发起交互式问答"
|
||||||
},
|
},
|
||||||
"smart_search": {
|
|
||||||
"name": "smart_search",
|
|
||||||
"desc": "智能代码搜索,支持模糊和语义搜索模式"
|
|
||||||
},
|
|
||||||
"team_msg": {
|
"team_msg": {
|
||||||
"name": "team_msg",
|
"name": "team_msg",
|
||||||
"desc": "Agent Team 持久化消息总线,用于团队协作通信"
|
"desc": "Agent Team 持久化消息总线,用于团队协作通信"
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
// ========================================
|
|
||||||
// CodexLens Manager Page Tests (v2)
|
|
||||||
// ========================================
|
|
||||||
// Tests for v2 search management page
|
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
||||||
import { render, screen } from '@/test/i18n';
|
|
||||||
import userEvent from '@testing-library/user-event';
|
|
||||||
import { CodexLensManagerPage } from './CodexLensManagerPage';
|
|
||||||
|
|
||||||
// Mock the v2 search manager hook
|
|
||||||
vi.mock('@/hooks/useV2SearchManager', () => ({
|
|
||||||
useV2SearchManager: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { useV2SearchManager } from '@/hooks/useV2SearchManager';
|
|
||||||
|
|
||||||
const mockStatus = {
|
|
||||||
indexed: true,
|
|
||||||
totalFiles: 150,
|
|
||||||
totalChunks: 1200,
|
|
||||||
lastIndexedAt: '2026-03-17T10:00:00Z',
|
|
||||||
dbSizeBytes: 5242880,
|
|
||||||
vectorDimension: 384,
|
|
||||||
ftsEnabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultHookReturn = {
|
|
||||||
status: mockStatus,
|
|
||||||
isLoadingStatus: false,
|
|
||||||
statusError: null,
|
|
||||||
refetchStatus: vi.fn(),
|
|
||||||
search: vi.fn().mockResolvedValue({
|
|
||||||
query: 'test',
|
|
||||||
results: [],
|
|
||||||
timingMs: 12.5,
|
|
||||||
totalResults: 0,
|
|
||||||
}),
|
|
||||||
isSearching: false,
|
|
||||||
searchResult: null,
|
|
||||||
reindex: vi.fn().mockResolvedValue(undefined),
|
|
||||||
isReindexing: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('CodexLensManagerPage (v2)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
(vi.mocked(useV2SearchManager) as any).mockReturnValue(defaultHookReturn);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render page title', () => {
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
// The title comes from i18n codexlens.title
|
|
||||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render index status section', () => {
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
// Check for file count display
|
|
||||||
expect(screen.getByText('150')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should render search input', () => {
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
const input = screen.getByPlaceholderText(/search query/i);
|
|
||||||
expect(input).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call refetchStatus on refresh click', async () => {
|
|
||||||
const refetchStatus = vi.fn();
|
|
||||||
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
|
||||||
...defaultHookReturn,
|
|
||||||
refetchStatus,
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
const refreshButton = screen.getByText(/Refresh/i);
|
|
||||||
await user.click(refreshButton);
|
|
||||||
|
|
||||||
expect(refetchStatus).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call search when clicking search button', async () => {
|
|
||||||
const searchFn = vi.fn().mockResolvedValue({
|
|
||||||
query: 'test query',
|
|
||||||
results: [],
|
|
||||||
timingMs: 5,
|
|
||||||
totalResults: 0,
|
|
||||||
});
|
|
||||||
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
|
||||||
...defaultHookReturn,
|
|
||||||
search: searchFn,
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search query/i);
|
|
||||||
await user.type(input, 'test query');
|
|
||||||
|
|
||||||
const searchButton = screen.getByText(/Search/i);
|
|
||||||
await user.click(searchButton);
|
|
||||||
|
|
||||||
expect(searchFn).toHaveBeenCalledWith('test query');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should display search results', () => {
|
|
||||||
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
|
||||||
...defaultHookReturn,
|
|
||||||
searchResult: {
|
|
||||||
query: 'auth',
|
|
||||||
results: [
|
|
||||||
{ file: 'src/auth.ts', score: 0.95, snippet: 'export function authenticate()' },
|
|
||||||
],
|
|
||||||
timingMs: 8.2,
|
|
||||||
totalResults: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
expect(screen.getByText('src/auth.ts')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('95.0%')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('export function authenticate()')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call reindex on button click', async () => {
|
|
||||||
const reindexFn = vi.fn().mockResolvedValue(undefined);
|
|
||||||
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
|
||||||
...defaultHookReturn,
|
|
||||||
reindex: reindexFn,
|
|
||||||
});
|
|
||||||
|
|
||||||
const user = userEvent.setup();
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
const reindexButton = screen.getByText(/Reindex/i);
|
|
||||||
await user.click(reindexButton);
|
|
||||||
|
|
||||||
expect(reindexFn).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show loading skeleton when status is loading', () => {
|
|
||||||
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
|
||||||
...defaultHookReturn,
|
|
||||||
status: null,
|
|
||||||
isLoadingStatus: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
// Should have pulse animation elements
|
|
||||||
const pulseElements = document.querySelectorAll('.animate-pulse');
|
|
||||||
expect(pulseElements.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show error alert when status fetch fails', () => {
|
|
||||||
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
|
||||||
...defaultHookReturn,
|
|
||||||
status: null,
|
|
||||||
statusError: new Error('Network error'),
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
// Error message should be visible
|
|
||||||
expect(screen.getByText(/Failed to load/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should show not indexed state', () => {
|
|
||||||
(vi.mocked(useV2SearchManager) as any).mockReturnValue({
|
|
||||||
...defaultHookReturn,
|
|
||||||
status: {
|
|
||||||
...mockStatus,
|
|
||||||
indexed: false,
|
|
||||||
totalFiles: 0,
|
|
||||||
totalChunks: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<CodexLensManagerPage />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Not Indexed/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('i18n - Chinese locale', () => {
|
|
||||||
it('should display translated text in Chinese', () => {
|
|
||||||
render(<CodexLensManagerPage />, { locale: 'zh' });
|
|
||||||
|
|
||||||
// Page title from zh codexlens.json
|
|
||||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
// ========================================
|
|
||||||
// CodexLens Manager Page (v2)
|
|
||||||
// ========================================
|
|
||||||
// V2 search management interface with index status, search test, and configuration
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useIntl } from 'react-intl';
|
|
||||||
import {
|
|
||||||
Search,
|
|
||||||
RefreshCw,
|
|
||||||
Database,
|
|
||||||
Zap,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
Clock,
|
|
||||||
FileText,
|
|
||||||
HardDrive,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Card } from '@/components/ui/Card';
|
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { useV2SearchManager } from '@/hooks';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
||||||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(dateStr: string | null): string {
|
|
||||||
if (!dateStr) return '-';
|
|
||||||
try {
|
|
||||||
return new Date(dateStr).toLocaleString();
|
|
||||||
} catch {
|
|
||||||
return dateStr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CodexLensManagerPage() {
|
|
||||||
const { formatMessage } = useIntl();
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
|
|
||||||
const {
|
|
||||||
status,
|
|
||||||
isLoadingStatus,
|
|
||||||
statusError,
|
|
||||||
refetchStatus,
|
|
||||||
search,
|
|
||||||
isSearching,
|
|
||||||
searchResult,
|
|
||||||
reindex,
|
|
||||||
isReindexing,
|
|
||||||
} = useV2SearchManager();
|
|
||||||
|
|
||||||
const handleSearch = async () => {
|
|
||||||
if (!searchQuery.trim()) return;
|
|
||||||
await search(searchQuery.trim());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
handleSearch();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Page Header */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
|
||||||
<Search className="w-6 h-6 text-primary" />
|
|
||||||
{formatMessage({ id: 'codexlens.title' })}
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
|
||||||
{formatMessage({ id: 'codexlens.description' })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={refetchStatus}
|
|
||||||
disabled={isLoadingStatus}
|
|
||||||
>
|
|
||||||
<RefreshCw className={cn('w-4 h-4 mr-2', isLoadingStatus && 'animate-spin')} />
|
|
||||||
{formatMessage({ id: 'common.actions.refresh' })}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => reindex()}
|
|
||||||
disabled={isReindexing}
|
|
||||||
>
|
|
||||||
<Zap className={cn('w-4 h-4 mr-2', isReindexing && 'animate-spin')} />
|
|
||||||
{isReindexing
|
|
||||||
? formatMessage({ id: 'codexlens.reindexing' })
|
|
||||||
: formatMessage({ id: 'codexlens.reindex' })
|
|
||||||
}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Alert */}
|
|
||||||
{statusError && (
|
|
||||||
<Card className="p-4 bg-destructive/10 border-destructive/20">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<AlertCircle className="w-4 h-4 text-destructive" />
|
|
||||||
<p className="text-sm text-destructive">
|
|
||||||
{formatMessage({ id: 'codexlens.statusError' })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Index Status Section */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<Database className="w-5 h-5 text-primary" />
|
|
||||||
{formatMessage({ id: 'codexlens.indexStatus.title' })}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{isLoadingStatus ? (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
{[1, 2, 3, 4].map((i) => (
|
|
||||||
<div key={i} className="h-16 bg-muted/50 rounded-lg animate-pulse" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : status ? (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
|
|
||||||
{status.indexed ? (
|
|
||||||
<CheckCircle2 className="w-5 h-5 text-green-500 mt-0.5" />
|
|
||||||
) : (
|
|
||||||
<AlertCircle className="w-5 h-5 text-yellow-500 mt-0.5" />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{formatMessage({ id: 'codexlens.indexStatus.status' })}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
{status.indexed
|
|
||||||
? formatMessage({ id: 'codexlens.indexStatus.ready' })
|
|
||||||
: formatMessage({ id: 'codexlens.indexStatus.notIndexed' })
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
|
|
||||||
<FileText className="w-5 h-5 text-blue-500 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{formatMessage({ id: 'codexlens.indexStatus.files' })}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium">{status.totalFiles.toLocaleString()}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
|
|
||||||
<HardDrive className="w-5 h-5 text-purple-500 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{formatMessage({ id: 'codexlens.indexStatus.dbSize' })}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium">{formatBytes(status.dbSizeBytes)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/30">
|
|
||||||
<Clock className="w-5 h-5 text-orange-500 mt-0.5" />
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{formatMessage({ id: 'codexlens.indexStatus.lastIndexed' })}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm font-medium">{formatDate(status.lastIndexedAt)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{formatMessage({ id: 'codexlens.indexStatus.unavailable' })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{status && (
|
|
||||||
<div className="mt-4 flex gap-4 text-xs text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
{formatMessage({ id: 'codexlens.indexStatus.chunks' })}: {status.totalChunks.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
{status.vectorDimension && (
|
|
||||||
<span>
|
|
||||||
{formatMessage({ id: 'codexlens.indexStatus.vectorDim' })}: {status.vectorDimension}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>
|
|
||||||
FTS: {status.ftsEnabled
|
|
||||||
? formatMessage({ id: 'codexlens.indexStatus.enabled' })
|
|
||||||
: formatMessage({ id: 'codexlens.indexStatus.disabled' })
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Search Test Section */}
|
|
||||||
<Card className="p-6">
|
|
||||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
|
||||||
<Search className="w-5 h-5 text-primary" />
|
|
||||||
{formatMessage({ id: 'codexlens.searchTest.title' })}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="flex gap-2 mb-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder={formatMessage({ id: 'codexlens.searchTest.placeholder' })}
|
|
||||||
className="flex-1 px-3 py-2 border border-input rounded-md bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
onClick={handleSearch}
|
|
||||||
disabled={isSearching || !searchQuery.trim()}
|
|
||||||
>
|
|
||||||
{isSearching ? (
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Search className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{formatMessage({ id: 'codexlens.searchTest.button' })}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{searchResult && (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{searchResult.totalResults} {formatMessage({ id: 'codexlens.searchTest.results' })}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{searchResult.timingMs.toFixed(1)}ms
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{searchResult.results.length > 0 ? (
|
|
||||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
||||||
{searchResult.results.map((result, idx) => (
|
|
||||||
<div
|
|
||||||
key={idx}
|
|
||||||
className="p-3 rounded-lg border border-border bg-muted/20 hover:bg-muted/40 transition-colors"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className="text-sm font-mono text-primary truncate">
|
|
||||||
{result.file}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground ml-2 shrink-0">
|
|
||||||
{(result.score * 100).toFixed(1)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<pre className="text-xs text-muted-foreground whitespace-pre-wrap line-clamp-3">
|
|
||||||
{result.snippet}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-muted-foreground text-center py-4">
|
|
||||||
{formatMessage({ id: 'codexlens.searchTest.noResults' })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CodexLensManagerPage;
|
|
||||||
@@ -29,7 +29,6 @@ export { RulesManagerPage } from './RulesManagerPage';
|
|||||||
export { PromptHistoryPage } from './PromptHistoryPage';
|
export { PromptHistoryPage } from './PromptHistoryPage';
|
||||||
export { ExplorerPage } from './ExplorerPage';
|
export { ExplorerPage } from './ExplorerPage';
|
||||||
export { GraphExplorerPage } from './GraphExplorerPage';
|
export { GraphExplorerPage } from './GraphExplorerPage';
|
||||||
export { CodexLensManagerPage } from './CodexLensManagerPage';
|
|
||||||
export { ApiSettingsPage } from './ApiSettingsPage';
|
export { ApiSettingsPage } from './ApiSettingsPage';
|
||||||
export { CliViewerPage } from './CliViewerPage';
|
export { CliViewerPage } from './CliViewerPage';
|
||||||
export { CliSessionSharePage } from './CliSessionSharePage';
|
export { CliSessionSharePage } from './CliSessionSharePage';
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ const RulesManagerPage = lazy(() => import('@/pages/RulesManagerPage').then(m =>
|
|||||||
const PromptHistoryPage = lazy(() => import('@/pages/PromptHistoryPage').then(m => ({ default: m.PromptHistoryPage })));
|
const PromptHistoryPage = lazy(() => import('@/pages/PromptHistoryPage').then(m => ({ default: m.PromptHistoryPage })));
|
||||||
const ExplorerPage = lazy(() => import('@/pages/ExplorerPage').then(m => ({ default: m.ExplorerPage })));
|
const ExplorerPage = lazy(() => import('@/pages/ExplorerPage').then(m => ({ default: m.ExplorerPage })));
|
||||||
const GraphExplorerPage = lazy(() => import('@/pages/GraphExplorerPage').then(m => ({ default: m.GraphExplorerPage })));
|
const GraphExplorerPage = lazy(() => import('@/pages/GraphExplorerPage').then(m => ({ default: m.GraphExplorerPage })));
|
||||||
const CodexLensManagerPage = lazy(() => import('@/pages/CodexLensManagerPage').then(m => ({ default: m.CodexLensManagerPage })));
|
|
||||||
const ApiSettingsPage = lazy(() => import('@/pages/ApiSettingsPage').then(m => ({ default: m.ApiSettingsPage })));
|
const ApiSettingsPage = lazy(() => import('@/pages/ApiSettingsPage').then(m => ({ default: m.ApiSettingsPage })));
|
||||||
const CliViewerPage = lazy(() => import('@/pages/CliViewerPage').then(m => ({ default: m.CliViewerPage })));
|
const CliViewerPage = lazy(() => import('@/pages/CliViewerPage').then(m => ({ default: m.CliViewerPage })));
|
||||||
const CliSessionSharePage = lazy(() => import('@/pages/CliSessionSharePage').then(m => ({ default: m.CliSessionSharePage })));
|
const CliSessionSharePage = lazy(() => import('@/pages/CliSessionSharePage').then(m => ({ default: m.CliSessionSharePage })));
|
||||||
@@ -170,10 +169,6 @@ const routes: RouteObject[] = [
|
|||||||
path: 'settings/specs',
|
path: 'settings/specs',
|
||||||
element: withErrorHandling(<SpecsSettingsPage />),
|
element: withErrorHandling(<SpecsSettingsPage />),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'settings/codexlens',
|
|
||||||
element: withErrorHandling(<CodexLensManagerPage />),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'api-settings',
|
path: 'api-settings',
|
||||||
element: withErrorHandling(<ApiSettingsPage />),
|
element: withErrorHandling(<ApiSettingsPage />),
|
||||||
@@ -260,7 +255,6 @@ export const ROUTES = {
|
|||||||
ENDPOINTS: '/settings/endpoints',
|
ENDPOINTS: '/settings/endpoints',
|
||||||
INSTALLATIONS: '/settings/installations',
|
INSTALLATIONS: '/settings/installations',
|
||||||
SETTINGS_RULES: '/settings/rules',
|
SETTINGS_RULES: '/settings/rules',
|
||||||
CODEXLENS_MANAGER: '/settings/codexlens',
|
|
||||||
API_SETTINGS: '/api-settings',
|
API_SETTINGS: '/api-settings',
|
||||||
EXPLORER: '/explorer',
|
EXPLORER: '/explorer',
|
||||||
GRAPH: '/graph',
|
GRAPH: '/graph',
|
||||||
|
|||||||
@@ -172,8 +172,6 @@ const mockMessages: Record<Locale, Record<string, string>> = {
|
|||||||
'mcp.ccw.tools.core_memory.desc': 'Core memory management',
|
'mcp.ccw.tools.core_memory.desc': 'Core memory management',
|
||||||
'mcp.ccw.tools.ask_question.name': 'Ask Question',
|
'mcp.ccw.tools.ask_question.name': 'Ask Question',
|
||||||
'mcp.ccw.tools.ask_question.desc': 'Interactive questions (A2UI)',
|
'mcp.ccw.tools.ask_question.desc': 'Interactive questions (A2UI)',
|
||||||
'mcp.ccw.tools.smart_search.name': 'Smart Search',
|
|
||||||
'mcp.ccw.tools.smart_search.desc': 'Intelligent code search',
|
|
||||||
'mcp.ccw.tools.team_msg.name': 'Team Message',
|
'mcp.ccw.tools.team_msg.name': 'Team Message',
|
||||||
'mcp.ccw.tools.team_msg.desc': 'Agent team message bus',
|
'mcp.ccw.tools.team_msg.desc': 'Agent team message bus',
|
||||||
'mcp.ccw.paths.label': 'Paths',
|
'mcp.ccw.paths.label': 'Paths',
|
||||||
@@ -348,8 +346,6 @@ const mockMessages: Record<Locale, Record<string, string>> = {
|
|||||||
'mcp.ccw.tools.core_memory.desc': '核心记忆管理',
|
'mcp.ccw.tools.core_memory.desc': '核心记忆管理',
|
||||||
'mcp.ccw.tools.ask_question.name': '提问',
|
'mcp.ccw.tools.ask_question.name': '提问',
|
||||||
'mcp.ccw.tools.ask_question.desc': '交互式问题(A2UI)',
|
'mcp.ccw.tools.ask_question.desc': '交互式问题(A2UI)',
|
||||||
'mcp.ccw.tools.smart_search.name': '智能搜索',
|
|
||||||
'mcp.ccw.tools.smart_search.desc': '智能代码搜索',
|
|
||||||
'mcp.ccw.tools.team_msg.name': '团队消息',
|
'mcp.ccw.tools.team_msg.name': '团队消息',
|
||||||
'mcp.ccw.tools.team_msg.desc': '代理团队消息总线',
|
'mcp.ccw.tools.team_msg.desc': '代理团队消息总线',
|
||||||
'mcp.ccw.paths.label': '路径',
|
'mcp.ccw.paths.label': '路径',
|
||||||
|
|||||||
@@ -40,9 +40,6 @@ export type {
|
|||||||
NotificationState,
|
NotificationState,
|
||||||
NotificationActions,
|
NotificationActions,
|
||||||
NotificationStore,
|
NotificationStore,
|
||||||
// Index Manager
|
|
||||||
IndexStatus,
|
|
||||||
IndexRebuildRequest,
|
|
||||||
// Rules
|
// Rules
|
||||||
Rule,
|
Rule,
|
||||||
RuleCreateInput,
|
RuleCreateInput,
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
# Memory Embedder Implementation Summary
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Created a Python script (`memory_embedder.py`) that bridges CCW to CodexLens semantic search by generating and searching embeddings for memory chunks stored in CCW's SQLite database.
|
|
||||||
|
|
||||||
## Files Created
|
|
||||||
|
|
||||||
### 1. `memory_embedder.py` (Main Script)
|
|
||||||
**Location**: `D:\Claude_dms3\ccw\scripts\memory_embedder.py`
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Reuses CodexLens embedder: `from codexlens.semantic.embedder import get_embedder`
|
|
||||||
- Uses jina-embeddings-v2-base-code (768 dimensions)
|
|
||||||
- Three commands: `embed`, `search`, `status`
|
|
||||||
- JSON output for easy integration
|
|
||||||
- Batch processing for efficiency
|
|
||||||
- Graceful error handling
|
|
||||||
|
|
||||||
**Commands**:
|
|
||||||
|
|
||||||
1. **embed** - Generate embeddings
|
|
||||||
```bash
|
|
||||||
python memory_embedder.py embed <db_path> [options]
|
|
||||||
Options:
|
|
||||||
--source-id ID # Only process specific source
|
|
||||||
--batch-size N # Batch size (default: 8)
|
|
||||||
--force # Re-embed existing chunks
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **search** - Semantic search
|
|
||||||
```bash
|
|
||||||
python memory_embedder.py search <db_path> <query> [options]
|
|
||||||
Options:
|
|
||||||
--top-k N # Number of results (default: 10)
|
|
||||||
--min-score F # Minimum score (default: 0.3)
|
|
||||||
--type TYPE # Filter by source type
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **status** - Get statistics
|
|
||||||
```bash
|
|
||||||
python memory_embedder.py status <db_path>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. `README-memory-embedder.md` (Documentation)
|
|
||||||
**Location**: `D:\Claude_dms3\ccw\scripts\README-memory-embedder.md`
|
|
||||||
|
|
||||||
**Contents**:
|
|
||||||
- Feature overview
|
|
||||||
- Requirements and installation
|
|
||||||
- Detailed usage examples
|
|
||||||
- Database path reference
|
|
||||||
- TypeScript integration guide
|
|
||||||
- Performance metrics
|
|
||||||
- Source type descriptions
|
|
||||||
|
|
||||||
### 3. `memory-embedder-example.ts` (Integration Example)
|
|
||||||
**Location**: `D:\Claude_dms3\ccw\scripts\memory-embedder-example.ts`
|
|
||||||
|
|
||||||
**Exported Functions**:
|
|
||||||
- `embedChunks(dbPath, options)` - Generate embeddings
|
|
||||||
- `searchMemory(dbPath, query, options)` - Semantic search
|
|
||||||
- `getEmbeddingStatus(dbPath)` - Get status
|
|
||||||
|
|
||||||
**Example Usage**:
|
|
||||||
```typescript
|
|
||||||
import { searchMemory, embedChunks, getEmbeddingStatus } from './memory-embedder-example';
|
|
||||||
|
|
||||||
// Check status
|
|
||||||
const status = getEmbeddingStatus(dbPath);
|
|
||||||
|
|
||||||
// Generate embeddings
|
|
||||||
const result = embedChunks(dbPath, { batchSize: 16 });
|
|
||||||
|
|
||||||
// Search
|
|
||||||
const matches = searchMemory(dbPath, 'authentication', {
|
|
||||||
topK: 5,
|
|
||||||
minScore: 0.5,
|
|
||||||
sourceType: 'workflow'
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
## Technical Implementation
|
|
||||||
|
|
||||||
### Database Schema
|
|
||||||
Uses existing `memory_chunks` table:
|
|
||||||
```sql
|
|
||||||
CREATE TABLE memory_chunks (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
source_id TEXT NOT NULL,
|
|
||||||
source_type TEXT NOT NULL,
|
|
||||||
chunk_index INTEGER NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
embedding BLOB,
|
|
||||||
metadata TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
UNIQUE(source_id, chunk_index)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Embedding Storage
|
|
||||||
- Format: `float32` bytes (numpy array)
|
|
||||||
- Dimension: 768 (jina-embeddings-v2-base-code)
|
|
||||||
- Storage: `np.array(emb, dtype=np.float32).tobytes()`
|
|
||||||
- Loading: `np.frombuffer(blob, dtype=np.float32)`
|
|
||||||
|
|
||||||
### Similarity Search
|
|
||||||
- Algorithm: Cosine similarity
|
|
||||||
- Formula: `np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))`
|
|
||||||
- Default threshold: 0.3
|
|
||||||
- Sorting: Descending by score
|
|
||||||
|
|
||||||
### Source Types
|
|
||||||
- `core_memory`: Strategic architectural context
|
|
||||||
- `workflow`: Session-based development history
|
|
||||||
- `cli_history`: Command execution logs
|
|
||||||
|
|
||||||
### Restore Commands
|
|
||||||
Generated automatically for each match:
|
|
||||||
- core_memory/cli_history: `ccw memory export <source_id>`
|
|
||||||
- workflow: `ccw session resume <source_id>`
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
### Required
|
|
||||||
- `numpy`: Array operations and cosine similarity
|
|
||||||
- `codex-lens[semantic]`: Embedding generation
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
```bash
|
|
||||||
pip install numpy codex-lens[semantic]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
### Script Validation
|
|
||||||
```bash
|
|
||||||
# Syntax check
|
|
||||||
python -m py_compile scripts/memory_embedder.py # OK
|
|
||||||
|
|
||||||
# Help output
|
|
||||||
python scripts/memory_embedder.py --help # Works
|
|
||||||
python scripts/memory_embedder.py embed --help # Works
|
|
||||||
python scripts/memory_embedder.py search --help # Works
|
|
||||||
python scripts/memory_embedder.py status --help # Works
|
|
||||||
|
|
||||||
# Status test
|
|
||||||
python scripts/memory_embedder.py status <db_path> # Works
|
|
||||||
```
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- Missing database: FileNotFoundError with clear message
|
|
||||||
- Missing CodexLens: ImportError with installation instructions
|
|
||||||
- Missing numpy: ImportError with installation instructions
|
|
||||||
- Database errors: JSON error response with success=false
|
|
||||||
- Missing table: Graceful error with JSON output
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- **Embedding speed**: ~8 chunks/second (batch size 8)
|
|
||||||
- **Search speed**: ~0.1-0.5 seconds for 1000 chunks
|
|
||||||
- **Model loading**: ~0.8 seconds (cached after first use via CodexLens singleton)
|
|
||||||
- **Batch processing**: Configurable batch size (default: 8)
|
|
||||||
|
|
||||||
## Output Format
|
|
||||||
|
|
||||||
All commands output JSON for easy parsing:
|
|
||||||
|
|
||||||
### Embed Result
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"chunks_processed": 50,
|
|
||||||
"chunks_failed": 0,
|
|
||||||
"elapsed_time": 12.34
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Search Result
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"matches": [
|
|
||||||
{
|
|
||||||
"source_id": "WFS-20250101-auth",
|
|
||||||
"source_type": "workflow",
|
|
||||||
"chunk_index": 2,
|
|
||||||
"content": "Implemented JWT...",
|
|
||||||
"score": 0.8542,
|
|
||||||
"restore_command": "ccw session resume WFS-20250101-auth"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Status Result
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"total_chunks": 150,
|
|
||||||
"embedded_chunks": 100,
|
|
||||||
"pending_chunks": 50,
|
|
||||||
"by_type": {
|
|
||||||
"core_memory": {"total": 80, "embedded": 60, "pending": 20}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. **TypeScript Integration**: Add to CCW's core memory routes
|
|
||||||
2. **CLI Command**: Create `ccw memory search` command
|
|
||||||
3. **Automatic Embedding**: Trigger embedding on memory creation
|
|
||||||
4. **Index Management**: Add rebuild/optimize commands
|
|
||||||
5. **Cluster Search**: Integrate with session clusters
|
|
||||||
|
|
||||||
## Code Quality
|
|
||||||
|
|
||||||
- ✅ Single responsibility per function
|
|
||||||
- ✅ Clear, descriptive naming
|
|
||||||
- ✅ Explicit error handling
|
|
||||||
- ✅ No premature abstractions
|
|
||||||
- ✅ Minimal debug output (essential logging only)
|
|
||||||
- ✅ ASCII-only characters (no emojis)
|
|
||||||
- ✅ GBK encoding compatible
|
|
||||||
- ✅ Type hints for all functions
|
|
||||||
- ✅ Comprehensive docstrings
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
# Memory Embedder - Quick Reference
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install numpy codex-lens[semantic]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
### Status
|
|
||||||
```bash
|
|
||||||
python scripts/memory_embedder.py status <db_path>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Embed All
|
|
||||||
```bash
|
|
||||||
python scripts/memory_embedder.py embed <db_path>
|
|
||||||
```
|
|
||||||
|
|
||||||
### Embed Specific Source
|
|
||||||
```bash
|
|
||||||
python scripts/memory_embedder.py embed <db_path> --source-id CMEM-20250101-120000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Re-embed (Force)
|
|
||||||
```bash
|
|
||||||
python scripts/memory_embedder.py embed <db_path> --force
|
|
||||||
```
|
|
||||||
|
|
||||||
### Search
|
|
||||||
```bash
|
|
||||||
python scripts/memory_embedder.py search <db_path> "authentication flow"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Advanced Search
|
|
||||||
```bash
|
|
||||||
python scripts/memory_embedder.py search <db_path> "rate limiting" \
|
|
||||||
--top-k 5 \
|
|
||||||
--min-score 0.5 \
|
|
||||||
--type workflow
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database Path
|
|
||||||
|
|
||||||
Find your database:
|
|
||||||
```bash
|
|
||||||
# Linux/Mac
|
|
||||||
~/.ccw/projects/<project-id>/core-memory/core_memory.db
|
|
||||||
|
|
||||||
# Windows
|
|
||||||
%USERPROFILE%\.ccw\projects\<project-id>\core-memory\core_memory.db
|
|
||||||
```
|
|
||||||
|
|
||||||
## TypeScript Integration
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
|
|
||||||
// Status
|
|
||||||
const status = JSON.parse(
|
|
||||||
execSync(`python scripts/memory_embedder.py status "${dbPath}"`, {
|
|
||||||
encoding: 'utf-8'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Embed
|
|
||||||
const result = JSON.parse(
|
|
||||||
execSync(`python scripts/memory_embedder.py embed "${dbPath}"`, {
|
|
||||||
encoding: 'utf-8'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Search
|
|
||||||
const matches = JSON.parse(
|
|
||||||
execSync(
|
|
||||||
`python scripts/memory_embedder.py search "${dbPath}" "query"`,
|
|
||||||
{ encoding: 'utf-8' }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Output Examples
|
|
||||||
|
|
||||||
### Status
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"total_chunks": 150,
|
|
||||||
"embedded_chunks": 100,
|
|
||||||
"pending_chunks": 50,
|
|
||||||
"by_type": {
|
|
||||||
"core_memory": {"total": 80, "embedded": 60, "pending": 20}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Embed
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"chunks_processed": 50,
|
|
||||||
"chunks_failed": 0,
|
|
||||||
"elapsed_time": 12.34
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Search
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"matches": [
|
|
||||||
{
|
|
||||||
"source_id": "WFS-20250101-auth",
|
|
||||||
"source_type": "workflow",
|
|
||||||
"chunk_index": 2,
|
|
||||||
"content": "Implemented JWT authentication...",
|
|
||||||
"score": 0.8542,
|
|
||||||
"restore_command": "ccw session resume WFS-20250101-auth"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Source Types
|
|
||||||
|
|
||||||
- `core_memory` - Strategic architectural context
|
|
||||||
- `workflow` - Session-based development history
|
|
||||||
- `cli_history` - Command execution logs
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- Embedding: ~8 chunks/second
|
|
||||||
- Search: ~0.1-0.5s for 1000 chunks
|
|
||||||
- Model load: ~0.8s (cached)
|
|
||||||
- Batch size: 8 (default, configurable)
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
# Memory Embedder
|
|
||||||
|
|
||||||
Bridge CCW to CodexLens semantic search by generating and searching embeddings for memory chunks.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Generate embeddings** for memory chunks using CodexLens's jina-embeddings-v2-base-code (768 dim)
|
|
||||||
- **Semantic search** across all memory types (core_memory, workflow, cli_history)
|
|
||||||
- **Status tracking** to monitor embedding progress
|
|
||||||
- **Batch processing** for efficient embedding generation
|
|
||||||
- **Restore commands** included in search results
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install numpy codex-lens[semantic]
|
|
||||||
```
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
|
|
||||||
### 1. Check Status
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python scripts/memory_embedder.py status <db_path>
|
|
||||||
```
|
|
||||||
|
|
||||||
Example output:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"total_chunks": 150,
|
|
||||||
"embedded_chunks": 100,
|
|
||||||
"pending_chunks": 50,
|
|
||||||
"by_type": {
|
|
||||||
"core_memory": {"total": 80, "embedded": 60, "pending": 20},
|
|
||||||
"workflow": {"total": 50, "embedded": 30, "pending": 20},
|
|
||||||
"cli_history": {"total": 20, "embedded": 10, "pending": 10}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Generate Embeddings
|
|
||||||
|
|
||||||
Embed all unembedded chunks:
|
|
||||||
```bash
|
|
||||||
python scripts/memory_embedder.py embed <db_path>
|
|
||||||
```
|
|
||||||
|
|
||||||
Embed specific source:
|
|
||||||
```bash
|
|
||||||
python scripts/memory_embedder.py embed <db_path> --source-id CMEM-20250101-120000
|
|
||||||
```
|
|
||||||
|
|
||||||
Re-embed all chunks (force):
|
|
||||||
```bash
|
|
||||||
python scripts/memory_embedder.py embed <db_path> --force
|
|
||||||
```
|
|
||||||
|
|
||||||
Adjust batch size (default 8):
|
|
||||||
```bash
|
|
||||||
python scripts/memory_embedder.py embed <db_path> --batch-size 16
|
|
||||||
```
|
|
||||||
|
|
||||||
Example output:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"chunks_processed": 50,
|
|
||||||
"chunks_failed": 0,
|
|
||||||
"elapsed_time": 12.34
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Semantic Search
|
|
||||||
|
|
||||||
Basic search:
|
|
||||||
```bash
|
|
||||||
python scripts/memory_embedder.py search <db_path> "authentication flow"
|
|
||||||
```
|
|
||||||
|
|
||||||
Advanced search:
|
|
||||||
```bash
|
|
||||||
python scripts/memory_embedder.py search <db_path> "rate limiting" \
|
|
||||||
--top-k 5 \
|
|
||||||
--min-score 0.5 \
|
|
||||||
--type workflow
|
|
||||||
```
|
|
||||||
|
|
||||||
Example output:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"success": true,
|
|
||||||
"matches": [
|
|
||||||
{
|
|
||||||
"source_id": "WFS-20250101-auth",
|
|
||||||
"source_type": "workflow",
|
|
||||||
"chunk_index": 2,
|
|
||||||
"content": "Implemented JWT-based authentication...",
|
|
||||||
"score": 0.8542,
|
|
||||||
"restore_command": "ccw session resume WFS-20250101-auth"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Database Path
|
|
||||||
|
|
||||||
The database is located in CCW's storage directory:
|
|
||||||
|
|
||||||
- **Windows**: `%USERPROFILE%\.ccw\projects\<project-id>\core-memory\core_memory.db`
|
|
||||||
- **Linux/Mac**: `~/.ccw/projects/<project-id>/core-memory/core_memory.db`
|
|
||||||
|
|
||||||
Find your project's database:
|
|
||||||
```bash
|
|
||||||
ccw memory list # Shows project path
|
|
||||||
# Then look in: ~/.ccw/projects/<hashed-path>/core-memory/core_memory.db
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration with CCW
|
|
||||||
|
|
||||||
This script is designed to be called from CCW's TypeScript code:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
|
|
||||||
// Embed chunks
|
|
||||||
const result = execSync(
|
|
||||||
`python scripts/memory_embedder.py embed ${dbPath}`,
|
|
||||||
{ encoding: 'utf-8' }
|
|
||||||
);
|
|
||||||
const { success, chunks_processed } = JSON.parse(result);
|
|
||||||
|
|
||||||
// Search
|
|
||||||
const searchResult = execSync(
|
|
||||||
`python scripts/memory_embedder.py search ${dbPath} "${query}" --top-k 10`,
|
|
||||||
{ encoding: 'utf-8' }
|
|
||||||
);
|
|
||||||
const { matches } = JSON.parse(searchResult);
|
|
||||||
```
|
|
||||||
|
|
||||||
## Performance
|
|
||||||
|
|
||||||
- **Embedding speed**: ~8 chunks/second (batch size 8)
|
|
||||||
- **Search speed**: ~0.1-0.5 seconds for 1000 chunks
|
|
||||||
- **Model loading**: ~0.8 seconds (cached after first use)
|
|
||||||
|
|
||||||
## Source Types
|
|
||||||
|
|
||||||
- `core_memory`: Strategic architectural context
|
|
||||||
- `workflow`: Session-based development history
|
|
||||||
- `cli_history`: Command execution logs
|
|
||||||
|
|
||||||
## Restore Commands
|
|
||||||
|
|
||||||
Search results include restore commands:
|
|
||||||
|
|
||||||
- **core_memory/cli_history**: `ccw memory export <source_id>`
|
|
||||||
- **workflow**: `ccw session resume <source_id>`
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
/**
|
|
||||||
* Example: Using Memory Embedder from TypeScript
|
|
||||||
*
|
|
||||||
* This shows how to integrate the Python memory embedder script
|
|
||||||
* into CCW's TypeScript codebase.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { execSync } from 'child_process';
|
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
interface EmbedResult {
|
|
||||||
success: boolean;
|
|
||||||
chunks_processed: number;
|
|
||||||
chunks_failed: number;
|
|
||||||
elapsed_time: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchMatch {
|
|
||||||
source_id: string;
|
|
||||||
source_type: 'core_memory' | 'workflow' | 'cli_history';
|
|
||||||
chunk_index: number;
|
|
||||||
content: string;
|
|
||||||
score: number;
|
|
||||||
restore_command: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchResult {
|
|
||||||
success: boolean;
|
|
||||||
matches: SearchMatch[];
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusResult {
|
|
||||||
total_chunks: number;
|
|
||||||
embedded_chunks: number;
|
|
||||||
pending_chunks: number;
|
|
||||||
by_type: Record<string, { total: number; embedded: number; pending: number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get path to memory embedder script
|
|
||||||
*/
|
|
||||||
function getEmbedderScript(): string {
|
|
||||||
return join(__dirname, 'memory_embedder.py');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute memory embedder command
|
|
||||||
*/
|
|
||||||
function execEmbedder(args: string[]): string {
|
|
||||||
const script = getEmbedderScript();
|
|
||||||
const command = `python "${script}" ${args.join(' ')}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return execSync(command, {
|
|
||||||
encoding: 'utf-8',
|
|
||||||
maxBuffer: 10 * 1024 * 1024 // 10MB buffer
|
|
||||||
});
|
|
||||||
} catch (error: any) {
|
|
||||||
// Try to parse error output as JSON
|
|
||||||
if (error.stdout) {
|
|
||||||
return error.stdout;
|
|
||||||
}
|
|
||||||
throw new Error(`Embedder failed: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate embeddings for memory chunks
|
|
||||||
*/
|
|
||||||
export function embedChunks(
|
|
||||||
dbPath: string,
|
|
||||||
options: {
|
|
||||||
sourceId?: string;
|
|
||||||
batchSize?: number;
|
|
||||||
force?: boolean;
|
|
||||||
} = {}
|
|
||||||
): EmbedResult {
|
|
||||||
const args = ['embed', `"${dbPath}"`];
|
|
||||||
|
|
||||||
if (options.sourceId) {
|
|
||||||
args.push('--source-id', options.sourceId);
|
|
||||||
}
|
|
||||||
if (options.batchSize) {
|
|
||||||
args.push('--batch-size', String(options.batchSize));
|
|
||||||
}
|
|
||||||
if (options.force) {
|
|
||||||
args.push('--force');
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = execEmbedder(args);
|
|
||||||
return JSON.parse(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Search memory chunks semantically
|
|
||||||
*/
|
|
||||||
export function searchMemory(
|
|
||||||
dbPath: string,
|
|
||||||
query: string,
|
|
||||||
options: {
|
|
||||||
topK?: number;
|
|
||||||
minScore?: number;
|
|
||||||
sourceType?: 'core_memory' | 'workflow' | 'cli_history';
|
|
||||||
} = {}
|
|
||||||
): SearchResult {
|
|
||||||
const args = ['search', `"${dbPath}"`, `"${query}"`];
|
|
||||||
|
|
||||||
if (options.topK) {
|
|
||||||
args.push('--top-k', String(options.topK));
|
|
||||||
}
|
|
||||||
if (options.minScore !== undefined) {
|
|
||||||
args.push('--min-score', String(options.minScore));
|
|
||||||
}
|
|
||||||
if (options.sourceType) {
|
|
||||||
args.push('--type', options.sourceType);
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = execEmbedder(args);
|
|
||||||
return JSON.parse(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get embedding status
|
|
||||||
*/
|
|
||||||
export function getEmbeddingStatus(dbPath: string): StatusResult {
|
|
||||||
const args = ['status', `"${dbPath}"`];
|
|
||||||
const output = execEmbedder(args);
|
|
||||||
return JSON.parse(output);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Example Usage
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
async function exampleUsage() {
|
|
||||||
const dbPath = join(process.env.HOME || '', '.ccw/projects/myproject/core-memory/core_memory.db');
|
|
||||||
|
|
||||||
// 1. Check status
|
|
||||||
console.log('Checking embedding status...');
|
|
||||||
const status = getEmbeddingStatus(dbPath);
|
|
||||||
console.log(`Total chunks: ${status.total_chunks}`);
|
|
||||||
console.log(`Embedded: ${status.embedded_chunks}`);
|
|
||||||
console.log(`Pending: ${status.pending_chunks}`);
|
|
||||||
|
|
||||||
// 2. Generate embeddings if needed
|
|
||||||
if (status.pending_chunks > 0) {
|
|
||||||
console.log('\nGenerating embeddings...');
|
|
||||||
const embedResult = embedChunks(dbPath, { batchSize: 16 });
|
|
||||||
console.log(`Processed: ${embedResult.chunks_processed}`);
|
|
||||||
console.log(`Time: ${embedResult.elapsed_time}s`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Search for relevant memories
|
|
||||||
console.log('\nSearching for authentication-related memories...');
|
|
||||||
const searchResult = searchMemory(dbPath, 'authentication flow', {
|
|
||||||
topK: 5,
|
|
||||||
minScore: 0.5
|
|
||||||
});
|
|
||||||
|
|
||||||
if (searchResult.success) {
|
|
||||||
console.log(`Found ${searchResult.matches.length} matches:`);
|
|
||||||
for (const match of searchResult.matches) {
|
|
||||||
console.log(`\n- ${match.source_id} (score: ${match.score})`);
|
|
||||||
console.log(` Type: ${match.source_type}`);
|
|
||||||
console.log(` Restore: ${match.restore_command}`);
|
|
||||||
console.log(` Content: ${match.content.substring(0, 100)}...`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Search specific source type
|
|
||||||
console.log('\nSearching workflows only...');
|
|
||||||
const workflowSearch = searchMemory(dbPath, 'API implementation', {
|
|
||||||
sourceType: 'workflow',
|
|
||||||
topK: 3
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(`Found ${workflowSearch.matches.length} workflow matches`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run example if executed directly
|
|
||||||
if (require.main === module) {
|
|
||||||
exampleUsage().catch(console.error);
|
|
||||||
}
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Memory Embedder - Bridge CCW to CodexLens semantic search
|
|
||||||
|
|
||||||
This script generates and searches embeddings for memory chunks stored in CCW's
|
|
||||||
SQLite database using CodexLens's embedder.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python memory_embedder.py embed <db_path> [--source-id ID] [--batch-size N] [--force]
|
|
||||||
python memory_embedder.py search <db_path> <query> [--top-k N] [--min-score F] [--type TYPE]
|
|
||||||
python memory_embedder.py status <db_path>
|
|
||||||
"""
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Dict, Any, Optional, Tuple
|
|
||||||
|
|
||||||
try:
|
|
||||||
import numpy as np
|
|
||||||
except ImportError:
|
|
||||||
print("Error: numpy is required. Install with: pip install numpy", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from codexlens.semantic.factory import get_embedder as get_embedder_factory
|
|
||||||
from codexlens.semantic.factory import clear_embedder_cache
|
|
||||||
from codexlens.config import Config as CodexLensConfig
|
|
||||||
except ImportError:
|
|
||||||
print("Error: CodexLens not found. Install with: pip install codex-lens[semantic]", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
class MemoryEmbedder:
|
|
||||||
"""Generate and search embeddings for memory chunks."""
|
|
||||||
|
|
||||||
def __init__(self, db_path: str):
|
|
||||||
"""Initialize embedder with database path."""
|
|
||||||
self.db_path = Path(db_path)
|
|
||||||
if not self.db_path.exists():
|
|
||||||
raise FileNotFoundError(f"Database not found: {db_path}")
|
|
||||||
|
|
||||||
self.conn = sqlite3.connect(str(self.db_path))
|
|
||||||
self.conn.row_factory = sqlite3.Row
|
|
||||||
|
|
||||||
# Load CodexLens configuration for embedding settings
|
|
||||||
try:
|
|
||||||
self._config = CodexLensConfig.load()
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Warning: Could not load CodexLens config, using defaults. Error: {e}", file=sys.stderr)
|
|
||||||
self._config = CodexLensConfig() # Use default config
|
|
||||||
|
|
||||||
# Lazy-load embedder to avoid ~0.8s model loading for status command
|
|
||||||
self._embedder = None
|
|
||||||
self._embedding_dim = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def embedding_dim(self) -> int:
|
|
||||||
"""Get embedding dimension from the embedder."""
|
|
||||||
if self._embedding_dim is None:
|
|
||||||
# Access embedder to get its dimension
|
|
||||||
self._embedding_dim = self.embedder.embedding_dim
|
|
||||||
return self._embedding_dim
|
|
||||||
|
|
||||||
@property
|
|
||||||
def embedder(self):
|
|
||||||
"""Lazy-load the embedder on first access using CodexLens config."""
|
|
||||||
if self._embedder is None:
|
|
||||||
# Use CodexLens configuration settings
|
|
||||||
backend = self._config.embedding_backend
|
|
||||||
model = self._config.embedding_model
|
|
||||||
use_gpu = self._config.embedding_use_gpu
|
|
||||||
|
|
||||||
# Use factory to create embedder based on backend type
|
|
||||||
if backend == "fastembed":
|
|
||||||
self._embedder = get_embedder_factory(
|
|
||||||
backend="fastembed",
|
|
||||||
profile=model,
|
|
||||||
use_gpu=use_gpu
|
|
||||||
)
|
|
||||||
elif backend == "litellm":
|
|
||||||
# For litellm backend, also pass endpoints if configured
|
|
||||||
endpoints = self._config.embedding_endpoints
|
|
||||||
strategy = self._config.embedding_strategy
|
|
||||||
cooldown = self._config.embedding_cooldown
|
|
||||||
|
|
||||||
self._embedder = get_embedder_factory(
|
|
||||||
backend="litellm",
|
|
||||||
model=model,
|
|
||||||
endpoints=endpoints if endpoints else None,
|
|
||||||
strategy=strategy,
|
|
||||||
cooldown=cooldown,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Fallback to fastembed with code profile
|
|
||||||
self._embedder = get_embedder_factory(
|
|
||||||
backend="fastembed",
|
|
||||||
profile="code",
|
|
||||||
use_gpu=True
|
|
||||||
)
|
|
||||||
return self._embedder
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
"""Close database connection."""
|
|
||||||
if self.conn:
|
|
||||||
self.conn.close()
|
|
||||||
|
|
||||||
def embed_chunks(
|
|
||||||
self,
|
|
||||||
source_id: Optional[str] = None,
|
|
||||||
batch_size: int = 8,
|
|
||||||
force: bool = False
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Generate embeddings for unembedded chunks.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
source_id: Only process chunks from this source
|
|
||||||
batch_size: Number of chunks to process in each batch
|
|
||||||
force: Re-embed chunks that already have embeddings
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Result dict with success, chunks_processed, chunks_failed, elapsed_time
|
|
||||||
"""
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
# Build query
|
|
||||||
query = "SELECT id, source_id, source_type, chunk_index, content FROM memory_chunks"
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if force:
|
|
||||||
# Process all chunks (with optional source filter)
|
|
||||||
if source_id:
|
|
||||||
query += " WHERE source_id = ?"
|
|
||||||
params.append(source_id)
|
|
||||||
else:
|
|
||||||
# Only process chunks without embeddings
|
|
||||||
query += " WHERE embedding IS NULL"
|
|
||||||
if source_id:
|
|
||||||
query += " AND source_id = ?"
|
|
||||||
params.append(source_id)
|
|
||||||
|
|
||||||
query += " ORDER BY id"
|
|
||||||
|
|
||||||
cursor = self.conn.cursor()
|
|
||||||
cursor.execute(query, params)
|
|
||||||
|
|
||||||
chunks_processed = 0
|
|
||||||
chunks_failed = 0
|
|
||||||
batch = []
|
|
||||||
batch_ids = []
|
|
||||||
|
|
||||||
for row in cursor:
|
|
||||||
batch.append(row["content"])
|
|
||||||
batch_ids.append(row["id"])
|
|
||||||
|
|
||||||
# Process batch when full
|
|
||||||
if len(batch) >= batch_size:
|
|
||||||
processed, failed = self._process_batch(batch, batch_ids)
|
|
||||||
chunks_processed += processed
|
|
||||||
chunks_failed += failed
|
|
||||||
batch = []
|
|
||||||
batch_ids = []
|
|
||||||
|
|
||||||
# Process remaining chunks
|
|
||||||
if batch:
|
|
||||||
processed, failed = self._process_batch(batch, batch_ids)
|
|
||||||
chunks_processed += processed
|
|
||||||
chunks_failed += failed
|
|
||||||
|
|
||||||
elapsed_time = time.time() - start_time
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": chunks_failed == 0,
|
|
||||||
"chunks_processed": chunks_processed,
|
|
||||||
"chunks_failed": chunks_failed,
|
|
||||||
"elapsed_time": round(elapsed_time, 2)
|
|
||||||
}
|
|
||||||
|
|
||||||
def _process_batch(self, texts: List[str], ids: List[int]) -> Tuple[int, int]:
|
|
||||||
"""Process a batch of texts and update embeddings."""
|
|
||||||
try:
|
|
||||||
# Generate embeddings for batch
|
|
||||||
embeddings = self.embedder.embed(texts)
|
|
||||||
|
|
||||||
processed = 0
|
|
||||||
failed = 0
|
|
||||||
|
|
||||||
# Update database
|
|
||||||
cursor = self.conn.cursor()
|
|
||||||
for chunk_id, embedding in zip(ids, embeddings):
|
|
||||||
try:
|
|
||||||
# Convert to numpy array and store as bytes
|
|
||||||
emb_array = np.array(embedding, dtype=np.float32)
|
|
||||||
emb_bytes = emb_array.tobytes()
|
|
||||||
|
|
||||||
cursor.execute(
|
|
||||||
"UPDATE memory_chunks SET embedding = ? WHERE id = ?",
|
|
||||||
(emb_bytes, chunk_id)
|
|
||||||
)
|
|
||||||
processed += 1
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error updating chunk {chunk_id}: {e}", file=sys.stderr)
|
|
||||||
failed += 1
|
|
||||||
|
|
||||||
self.conn.commit()
|
|
||||||
return processed, failed
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error processing batch: {e}", file=sys.stderr)
|
|
||||||
return 0, len(ids)
|
|
||||||
|
|
||||||
def search(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
top_k: int = 10,
|
|
||||||
min_score: float = 0.3,
|
|
||||||
source_type: Optional[str] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Perform semantic search on memory chunks.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query text
|
|
||||||
top_k: Number of results to return
|
|
||||||
min_score: Minimum similarity score (0-1)
|
|
||||||
source_type: Filter by source type (core_memory, workflow, cli_history)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Result dict with success and matches list
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Generate query embedding
|
|
||||||
query_embedding = self.embedder.embed_single(query)
|
|
||||||
query_array = np.array(query_embedding, dtype=np.float32)
|
|
||||||
|
|
||||||
# Build database query
|
|
||||||
sql = """
|
|
||||||
SELECT id, source_id, source_type, chunk_index, content, embedding
|
|
||||||
FROM memory_chunks
|
|
||||||
WHERE embedding IS NOT NULL
|
|
||||||
"""
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if source_type:
|
|
||||||
sql += " AND source_type = ?"
|
|
||||||
params.append(source_type)
|
|
||||||
|
|
||||||
cursor = self.conn.cursor()
|
|
||||||
cursor.execute(sql, params)
|
|
||||||
|
|
||||||
# Calculate similarities
|
|
||||||
matches = []
|
|
||||||
for row in cursor:
|
|
||||||
# Load embedding from bytes
|
|
||||||
emb_bytes = row["embedding"]
|
|
||||||
emb_array = np.frombuffer(emb_bytes, dtype=np.float32)
|
|
||||||
|
|
||||||
# Cosine similarity
|
|
||||||
score = float(
|
|
||||||
np.dot(query_array, emb_array) /
|
|
||||||
(np.linalg.norm(query_array) * np.linalg.norm(emb_array))
|
|
||||||
)
|
|
||||||
|
|
||||||
if score >= min_score:
|
|
||||||
# Generate restore command
|
|
||||||
restore_command = self._get_restore_command(
|
|
||||||
row["source_id"],
|
|
||||||
row["source_type"]
|
|
||||||
)
|
|
||||||
|
|
||||||
matches.append({
|
|
||||||
"source_id": row["source_id"],
|
|
||||||
"source_type": row["source_type"],
|
|
||||||
"chunk_index": row["chunk_index"],
|
|
||||||
"content": row["content"],
|
|
||||||
"score": round(score, 4),
|
|
||||||
"restore_command": restore_command
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by score and limit
|
|
||||||
matches.sort(key=lambda x: x["score"], reverse=True)
|
|
||||||
matches = matches[:top_k]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"matches": matches
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": str(e),
|
|
||||||
"matches": []
|
|
||||||
}
|
|
||||||
|
|
||||||
def _get_restore_command(self, source_id: str, source_type: str) -> str:
|
|
||||||
"""Generate restore command for a source."""
|
|
||||||
if source_type in ("core_memory", "cli_history"):
|
|
||||||
return f"ccw memory export {source_id}"
|
|
||||||
elif source_type == "workflow":
|
|
||||||
return f"ccw session resume {source_id}"
|
|
||||||
else:
|
|
||||||
return f"# Unknown source type: {source_type}"
|
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
|
||||||
"""Get embedding status statistics."""
|
|
||||||
cursor = self.conn.cursor()
|
|
||||||
|
|
||||||
# Total chunks
|
|
||||||
cursor.execute("SELECT COUNT(*) as count FROM memory_chunks")
|
|
||||||
total_chunks = cursor.fetchone()["count"]
|
|
||||||
|
|
||||||
# Embedded chunks
|
|
||||||
cursor.execute("SELECT COUNT(*) as count FROM memory_chunks WHERE embedding IS NOT NULL")
|
|
||||||
embedded_chunks = cursor.fetchone()["count"]
|
|
||||||
|
|
||||||
# By type
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT
|
|
||||||
source_type,
|
|
||||||
COUNT(*) as total,
|
|
||||||
SUM(CASE WHEN embedding IS NOT NULL THEN 1 ELSE 0 END) as embedded
|
|
||||||
FROM memory_chunks
|
|
||||||
GROUP BY source_type
|
|
||||||
""")
|
|
||||||
|
|
||||||
by_type = {}
|
|
||||||
for row in cursor:
|
|
||||||
by_type[row["source_type"]] = {
|
|
||||||
"total": row["total"],
|
|
||||||
"embedded": row["embedded"],
|
|
||||||
"pending": row["total"] - row["embedded"]
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
"total_chunks": total_chunks,
|
|
||||||
"embedded_chunks": embedded_chunks,
|
|
||||||
"pending_chunks": total_chunks - embedded_chunks,
|
|
||||||
"by_type": by_type
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main entry point."""
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Memory Embedder - Bridge CCW to CodexLens semantic search"
|
|
||||||
)
|
|
||||||
|
|
||||||
subparsers = parser.add_subparsers(dest="command", help="Command to execute")
|
|
||||||
subparsers.required = True
|
|
||||||
|
|
||||||
# Embed command
|
|
||||||
embed_parser = subparsers.add_parser("embed", help="Generate embeddings for chunks")
|
|
||||||
embed_parser.add_argument("db_path", help="Path to SQLite database")
|
|
||||||
embed_parser.add_argument("--source-id", help="Only process chunks from this source")
|
|
||||||
embed_parser.add_argument("--batch-size", type=int, default=8, help="Batch size (default: 8)")
|
|
||||||
embed_parser.add_argument("--force", action="store_true", help="Re-embed existing chunks")
|
|
||||||
|
|
||||||
# Search command
|
|
||||||
search_parser = subparsers.add_parser("search", help="Semantic search")
|
|
||||||
search_parser.add_argument("db_path", help="Path to SQLite database")
|
|
||||||
search_parser.add_argument("query", help="Search query")
|
|
||||||
search_parser.add_argument("--top-k", type=int, default=10, help="Number of results (default: 10)")
|
|
||||||
search_parser.add_argument("--min-score", type=float, default=0.3, help="Minimum score (default: 0.3)")
|
|
||||||
search_parser.add_argument("--type", dest="source_type", help="Filter by source type")
|
|
||||||
|
|
||||||
# Status command
|
|
||||||
status_parser = subparsers.add_parser("status", help="Get embedding status")
|
|
||||||
status_parser.add_argument("db_path", help="Path to SQLite database")
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
try:
|
|
||||||
embedder = MemoryEmbedder(args.db_path)
|
|
||||||
|
|
||||||
if args.command == "embed":
|
|
||||||
result = embedder.embed_chunks(
|
|
||||||
source_id=args.source_id,
|
|
||||||
batch_size=args.batch_size,
|
|
||||||
force=args.force
|
|
||||||
)
|
|
||||||
print(json.dumps(result, indent=2))
|
|
||||||
|
|
||||||
elif args.command == "search":
|
|
||||||
result = embedder.search(
|
|
||||||
query=args.query,
|
|
||||||
top_k=args.top_k,
|
|
||||||
min_score=args.min_score,
|
|
||||||
source_type=args.source_type
|
|
||||||
)
|
|
||||||
print(json.dumps(result, indent=2))
|
|
||||||
|
|
||||||
elif args.command == "status":
|
|
||||||
result = embedder.get_status()
|
|
||||||
print(json.dumps(result, indent=2))
|
|
||||||
|
|
||||||
embedder.close()
|
|
||||||
|
|
||||||
# Exit with error code if operation failed
|
|
||||||
if "success" in result and not result["success"]:
|
|
||||||
# Clean up ONNX resources before exit
|
|
||||||
clear_embedder_cache()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# Clean up ONNX resources to ensure process can exit cleanly
|
|
||||||
# This releases fastembed/ONNX Runtime threads that would otherwise
|
|
||||||
# prevent the Python interpreter from shutting down
|
|
||||||
clear_embedder_cache()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# Clean up ONNX resources even on error
|
|
||||||
try:
|
|
||||||
clear_embedder_cache()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
print(json.dumps({
|
|
||||||
"success": False,
|
|
||||||
"error": str(e)
|
|
||||||
}, indent=2), file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,245 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Test script for memory_embedder.py
|
|
||||||
|
|
||||||
Creates a temporary database with test data and verifies all commands work.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sqlite3
|
|
||||||
import tempfile
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
def create_test_database():
|
|
||||||
"""Create a temporary database with test chunks."""
|
|
||||||
# Create temp file
|
|
||||||
temp_db = tempfile.NamedTemporaryFile(suffix='.db', delete=False)
|
|
||||||
temp_db.close()
|
|
||||||
|
|
||||||
conn = sqlite3.connect(temp_db.name)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Create schema
|
|
||||||
cursor.execute("""
|
|
||||||
CREATE TABLE memory_chunks (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
source_id TEXT NOT NULL,
|
|
||||||
source_type TEXT NOT NULL,
|
|
||||||
chunk_index INTEGER NOT NULL,
|
|
||||||
content TEXT NOT NULL,
|
|
||||||
embedding BLOB,
|
|
||||||
metadata TEXT,
|
|
||||||
created_at TEXT NOT NULL,
|
|
||||||
UNIQUE(source_id, chunk_index)
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Insert test data
|
|
||||||
test_chunks = [
|
|
||||||
("CMEM-20250101-001", "core_memory", 0, "Implemented authentication using JWT tokens with refresh mechanism"),
|
|
||||||
("CMEM-20250101-001", "core_memory", 1, "Added rate limiting to API endpoints using Redis"),
|
|
||||||
("WFS-20250101-auth", "workflow", 0, "Created login endpoint with password hashing"),
|
|
||||||
("WFS-20250101-auth", "workflow", 1, "Implemented session management with token rotation"),
|
|
||||||
("CLI-20250101-001", "cli_history", 0, "Executed database migration for user table"),
|
|
||||||
]
|
|
||||||
|
|
||||||
now = datetime.now().isoformat()
|
|
||||||
for source_id, source_type, chunk_index, content in test_chunks:
|
|
||||||
cursor.execute(
|
|
||||||
"""
|
|
||||||
INSERT INTO memory_chunks (source_id, source_type, chunk_index, content, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(source_id, source_type, chunk_index, content, now)
|
|
||||||
)
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
return temp_db.name
|
|
||||||
|
|
||||||
|
|
||||||
def run_command(args):
|
|
||||||
"""Run memory_embedder.py with given arguments."""
|
|
||||||
script = Path(__file__).parent / "memory_embedder.py"
|
|
||||||
cmd = ["python", str(script)] + args
|
|
||||||
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True
|
|
||||||
)
|
|
||||||
|
|
||||||
return result.returncode, result.stdout, result.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def test_status(db_path):
|
|
||||||
"""Test status command."""
|
|
||||||
print("Testing status command...")
|
|
||||||
returncode, stdout, stderr = run_command(["status", db_path])
|
|
||||||
|
|
||||||
if returncode != 0:
|
|
||||||
print(f"[FAIL] Status failed: {stderr}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
result = json.loads(stdout)
|
|
||||||
expected_total = 5
|
|
||||||
|
|
||||||
if result["total_chunks"] != expected_total:
|
|
||||||
print(f"[FAIL] Expected {expected_total} chunks, got {result['total_chunks']}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if result["embedded_chunks"] != 0:
|
|
||||||
print(f"[FAIL] Expected 0 embedded chunks, got {result['embedded_chunks']}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"[PASS] Status OK: {result['total_chunks']} total, {result['embedded_chunks']} embedded")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def test_embed(db_path):
|
|
||||||
"""Test embed command."""
|
|
||||||
print("\nTesting embed command...")
|
|
||||||
returncode, stdout, stderr = run_command(["embed", db_path, "--batch-size", "2"])
|
|
||||||
|
|
||||||
if returncode != 0:
|
|
||||||
print(f"[FAIL] Embed failed: {stderr}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
result = json.loads(stdout)
|
|
||||||
|
|
||||||
if not result["success"]:
|
|
||||||
print(f"[FAIL] Embed unsuccessful")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if result["chunks_processed"] != 5:
|
|
||||||
print(f"[FAIL] Expected 5 processed, got {result['chunks_processed']}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if result["chunks_failed"] != 0:
|
|
||||||
print(f"[FAIL] Expected 0 failed, got {result['chunks_failed']}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"[PASS] Embed OK: {result['chunks_processed']} processed in {result['elapsed_time']}s")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def test_search(db_path):
|
|
||||||
"""Test search command."""
|
|
||||||
print("\nTesting search command...")
|
|
||||||
returncode, stdout, stderr = run_command([
|
|
||||||
"search", db_path, "authentication JWT",
|
|
||||||
"--top-k", "3",
|
|
||||||
"--min-score", "0.3"
|
|
||||||
])
|
|
||||||
|
|
||||||
if returncode != 0:
|
|
||||||
print(f"[FAIL] Search failed: {stderr}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
result = json.loads(stdout)
|
|
||||||
|
|
||||||
if not result["success"]:
|
|
||||||
print(f"[FAIL] Search unsuccessful: {result.get('error', 'Unknown error')}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
if len(result["matches"]) == 0:
|
|
||||||
print(f"[FAIL] Expected at least 1 match, got 0")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"[PASS] Search OK: {len(result['matches'])} matches found")
|
|
||||||
|
|
||||||
# Show top match
|
|
||||||
top_match = result["matches"][0]
|
|
||||||
print(f" Top match: {top_match['source_id']} (score: {top_match['score']})")
|
|
||||||
print(f" Content: {top_match['content'][:60]}...")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def test_source_filter(db_path):
|
|
||||||
"""Test search with source type filter."""
|
|
||||||
print("\nTesting source type filter...")
|
|
||||||
returncode, stdout, stderr = run_command([
|
|
||||||
"search", db_path, "authentication",
|
|
||||||
"--type", "workflow"
|
|
||||||
])
|
|
||||||
|
|
||||||
if returncode != 0:
|
|
||||||
print(f"[FAIL] Filtered search failed: {stderr}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
result = json.loads(stdout)
|
|
||||||
|
|
||||||
if not result["success"]:
|
|
||||||
print(f"[FAIL] Filtered search unsuccessful")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Verify all matches are workflow type
|
|
||||||
for match in result["matches"]:
|
|
||||||
if match["source_type"] != "workflow":
|
|
||||||
print(f"[FAIL] Expected workflow type, got {match['source_type']}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"[PASS] Filter OK: {len(result['matches'])} workflow matches")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Run all tests."""
|
|
||||||
print("Memory Embedder Test Suite")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Create test database
|
|
||||||
print("\nCreating test database...")
|
|
||||||
db_path = create_test_database()
|
|
||||||
print(f"[PASS] Database created: {db_path}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Run tests
|
|
||||||
tests = [
|
|
||||||
("Status", test_status),
|
|
||||||
("Embed", test_embed),
|
|
||||||
("Search", test_search),
|
|
||||||
("Source Filter", test_source_filter),
|
|
||||||
]
|
|
||||||
|
|
||||||
passed = 0
|
|
||||||
failed = 0
|
|
||||||
|
|
||||||
for name, test_func in tests:
|
|
||||||
try:
|
|
||||||
if test_func(db_path):
|
|
||||||
passed += 1
|
|
||||||
else:
|
|
||||||
failed += 1
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[FAIL] {name} crashed: {e}")
|
|
||||||
failed += 1
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print(f"Results: {passed} passed, {failed} failed")
|
|
||||||
|
|
||||||
if failed == 0:
|
|
||||||
print("[PASS] All tests passed!")
|
|
||||||
return 0
|
|
||||||
else:
|
|
||||||
print("[FAIL] Some tests failed")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
finally:
|
|
||||||
# Cleanup
|
|
||||||
import os
|
|
||||||
try:
|
|
||||||
os.unlink(db_path)
|
|
||||||
print(f"\n[PASS] Cleaned up test database")
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
exit(main())
|
|
||||||
@@ -1,473 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Unified Memory Embedder - Bridge CCW to CodexLens VectorStore (HNSW)
|
|
||||||
|
|
||||||
Uses CodexLens VectorStore for HNSW-indexed vector storage and search,
|
|
||||||
replacing full-table-scan cosine similarity with sub-10ms approximate
|
|
||||||
nearest neighbor lookups.
|
|
||||||
|
|
||||||
Protocol: JSON via stdin/stdout
|
|
||||||
Operations: embed, search, search_by_vector, status, reindex
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
echo '{"operation":"embed","store_path":"...","chunks":[...]}' | python unified_memory_embedder.py
|
|
||||||
echo '{"operation":"search","store_path":"...","query":"..."}' | python unified_memory_embedder.py
|
|
||||||
echo '{"operation":"status","store_path":"..."}' | python unified_memory_embedder.py
|
|
||||||
echo '{"operation":"reindex","store_path":"..."}' | python unified_memory_embedder.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
|
|
||||||
try:
|
|
||||||
import numpy as np
|
|
||||||
except ImportError:
|
|
||||||
print(json.dumps({
|
|
||||||
"success": False,
|
|
||||||
"error": "numpy is required. Install with: pip install numpy"
|
|
||||||
}))
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from codexlens.semantic.factory import get_embedder, clear_embedder_cache
|
|
||||||
from codexlens.semantic.vector_store import VectorStore
|
|
||||||
from codexlens.entities import SemanticChunk
|
|
||||||
except ImportError:
|
|
||||||
print(json.dumps({
|
|
||||||
"success": False,
|
|
||||||
"error": "CodexLens not found. Install with: pip install codex-lens[semantic]"
|
|
||||||
}))
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
# Valid category values for filtering
|
|
||||||
VALID_CATEGORIES = {"core_memory", "cli_history", "workflow", "entity", "pattern"}
|
|
||||||
|
|
||||||
|
|
||||||
class UnifiedMemoryEmbedder:
|
|
||||||
"""Unified embedder backed by CodexLens VectorStore (HNSW)."""
|
|
||||||
|
|
||||||
def __init__(self, store_path: str):
|
|
||||||
"""
|
|
||||||
Initialize with path to VectorStore database directory.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
store_path: Directory containing vectors.db and vectors.hnsw
|
|
||||||
"""
|
|
||||||
self.store_path = Path(store_path)
|
|
||||||
self.store_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
db_path = str(self.store_path / "vectors.db")
|
|
||||||
self.store = VectorStore(db_path)
|
|
||||||
|
|
||||||
# Lazy-load embedder to avoid ~0.8s model loading for status command
|
|
||||||
self._embedder = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def embedder(self):
|
|
||||||
"""Lazy-load the embedder on first access."""
|
|
||||||
if self._embedder is None:
|
|
||||||
self._embedder = get_embedder(
|
|
||||||
backend="fastembed",
|
|
||||||
profile="code",
|
|
||||||
use_gpu=True
|
|
||||||
)
|
|
||||||
return self._embedder
|
|
||||||
|
|
||||||
def embed(self, chunks: List[Dict[str, Any]], batch_size: int = 8) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Embed chunks and insert into VectorStore.
|
|
||||||
|
|
||||||
Each chunk dict must contain:
|
|
||||||
- content: str
|
|
||||||
- source_id: str
|
|
||||||
- source_type: str (e.g. "core_memory", "workflow", "cli_history")
|
|
||||||
- category: str (e.g. "core_memory", "cli_history", "workflow", "entity", "pattern")
|
|
||||||
|
|
||||||
Optional fields:
|
|
||||||
- chunk_index: int (default 0)
|
|
||||||
- metadata: dict (additional metadata)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
chunks: List of chunk dicts to embed
|
|
||||||
batch_size: Number of chunks to embed per batch
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Result dict with success, chunks_processed, chunks_failed, elapsed_time
|
|
||||||
"""
|
|
||||||
start_time = time.time()
|
|
||||||
chunks_processed = 0
|
|
||||||
chunks_failed = 0
|
|
||||||
|
|
||||||
if not chunks:
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"chunks_processed": 0,
|
|
||||||
"chunks_failed": 0,
|
|
||||||
"elapsed_time": 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
# Process in batches
|
|
||||||
for i in range(0, len(chunks), batch_size):
|
|
||||||
batch = chunks[i:i + batch_size]
|
|
||||||
texts = [c["content"] for c in batch]
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Batch embed
|
|
||||||
embeddings = self.embedder.embed_to_numpy(texts)
|
|
||||||
|
|
||||||
# Build SemanticChunks and insert
|
|
||||||
semantic_chunks = []
|
|
||||||
for j, chunk_data in enumerate(batch):
|
|
||||||
category = chunk_data.get("category", chunk_data.get("source_type", "core_memory"))
|
|
||||||
source_id = chunk_data.get("source_id", "")
|
|
||||||
chunk_index = chunk_data.get("chunk_index", 0)
|
|
||||||
extra_meta = chunk_data.get("metadata", {})
|
|
||||||
|
|
||||||
# Build metadata dict for VectorStore
|
|
||||||
metadata = {
|
|
||||||
"source_id": source_id,
|
|
||||||
"source_type": chunk_data.get("source_type", ""),
|
|
||||||
"chunk_index": chunk_index,
|
|
||||||
**extra_meta
|
|
||||||
}
|
|
||||||
|
|
||||||
sc = SemanticChunk(
|
|
||||||
content=chunk_data["content"],
|
|
||||||
embedding=embeddings[j].tolist(),
|
|
||||||
metadata=metadata
|
|
||||||
)
|
|
||||||
semantic_chunks.append((sc, source_id, category))
|
|
||||||
|
|
||||||
# Insert into VectorStore
|
|
||||||
for sc, file_path, category in semantic_chunks:
|
|
||||||
try:
|
|
||||||
self.store.add_chunk(sc, file_path=file_path, category=category)
|
|
||||||
chunks_processed += 1
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error inserting chunk: {e}", file=sys.stderr)
|
|
||||||
chunks_failed += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error embedding batch starting at {i}: {e}", file=sys.stderr)
|
|
||||||
chunks_failed += len(batch)
|
|
||||||
|
|
||||||
elapsed_time = time.time() - start_time
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": chunks_failed == 0,
|
|
||||||
"chunks_processed": chunks_processed,
|
|
||||||
"chunks_failed": chunks_failed,
|
|
||||||
"elapsed_time": round(elapsed_time, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
def search(
|
|
||||||
self,
|
|
||||||
query: str,
|
|
||||||
top_k: int = 10,
|
|
||||||
min_score: float = 0.3,
|
|
||||||
category: Optional[str] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Search VectorStore using HNSW index.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
query: Search query text
|
|
||||||
top_k: Number of results
|
|
||||||
min_score: Minimum similarity threshold
|
|
||||||
category: Optional category filter
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Result dict with success and matches list
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
# Generate query embedding (embed_to_numpy accepts single string)
|
|
||||||
query_emb = self.embedder.embed_to_numpy(query)[0].tolist()
|
|
||||||
|
|
||||||
# Search via VectorStore HNSW
|
|
||||||
results = self.store.search_similar(
|
|
||||||
query_emb,
|
|
||||||
top_k=top_k,
|
|
||||||
min_score=min_score,
|
|
||||||
category=category
|
|
||||||
)
|
|
||||||
|
|
||||||
elapsed_time = time.time() - start_time
|
|
||||||
|
|
||||||
matches = []
|
|
||||||
for result in results:
|
|
||||||
meta = result.metadata if result.metadata else {}
|
|
||||||
if isinstance(meta, str):
|
|
||||||
try:
|
|
||||||
meta = json.loads(meta)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
meta = {}
|
|
||||||
|
|
||||||
matches.append({
|
|
||||||
"content": result.content or result.excerpt or "",
|
|
||||||
"score": round(float(result.score), 4),
|
|
||||||
"source_id": meta.get("source_id", result.path or ""),
|
|
||||||
"source_type": meta.get("source_type", ""),
|
|
||||||
"chunk_index": meta.get("chunk_index", 0),
|
|
||||||
"category": meta.get("category", ""),
|
|
||||||
"metadata": meta
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"matches": matches,
|
|
||||||
"elapsed_time": round(elapsed_time, 3),
|
|
||||||
"total_searched": len(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"matches": [],
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def search_by_vector(
|
|
||||||
self,
|
|
||||||
vector: List[float],
|
|
||||||
top_k: int = 10,
|
|
||||||
min_score: float = 0.3,
|
|
||||||
category: Optional[str] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Search VectorStore using a pre-computed embedding vector (no re-embedding).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
vector: Pre-computed embedding vector (list of floats)
|
|
||||||
top_k: Number of results
|
|
||||||
min_score: Minimum similarity threshold
|
|
||||||
category: Optional category filter
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Result dict with success and matches list
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
# Search via VectorStore HNSW directly with provided vector
|
|
||||||
results = self.store.search_similar(
|
|
||||||
vector,
|
|
||||||
top_k=top_k,
|
|
||||||
min_score=min_score,
|
|
||||||
category=category
|
|
||||||
)
|
|
||||||
|
|
||||||
elapsed_time = time.time() - start_time
|
|
||||||
|
|
||||||
matches = []
|
|
||||||
for result in results:
|
|
||||||
meta = result.metadata if result.metadata else {}
|
|
||||||
if isinstance(meta, str):
|
|
||||||
try:
|
|
||||||
meta = json.loads(meta)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
meta = {}
|
|
||||||
|
|
||||||
matches.append({
|
|
||||||
"content": result.content or result.excerpt or "",
|
|
||||||
"score": round(float(result.score), 4),
|
|
||||||
"source_id": meta.get("source_id", result.path or ""),
|
|
||||||
"source_type": meta.get("source_type", ""),
|
|
||||||
"chunk_index": meta.get("chunk_index", 0),
|
|
||||||
"category": meta.get("category", ""),
|
|
||||||
"metadata": meta
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"matches": matches,
|
|
||||||
"elapsed_time": round(elapsed_time, 3),
|
|
||||||
"total_searched": len(results)
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"matches": [],
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def status(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Get VectorStore index status.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Status dict with total_chunks, hnsw_available, dimension, etc.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
total_chunks = self.store.count_chunks()
|
|
||||||
hnsw_available = self.store.ann_available
|
|
||||||
hnsw_count = self.store.ann_count
|
|
||||||
dimension = self.store.dimension or 768
|
|
||||||
|
|
||||||
# Count per category from SQLite
|
|
||||||
categories = {}
|
|
||||||
try:
|
|
||||||
import sqlite3
|
|
||||||
db_path = str(self.store_path / "vectors.db")
|
|
||||||
with sqlite3.connect(db_path) as conn:
|
|
||||||
rows = conn.execute(
|
|
||||||
"SELECT category, COUNT(*) FROM semantic_chunks GROUP BY category"
|
|
||||||
).fetchall()
|
|
||||||
for row in rows:
|
|
||||||
categories[row[0] or "unknown"] = row[1]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"total_chunks": total_chunks,
|
|
||||||
"hnsw_available": hnsw_available,
|
|
||||||
"hnsw_count": hnsw_count,
|
|
||||||
"dimension": dimension,
|
|
||||||
"categories": categories,
|
|
||||||
"model_config": {
|
|
||||||
"backend": "fastembed",
|
|
||||||
"profile": "code",
|
|
||||||
"dimension": 768,
|
|
||||||
"max_tokens": 8192
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"total_chunks": 0,
|
|
||||||
"hnsw_available": False,
|
|
||||||
"hnsw_count": 0,
|
|
||||||
"dimension": 0,
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
def reindex(self) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Rebuild HNSW index from scratch.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Result dict with success and timing
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
self.store.rebuild_ann_index()
|
|
||||||
|
|
||||||
elapsed_time = time.time() - start_time
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"hnsw_count": self.store.ann_count,
|
|
||||||
"elapsed_time": round(elapsed_time, 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"error": str(e)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main entry point. Reads JSON from stdin, writes JSON to stdout."""
|
|
||||||
try:
|
|
||||||
raw_input = sys.stdin.read()
|
|
||||||
if not raw_input.strip():
|
|
||||||
print(json.dumps({
|
|
||||||
"success": False,
|
|
||||||
"error": "No input provided. Send JSON via stdin."
|
|
||||||
}))
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
request = json.loads(raw_input)
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
print(json.dumps({
|
|
||||||
"success": False,
|
|
||||||
"error": f"Invalid JSON input: {e}"
|
|
||||||
}))
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
operation = request.get("operation")
|
|
||||||
store_path = request.get("store_path")
|
|
||||||
|
|
||||||
if not operation:
|
|
||||||
print(json.dumps({
|
|
||||||
"success": False,
|
|
||||||
"error": "Missing required field: operation"
|
|
||||||
}))
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if not store_path:
|
|
||||||
print(json.dumps({
|
|
||||||
"success": False,
|
|
||||||
"error": "Missing required field: store_path"
|
|
||||||
}))
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
try:
|
|
||||||
embedder = UnifiedMemoryEmbedder(store_path)
|
|
||||||
|
|
||||||
if operation == "embed":
|
|
||||||
chunks = request.get("chunks", [])
|
|
||||||
batch_size = request.get("batch_size", 8)
|
|
||||||
result = embedder.embed(chunks, batch_size=batch_size)
|
|
||||||
|
|
||||||
elif operation == "search":
|
|
||||||
query = request.get("query", "")
|
|
||||||
if not query:
|
|
||||||
result = {"success": False, "error": "Missing required field: query", "matches": []}
|
|
||||||
else:
|
|
||||||
top_k = request.get("top_k", 10)
|
|
||||||
min_score = request.get("min_score", 0.3)
|
|
||||||
category = request.get("category")
|
|
||||||
result = embedder.search(query, top_k=top_k, min_score=min_score, category=category)
|
|
||||||
|
|
||||||
elif operation == "search_by_vector":
|
|
||||||
vector = request.get("vector", [])
|
|
||||||
if not vector:
|
|
||||||
result = {"success": False, "error": "Missing required field: vector", "matches": []}
|
|
||||||
else:
|
|
||||||
top_k = request.get("top_k", 10)
|
|
||||||
min_score = request.get("min_score", 0.3)
|
|
||||||
category = request.get("category")
|
|
||||||
result = embedder.search_by_vector(vector, top_k=top_k, min_score=min_score, category=category)
|
|
||||||
|
|
||||||
elif operation == "status":
|
|
||||||
result = embedder.status()
|
|
||||||
|
|
||||||
elif operation == "reindex":
|
|
||||||
result = embedder.reindex()
|
|
||||||
|
|
||||||
else:
|
|
||||||
result = {
|
|
||||||
"success": False,
|
|
||||||
"error": f"Unknown operation: {operation}. Valid: embed, search, search_by_vector, status, reindex"
|
|
||||||
}
|
|
||||||
|
|
||||||
print(json.dumps(result))
|
|
||||||
|
|
||||||
# Clean up ONNX resources to ensure process can exit cleanly
|
|
||||||
clear_embedder_cache()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
try:
|
|
||||||
clear_embedder_cache()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
print(json.dumps({
|
|
||||||
"success": False,
|
|
||||||
"error": str(e)
|
|
||||||
}))
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -3,7 +3,6 @@ import { URL } from 'url';
|
|||||||
import { getCoreMemoryStore } from '../core-memory-store.js';
|
import { getCoreMemoryStore } from '../core-memory-store.js';
|
||||||
import type { CoreMemory, SessionCluster, ClusterMember, ClusterRelation } from '../core-memory-store.js';
|
import type { CoreMemory, SessionCluster, ClusterMember, ClusterRelation } from '../core-memory-store.js';
|
||||||
import { getEmbeddingStatus, generateEmbeddings } from '../memory-embedder-bridge.js';
|
import { getEmbeddingStatus, generateEmbeddings } from '../memory-embedder-bridge.js';
|
||||||
import { checkSemanticStatus } from '../../tools/codex-lens.js';
|
|
||||||
import { MemoryJobScheduler } from '../memory-job-scheduler.js';
|
import { MemoryJobScheduler } from '../memory-job-scheduler.js';
|
||||||
import type { JobStatus } from '../memory-job-scheduler.js';
|
import type { JobStatus } from '../memory-job-scheduler.js';
|
||||||
import { StoragePaths } from '../../config/storage-paths.js';
|
import { StoragePaths } from '../../config/storage-paths.js';
|
||||||
@@ -781,8 +780,8 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise<boolean
|
|||||||
const projectPath = url.searchParams.get('path') || initialPath;
|
const projectPath = url.searchParams.get('path') || initialPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check semantic status using CodexLens's check (same as status page)
|
// Semantic status: codexlens v1 removed, always unavailable via this path
|
||||||
const semanticStatus = await checkSemanticStatus();
|
const semanticStatus = { available: false, error: 'Use codexlens MCP server instead' };
|
||||||
|
|
||||||
if (!semanticStatus.available) {
|
if (!semanticStatus.available) {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
@@ -825,8 +824,7 @@ export async function handleCoreMemoryRoutes(ctx: RouteContext): Promise<boolean
|
|||||||
const basePath = projectPath || initialPath;
|
const basePath = projectPath || initialPath;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check semantic status using CodexLens's check
|
const semanticStatus = { available: false, error: 'Use codexlens MCP server instead' };
|
||||||
const semanticStatus = await checkSemanticStatus();
|
|
||||||
if (!semanticStatus.available) {
|
if (!semanticStatus.available) {
|
||||||
return { error: semanticStatus.error || 'Semantic search not available. Install it from CLI > CodexLens > Semantic page.', status: 503 };
|
return { error: semanticStatus.error || 'Semantic search not available. Install it from CLI > CodexLens > Semantic page.', status: 503 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1084,7 +1084,35 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|||||||
* Handle MCP routes
|
* Handle MCP routes
|
||||||
* @returns true if route was handled, false otherwise
|
* @returns true if route was handled, false otherwise
|
||||||
*/
|
*/
|
||||||
|
// Seed built-in MCP templates once
|
||||||
|
let _templateSeeded = false;
|
||||||
|
function seedBuiltinTemplates(): void {
|
||||||
|
if (_templateSeeded) return;
|
||||||
|
_templateSeeded = true;
|
||||||
|
try {
|
||||||
|
McpTemplatesDb.saveTemplate({
|
||||||
|
name: 'codexlens',
|
||||||
|
description: 'CodexLens semantic code search (vector + FTS + reranking)',
|
||||||
|
serverConfig: {
|
||||||
|
command: 'uvx',
|
||||||
|
args: ['--from', 'codexlens-search[mcp]', 'codexlens-mcp'],
|
||||||
|
env: {
|
||||||
|
CODEXLENS_EMBED_API_URL: '',
|
||||||
|
CODEXLENS_EMBED_API_KEY: '',
|
||||||
|
CODEXLENS_EMBED_API_MODEL: 'text-embedding-3-small',
|
||||||
|
CODEXLENS_EMBED_DIM: '1536',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
category: 'code-search',
|
||||||
|
tags: ['search', 'semantic', 'code-intelligence'],
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Template may already exist — ignore upsert errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function handleMcpRoutes(ctx: RouteContext): Promise<boolean> {
|
export async function handleMcpRoutes(ctx: RouteContext): Promise<boolean> {
|
||||||
|
seedBuiltinTemplates();
|
||||||
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
|
const { pathname, url, req, res, initialPath, handlePostRequest, broadcastToClients } = ctx;
|
||||||
|
|
||||||
// API: Get MCP configuration (includes both Claude and Codex)
|
// API: Get MCP configuration (includes both Claude and Codex)
|
||||||
@@ -1230,13 +1258,13 @@ export async function handleMcpRoutes(ctx: RouteContext): Promise<boolean> {
|
|||||||
const enabledToolsRaw = envInput.enabledTools;
|
const enabledToolsRaw = envInput.enabledTools;
|
||||||
let enabledToolsEnv: string;
|
let enabledToolsEnv: string;
|
||||||
if (enabledToolsRaw === undefined || enabledToolsRaw === null) {
|
if (enabledToolsRaw === undefined || enabledToolsRaw === null) {
|
||||||
enabledToolsEnv = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
|
enabledToolsEnv = 'write_file,edit_file,read_file,core_memory,ask_question';
|
||||||
} else if (Array.isArray(enabledToolsRaw)) {
|
} else if (Array.isArray(enabledToolsRaw)) {
|
||||||
enabledToolsEnv = enabledToolsRaw.filter((t): t is string => typeof t === 'string').join(',');
|
enabledToolsEnv = enabledToolsRaw.filter((t): t is string => typeof t === 'string').join(',');
|
||||||
} else if (typeof enabledToolsRaw === 'string') {
|
} else if (typeof enabledToolsRaw === 'string') {
|
||||||
enabledToolsEnv = enabledToolsRaw;
|
enabledToolsEnv = enabledToolsRaw;
|
||||||
} else {
|
} else {
|
||||||
enabledToolsEnv = 'write_file,edit_file,read_file,core_memory,ask_question,smart_search';
|
enabledToolsEnv = 'write_file,edit_file,read_file,core_memory,ask_question';
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectRoot = typeof envInput.projectRoot === 'string' ? envInput.projectRoot : undefined;
|
const projectRoot = typeof envInput.projectRoot === 'string' ? envInput.projectRoot : undefined;
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import {
|
|||||||
CallToolRequestSchema,
|
CallToolRequestSchema,
|
||||||
ListToolsRequestSchema,
|
ListToolsRequestSchema,
|
||||||
} from '@modelcontextprotocol/sdk/types.js';
|
} from '@modelcontextprotocol/sdk/types.js';
|
||||||
import { getAllToolSchemas, executeTool, executeToolWithProgress } from '../tools/index.js';
|
import { getAllToolSchemas, executeTool } from '../tools/index.js';
|
||||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
import type { ToolSchema } from '../types/tool.js';
|
||||||
import { getProjectRoot, getAllowedDirectories, isSandboxEnabled } from '../utils/path-validator.js';
|
import { getProjectRoot, getAllowedDirectories, isSandboxEnabled } from '../utils/path-validator.js';
|
||||||
|
|
||||||
const SERVER_NAME = 'ccw-tools';
|
const SERVER_NAME = 'ccw-tools';
|
||||||
@@ -23,7 +23,7 @@ const ENV_ALLOWED_DIRS = 'CCW_ALLOWED_DIRS';
|
|||||||
const STDIO_DISCONNECT_ERROR_CODES = new Set(['EPIPE', 'ERR_STREAM_DESTROYED']);
|
const STDIO_DISCONNECT_ERROR_CODES = new Set(['EPIPE', 'ERR_STREAM_DESTROYED']);
|
||||||
|
|
||||||
// Default enabled tools (core set - file operations, core memory, and smart search)
|
// Default enabled tools (core set - file operations, core memory, and smart search)
|
||||||
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'read_many_files', 'read_outline', 'core_memory', 'smart_search'];
|
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'read_many_files', 'read_outline', 'core_memory'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get list of enabled tools from environment or defaults
|
* Get list of enabled tools from environment or defaults
|
||||||
@@ -151,19 +151,7 @@ function createServer(): Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For smart_search init action, use progress-aware execution
|
const result = await executeTool(name, args || {});
|
||||||
const isInitAction = name === 'smart_search' && args?.action === 'init';
|
|
||||||
|
|
||||||
let result: ToolResult;
|
|
||||||
if (isInitAction) {
|
|
||||||
// Execute with progress callback that writes to stderr
|
|
||||||
result = await executeToolWithProgress(name, args || {}, (progress) => {
|
|
||||||
// Output progress to stderr (visible in terminal, doesn't interfere with JSON-RPC)
|
|
||||||
console.error(`[Progress] ${progress.percent}% - ${progress.message}`);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
result = await executeTool(name, args || {});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,213 +0,0 @@
|
|||||||
/**
|
|
||||||
* CodexLens Tool - STUB (v1 removed)
|
|
||||||
*
|
|
||||||
* The v1 Python bridge has been removed. This module provides no-op stubs
|
|
||||||
* so that existing consumers compile without errors.
|
|
||||||
* Semantic search is now handled entirely by codexlens-search v2.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Types (kept for backward compatibility)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface ReadyStatus {
|
|
||||||
ready: boolean;
|
|
||||||
installed: boolean;
|
|
||||||
error?: string;
|
|
||||||
version?: string;
|
|
||||||
pythonVersion?: string;
|
|
||||||
venvPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SemanticStatus {
|
|
||||||
available: boolean;
|
|
||||||
backend?: string;
|
|
||||||
accelerator?: string;
|
|
||||||
providers?: string[];
|
|
||||||
litellmAvailable?: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BootstrapResult {
|
|
||||||
success: boolean;
|
|
||||||
message?: string;
|
|
||||||
error?: string;
|
|
||||||
details?: {
|
|
||||||
pythonVersion?: string;
|
|
||||||
venvPath?: string;
|
|
||||||
packagePath?: string;
|
|
||||||
installer?: 'uv' | 'pip';
|
|
||||||
editable?: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExecuteResult {
|
|
||||||
success: boolean;
|
|
||||||
output?: string;
|
|
||||||
error?: string;
|
|
||||||
message?: string;
|
|
||||||
warning?: string;
|
|
||||||
results?: unknown;
|
|
||||||
files?: unknown;
|
|
||||||
symbols?: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ExecuteOptions {
|
|
||||||
timeout?: number;
|
|
||||||
cwd?: string;
|
|
||||||
onProgress?: (progress: ProgressInfo) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProgressInfo {
|
|
||||||
stage: string;
|
|
||||||
message: string;
|
|
||||||
percent: number;
|
|
||||||
filesProcessed?: number;
|
|
||||||
totalFiles?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type GpuMode = 'cpu' | 'cuda' | 'directml';
|
|
||||||
|
|
||||||
interface PythonEnvInfo {
|
|
||||||
version: string;
|
|
||||||
majorMinor: string;
|
|
||||||
architecture: number;
|
|
||||||
compatible: boolean;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// No-op implementations
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const V1_REMOVED = 'CodexLens v1 has been removed. Use codexlens-search v2.';
|
|
||||||
|
|
||||||
async function ensureReady(): Promise<ReadyStatus> {
|
|
||||||
return { ready: false, installed: false, error: V1_REMOVED };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function executeCodexLens(_args: string[], _options: ExecuteOptions = {}): Promise<ExecuteResult> {
|
|
||||||
return { success: false, error: V1_REMOVED };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkVenvStatus(_force?: boolean): Promise<ReadyStatus> {
|
|
||||||
return { ready: false, installed: false, error: V1_REMOVED };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrapVenv(): Promise<BootstrapResult> {
|
|
||||||
return { success: false, error: V1_REMOVED };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkSemanticStatus(_force?: boolean): Promise<SemanticStatus> {
|
|
||||||
return { available: false, error: V1_REMOVED };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureLiteLLMEmbedderReady(): Promise<BootstrapResult> {
|
|
||||||
return { success: false, error: V1_REMOVED };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installSemantic(_gpuMode: GpuMode = 'cpu'): Promise<BootstrapResult> {
|
|
||||||
return { success: false, error: V1_REMOVED };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function detectGpuSupport(): Promise<{ mode: GpuMode; available: GpuMode[]; info: string; pythonEnv?: PythonEnvInfo }> {
|
|
||||||
return { mode: 'cpu', available: ['cpu'], info: V1_REMOVED };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uninstallCodexLens(): Promise<BootstrapResult> {
|
|
||||||
return { success: false, error: V1_REMOVED };
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelIndexing(): { success: boolean; message?: string; error?: string } {
|
|
||||||
return { success: false, error: V1_REMOVED };
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIndexingInProgress(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrapWithUv(_gpuMode: GpuMode = 'cpu'): Promise<BootstrapResult> {
|
|
||||||
return { success: false, error: V1_REMOVED };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function installSemanticWithUv(_gpuMode: GpuMode = 'cpu'): Promise<BootstrapResult> {
|
|
||||||
return { success: false, error: V1_REMOVED };
|
|
||||||
}
|
|
||||||
|
|
||||||
function useCodexLensV2(): boolean {
|
|
||||||
return true; // v2 is now the only option
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCodexLensV2Installed(): boolean {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bootstrapV2WithUv(): Promise<BootstrapResult> {
|
|
||||||
return { success: false, error: V1_REMOVED };
|
|
||||||
}
|
|
||||||
|
|
||||||
function getVenvPythonPath(): string {
|
|
||||||
return 'python';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tool schema / handler (no-op)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const schema: ToolSchema = {
|
|
||||||
name: 'codex_lens',
|
|
||||||
description: '[REMOVED] CodexLens v1 tool has been removed. Use smart_search instead.',
|
|
||||||
inputSchema: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
action: { type: 'string', description: 'Action (v1 removed)' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function handler(_params: Record<string, unknown>): Promise<ToolResult<ExecuteResult>> {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: V1_REMOVED,
|
|
||||||
result: { success: false, error: V1_REMOVED },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Exports
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type { ProgressInfo, ExecuteOptions, GpuMode, PythonEnvInfo };
|
|
||||||
|
|
||||||
export {
|
|
||||||
ensureReady,
|
|
||||||
executeCodexLens,
|
|
||||||
checkVenvStatus,
|
|
||||||
bootstrapVenv,
|
|
||||||
checkSemanticStatus,
|
|
||||||
ensureLiteLLMEmbedderReady,
|
|
||||||
installSemantic,
|
|
||||||
detectGpuSupport,
|
|
||||||
uninstallCodexLens,
|
|
||||||
cancelIndexing,
|
|
||||||
isIndexingInProgress,
|
|
||||||
bootstrapWithUv,
|
|
||||||
installSemanticWithUv,
|
|
||||||
useCodexLensV2,
|
|
||||||
isCodexLensV2Installed,
|
|
||||||
bootstrapV2WithUv,
|
|
||||||
getVenvPythonPath,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const __testables = {};
|
|
||||||
|
|
||||||
export const codexLensTool = {
|
|
||||||
name: schema.name,
|
|
||||||
description: schema.description,
|
|
||||||
parameters: schema.inputSchema,
|
|
||||||
execute: async (_params: Record<string, unknown>) => {
|
|
||||||
return { success: false, error: V1_REMOVED };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -18,10 +18,7 @@ import * as generateDddDocsMod from './generate-ddd-docs.js';
|
|||||||
import * as convertTokensToCssMod from './convert-tokens-to-css.js';
|
import * as convertTokensToCssMod from './convert-tokens-to-css.js';
|
||||||
import * as sessionManagerMod from './session-manager.js';
|
import * as sessionManagerMod from './session-manager.js';
|
||||||
import * as cliExecutorMod from './cli-executor.js';
|
import * as cliExecutorMod from './cli-executor.js';
|
||||||
import * as smartSearchMod from './smart-search.js';
|
// codex_lens / smart_search removed - use codexlens MCP server instead
|
||||||
import { executeInitWithProgress } from './smart-search.js';
|
|
||||||
// codex_lens removed - functionality integrated into smart_search
|
|
||||||
// codex_lens_lsp removed - v1 LSP bridge removed
|
|
||||||
import * as readFileMod from './read-file.js';
|
import * as readFileMod from './read-file.js';
|
||||||
import * as readManyFilesMod from './read-many-files.js';
|
import * as readManyFilesMod from './read-many-files.js';
|
||||||
import * as readOutlineMod from './read-outline.js';
|
import * as readOutlineMod from './read-outline.js';
|
||||||
@@ -30,7 +27,7 @@ import * as contextCacheMod from './context-cache.js';
|
|||||||
import * as skillContextLoaderMod from './skill-context-loader.js';
|
import * as skillContextLoaderMod from './skill-context-loader.js';
|
||||||
import * as askQuestionMod from './ask-question.js';
|
import * as askQuestionMod from './ask-question.js';
|
||||||
import * as teamMsgMod from './team-msg.js';
|
import * as teamMsgMod from './team-msg.js';
|
||||||
import type { ProgressInfo } from './codex-lens.js';
|
|
||||||
|
|
||||||
// Import legacy JS tools
|
// Import legacy JS tools
|
||||||
import { uiGeneratePreviewTool } from './ui-generate-preview.js';
|
import { uiGeneratePreviewTool } from './ui-generate-preview.js';
|
||||||
@@ -272,60 +269,6 @@ function sanitizeResult(result: unknown): unknown {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a tool with progress callback (for init actions)
|
|
||||||
*/
|
|
||||||
export async function executeToolWithProgress(
|
|
||||||
name: string,
|
|
||||||
params: Record<string, unknown> = {},
|
|
||||||
onProgress?: (progress: ProgressInfo) => void
|
|
||||||
): Promise<{
|
|
||||||
success: boolean;
|
|
||||||
result?: unknown;
|
|
||||||
error?: string;
|
|
||||||
}> {
|
|
||||||
// For smart_search init, use special progress-aware execution
|
|
||||||
if (name === 'smart_search' && params.action === 'init') {
|
|
||||||
try {
|
|
||||||
// Notify dashboard - execution started
|
|
||||||
notifyDashboard({
|
|
||||||
toolName: name,
|
|
||||||
status: 'started',
|
|
||||||
params: sanitizeParams(params)
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await executeInitWithProgress(params, onProgress);
|
|
||||||
|
|
||||||
// Notify dashboard - execution completed
|
|
||||||
notifyDashboard({
|
|
||||||
toolName: name,
|
|
||||||
status: 'completed',
|
|
||||||
result: sanitizeResult(result)
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: result.success,
|
|
||||||
result,
|
|
||||||
error: result.error
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
notifyDashboard({
|
|
||||||
toolName: name,
|
|
||||||
status: 'failed',
|
|
||||||
error: (error as Error).message || 'Tool execution failed'
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: (error as Error).message || 'Tool execution failed'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to regular execution for other tools
|
|
||||||
return executeTool(name, params);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get tool schema in MCP-compatible format
|
* Get tool schema in MCP-compatible format
|
||||||
*/
|
*/
|
||||||
@@ -363,9 +306,7 @@ registerTool(toLegacyTool(generateDddDocsMod));
|
|||||||
registerTool(toLegacyTool(convertTokensToCssMod));
|
registerTool(toLegacyTool(convertTokensToCssMod));
|
||||||
registerTool(toLegacyTool(sessionManagerMod));
|
registerTool(toLegacyTool(sessionManagerMod));
|
||||||
registerTool(toLegacyTool(cliExecutorMod));
|
registerTool(toLegacyTool(cliExecutorMod));
|
||||||
registerTool(toLegacyTool(smartSearchMod));
|
// codex_lens / smart_search removed - use codexlens MCP server instead
|
||||||
// codex_lens removed - functionality integrated into smart_search
|
|
||||||
// codex_lens_lsp removed - v1 LSP bridge removed
|
|
||||||
registerTool(toLegacyTool(readFileMod));
|
registerTool(toLegacyTool(readFileMod));
|
||||||
registerTool(toLegacyTool(readManyFilesMod));
|
registerTool(toLegacyTool(readManyFilesMod));
|
||||||
registerTool(toLegacyTool(readOutlineMod));
|
registerTool(toLegacyTool(readOutlineMod));
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
* Auto-generates contextual file references for CLI execution
|
* Auto-generates contextual file references for CLI execution
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { executeCodexLens, ensureReady as ensureCodexLensReady } from './codex-lens.js';
|
// codex-lens v1 removed — no-op stubs for backward compatibility
|
||||||
|
async function ensureCodexLensReady(): Promise<{ ready: boolean }> { return { ready: false }; }
|
||||||
|
async function executeCodexLens(_args: string[], _opts?: { cwd?: string }): Promise<{ success: boolean; output?: string }> { return { success: false }; }
|
||||||
|
|
||||||
// Options for smart context generation
|
// Options for smart context generation
|
||||||
export interface SmartContextOptions {
|
export interface SmartContextOptions {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,93 +0,0 @@
|
|||||||
/**
|
|
||||||
* Regression test: CodexLens bootstrap falls back to pip when UV bootstrap fails.
|
|
||||||
*
|
|
||||||
* We simulate a "broken UV" by pointing CCW_UV_PATH to the current Node executable.
|
|
||||||
* `node --version` exits 0 so isUvAvailable() returns true, but `node venv ...` fails,
|
|
||||||
* forcing the bootstrap code to try the pip path.
|
|
||||||
*
|
|
||||||
* This test runs bootstrapVenv in a child process to avoid mutating process-wide
|
|
||||||
* environment variables that could affect other tests.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// repo root: <repo>/ccw/tests -> <repo>
|
|
||||||
const REPO_ROOT = join(__dirname, '..', '..');
|
|
||||||
|
|
||||||
function runNodeEvalModule(script, env) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
|
|
||||||
cwd: REPO_ROOT,
|
|
||||||
env,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
windowsHide: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
let stdout = '';
|
|
||||||
let stderr = '';
|
|
||||||
|
|
||||||
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
||||||
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
||||||
|
|
||||||
child.on('error', (err) => reject(err));
|
|
||||||
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('CodexLens bootstrap fallback', () => {
|
|
||||||
it('falls back to pip when UV bootstrap fails', { timeout: 10 * 60 * 1000 }, async () => {
|
|
||||||
const dataDir = mkdtempSync(join(tmpdir(), 'codexlens-bootstrap-fallback-'));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const script = `
|
|
||||||
import { bootstrapVenv } from './ccw/dist/tools/codex-lens.js';
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const result = await bootstrapVenv();
|
|
||||||
console.log('@@RESULT@@' + JSON.stringify(result));
|
|
||||||
})().catch((e) => {
|
|
||||||
console.error(e?.stack || String(e));
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
`;
|
|
||||||
|
|
||||||
const env = {
|
|
||||||
...process.env,
|
|
||||||
// Isolate test venv + dependencies from user/global CodexLens state.
|
|
||||||
CODEXLENS_DATA_DIR: dataDir,
|
|
||||||
// Make isUvAvailable() return true, but createVenv() fail.
|
|
||||||
CCW_UV_PATH: process.execPath,
|
|
||||||
};
|
|
||||||
|
|
||||||
const { code, stdout, stderr } = await runNodeEvalModule(script, env);
|
|
||||||
assert.equal(code, 0, `bootstrapVenv child process failed:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`);
|
|
||||||
|
|
||||||
const marker = '@@RESULT@@';
|
|
||||||
const idx = stdout.lastIndexOf(marker);
|
|
||||||
assert.ok(idx !== -1, `Missing result marker in stdout:\n${stdout}`);
|
|
||||||
|
|
||||||
const jsonText = stdout.slice(idx + marker.length).trim();
|
|
||||||
const parsed = JSON.parse(jsonText);
|
|
||||||
|
|
||||||
assert.equal(parsed?.success, true, `Expected success=true, got:\n${jsonText}`);
|
|
||||||
assert.ok(Array.isArray(parsed.warnings), 'Expected warnings array on pip fallback result');
|
|
||||||
assert.ok(parsed.warnings.some((w) => String(w).includes('UV bootstrap failed')), `Expected UV failure warning, got: ${JSON.stringify(parsed.warnings)}`);
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
rmSync(dataDir, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// Best effort cleanup; leave artifacts only if Windows locks prevent removal.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { after, describe, it } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
|
|
||||||
const tempDirs = [];
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
for (const dir of tempDirs) {
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CodexLens CLI compatibility retries', () => {
|
|
||||||
it('builds hidden Python spawn options for CLI invocations', async () => {
|
|
||||||
const moduleUrl = new URL(`../dist/tools/codex-lens.js?spawn-opts=${Date.now()}`, import.meta.url).href;
|
|
||||||
const { __testables } = await import(moduleUrl);
|
|
||||||
|
|
||||||
const options = __testables.buildCodexLensSpawnOptions(tmpdir(), 12345);
|
|
||||||
|
|
||||||
assert.equal(options.cwd, tmpdir());
|
|
||||||
assert.equal(options.shell, false);
|
|
||||||
assert.equal(options.timeout, 12345);
|
|
||||||
assert.equal(options.windowsHide, true);
|
|
||||||
assert.equal(options.env.PYTHONIOENCODING, 'utf-8');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('probes Python version without a shell-backed console window', async () => {
|
|
||||||
const moduleUrl = new URL(`../dist/tools/codex-lens.js?python-probe=${Date.now()}`, import.meta.url).href;
|
|
||||||
const { __testables } = await import(moduleUrl);
|
|
||||||
const probeCalls = [];
|
|
||||||
|
|
||||||
const version = __testables.probePythonVersion({ command: 'python', args: [], display: 'python' }, (command, args, options) => {
|
|
||||||
probeCalls.push({ command, args, options });
|
|
||||||
return { status: 0, stdout: '', stderr: 'Python 3.11.9\n' };
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(version, 'Python 3.11.9');
|
|
||||||
assert.equal(probeCalls.length, 1);
|
|
||||||
assert.equal(probeCalls[0].command, 'python');
|
|
||||||
assert.deepEqual(probeCalls[0].args, ['--version']);
|
|
||||||
assert.equal(probeCalls[0].options.shell, false);
|
|
||||||
assert.equal(probeCalls[0].options.windowsHide, true);
|
|
||||||
assert.equal(probeCalls[0].options.env.PYTHONIOENCODING, 'utf-8');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('initializes a tiny index even when CLI emits compatibility conflicts first', async () => {
|
|
||||||
const moduleUrl = new URL(`../dist/tools/codex-lens.js?compat=${Date.now()}`, import.meta.url).href;
|
|
||||||
const { checkVenvStatus, executeCodexLens } = await import(moduleUrl);
|
|
||||||
|
|
||||||
const ready = await checkVenvStatus(true);
|
|
||||||
if (!ready.ready) {
|
|
||||||
console.log('Skipping: CodexLens not ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectDir = mkdtempSync(join(tmpdir(), 'codexlens-init-'));
|
|
||||||
tempDirs.push(projectDir);
|
|
||||||
writeFileSync(join(projectDir, 'sample.ts'), 'export const sample = 1;\n');
|
|
||||||
|
|
||||||
const result = await executeCodexLens(['index', 'init', projectDir, '--force'], { timeout: 600000 });
|
|
||||||
|
|
||||||
assert.equal(result.success, true, result.error ?? 'Expected init to succeed');
|
|
||||||
assert.ok((result.output ?? '').length > 0 || (result.warning ?? '').length > 0, 'Expected init output or compatibility warning');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('synthesizes a machine-readable fallback when JSON search output is empty', async () => {
|
|
||||||
const moduleUrl = new URL(`../dist/tools/codex-lens.js?compat-empty=${Date.now()}`, import.meta.url).href;
|
|
||||||
const { __testables } = await import(moduleUrl);
|
|
||||||
|
|
||||||
const normalized = __testables.normalizeSearchCommandResult(
|
|
||||||
{ success: true },
|
|
||||||
{ query: 'missing symbol', cwd: tmpdir(), limit: 5, filesOnly: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(normalized.success, true);
|
|
||||||
assert.match(normalized.warning ?? '', /empty stdout/i);
|
|
||||||
assert.deepEqual(normalized.results, {
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
query: 'missing symbol',
|
|
||||||
count: 0,
|
|
||||||
results: [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns structured semantic search results for a local embedded workspace', async () => {
|
|
||||||
const codexLensUrl = new URL(`../dist/tools/codex-lens.js?compat-search=${Date.now()}`, import.meta.url).href;
|
|
||||||
const smartSearchUrl = new URL(`../dist/tools/smart-search.js?compat-search=${Date.now()}`, import.meta.url).href;
|
|
||||||
const codexLensModule = await import(codexLensUrl);
|
|
||||||
const smartSearchModule = await import(smartSearchUrl);
|
|
||||||
|
|
||||||
const ready = await codexLensModule.checkVenvStatus(true);
|
|
||||||
if (!ready.ready) {
|
|
||||||
console.log('Skipping: CodexLens not ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const semantic = await codexLensModule.checkSemanticStatus();
|
|
||||||
if (!semantic.available) {
|
|
||||||
console.log('Skipping: semantic dependencies not ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectDir = mkdtempSync(join(tmpdir(), 'codexlens-search-'));
|
|
||||||
tempDirs.push(projectDir);
|
|
||||||
writeFileSync(
|
|
||||||
join(projectDir, 'sample.ts'),
|
|
||||||
'export function greet(name) { return `hello ${name}`; }\nexport const sum = (a, b) => a + b;\n',
|
|
||||||
);
|
|
||||||
|
|
||||||
const init = await smartSearchModule.handler({ action: 'init', path: projectDir });
|
|
||||||
assert.equal(init.success, true, init.error ?? 'Expected smart-search init to succeed');
|
|
||||||
|
|
||||||
const embed = await smartSearchModule.handler({
|
|
||||||
action: 'embed',
|
|
||||||
path: projectDir,
|
|
||||||
embeddingBackend: 'local',
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
assert.equal(embed.success, true, embed.error ?? 'Expected smart-search embed to succeed');
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'search',
|
|
||||||
path: projectDir,
|
|
||||||
query: 'greet function',
|
|
||||||
mode: 'semantic',
|
|
||||||
format: 'json',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(result.success, true, result.error ?? 'Expected semantic search compatibility fallback to succeed');
|
|
||||||
const payload = result.results?.result ?? result.results;
|
|
||||||
assert.ok(Array.isArray(payload?.results), 'Expected structured search results payload');
|
|
||||||
assert.ok(payload.results.length > 0, 'Expected at least one structured semantic search result');
|
|
||||||
assert.doesNotMatch(result.error ?? '', /unexpected extra arguments/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,485 +0,0 @@
|
|||||||
/**
|
|
||||||
* Integration Tests for CodexLens with actual file operations
|
|
||||||
*
|
|
||||||
* These tests create temporary files and directories to test
|
|
||||||
* the full indexing and search workflow.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, before, after } from 'node:test';
|
|
||||||
import assert from 'node:assert';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { existsSync, mkdirSync, rmSync, writeFileSync, readdirSync } from 'fs';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// Import the codex-lens module
|
|
||||||
const codexLensPath = new URL('../dist/tools/codex-lens.js', import.meta.url).href;
|
|
||||||
|
|
||||||
describe('CodexLens Full Integration Tests', async () => {
|
|
||||||
let codexLensModule;
|
|
||||||
let testDir;
|
|
||||||
let isReady = false;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
try {
|
|
||||||
codexLensModule = await import(codexLensPath);
|
|
||||||
|
|
||||||
// Check if CodexLens is installed
|
|
||||||
const status = await codexLensModule.checkVenvStatus();
|
|
||||||
isReady = status.ready;
|
|
||||||
|
|
||||||
if (!isReady) {
|
|
||||||
console.log('CodexLens not installed - some integration tests will be skipped');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create temporary test directory
|
|
||||||
testDir = join(tmpdir(), `codexlens-test-${Date.now()}`);
|
|
||||||
mkdirSync(testDir, { recursive: true });
|
|
||||||
|
|
||||||
// Create test Python files
|
|
||||||
writeFileSync(join(testDir, 'main.py'), `
|
|
||||||
"""Main module for testing."""
|
|
||||||
|
|
||||||
def hello_world():
|
|
||||||
"""Say hello to the world."""
|
|
||||||
print("Hello, World!")
|
|
||||||
return "hello"
|
|
||||||
|
|
||||||
def calculate_sum(a, b):
|
|
||||||
"""Calculate sum of two numbers."""
|
|
||||||
return a + b
|
|
||||||
|
|
||||||
class Calculator:
|
|
||||||
"""A simple calculator class."""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
self.result = 0
|
|
||||||
|
|
||||||
def add(self, value):
|
|
||||||
"""Add value to result."""
|
|
||||||
self.result += value
|
|
||||||
return self.result
|
|
||||||
|
|
||||||
def subtract(self, value):
|
|
||||||
"""Subtract value from result."""
|
|
||||||
self.result -= value
|
|
||||||
return self.result
|
|
||||||
`);
|
|
||||||
|
|
||||||
writeFileSync(join(testDir, 'utils.py'), `
|
|
||||||
"""Utility functions."""
|
|
||||||
|
|
||||||
def format_string(text):
|
|
||||||
"""Format a string."""
|
|
||||||
return text.strip().lower()
|
|
||||||
|
|
||||||
def validate_email(email):
|
|
||||||
"""Validate email format."""
|
|
||||||
return "@" in email and "." in email
|
|
||||||
|
|
||||||
async def fetch_data(url):
|
|
||||||
"""Fetch data from URL (async)."""
|
|
||||||
pass
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Create test JavaScript file
|
|
||||||
writeFileSync(join(testDir, 'app.js'), `
|
|
||||||
/**
|
|
||||||
* Main application module
|
|
||||||
*/
|
|
||||||
|
|
||||||
function initApp() {
|
|
||||||
console.log('App initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const processData = async (data) => {
|
|
||||||
return data.map(item => item.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
class Application {
|
|
||||||
constructor(name) {
|
|
||||||
this.name = name;
|
|
||||||
}
|
|
||||||
|
|
||||||
start() {
|
|
||||||
console.log(\`Starting \${this.name}\`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { initApp, processData, Application };
|
|
||||||
`);
|
|
||||||
|
|
||||||
console.log(`Test directory created at: ${testDir}`);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Setup failed:', err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
after(async () => {
|
|
||||||
// Cleanup test directory
|
|
||||||
if (testDir && existsSync(testDir)) {
|
|
||||||
try {
|
|
||||||
rmSync(testDir, { recursive: true, force: true });
|
|
||||||
console.log('Test directory cleaned up');
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Cleanup failed:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Index Initialization', () => {
|
|
||||||
it('should initialize index for test directory', async () => {
|
|
||||||
if (!isReady || !testDir) {
|
|
||||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'init',
|
|
||||||
path: testDir
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
assert.ok('success' in result, 'Result should have success property');
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// CodexLens stores indexes in the global data directory (e.g. ~/.codexlens/indexes)
|
|
||||||
// rather than creating a per-project ".codexlens" folder.
|
|
||||||
assert.ok(true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create index.db file', async () => {
|
|
||||||
if (!isReady || !testDir) {
|
|
||||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const indexDb = join(testDir, '.codexlens', 'index.db');
|
|
||||||
|
|
||||||
// May need to wait for previous init to complete
|
|
||||||
// Index.db should exist after successful init
|
|
||||||
if (existsSync(join(testDir, '.codexlens'))) {
|
|
||||||
// Check files in .codexlens directory
|
|
||||||
const files = readdirSync(join(testDir, '.codexlens'));
|
|
||||||
console.log('.codexlens contents:', files);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Status Query', () => {
|
|
||||||
it('should return index status for test directory', async () => {
|
|
||||||
if (!isReady || !testDir) {
|
|
||||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'status',
|
|
||||||
path: testDir
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
console.log('Index status:', JSON.stringify(result, null, 2));
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Navigate nested structure: result.status.result or result.result
|
|
||||||
const statusData = result.status?.result || result.result || result.status || result;
|
|
||||||
const hasIndexInfo = (
|
|
||||||
'files' in statusData ||
|
|
||||||
'db_path' in statusData ||
|
|
||||||
result.output ||
|
|
||||||
(result.status && 'success' in result.status)
|
|
||||||
);
|
|
||||||
assert.ok(hasIndexInfo, 'Status should contain index information or raw output');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Symbol Extraction', () => {
|
|
||||||
it('should extract symbols from Python file', async () => {
|
|
||||||
if (!isReady || !testDir) {
|
|
||||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'symbol',
|
|
||||||
file: join(testDir, 'main.py')
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
console.log('Symbols found:', result.symbols || result.output);
|
|
||||||
|
|
||||||
// Parse output if needed
|
|
||||||
let symbols = result.symbols;
|
|
||||||
if (!symbols && result.output) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
symbols = parsed.result?.file?.symbols || parsed.symbols;
|
|
||||||
} catch {
|
|
||||||
// Keep raw output
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (symbols && Array.isArray(symbols)) {
|
|
||||||
// Check for expected symbols
|
|
||||||
const symbolNames = symbols.map(s => s.name);
|
|
||||||
assert.ok(symbolNames.includes('hello_world') || symbolNames.some(n => n.includes('hello')),
|
|
||||||
'Should find hello_world function');
|
|
||||||
assert.ok(symbolNames.includes('Calculator') || symbolNames.some(n => n.includes('Calc')),
|
|
||||||
'Should find Calculator class');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should extract symbols from JavaScript file', async () => {
|
|
||||||
if (!isReady || !testDir) {
|
|
||||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'symbol',
|
|
||||||
file: join(testDir, 'app.js')
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
console.log('JS Symbols found:', result.symbols || result.output);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Full-Text Search', () => {
|
|
||||||
it('should search for text in indexed files', async () => {
|
|
||||||
if (!isReady || !testDir) {
|
|
||||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First ensure index is initialized
|
|
||||||
await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'init',
|
|
||||||
path: testDir
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'search',
|
|
||||||
query: 'hello',
|
|
||||||
path: testDir,
|
|
||||||
limit: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
console.log('Search results:', result.results || result.output);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should search for class names', async () => {
|
|
||||||
if (!isReady || !testDir) {
|
|
||||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'search',
|
|
||||||
query: 'Calculator',
|
|
||||||
path: testDir,
|
|
||||||
limit: 10
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
console.log('Class search results:', result.results || result.output);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Incremental Update', () => {
|
|
||||||
it('should update index when file changes', async () => {
|
|
||||||
if (!isReady || !testDir) {
|
|
||||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a new file
|
|
||||||
const newFile = join(testDir, 'new_module.py');
|
|
||||||
writeFileSync(newFile, `
|
|
||||||
def new_function():
|
|
||||||
"""A newly added function."""
|
|
||||||
return "new"
|
|
||||||
`);
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'update',
|
|
||||||
files: [newFile],
|
|
||||||
path: testDir
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
console.log('Update result:', result.updateResult || result.output);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle deleted files in update', async () => {
|
|
||||||
if (!isReady || !testDir) {
|
|
||||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reference a non-existent file
|
|
||||||
const deletedFile = join(testDir, 'deleted_file.py');
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'update',
|
|
||||||
files: [deletedFile],
|
|
||||||
path: testDir
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
// Should handle gracefully without crashing
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CodexLens CLI Commands via executeCodexLens', async () => {
|
|
||||||
let codexLensModule;
|
|
||||||
let isReady = false;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
try {
|
|
||||||
codexLensModule = await import(codexLensPath);
|
|
||||||
const status = await codexLensModule.checkVenvStatus();
|
|
||||||
isReady = status.ready;
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Setup failed:', err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should execute --version command', async () => {
|
|
||||||
if (!isReady) {
|
|
||||||
console.log('Skipping: CodexLens not ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: codexlens may not have --version, use --help instead
|
|
||||||
const result = await codexLensModule.executeCodexLens(['--help']);
|
|
||||||
assert.ok(typeof result === 'object');
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
assert.ok(result.output, 'Should have output');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should execute status --json command', async () => {
|
|
||||||
if (!isReady) {
|
|
||||||
console.log('Skipping: CodexLens not ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await codexLensModule.executeCodexLens(['status', '--json'], {
|
|
||||||
cwd: __dirname
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(typeof result === 'object');
|
|
||||||
|
|
||||||
if (result.success && result.output) {
|
|
||||||
// Try to parse JSON output
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(result.output);
|
|
||||||
assert.ok(typeof parsed === 'object', 'Output should be valid JSON');
|
|
||||||
} catch {
|
|
||||||
// Output might not be JSON if index doesn't exist
|
|
||||||
console.log('Status output (non-JSON):', result.output);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle inspect command', async () => {
|
|
||||||
if (!isReady) {
|
|
||||||
console.log('Skipping: CodexLens not ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use this test file as input
|
|
||||||
const testFile = join(__dirname, 'codex-lens.test.js');
|
|
||||||
if (!existsSync(testFile)) {
|
|
||||||
console.log('Skipping: Test file not found');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await codexLensModule.executeCodexLens([
|
|
||||||
'inspect', testFile, '--json'
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.ok(typeof result === 'object');
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
console.log('Inspect result received');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CodexLens Workspace Detection', async () => {
|
|
||||||
let codexLensModule;
|
|
||||||
let isReady = false;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
try {
|
|
||||||
codexLensModule = await import(codexLensPath);
|
|
||||||
const status = await codexLensModule.checkVenvStatus();
|
|
||||||
isReady = status.ready;
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Setup failed:', err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should detect existing workspace', async () => {
|
|
||||||
if (!isReady) {
|
|
||||||
console.log('Skipping: CodexLens not ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get status from project root where .codexlens might exist
|
|
||||||
const projectRoot = join(__dirname, '..', '..');
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'status',
|
|
||||||
path: projectRoot
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(typeof result === 'object');
|
|
||||||
console.log('Project root status:', result.success ? 'Found' : 'Not found');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should use global database when workspace not found', async () => {
|
|
||||||
if (!isReady) {
|
|
||||||
console.log('Skipping: CodexLens not ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a path that definitely won't have .codexlens
|
|
||||||
const tempPath = tmpdir();
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'status',
|
|
||||||
path: tempPath
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(typeof result === 'object');
|
|
||||||
// Should fall back to global database
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,521 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for CodexLens API endpoints and tool integration
|
|
||||||
*
|
|
||||||
* Tests the following endpoints:
|
|
||||||
* - GET /api/codexlens/status
|
|
||||||
* - POST /api/codexlens/bootstrap
|
|
||||||
* - POST /api/codexlens/init
|
|
||||||
* - GET /api/codexlens/semantic/status
|
|
||||||
* - POST /api/codexlens/semantic/install
|
|
||||||
*
|
|
||||||
* Also tests the codex-lens.js tool functions directly
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, before, after, mock } from 'node:test';
|
|
||||||
import assert from 'node:assert';
|
|
||||||
import { createServer } from 'http';
|
|
||||||
import { join, dirname } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
||||||
import { homedir, tmpdir } from 'os';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// Import the codex-lens module - use file:// URL format for Windows compatibility
|
|
||||||
const codexLensPath = new URL('../dist/tools/codex-lens.js', import.meta.url).href;
|
|
||||||
|
|
||||||
describe('CodexLens Tool Functions', async () => {
|
|
||||||
let codexLensModule;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
try {
|
|
||||||
codexLensModule = await import(codexLensPath);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Note: codex-lens module import skipped (module may not be available):', err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('checkVenvStatus', () => {
|
|
||||||
it('should return an object with ready property', async () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await codexLensModule.checkVenvStatus();
|
|
||||||
assert.ok(typeof status === 'object', 'Status should be an object');
|
|
||||||
assert.ok('ready' in status, 'Status should have ready property');
|
|
||||||
assert.ok(typeof status.ready === 'boolean', 'ready should be boolean');
|
|
||||||
|
|
||||||
if (status.ready) {
|
|
||||||
assert.ok('version' in status, 'Ready status should include version');
|
|
||||||
} else {
|
|
||||||
assert.ok('error' in status, 'Not ready status should include error');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('checkSemanticStatus', () => {
|
|
||||||
it('should return semantic availability status', async () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await codexLensModule.checkSemanticStatus();
|
|
||||||
assert.ok(typeof status === 'object', 'Status should be an object');
|
|
||||||
assert.ok('available' in status, 'Status should have available property');
|
|
||||||
assert.ok(typeof status.available === 'boolean', 'available should be boolean');
|
|
||||||
|
|
||||||
if (status.available) {
|
|
||||||
assert.ok('backend' in status, 'Available status should include backend');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('executeCodexLens', () => {
|
|
||||||
it('should execute codexlens command and return result', async () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// First check if CodexLens is ready
|
|
||||||
const status = await codexLensModule.checkVenvStatus();
|
|
||||||
if (!status.ready) {
|
|
||||||
console.log('Skipping: CodexLens not installed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Execute a simple status command
|
|
||||||
const result = await codexLensModule.executeCodexLens(['--help']);
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
assert.ok('success' in result, 'Result should have success property');
|
|
||||||
|
|
||||||
// --help should succeed
|
|
||||||
if (result.success) {
|
|
||||||
assert.ok('output' in result, 'Success result should have output');
|
|
||||||
assert.ok(result.output.includes('CodexLens') || result.output.includes('codexlens'),
|
|
||||||
'Help output should mention CodexLens');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle timeout gracefully', async () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await codexLensModule.checkVenvStatus();
|
|
||||||
if (!status.ready) {
|
|
||||||
console.log('Skipping: CodexLens not installed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a very short timeout to trigger timeout behavior
|
|
||||||
// Note: This test may not always trigger timeout depending on system speed
|
|
||||||
const result = await codexLensModule.executeCodexLens(['status', '--json'], { timeout: 1 });
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
assert.ok('success' in result, 'Result should have success property');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('codexLensTool.execute', () => {
|
|
||||||
it('should handle check action', async () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({ action: 'check' });
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
assert.ok('ready' in result, 'Check result should have ready property');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return error for unknown action', async () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({ action: 'unknown_action' });
|
|
||||||
assert.strictEqual(result.success, false, 'Should return success: false');
|
|
||||||
assert.ok(result.error, 'Should have error message');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle status action', async () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkResult = await codexLensModule.checkVenvStatus();
|
|
||||||
if (!checkResult.ready) {
|
|
||||||
console.log('Skipping: CodexLens not installed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'status',
|
|
||||||
path: __dirname
|
|
||||||
});
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
assert.ok('success' in result, 'Result should have success property');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CodexLens API Endpoints (Integration)', async () => {
|
|
||||||
// These tests require a running server
|
|
||||||
// They test the actual HTTP endpoints
|
|
||||||
|
|
||||||
const TEST_PORT = 19999;
|
|
||||||
let serverModule;
|
|
||||||
let server;
|
|
||||||
let baseUrl;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
// Note: We cannot easily start the ccw server in tests
|
|
||||||
// So we test the endpoint handlers directly or mock the server
|
|
||||||
baseUrl = `http://localhost:${TEST_PORT}`;
|
|
||||||
|
|
||||||
// Try to import server module for handler testing
|
|
||||||
try {
|
|
||||||
// serverModule = await import(join(__dirname, '..', 'src', 'core', 'server.js'));
|
|
||||||
console.log('Note: Server integration tests require manual server start');
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Server module not available for direct testing');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /api/codexlens/status', () => {
|
|
||||||
it('should return JSON response with ready status', async () => {
|
|
||||||
// This test requires a running server
|
|
||||||
// Skip if server is not running
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${baseUrl}/api/codexlens/status`);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
assert.ok(typeof data === 'object', 'Response should be JSON object');
|
|
||||||
assert.ok('ready' in data, 'Response should have ready property');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err.cause?.code === 'ECONNREFUSED') {
|
|
||||||
console.log('Skipping: Server not running on port', TEST_PORT);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('POST /api/codexlens/init', () => {
|
|
||||||
it('should initialize index for given path', async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${baseUrl}/api/codexlens/init`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ path: __dirname })
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
assert.ok(typeof data === 'object', 'Response should be JSON object');
|
|
||||||
assert.ok('success' in data, 'Response should have success property');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err.cause?.code === 'ECONNREFUSED') {
|
|
||||||
console.log('Skipping: Server not running on port', TEST_PORT);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('GET /api/codexlens/semantic/status', () => {
|
|
||||||
it('should return semantic search status', async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${baseUrl}/api/codexlens/semantic/status`);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
assert.ok(typeof data === 'object', 'Response should be JSON object');
|
|
||||||
assert.ok('available' in data, 'Response should have available property');
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err.cause?.code === 'ECONNREFUSED') {
|
|
||||||
console.log('Skipping: Server not running on port', TEST_PORT);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CodexLens Tool Definition', async () => {
|
|
||||||
let codexLensModule;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
try {
|
|
||||||
codexLensModule = await import(codexLensPath);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Note: codex-lens module not available');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct tool name', () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.strictEqual(codexLensModule.codexLensTool.name, 'codex_lens');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have description', () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.ok(codexLensModule.codexLensTool.description, 'Should have description');
|
|
||||||
assert.ok(codexLensModule.codexLensTool.description.includes('CodexLens'),
|
|
||||||
'Description should mention CodexLens');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have parameters schema', () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { parameters } = codexLensModule.codexLensTool;
|
|
||||||
assert.ok(parameters, 'Should have parameters');
|
|
||||||
assert.strictEqual(parameters.type, 'object');
|
|
||||||
assert.ok(parameters.properties, 'Should have properties');
|
|
||||||
assert.ok(parameters.properties.action, 'Should have action property');
|
|
||||||
assert.deepStrictEqual(parameters.required, ['action'], 'action should be required');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support all documented actions', () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { parameters } = codexLensModule.codexLensTool;
|
|
||||||
const supportedActions = parameters.properties.action.enum;
|
|
||||||
|
|
||||||
const expectedActions = ['init', 'search', 'symbol', 'status', 'update', 'bootstrap', 'check'];
|
|
||||||
|
|
||||||
for (const action of expectedActions) {
|
|
||||||
assert.ok(supportedActions.includes(action), `Should support ${action} action`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have execute function', () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.ok(typeof codexLensModule.codexLensTool.execute === 'function',
|
|
||||||
'Should have execute function');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CodexLens Path Configuration', () => {
|
|
||||||
it('should use correct venv path based on platform', async () => {
|
|
||||||
const codexLensDataDir = join(homedir(), '.codexlens');
|
|
||||||
const codexLensVenv = join(codexLensDataDir, 'venv');
|
|
||||||
|
|
||||||
const expectedPython = process.platform === 'win32'
|
|
||||||
? join(codexLensVenv, 'Scripts', 'python.exe')
|
|
||||||
: join(codexLensVenv, 'bin', 'python');
|
|
||||||
|
|
||||||
// Just verify the path construction logic is correct
|
|
||||||
assert.ok(expectedPython.includes('codexlens'), 'Python path should include codexlens');
|
|
||||||
assert.ok(expectedPython.includes('venv'), 'Python path should include venv');
|
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
assert.ok(expectedPython.includes('Scripts'), 'Windows should use Scripts directory');
|
|
||||||
assert.ok(expectedPython.endsWith('.exe'), 'Windows should have .exe extension');
|
|
||||||
} else {
|
|
||||||
assert.ok(expectedPython.includes('bin'), 'Unix should use bin directory');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CodexLens Error Handling', async () => {
|
|
||||||
let codexLensModule;
|
|
||||||
const testTempDirs = []; // Track temp directories for cleanup
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
// Clean up temp directories created during tests
|
|
||||||
for (const dir of testTempDirs) {
|
|
||||||
try {
|
|
||||||
rmSync(dir, { recursive: true, force: true });
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up any indexes created for temp directories
|
|
||||||
const indexDir = join(homedir(), '.codexlens', 'indexes');
|
|
||||||
const tempIndexPattern = join(indexDir, 'C', 'Users', '*', 'AppData', 'Local', 'Temp', 'ccw-codexlens-update-*');
|
|
||||||
try {
|
|
||||||
const glob = require('glob');
|
|
||||||
const matches = glob.sync(tempIndexPattern.replace(/\\/g, '/'));
|
|
||||||
for (const match of matches) {
|
|
||||||
rmSync(match, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// glob may not be available, try direct cleanup
|
|
||||||
try {
|
|
||||||
const tempPath = join(indexDir, 'C', 'Users');
|
|
||||||
if (existsSync(tempPath)) {
|
|
||||||
console.log('Note: Temp indexes may need manual cleanup at:', indexDir);
|
|
||||||
}
|
|
||||||
} catch (e2) {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
try {
|
|
||||||
codexLensModule = await import(codexLensPath);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Note: codex-lens module not available');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle missing file parameter for symbol action', async () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkResult = await codexLensModule.checkVenvStatus();
|
|
||||||
if (!checkResult.ready) {
|
|
||||||
console.log('Skipping: CodexLens not installed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'symbol'
|
|
||||||
// file is missing
|
|
||||||
});
|
|
||||||
|
|
||||||
// Should either error or return success: false
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support update action without files parameter', async () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkResult = await codexLensModule.checkVenvStatus();
|
|
||||||
if (!checkResult.ready) {
|
|
||||||
console.log('Skipping: CodexLens not installed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateRoot = mkdtempSync(join(tmpdir(), 'ccw-codexlens-update-'));
|
|
||||||
testTempDirs.push(updateRoot); // Track for cleanup
|
|
||||||
writeFileSync(join(updateRoot, 'main.py'), 'def hello():\n return 1\n', 'utf8');
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'update',
|
|
||||||
path: updateRoot,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
assert.ok('success' in result, 'Result should have success property');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should ignore extraneous files parameter for update action', async () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkResult = await codexLensModule.checkVenvStatus();
|
|
||||||
if (!checkResult.ready) {
|
|
||||||
console.log('Skipping: CodexLens not installed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateRoot = mkdtempSync(join(tmpdir(), 'ccw-codexlens-update-'));
|
|
||||||
testTempDirs.push(updateRoot); // Track for cleanup
|
|
||||||
writeFileSync(join(updateRoot, 'main.py'), 'def hello():\n return 1\n', 'utf8');
|
|
||||||
|
|
||||||
const result = await codexLensModule.codexLensTool.execute({
|
|
||||||
action: 'update',
|
|
||||||
path: updateRoot,
|
|
||||||
files: []
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
|
||||||
assert.ok('success' in result, 'Result should have success property');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CodexLens Search Parameters', async () => {
|
|
||||||
let codexLensModule;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
try {
|
|
||||||
codexLensModule = await import(codexLensPath);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Note: codex-lens module not available');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support text and semantic search modes', () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { parameters } = codexLensModule.codexLensTool;
|
|
||||||
const modeEnum = parameters.properties.mode?.enum;
|
|
||||||
|
|
||||||
assert.ok(modeEnum, 'Should have mode enum');
|
|
||||||
assert.ok(modeEnum.includes('text'), 'Should support text mode');
|
|
||||||
assert.ok(modeEnum.includes('semantic'), 'Should support semantic mode');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have limit parameter with default', () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { parameters } = codexLensModule.codexLensTool;
|
|
||||||
const limitProp = parameters.properties.limit;
|
|
||||||
|
|
||||||
assert.ok(limitProp, 'Should have limit property');
|
|
||||||
assert.strictEqual(limitProp.type, 'number', 'limit should be number');
|
|
||||||
assert.strictEqual(limitProp.default, 20, 'Default limit should be 20');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support output format options', () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { parameters } = codexLensModule.codexLensTool;
|
|
||||||
const formatEnum = parameters.properties.format?.enum;
|
|
||||||
|
|
||||||
assert.ok(formatEnum, 'Should have format enum');
|
|
||||||
assert.ok(formatEnum.includes('json'), 'Should support json format');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -161,54 +161,16 @@ describe('E2E: MCP Tool Execution', async () => {
|
|||||||
|
|
||||||
// Verify essential tools are present
|
// Verify essential tools are present
|
||||||
const toolNames = response.result.tools.map((t: any) => t.name);
|
const toolNames = response.result.tools.map((t: any) => t.name);
|
||||||
assert.ok(toolNames.includes('smart_search'));
|
|
||||||
assert.ok(toolNames.includes('edit_file'));
|
assert.ok(toolNames.includes('edit_file'));
|
||||||
assert.ok(toolNames.includes('write_file'));
|
assert.ok(toolNames.includes('write_file'));
|
||||||
assert.ok(toolNames.includes('session_manager'));
|
assert.ok(toolNames.includes('session_manager'));
|
||||||
|
|
||||||
// Verify tool schema structure
|
// Verify tool schema structure
|
||||||
const smartSearch = response.result.tools.find((t: any) => t.name === 'smart_search');
|
const editFile = response.result.tools.find((t: any) => t.name === 'edit_file');
|
||||||
assert.ok(smartSearch.description);
|
assert.ok(editFile.description);
|
||||||
assert.ok(smartSearch.inputSchema);
|
assert.ok(editFile.inputSchema);
|
||||||
assert.equal(smartSearch.inputSchema.type, 'object');
|
assert.equal(editFile.inputSchema.type, 'object');
|
||||||
assert.ok(smartSearch.inputSchema.properties);
|
assert.ok(editFile.inputSchema.properties);
|
||||||
});
|
|
||||||
|
|
||||||
it('executes smart_search tool with valid parameters', async () => {
|
|
||||||
const response = await mcpClient.call('tools/call', {
|
|
||||||
name: 'smart_search',
|
|
||||||
arguments: {
|
|
||||||
action: 'status',
|
|
||||||
path: process.cwd()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(response.jsonrpc, '2.0');
|
|
||||||
assert.ok(response.result);
|
|
||||||
assert.ok(Array.isArray(response.result.content));
|
|
||||||
assert.equal(response.result.content[0].type, 'text');
|
|
||||||
assert.ok(response.result.content[0].text.length > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('validates required parameters and returns error for missing params', async () => {
|
|
||||||
const response = await mcpClient.call('tools/call', {
|
|
||||||
name: 'smart_search',
|
|
||||||
arguments: {
|
|
||||||
action: 'search'
|
|
||||||
// Missing required 'query' parameter
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(response.jsonrpc, '2.0');
|
|
||||||
assert.ok(response.result);
|
|
||||||
assert.equal(response.result.isError, true);
|
|
||||||
// Error message should mention query is required
|
|
||||||
assert.ok(
|
|
||||||
response.result.content[0].text.includes('Query is required') ||
|
|
||||||
response.result.content[0].text.includes('query') ||
|
|
||||||
response.result.content[0].text.includes('required'),
|
|
||||||
`Expected error about missing query, got: ${response.result.content[0].text}`
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns error for non-existent tool', async () => {
|
it('returns error for non-existent tool', async () => {
|
||||||
@@ -374,10 +336,6 @@ describe('E2E: MCP Tool Execution', async () => {
|
|||||||
it('handles concurrent tool calls without interference', async () => {
|
it('handles concurrent tool calls without interference', async () => {
|
||||||
const calls = await Promise.all([
|
const calls = await Promise.all([
|
||||||
mcpClient.call('tools/list', {}),
|
mcpClient.call('tools/list', {}),
|
||||||
mcpClient.call('tools/call', {
|
|
||||||
name: 'smart_search',
|
|
||||||
arguments: { action: 'status', path: process.cwd() }
|
|
||||||
}),
|
|
||||||
mcpClient.call('tools/call', {
|
mcpClient.call('tools/call', {
|
||||||
name: 'session_manager',
|
name: 'session_manager',
|
||||||
arguments: { operation: 'list', location: 'active' }
|
arguments: { operation: 'list', location: 'active' }
|
||||||
@@ -392,8 +350,7 @@ describe('E2E: MCP Tool Execution', async () => {
|
|||||||
|
|
||||||
// Verify different results
|
// Verify different results
|
||||||
assert.ok(Array.isArray(calls[0].result.tools)); // tools/list
|
assert.ok(Array.isArray(calls[0].result.tools)); // tools/list
|
||||||
assert.ok(calls[1].result.content); // smart_search
|
assert.ok(calls[1].result.content); // session_manager
|
||||||
assert.ok(calls[2].result.content); // session_manager
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('validates path parameters for security (path traversal prevention)', async () => {
|
it('validates path parameters for security (path traversal prevention)', async () => {
|
||||||
@@ -415,24 +372,6 @@ describe('E2E: MCP Tool Execution', async () => {
|
|||||||
assert.ok(hasError);
|
assert.ok(hasError);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('supports progress reporting for long-running operations', async () => {
|
|
||||||
// smart_search init action supports progress reporting
|
|
||||||
const response = await mcpClient.call('tools/call', {
|
|
||||||
name: 'smart_search',
|
|
||||||
arguments: {
|
|
||||||
action: 'status',
|
|
||||||
path: process.cwd()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(response.jsonrpc, '2.0');
|
|
||||||
assert.ok(response.result);
|
|
||||||
assert.ok(response.result.content);
|
|
||||||
|
|
||||||
// For status action, should return immediately
|
|
||||||
// Progress is logged to stderr but doesn't affect result structure
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles tool execution timeout gracefully', async () => {
|
it('handles tool execution timeout gracefully', async () => {
|
||||||
// Create a tool call that should complete quickly
|
// Create a tool call that should complete quickly
|
||||||
// If it times out, the client will throw
|
// If it times out, the client will throw
|
||||||
@@ -495,14 +434,10 @@ describe('E2E: MCP Tool Execution', async () => {
|
|||||||
|
|
||||||
it('preserves parameter types in tool execution', async () => {
|
it('preserves parameter types in tool execution', async () => {
|
||||||
const response = await mcpClient.call('tools/call', {
|
const response = await mcpClient.call('tools/call', {
|
||||||
name: 'smart_search',
|
name: 'session_manager',
|
||||||
arguments: {
|
arguments: {
|
||||||
action: 'find_files',
|
operation: 'list',
|
||||||
pattern: '*.json',
|
location: 'active'
|
||||||
path: process.cwd(),
|
|
||||||
limit: 10, // Number
|
|
||||||
offset: 0, // Number
|
|
||||||
caseSensitive: true // Boolean
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,403 +0,0 @@
|
|||||||
/**
|
|
||||||
* Unit tests for LiteLLM client bridge (ccw/dist/tools/litellm-client.js).
|
|
||||||
*
|
|
||||||
* Notes:
|
|
||||||
* - Uses Node's built-in test runner (node:test) (no Jest in this repo).
|
|
||||||
* - Stubs `child_process.spawn` to avoid depending on local Python/ccw_litellm installation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { after, beforeEach, describe, it } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { EventEmitter } from 'node:events';
|
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
const childProcess = require('child_process') as typeof import('child_process');
|
|
||||||
|
|
||||||
type SpawnBehavior =
|
|
||||||
| { type: 'close'; code?: number; stdout?: string; stderr?: string }
|
|
||||||
| { type: 'error'; error: Error }
|
|
||||||
| { type: 'hang' };
|
|
||||||
|
|
||||||
class FakeChildProcess extends EventEmitter {
|
|
||||||
stdout = new EventEmitter();
|
|
||||||
stderr = new EventEmitter();
|
|
||||||
killCalls: string[] = [];
|
|
||||||
|
|
||||||
kill(signal?: NodeJS.Signals | number | string): boolean {
|
|
||||||
this.killCalls.push(signal === undefined ? 'undefined' : String(signal));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type SpawnCall = {
|
|
||||||
command: string;
|
|
||||||
args: string[];
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
options: any;
|
|
||||||
proc: FakeChildProcess;
|
|
||||||
};
|
|
||||||
|
|
||||||
const spawnCalls: SpawnCall[] = [];
|
|
||||||
const spawnPlan: SpawnBehavior[] = [];
|
|
||||||
|
|
||||||
const originalSpawn = childProcess.spawn;
|
|
||||||
|
|
||||||
childProcess.spawn = ((command: string, args: string[] = [], options: any = {}) => {
|
|
||||||
const normalizedArgs = (args ?? []).map(String);
|
|
||||||
const shouldIntercept = normalizedArgs[0] === '-m' && normalizedArgs[1] === 'ccw_litellm.cli';
|
|
||||||
if (!shouldIntercept) {
|
|
||||||
return originalSpawn(command as any, args as any, options as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
const proc = new FakeChildProcess();
|
|
||||||
spawnCalls.push({ command: String(command), args: normalizedArgs, options, proc });
|
|
||||||
|
|
||||||
const next = spawnPlan.shift() ?? { type: 'close', code: 0, stdout: '' };
|
|
||||||
|
|
||||||
queueMicrotask(() => {
|
|
||||||
if (next.type === 'error') {
|
|
||||||
proc.emit('error', next.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (next.type === 'close') {
|
|
||||||
if (next.stdout !== undefined) proc.stdout.emit('data', next.stdout);
|
|
||||||
if (next.stderr !== undefined) proc.stderr.emit('data', next.stderr);
|
|
||||||
proc.emit('close', next.code ?? 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// hang: intentionally do nothing
|
|
||||||
});
|
|
||||||
|
|
||||||
return proc as any;
|
|
||||||
}) as any;
|
|
||||||
|
|
||||||
function getClientModuleUrl(): URL {
|
|
||||||
const url = new URL('../dist/tools/litellm-client.js', import.meta.url);
|
|
||||||
url.searchParams.set('t', `${Date.now()}-${Math.random()}`);
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
let mod: any;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
spawnCalls.length = 0;
|
|
||||||
spawnPlan.length = 0;
|
|
||||||
mod = await import(getClientModuleUrl().href);
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
childProcess.spawn = originalSpawn;
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('LiteLLM client bridge', () => {
|
|
||||||
it('uses default pythonPath and version check arguments', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 0, stdout: '1.2.3\n' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient();
|
|
||||||
const available = await client.isAvailable();
|
|
||||||
|
|
||||||
assert.equal(available, true);
|
|
||||||
assert.equal(spawnCalls.length, 1);
|
|
||||||
assert.equal(spawnCalls[0].command, mod.getCodexLensVenvPython());
|
|
||||||
assert.deepEqual(spawnCalls[0].args, ['-m', 'ccw_litellm.cli', 'version']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses custom pythonPath when provided', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 0, stdout: 'ok' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ pythonPath: 'python3', timeout: 10 });
|
|
||||||
await client.chat('hello', 'default');
|
|
||||||
|
|
||||||
assert.equal(spawnCalls.length, 1);
|
|
||||||
assert.equal(spawnCalls[0].command, 'python3');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('spawns LiteLLM Python with hidden window options', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 0, stdout: '1.2.3\n' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
const available = await client.isAvailable();
|
|
||||||
|
|
||||||
assert.equal(available, true);
|
|
||||||
assert.equal(spawnCalls.length, 1);
|
|
||||||
assert.equal(spawnCalls[0].options.shell, false);
|
|
||||||
assert.equal(spawnCalls[0].options.windowsHide, true);
|
|
||||||
assert.equal(spawnCalls[0].options.env.PYTHONIOENCODING, 'utf-8');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isAvailable returns false on spawn error', async () => {
|
|
||||||
spawnPlan.push({ type: 'error', error: new Error('ENOENT') });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
const available = await client.isAvailable();
|
|
||||||
|
|
||||||
assert.equal(available, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getStatus returns version on success', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 0, stdout: 'v9.9.9\n' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
const status = await client.getStatus();
|
|
||||||
|
|
||||||
assert.equal(status.available, true);
|
|
||||||
assert.equal(status.version, 'v9.9.9');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getStatus returns error details on non-zero exit', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'HTTP 500 Internal Server Error' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
const status = await client.getStatus();
|
|
||||||
|
|
||||||
assert.equal(status.available, false);
|
|
||||||
assert.ok(String(status.error).includes('HTTP 500'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getConfig parses JSON output', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 0, stdout: JSON.stringify({ ok: true }) });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
const cfg = await client.getConfig();
|
|
||||||
|
|
||||||
assert.deepEqual(cfg, { ok: true });
|
|
||||||
assert.equal(spawnCalls.length, 1);
|
|
||||||
assert.deepEqual(spawnCalls[0].args, ['-m', 'ccw_litellm.cli', 'config']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getConfig throws on malformed JSON', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 0, stdout: '{not-json' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
await assert.rejects(() => client.getConfig());
|
|
||||||
});
|
|
||||||
|
|
||||||
it('embed rejects empty texts input and does not spawn', async () => {
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
await assert.rejects(() => client.embed([]), /texts array cannot be empty/);
|
|
||||||
assert.equal(spawnCalls.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('embed rejects null/undefined input', async () => {
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
await assert.rejects(() => client.embed(null as any), /texts array cannot be empty/);
|
|
||||||
await assert.rejects(() => client.embed(undefined as any), /texts array cannot be empty/);
|
|
||||||
assert.equal(spawnCalls.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('embed returns vectors with derived dimensions', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 0, stdout: JSON.stringify([[1, 2, 3], [4, 5, 6]]) });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
const res = await client.embed(['a', 'b'], 'embed-model');
|
|
||||||
|
|
||||||
assert.equal(res.model, 'embed-model');
|
|
||||||
assert.equal(res.dimensions, 3);
|
|
||||||
assert.deepEqual(res.vectors, [
|
|
||||||
[1, 2, 3],
|
|
||||||
[4, 5, 6],
|
|
||||||
]);
|
|
||||||
|
|
||||||
assert.equal(spawnCalls.length, 1);
|
|
||||||
assert.deepEqual(spawnCalls[0].args, [
|
|
||||||
'-m',
|
|
||||||
'ccw_litellm.cli',
|
|
||||||
'embed',
|
|
||||||
'--model',
|
|
||||||
'embed-model',
|
|
||||||
'--output',
|
|
||||||
'json',
|
|
||||||
'a',
|
|
||||||
'b',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('embed throws on malformed JSON output', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 0, stdout: 'not-json' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
await assert.rejects(() => client.embed(['a'], 'embed-model'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('chat rejects empty message and does not spawn', async () => {
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
await assert.rejects(() => client.chat(''), /message cannot be empty/);
|
|
||||||
assert.equal(spawnCalls.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('chat returns trimmed stdout on success', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 0, stdout: 'Hello\n' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
const out = await client.chat('hi', 'chat-model');
|
|
||||||
|
|
||||||
assert.equal(out, 'Hello');
|
|
||||||
assert.equal(spawnCalls.length, 1);
|
|
||||||
assert.deepEqual(spawnCalls[0].args, ['-m', 'ccw_litellm.cli', 'chat', '--model', 'chat-model', 'hi']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('chat propagates auth errors (401)', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'HTTP 401 Unauthorized' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /401/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('chat propagates auth errors (403)', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'HTTP 403 Forbidden' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /403/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('chat propagates rate limit errors (429)', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'HTTP 429 Too Many Requests' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /429/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('chat propagates server errors (500)', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'HTTP 500 Internal Server Error' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /500/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('chat propagates server errors (503)', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'HTTP 503 Service Unavailable' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /503/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('chat falls back to exit code when stderr is empty', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 2, stdout: '' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /Process exited with code 2/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('chat surfaces spawn failures with descriptive message', async () => {
|
|
||||||
spawnPlan.push({ type: 'error', error: new Error('spawn ENOENT') });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /Failed to spawn Python process: spawn ENOENT/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('chat enforces timeout and terminates process', async () => {
|
|
||||||
const originalSetTimeout = global.setTimeout;
|
|
||||||
let observedDelay: number | null = null;
|
|
||||||
|
|
||||||
(global as any).setTimeout = ((fn: any, delay: number, ...args: any[]) => {
|
|
||||||
observedDelay = delay;
|
|
||||||
return originalSetTimeout(fn, 0, ...args);
|
|
||||||
}) as any;
|
|
||||||
|
|
||||||
try {
|
|
||||||
spawnPlan.push({ type: 'hang' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 11 });
|
|
||||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /Command timed out after 22ms/);
|
|
||||||
|
|
||||||
assert.equal(observedDelay, 22);
|
|
||||||
assert.equal(spawnCalls.length, 1);
|
|
||||||
assert.ok(spawnCalls[0].proc.killCalls.includes('SIGTERM'));
|
|
||||||
} finally {
|
|
||||||
(global as any).setTimeout = originalSetTimeout;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('chatMessages rejects empty inputs', async () => {
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
await assert.rejects(() => client.chatMessages([]), /messages array cannot be empty/);
|
|
||||||
await assert.rejects(() => client.chatMessages(null as any), /messages array cannot be empty/);
|
|
||||||
assert.equal(spawnCalls.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('chatMessages uses the last message content', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 0, stdout: 'OK' });
|
|
||||||
|
|
||||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
|
||||||
const res = await client.chatMessages(
|
|
||||||
[
|
|
||||||
{ role: 'user', content: 'first' },
|
|
||||||
{ role: 'user', content: 'last' },
|
|
||||||
],
|
|
||||||
'chat-model',
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(res.content, 'OK');
|
|
||||||
assert.equal(res.model, 'chat-model');
|
|
||||||
assert.equal(spawnCalls.length, 1);
|
|
||||||
assert.equal(spawnCalls[0].args.at(-1), 'last');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getLiteLLMClient returns a singleton instance', () => {
|
|
||||||
const c1 = mod.getLiteLLMClient();
|
|
||||||
const c2 = mod.getLiteLLMClient();
|
|
||||||
assert.equal(c1, c2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('checkLiteLLMAvailable returns false when version check fails', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'ccw_litellm not installed' });
|
|
||||||
|
|
||||||
const available = await mod.checkLiteLLMAvailable();
|
|
||||||
assert.equal(available, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('getLiteLLMStatus includes error message when unavailable', async () => {
|
|
||||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'ccw_litellm not installed' });
|
|
||||||
|
|
||||||
const status = await mod.getLiteLLMStatus();
|
|
||||||
assert.equal(status.available, false);
|
|
||||||
assert.ok(String(status.error).includes('ccw_litellm not installed'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getCodexLensVenvPython (Issue #68 fix)', () => {
|
|
||||||
it('should be exported from the module', async () => {
|
|
||||||
assert.ok(typeof mod.getCodexLensVenvPython === 'function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return a string path', async () => {
|
|
||||||
const pythonPath = mod.getCodexLensVenvPython();
|
|
||||||
assert.equal(typeof pythonPath, 'string');
|
|
||||||
assert.ok(pythonPath.length > 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return correct path structure for CodexLens venv', async () => {
|
|
||||||
const pythonPath = mod.getCodexLensVenvPython();
|
|
||||||
|
|
||||||
// On Windows: should contain Scripts/python.exe
|
|
||||||
// On Unix: should contain bin/python
|
|
||||||
const isWindows = process.platform === 'win32';
|
|
||||||
|
|
||||||
if (isWindows) {
|
|
||||||
// Either it's the venv path with Scripts, or fallback to 'python'
|
|
||||||
const isVenvPath = pythonPath.includes('Scripts') && pythonPath.includes('python');
|
|
||||||
const isFallback = pythonPath === 'python';
|
|
||||||
assert.ok(isVenvPath || isFallback, `Expected venv path or 'python' fallback, got: ${pythonPath}`);
|
|
||||||
} else {
|
|
||||||
// On Unix: either venv path with bin/python, or fallback
|
|
||||||
const isVenvPath = pythonPath.includes('bin') && pythonPath.includes('python');
|
|
||||||
const isFallback = pythonPath === 'python';
|
|
||||||
assert.ok(isVenvPath || isFallback, `Expected venv path or 'python' fallback, got: ${pythonPath}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should include .codexlens/venv in path when venv exists', async () => {
|
|
||||||
const pythonPath = mod.getCodexLensVenvPython();
|
|
||||||
|
|
||||||
// If not falling back to 'python', should contain .codexlens/venv
|
|
||||||
if (pythonPath !== 'python') {
|
|
||||||
assert.ok(pythonPath.includes('.codexlens'), `Expected .codexlens in path, got: ${pythonPath}`);
|
|
||||||
assert.ok(pythonPath.includes('venv'), `Expected venv in path, got: ${pythonPath}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -97,7 +97,7 @@ describe('MCP Server', () => {
|
|||||||
const toolNames = response.result.tools.map(t => t.name);
|
const toolNames = response.result.tools.map(t => t.name);
|
||||||
assert(toolNames.includes('edit_file'));
|
assert(toolNames.includes('edit_file'));
|
||||||
assert(toolNames.includes('write_file'));
|
assert(toolNames.includes('write_file'));
|
||||||
assert(toolNames.includes('smart_search'));
|
// smart_search removed - use codexlens MCP server instead
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should respond to tools/call request', async () => {
|
it('should respond to tools/call request', async () => {
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for smart_search with enrich parameter
|
|
||||||
*
|
|
||||||
* Tests the following:
|
|
||||||
* - enrich parameter is passed to codex-lens
|
|
||||||
* - relationship data is parsed from response
|
|
||||||
* - SemanticMatch interface with relationships field
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, before, mock } from 'node:test';
|
|
||||||
import assert from 'node:assert';
|
|
||||||
import { dirname, join } from 'path';
|
|
||||||
import { fileURLToPath } from 'url';
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = dirname(__filename);
|
|
||||||
|
|
||||||
// Import the smart-search module (exports schema, not smartSearchTool)
|
|
||||||
const smartSearchPath = new URL('../dist/tools/smart-search.js', import.meta.url).href;
|
|
||||||
|
|
||||||
describe('Smart Search Enrich Parameter', async () => {
|
|
||||||
let smartSearchModule;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
try {
|
|
||||||
smartSearchModule = await import(smartSearchPath);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Note: smart-search module import skipped:', err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Parameter Schema', () => {
|
|
||||||
it('should have enrich parameter in schema', async () => {
|
|
||||||
if (!smartSearchModule) {
|
|
||||||
console.log('Skipping: smart-search module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { schema } = smartSearchModule;
|
|
||||||
assert.ok(schema, 'Should export schema');
|
|
||||||
// Schema uses inputSchema (MCP standard), not parameters
|
|
||||||
const params = schema.inputSchema || schema.parameters;
|
|
||||||
assert.ok(params, 'Should have inputSchema or parameters');
|
|
||||||
|
|
||||||
const props = params.properties;
|
|
||||||
assert.ok(props.enrich, 'Should have enrich parameter');
|
|
||||||
assert.strictEqual(props.enrich.type, 'boolean', 'enrich should be boolean');
|
|
||||||
assert.strictEqual(props.enrich.default, false, 'enrich should default to false');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should describe enrich parameter purpose', async () => {
|
|
||||||
if (!smartSearchModule) {
|
|
||||||
console.log('Skipping: smart-search module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { schema } = smartSearchModule;
|
|
||||||
const params = schema.inputSchema || schema.parameters;
|
|
||||||
const enrichDesc = params.properties.enrich?.description || '';
|
|
||||||
|
|
||||||
// Description should mention relationships or graph
|
|
||||||
const mentionsRelationships = enrichDesc.toLowerCase().includes('relationship') ||
|
|
||||||
enrichDesc.toLowerCase().includes('graph') ||
|
|
||||||
enrichDesc.toLowerCase().includes('enrich');
|
|
||||||
assert.ok(mentionsRelationships, 'enrich description should mention relationships/graph');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SemanticMatch Interface', () => {
|
|
||||||
it('should handle results with relationships field', async () => {
|
|
||||||
if (!smartSearchModule) {
|
|
||||||
console.log('Skipping: smart-search module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a mock result with relationships
|
|
||||||
const mockResult = {
|
|
||||||
file: 'test.py',
|
|
||||||
score: 0.95,
|
|
||||||
content: 'def main(): pass',
|
|
||||||
symbol: 'main',
|
|
||||||
relationships: [
|
|
||||||
{
|
|
||||||
type: 'calls',
|
|
||||||
direction: 'outgoing',
|
|
||||||
target: 'helper',
|
|
||||||
file: 'test.py',
|
|
||||||
line: 5
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'called_by',
|
|
||||||
direction: 'incoming',
|
|
||||||
source: 'entrypoint',
|
|
||||||
file: 'app.py',
|
|
||||||
line: 10
|
|
||||||
}
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
// Verify structure
|
|
||||||
assert.ok(Array.isArray(mockResult.relationships), 'relationships should be array');
|
|
||||||
assert.strictEqual(mockResult.relationships.length, 2, 'should have 2 relationships');
|
|
||||||
|
|
||||||
const outgoing = mockResult.relationships[0];
|
|
||||||
assert.strictEqual(outgoing.type, 'calls');
|
|
||||||
assert.strictEqual(outgoing.direction, 'outgoing');
|
|
||||||
assert.ok(outgoing.target, 'outgoing should have target');
|
|
||||||
|
|
||||||
const incoming = mockResult.relationships[1];
|
|
||||||
assert.strictEqual(incoming.type, 'called_by');
|
|
||||||
assert.strictEqual(incoming.direction, 'incoming');
|
|
||||||
assert.ok(incoming.source, 'incoming should have source');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('RelationshipInfo Structure', () => {
|
|
||||||
it('should validate relationship info structure', () => {
|
|
||||||
// Test the expected structure of RelationshipInfo
|
|
||||||
const validRelationship = {
|
|
||||||
type: 'calls',
|
|
||||||
direction: 'outgoing',
|
|
||||||
target: 'some_function',
|
|
||||||
file: 'module.py',
|
|
||||||
line: 42
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.ok(['calls', 'imports', 'extends', 'called_by', 'imported_by', 'extended_by']
|
|
||||||
.includes(validRelationship.type), 'type should be valid relationship type');
|
|
||||||
assert.ok(['outgoing', 'incoming'].includes(validRelationship.direction),
|
|
||||||
'direction should be outgoing or incoming');
|
|
||||||
assert.ok(typeof validRelationship.file === 'string', 'file should be string');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should allow optional line number', () => {
|
|
||||||
const withLine = {
|
|
||||||
type: 'calls',
|
|
||||||
direction: 'outgoing',
|
|
||||||
target: 'func',
|
|
||||||
file: 'test.py',
|
|
||||||
line: 10
|
|
||||||
};
|
|
||||||
|
|
||||||
const withoutLine = {
|
|
||||||
type: 'imports',
|
|
||||||
direction: 'outgoing',
|
|
||||||
target: 'os',
|
|
||||||
file: 'test.py'
|
|
||||||
// line is optional
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.strictEqual(withLine.line, 10);
|
|
||||||
assert.strictEqual(withoutLine.line, undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Smart Search Tool Definition', async () => {
|
|
||||||
let smartSearchModule;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
try {
|
|
||||||
smartSearchModule = await import(smartSearchPath);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Note: smart-search module not available');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct tool name', () => {
|
|
||||||
if (!smartSearchModule) {
|
|
||||||
console.log('Skipping: smart-search module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.strictEqual(smartSearchModule.schema.name, 'smart_search');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have all required parameters', () => {
|
|
||||||
if (!smartSearchModule) {
|
|
||||||
console.log('Skipping: smart-search module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = smartSearchModule.schema.inputSchema || smartSearchModule.schema.parameters;
|
|
||||||
const props = params.properties;
|
|
||||||
|
|
||||||
// Core parameters
|
|
||||||
assert.ok(props.action, 'Should have action parameter');
|
|
||||||
assert.ok(props.query, 'Should have query parameter');
|
|
||||||
assert.ok(props.path, 'Should have path parameter');
|
|
||||||
|
|
||||||
// Search parameters
|
|
||||||
assert.ok(props.mode, 'Should have mode parameter');
|
|
||||||
assert.ok(props.maxResults || props.limit, 'Should have maxResults/limit parameter');
|
|
||||||
|
|
||||||
// New enrich parameter
|
|
||||||
assert.ok(props.enrich, 'Should have enrich parameter');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should support search modes', () => {
|
|
||||||
if (!smartSearchModule) {
|
|
||||||
console.log('Skipping: smart-search module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = smartSearchModule.schema.inputSchema || smartSearchModule.schema.parameters;
|
|
||||||
const modeEnum = params.properties.mode?.enum;
|
|
||||||
|
|
||||||
assert.ok(modeEnum, 'Should have mode enum');
|
|
||||||
assert.ok(modeEnum.includes('fuzzy'), 'Should support fuzzy mode');
|
|
||||||
assert.ok(modeEnum.includes('semantic'), 'Should support semantic mode');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Enrich Flag Integration', async () => {
|
|
||||||
let codexLensModule;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
try {
|
|
||||||
const codexLensPath = new URL('../dist/tools/codex-lens.js', import.meta.url).href;
|
|
||||||
codexLensModule = await import(codexLensPath);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Note: codex-lens module not available');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('codex-lens should support enrich parameter', () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use schema export (primary) or codexLensTool (backward-compatible)
|
|
||||||
const toolDef = codexLensModule.schema || codexLensModule.codexLensTool;
|
|
||||||
assert.ok(toolDef, 'Should have schema or codexLensTool export');
|
|
||||||
|
|
||||||
// Schema uses inputSchema (MCP standard), codexLensTool uses parameters
|
|
||||||
const params = toolDef.inputSchema || toolDef.parameters;
|
|
||||||
const props = params.properties;
|
|
||||||
assert.ok(props.enrich, 'should have enrich parameter');
|
|
||||||
assert.strictEqual(props.enrich.type, 'boolean', 'enrich should be boolean');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should pass enrich flag to command line', async () => {
|
|
||||||
if (!codexLensModule) {
|
|
||||||
console.log('Skipping: codex-lens module not available');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if executeCodexLens function is exported
|
|
||||||
const { executeCodexLens } = codexLensModule;
|
|
||||||
if (executeCodexLens) {
|
|
||||||
// The function should be available for passing enrich parameter
|
|
||||||
assert.ok(typeof executeCodexLens === 'function', 'executeCodexLens should be a function');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tests for query intent detection + adaptive RRF weights (TypeScript/Python parity).
|
|
||||||
*
|
|
||||||
* References:
|
|
||||||
* - `ccw/src/tools/smart-search.ts` (detectQueryIntent, adjustWeightsByIntent, getRRFWeights)
|
|
||||||
* - `codex-lens/src/codexlens/search/hybrid_search.py` (weight intent concept + defaults)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { describe, it, before } from 'node:test';
|
|
||||||
import assert from 'node:assert';
|
|
||||||
|
|
||||||
const smartSearchPath = new URL('../dist/tools/smart-search.js', import.meta.url).href;
|
|
||||||
|
|
||||||
describe('Smart Search - Query Intent + RRF Weights', async () => {
|
|
||||||
/** @type {any} */
|
|
||||||
let smartSearchModule;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
try {
|
|
||||||
smartSearchModule = await import(smartSearchPath);
|
|
||||||
} catch (err) {
|
|
||||||
// Keep tests non-blocking for environments that haven't built `ccw/dist` yet.
|
|
||||||
console.log('Note: smart-search module import skipped:', err.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('detectQueryIntent', () => {
|
|
||||||
it('classifies "def authenticate" as keyword', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
assert.strictEqual(smartSearchModule.detectQueryIntent('def authenticate'), 'keyword');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies CamelCase identifiers as keyword', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
assert.strictEqual(smartSearchModule.detectQueryIntent('MyClass'), 'keyword');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies snake_case identifiers as keyword', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
assert.strictEqual(smartSearchModule.detectQueryIntent('user_id'), 'keyword');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies namespace separators "::" as keyword', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
assert.strictEqual(smartSearchModule.detectQueryIntent('UserService::authenticate'), 'keyword');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies pointer arrows "->" as keyword', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
assert.strictEqual(smartSearchModule.detectQueryIntent('ptr->next'), 'keyword');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies dotted member access as keyword', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
assert.strictEqual(smartSearchModule.detectQueryIntent('foo.bar'), 'keyword');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies natural language questions as semantic', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
assert.strictEqual(smartSearchModule.detectQueryIntent('how to handle user login'), 'semantic');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies interrogatives with question marks as semantic', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
assert.strictEqual(smartSearchModule.detectQueryIntent('what is authentication?'), 'semantic');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies queries with both code + NL signals as mixed', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
assert.strictEqual(smartSearchModule.detectQueryIntent('why does FooBar crash?'), 'mixed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('classifies long NL queries containing identifiers as mixed', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
assert.strictEqual(smartSearchModule.detectQueryIntent('how to use user_id in query'), 'mixed');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('classifyIntent lexical routing', () => {
|
|
||||||
it('routes config/backend queries to exact when index and embeddings are available', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
const classification = smartSearchModule.__testables.classifyIntent(
|
|
||||||
'embedding backend fastembed local litellm api config',
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
assert.strictEqual(classification.mode, 'exact');
|
|
||||||
assert.match(classification.reasoning, /lexical priority/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('routes generated artifact queries to exact when index and embeddings are available', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
const classification = smartSearchModule.__testables.classifyIntent('dist bundle output', true, true);
|
|
||||||
assert.strictEqual(classification.mode, 'exact');
|
|
||||||
assert.match(classification.reasoning, /generated artifact/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('adjustWeightsByIntent', () => {
|
|
||||||
it('maps keyword intent to exact-heavy weights', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
const weights = smartSearchModule.adjustWeightsByIntent('keyword', { exact: 0.3, fuzzy: 0.1, vector: 0.6 });
|
|
||||||
assert.deepStrictEqual(weights, { exact: 0.5, fuzzy: 0.1, vector: 0.4 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getRRFWeights parity set', () => {
|
|
||||||
it('produces stable weights for 20 representative queries', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const base = { exact: 0.3, fuzzy: 0.1, vector: 0.6 };
|
|
||||||
const expected = [
|
|
||||||
['def authenticate', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
|
||||||
['class UserService', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
|
||||||
['user_id', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
|
||||||
['MyClass', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
|
||||||
['Foo::Bar', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
|
||||||
['ptr->next', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
|
||||||
['foo.bar', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
|
||||||
['import os', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
|
||||||
['how to handle user login', { exact: 0.2, fuzzy: 0.1, vector: 0.7 }],
|
|
||||||
['what is the best way to search?', { exact: 0.2, fuzzy: 0.1, vector: 0.7 }],
|
|
||||||
['explain the authentication flow', { exact: 0.2, fuzzy: 0.1, vector: 0.7 }],
|
|
||||||
['generate embeddings for this repo', { exact: 0.2, fuzzy: 0.1, vector: 0.7 }],
|
|
||||||
['how does FooBar work', base],
|
|
||||||
['user_id how to handle', base],
|
|
||||||
['Find UserService::authenticate method', base],
|
|
||||||
['where is foo.bar used', base],
|
|
||||||
['parse_json function', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
|
||||||
['How to parse_json output?', base],
|
|
||||||
['', base],
|
|
||||||
['authentication', base],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [query, expectedWeights] of expected) {
|
|
||||||
const actual = smartSearchModule.getRRFWeights(query, base);
|
|
||||||
assert.deepStrictEqual(actual, expectedWeights, `unexpected weights for query: ${JSON.stringify(query)}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,703 +0,0 @@
|
|||||||
import { after, afterEach, before, describe, it } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
const smartSearchPath = new URL('../dist/tools/smart-search.js', import.meta.url).href;
|
|
||||||
const originalAutoInitMissing = process.env.CODEXLENS_AUTO_INIT_MISSING;
|
|
||||||
const originalAutoEmbedMissing = process.env.CODEXLENS_AUTO_EMBED_MISSING;
|
|
||||||
|
|
||||||
describe('Smart Search MCP usage defaults and path handling', async () => {
|
|
||||||
let smartSearchModule;
|
|
||||||
const tempDirs = [];
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
process.env.CODEXLENS_AUTO_INIT_MISSING = 'false';
|
|
||||||
try {
|
|
||||||
smartSearchModule = await import(smartSearchPath);
|
|
||||||
} catch (err) {
|
|
||||||
console.log('Note: smart-search module import skipped:', err?.message ?? String(err));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
if (originalAutoInitMissing === undefined) {
|
|
||||||
delete process.env.CODEXLENS_AUTO_INIT_MISSING;
|
|
||||||
} else {
|
|
||||||
process.env.CODEXLENS_AUTO_INIT_MISSING = originalAutoInitMissing;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (originalAutoEmbedMissing === undefined) {
|
|
||||||
delete process.env.CODEXLENS_AUTO_EMBED_MISSING;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
process.env.CODEXLENS_AUTO_EMBED_MISSING = originalAutoEmbedMissing;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
while (tempDirs.length > 0) {
|
|
||||||
rmSync(tempDirs.pop(), { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
if (smartSearchModule?.__testables) {
|
|
||||||
smartSearchModule.__testables.__resetRuntimeOverrides();
|
|
||||||
smartSearchModule.__testables.__resetBackgroundJobs();
|
|
||||||
}
|
|
||||||
process.env.CODEXLENS_AUTO_INIT_MISSING = 'false';
|
|
||||||
delete process.env.CODEXLENS_AUTO_EMBED_MISSING;
|
|
||||||
});
|
|
||||||
|
|
||||||
function createWorkspace() {
|
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'ccw-smart-search-'));
|
|
||||||
tempDirs.push(dir);
|
|
||||||
return dir;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDetachedChild() {
|
|
||||||
return {
|
|
||||||
on() {
|
|
||||||
return this;
|
|
||||||
},
|
|
||||||
unref() {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
it('keeps schema defaults aligned with runtime docs', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const { schema } = smartSearchModule;
|
|
||||||
const props = schema.inputSchema.properties;
|
|
||||||
|
|
||||||
assert.equal(props.maxResults.default, 5);
|
|
||||||
assert.equal(props.limit.default, 5);
|
|
||||||
assert.match(schema.description, /static FTS index/i);
|
|
||||||
assert.match(schema.description, /semantic\/vector embeddings/i);
|
|
||||||
assert.ok(props.action.enum.includes('embed'));
|
|
||||||
assert.match(props.embeddingBackend.description, /litellm\/api/i);
|
|
||||||
assert.match(props.apiMaxWorkers.description, /endpoint pool/i);
|
|
||||||
assert.match(schema.description, /apiMaxWorkers=8/i);
|
|
||||||
assert.match(props.path.description, /single file path/i);
|
|
||||||
assert.ok(props.output_mode.enum.includes('ace'));
|
|
||||||
assert.match(props.output_mode.description, /ACE-style/i);
|
|
||||||
assert.equal(props.output_mode.default, 'ace');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults auto embedding warmup off on Windows unless explicitly enabled', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const { __testables } = smartSearchModule;
|
|
||||||
delete process.env.CODEXLENS_AUTO_EMBED_MISSING;
|
|
||||||
assert.equal(__testables.isAutoEmbedMissingEnabled(undefined), process.platform !== 'win32');
|
|
||||||
assert.equal(__testables.isAutoEmbedMissingEnabled({}), process.platform !== 'win32');
|
|
||||||
assert.equal(
|
|
||||||
__testables.isAutoEmbedMissingEnabled({ embedding_auto_embed_missing: true }),
|
|
||||||
process.platform === 'win32' ? false : true,
|
|
||||||
);
|
|
||||||
assert.equal(__testables.isAutoEmbedMissingEnabled({ embedding_auto_embed_missing: false }), false);
|
|
||||||
process.env.CODEXLENS_AUTO_EMBED_MISSING = 'true';
|
|
||||||
assert.equal(__testables.isAutoEmbedMissingEnabled({ embedding_auto_embed_missing: false }), true);
|
|
||||||
process.env.CODEXLENS_AUTO_EMBED_MISSING = 'off';
|
|
||||||
assert.equal(__testables.isAutoEmbedMissingEnabled({ embedding_auto_embed_missing: true }), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults auto index warmup off on Windows unless explicitly enabled', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const { __testables } = smartSearchModule;
|
|
||||||
delete process.env.CODEXLENS_AUTO_INIT_MISSING;
|
|
||||||
assert.equal(__testables.isAutoInitMissingEnabled(), process.platform !== 'win32');
|
|
||||||
process.env.CODEXLENS_AUTO_INIT_MISSING = 'off';
|
|
||||||
assert.equal(__testables.isAutoInitMissingEnabled(), false);
|
|
||||||
process.env.CODEXLENS_AUTO_INIT_MISSING = '1';
|
|
||||||
assert.equal(__testables.isAutoInitMissingEnabled(), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('explains when Windows disables background warmup by default', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const { __testables } = smartSearchModule;
|
|
||||||
delete process.env.CODEXLENS_AUTO_INIT_MISSING;
|
|
||||||
delete process.env.CODEXLENS_AUTO_EMBED_MISSING;
|
|
||||||
|
|
||||||
const initReason = __testables.getAutoInitMissingDisabledReason();
|
|
||||||
const embedReason = __testables.getAutoEmbedMissingDisabledReason({});
|
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
assert.match(initReason, /disabled by default on Windows/i);
|
|
||||||
assert.match(embedReason, /disabled by default on Windows/i);
|
|
||||||
assert.match(embedReason, /auto_embed_missing=true/i);
|
|
||||||
} else {
|
|
||||||
assert.match(initReason, /disabled/i);
|
|
||||||
assert.match(embedReason, /disabled/i);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds hidden subprocess options for Smart Search child processes', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const options = smartSearchModule.__testables.buildSmartSearchSpawnOptions(tmpdir(), {
|
|
||||||
detached: true,
|
|
||||||
stdio: 'ignore',
|
|
||||||
timeout: 12345,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(options.cwd, tmpdir());
|
|
||||||
assert.equal(options.shell, false);
|
|
||||||
assert.equal(options.windowsHide, true);
|
|
||||||
assert.equal(options.detached, true);
|
|
||||||
assert.equal(options.timeout, 12345);
|
|
||||||
assert.equal(options.env.PYTHONIOENCODING, 'utf-8');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('avoids detached background warmup children on Windows consoles', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
smartSearchModule.__testables.shouldDetachBackgroundSmartSearchProcess(),
|
|
||||||
process.platform !== 'win32',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('checks tool availability without shell-based lookup popups', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const lookupCalls = [];
|
|
||||||
const available = smartSearchModule.__testables.checkToolAvailability(
|
|
||||||
'rg',
|
|
||||||
(command, args, options) => {
|
|
||||||
lookupCalls.push({ command, args, options });
|
|
||||||
return { status: 0, stdout: '', stderr: '' };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(available, true);
|
|
||||||
assert.equal(lookupCalls.length, 1);
|
|
||||||
assert.equal(lookupCalls[0].command, process.platform === 'win32' ? 'where' : 'which');
|
|
||||||
assert.deepEqual(lookupCalls[0].args, ['rg']);
|
|
||||||
assert.equal(lookupCalls[0].options.shell, false);
|
|
||||||
assert.equal(lookupCalls[0].options.windowsHide, true);
|
|
||||||
assert.equal(lookupCalls[0].options.stdio, 'ignore');
|
|
||||||
assert.equal(lookupCalls[0].options.env.PYTHONIOENCODING, 'utf-8');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts background static index build once for unindexed paths', async () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const { __testables } = smartSearchModule;
|
|
||||||
const dir = createWorkspace();
|
|
||||||
const fakePython = join(dir, 'python.exe');
|
|
||||||
writeFileSync(fakePython, '');
|
|
||||||
process.env.CODEXLENS_AUTO_INIT_MISSING = 'true';
|
|
||||||
|
|
||||||
const spawnCalls = [];
|
|
||||||
__testables.__setRuntimeOverrides({
|
|
||||||
getVenvPythonPath: () => fakePython,
|
|
||||||
now: () => 1234567890,
|
|
||||||
spawnProcess: (command, args, options) => {
|
|
||||||
spawnCalls.push({ command, args, options });
|
|
||||||
return createDetachedChild();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const scope = { workingDirectory: dir, searchPaths: ['.'] };
|
|
||||||
const indexStatus = { indexed: false, has_embeddings: false };
|
|
||||||
|
|
||||||
const first = await __testables.maybeStartBackgroundAutoInit(scope, indexStatus);
|
|
||||||
const second = await __testables.maybeStartBackgroundAutoInit(scope, indexStatus);
|
|
||||||
|
|
||||||
assert.match(first.note, /started/i);
|
|
||||||
assert.match(second.note, /already running/i);
|
|
||||||
assert.equal(spawnCalls.length, 1);
|
|
||||||
assert.equal(spawnCalls[0].command, fakePython);
|
|
||||||
assert.deepEqual(spawnCalls[0].args, ['-m', 'codexlens', 'index', 'init', dir, '--no-embeddings']);
|
|
||||||
assert.equal(spawnCalls[0].options.cwd, dir);
|
|
||||||
assert.equal(
|
|
||||||
spawnCalls[0].options.detached,
|
|
||||||
smartSearchModule.__testables.shouldDetachBackgroundSmartSearchProcess(),
|
|
||||||
);
|
|
||||||
assert.equal(spawnCalls[0].options.windowsHide, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts background embedding build without detached Windows consoles', async () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const { __testables } = smartSearchModule;
|
|
||||||
const dir = createWorkspace();
|
|
||||||
const fakePython = join(dir, 'python.exe');
|
|
||||||
writeFileSync(fakePython, '');
|
|
||||||
process.env.CODEXLENS_AUTO_EMBED_MISSING = 'true';
|
|
||||||
|
|
||||||
const spawnCalls = [];
|
|
||||||
__testables.__setRuntimeOverrides({
|
|
||||||
getVenvPythonPath: () => fakePython,
|
|
||||||
checkSemanticStatus: async () => ({ available: true, litellmAvailable: true }),
|
|
||||||
now: () => 1234567890,
|
|
||||||
spawnProcess: (command, args, options) => {
|
|
||||||
spawnCalls.push({ command, args, options });
|
|
||||||
return createDetachedChild();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = await __testables.maybeStartBackgroundAutoEmbed(
|
|
||||||
{ workingDirectory: dir, searchPaths: ['.'] },
|
|
||||||
{
|
|
||||||
indexed: true,
|
|
||||||
has_embeddings: false,
|
|
||||||
config: { embedding_backend: 'fastembed' },
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(status.note, /started/i);
|
|
||||||
assert.equal(spawnCalls.length, 1);
|
|
||||||
assert.equal(spawnCalls[0].command, fakePython);
|
|
||||||
assert.deepEqual(spawnCalls[0].args.slice(0, 1), ['-c']);
|
|
||||||
assert.equal(spawnCalls[0].options.cwd, dir);
|
|
||||||
assert.equal(
|
|
||||||
spawnCalls[0].options.detached,
|
|
||||||
smartSearchModule.__testables.shouldDetachBackgroundSmartSearchProcess(),
|
|
||||||
);
|
|
||||||
assert.equal(spawnCalls[0].options.windowsHide, true);
|
|
||||||
assert.equal(spawnCalls[0].options.stdio, 'ignore');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces warnings when background static index warmup cannot start', async () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const { __testables } = smartSearchModule;
|
|
||||||
const dir = createWorkspace();
|
|
||||||
process.env.CODEXLENS_AUTO_INIT_MISSING = 'true';
|
|
||||||
|
|
||||||
__testables.__setRuntimeOverrides({
|
|
||||||
getVenvPythonPath: () => join(dir, 'missing-python.exe'),
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = await __testables.maybeStartBackgroundAutoInit(
|
|
||||||
{ workingDirectory: dir, searchPaths: ['.'] },
|
|
||||||
{ indexed: false, has_embeddings: false },
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.match(status.warning, /Automatic static index warmup could not start/i);
|
|
||||||
assert.match(status.warning, /not ready yet/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('honors explicit small limit values', async () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const dir = createWorkspace();
|
|
||||||
const file = join(dir, 'many.ts');
|
|
||||||
writeFileSync(file, ['const hit = 1;', 'const hit = 2;', 'const hit = 3;'].join('\n'));
|
|
||||||
|
|
||||||
const toolResult = await smartSearchModule.handler({
|
|
||||||
action: 'search',
|
|
||||||
query: 'hit',
|
|
||||||
path: dir,
|
|
||||||
output_mode: 'full',
|
|
||||||
limit: 1,
|
|
||||||
regex: false,
|
|
||||||
tokenize: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(toolResult.success, true, toolResult.error);
|
|
||||||
assert.equal(toolResult.result.success, true);
|
|
||||||
assert.equal(toolResult.result.results.length, 1);
|
|
||||||
assert.equal(toolResult.result.metadata.pagination.limit, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('scopes search results to a single file path', async () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const dir = createWorkspace();
|
|
||||||
const target = join(dir, 'target.ts');
|
|
||||||
const other = join(dir, 'other.ts');
|
|
||||||
writeFileSync(target, 'const TARGET_TOKEN = 1;\n');
|
|
||||||
writeFileSync(other, 'const TARGET_TOKEN = 2;\n');
|
|
||||||
|
|
||||||
const toolResult = await smartSearchModule.handler({
|
|
||||||
action: 'search',
|
|
||||||
query: 'TARGET_TOKEN',
|
|
||||||
path: target,
|
|
||||||
output_mode: 'full',
|
|
||||||
regex: false,
|
|
||||||
tokenize: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(toolResult.success, true, toolResult.error);
|
|
||||||
assert.equal(toolResult.result.success, true);
|
|
||||||
assert.ok(Array.isArray(toolResult.result.results));
|
|
||||||
assert.ok(toolResult.result.results.length >= 1);
|
|
||||||
|
|
||||||
const normalizedFiles = toolResult.result.results.map((item) => String(item.file).replace(/\\/g, '/'));
|
|
||||||
assert.ok(normalizedFiles.every((file) => file.endsWith('/target.ts') || file === 'target.ts'));
|
|
||||||
assert.ok(normalizedFiles.every((file) => !file.endsWith('/other.ts')));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('normalizes wrapped multiline query and file path inputs', async () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const dir = createWorkspace();
|
|
||||||
const nestedDir = join(dir, 'hydro_generator_module', 'builders');
|
|
||||||
mkdirSync(nestedDir, { recursive: true });
|
|
||||||
const target = join(nestedDir, 'full_machine_builders.py');
|
|
||||||
writeFileSync(target, 'def _resolve_rotor_inner():\n return rotor_main_seg\n');
|
|
||||||
|
|
||||||
const wrappedPath = target.replace(/([\\/])builders([\\/])/, '$1\n builders$2');
|
|
||||||
const wrappedQuery = '_resolve_rotor_inner OR\n rotor_main_seg';
|
|
||||||
|
|
||||||
const toolResult = await smartSearchModule.handler({
|
|
||||||
action: 'search',
|
|
||||||
query: wrappedQuery,
|
|
||||||
path: wrappedPath,
|
|
||||||
output_mode: 'full',
|
|
||||||
regex: false,
|
|
||||||
caseSensitive: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(toolResult.success, true, toolResult.error);
|
|
||||||
assert.equal(toolResult.result.success, true);
|
|
||||||
assert.ok(toolResult.result.results.length >= 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to literal ripgrep matching for invalid regex-like code queries', async () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const dir = createWorkspace();
|
|
||||||
const target = join(dir, 'component.ts');
|
|
||||||
writeFileSync(target, 'defineExpose({ handleResize });\n');
|
|
||||||
|
|
||||||
const toolResult = await smartSearchModule.handler({
|
|
||||||
action: 'search',
|
|
||||||
query: 'defineExpose({ handleResize',
|
|
||||||
path: dir,
|
|
||||||
output_mode: 'full',
|
|
||||||
limit: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(toolResult.success, true, toolResult.error);
|
|
||||||
assert.equal(toolResult.result.success, true);
|
|
||||||
assert.ok(toolResult.result.results.length >= 1);
|
|
||||||
assert.match(toolResult.result.metadata.warning, /literal ripgrep matching/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders grouped ace-style output by default with multi-line chunks', async () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const dir = createWorkspace();
|
|
||||||
const target = join(dir, 'ace-target.ts');
|
|
||||||
writeFileSync(target, [
|
|
||||||
'const before = 1;',
|
|
||||||
'const TARGET_TOKEN = 1;',
|
|
||||||
'const after = 2;',
|
|
||||||
'',
|
|
||||||
'function useToken() {',
|
|
||||||
' return TARGET_TOKEN;',
|
|
||||||
'}',
|
|
||||||
].join('\n'));
|
|
||||||
|
|
||||||
const toolResult = await smartSearchModule.handler({
|
|
||||||
action: 'search',
|
|
||||||
query: 'TARGET_TOKEN',
|
|
||||||
path: dir,
|
|
||||||
contextLines: 1,
|
|
||||||
regex: false,
|
|
||||||
tokenize: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(toolResult.success, true, toolResult.error);
|
|
||||||
assert.equal(toolResult.result.success, true);
|
|
||||||
assert.equal(toolResult.result.results.format, 'ace');
|
|
||||||
assert.equal(Array.isArray(toolResult.result.results.groups), true);
|
|
||||||
assert.equal(Array.isArray(toolResult.result.results.sections), true);
|
|
||||||
assert.equal(toolResult.result.results.groups.length, 1);
|
|
||||||
assert.equal(toolResult.result.results.groups[0].sections.length, 2);
|
|
||||||
assert.match(toolResult.result.results.text, /The following code sections were retrieved:/);
|
|
||||||
assert.match(toolResult.result.results.text, /Path: .*ace-target\.ts/);
|
|
||||||
assert.match(toolResult.result.results.text, /Chunk 1: lines 1-3/);
|
|
||||||
assert.match(toolResult.result.results.text, />\s+2 \| const TARGET_TOKEN = 1;/);
|
|
||||||
assert.match(toolResult.result.results.text, /Chunk 2: lines 5-7/);
|
|
||||||
assert.equal(toolResult.result.metadata.pagination.total >= 1, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('defaults embed selection to local-fast for bulk indexing', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const selection = smartSearchModule.__testables.resolveEmbeddingSelection(undefined, undefined, {
|
|
||||||
embedding_backend: 'litellm',
|
|
||||||
embedding_model: 'qwen3-embedding-sf',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(selection.backend, 'fastembed');
|
|
||||||
assert.equal(selection.model, 'fast');
|
|
||||||
assert.equal(selection.preset, 'bulk-local-fast');
|
|
||||||
assert.match(selection.note, /local-fast/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps explicit api embedding selection when requested', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const selection = smartSearchModule.__testables.resolveEmbeddingSelection('api', 'qwen3-embedding-sf', {
|
|
||||||
embedding_backend: 'fastembed',
|
|
||||||
embedding_model: 'fast',
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(selection.backend, 'litellm');
|
|
||||||
assert.equal(selection.model, 'qwen3-embedding-sf');
|
|
||||||
assert.equal(selection.preset, 'explicit');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses warning-prefixed JSON and plain-text file lists for semantic fallback', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const dir = createWorkspace();
|
|
||||||
const target = join(dir, 'target.ts');
|
|
||||||
writeFileSync(target, 'export const target = 1;\n');
|
|
||||||
|
|
||||||
const parsed = smartSearchModule.__testables.parseCodexLensJsonOutput([
|
|
||||||
'RuntimeWarning: compatibility shim',
|
|
||||||
JSON.stringify({ results: [{ file: 'target.ts', score: 0.25, excerpt: 'target' }] }),
|
|
||||||
].join('\n'));
|
|
||||||
assert.equal(Array.isArray(parsed.results), true);
|
|
||||||
assert.equal(parsed.results[0].file, 'target.ts');
|
|
||||||
|
|
||||||
const matches = smartSearchModule.__testables.parsePlainTextFileMatches(target, {
|
|
||||||
workingDirectory: dir,
|
|
||||||
searchPaths: ['.'],
|
|
||||||
});
|
|
||||||
assert.equal(matches.length, 1);
|
|
||||||
assert.match(String(matches[0].file).replace(/\\/g, '/'), /target\.ts$/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses root-scoped embedding status instead of subtree artifacts', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const summary = smartSearchModule.__testables.extractEmbeddingsStatusSummary({
|
|
||||||
total_indexes: 3,
|
|
||||||
indexes_with_embeddings: 2,
|
|
||||||
total_chunks: 24,
|
|
||||||
coverage_percent: 66.7,
|
|
||||||
root: {
|
|
||||||
total_files: 4,
|
|
||||||
files_with_embeddings: 0,
|
|
||||||
total_chunks: 0,
|
|
||||||
coverage_percent: 0,
|
|
||||||
has_embeddings: false,
|
|
||||||
},
|
|
||||||
subtree: {
|
|
||||||
total_indexes: 3,
|
|
||||||
indexes_with_embeddings: 2,
|
|
||||||
total_files: 12,
|
|
||||||
files_with_embeddings: 8,
|
|
||||||
total_chunks: 24,
|
|
||||||
coverage_percent: 66.7,
|
|
||||||
},
|
|
||||||
centralized: {
|
|
||||||
dense_index_exists: true,
|
|
||||||
binary_index_exists: true,
|
|
||||||
meta_db_exists: true,
|
|
||||||
usable: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(summary.coveragePercent, 0);
|
|
||||||
assert.equal(summary.totalChunks, 0);
|
|
||||||
assert.equal(summary.hasEmbeddings, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts validated root centralized readiness from CLI status payloads', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const summary = smartSearchModule.__testables.extractEmbeddingsStatusSummary({
|
|
||||||
total_indexes: 2,
|
|
||||||
indexes_with_embeddings: 1,
|
|
||||||
total_chunks: 10,
|
|
||||||
coverage_percent: 25,
|
|
||||||
root: {
|
|
||||||
total_files: 2,
|
|
||||||
files_with_embeddings: 1,
|
|
||||||
total_chunks: 3,
|
|
||||||
coverage_percent: 50,
|
|
||||||
has_embeddings: true,
|
|
||||||
},
|
|
||||||
centralized: {
|
|
||||||
usable: true,
|
|
||||||
dense_ready: true,
|
|
||||||
chunk_metadata_rows: 3,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(summary.coveragePercent, 50);
|
|
||||||
assert.equal(summary.totalChunks, 3);
|
|
||||||
assert.equal(summary.hasEmbeddings, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prefers embeddings_status over legacy embeddings summary payloads', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const payload = smartSearchModule.__testables.selectEmbeddingsStatusPayload({
|
|
||||||
embeddings: {
|
|
||||||
total_indexes: 7,
|
|
||||||
indexes_with_embeddings: 4,
|
|
||||||
total_chunks: 99,
|
|
||||||
},
|
|
||||||
embeddings_status: {
|
|
||||||
total_indexes: 7,
|
|
||||||
total_chunks: 3,
|
|
||||||
root: {
|
|
||||||
total_files: 2,
|
|
||||||
files_with_embeddings: 1,
|
|
||||||
total_chunks: 3,
|
|
||||||
coverage_percent: 50,
|
|
||||||
has_embeddings: true,
|
|
||||||
},
|
|
||||||
centralized: {
|
|
||||||
usable: true,
|
|
||||||
dense_ready: true,
|
|
||||||
chunk_metadata_rows: 3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(payload.root.total_chunks, 3);
|
|
||||||
assert.equal(payload.centralized.usable, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('recognizes CodexLens CLI compatibility failures and invalid regex fallback', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const compatibilityError = [
|
|
||||||
'UsageError: Got unexpected extra arguments (20 0 fts)',
|
|
||||||
'TypeError: TyperArgument.make_metavar() takes 1 positional argument but 2 were given',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
smartSearchModule.__testables.isCodexLensCliCompatibilityError(compatibilityError),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
const resolution = smartSearchModule.__testables.resolveRipgrepQueryMode(
|
|
||||||
'defineExpose({ handleResize',
|
|
||||||
true,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(resolution.regex, false);
|
|
||||||
assert.equal(resolution.literalFallback, true);
|
|
||||||
assert.match(resolution.warning, /literal ripgrep matching/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suppresses compatibility-only fuzzy warnings when ripgrep already produced hits', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
smartSearchModule.__testables.shouldSurfaceCodexLensFtsCompatibilityWarning({
|
|
||||||
compatibilityTriggeredThisQuery: true,
|
|
||||||
skipExactDueToCompatibility: false,
|
|
||||||
ripgrepResultCount: 2,
|
|
||||||
}),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
smartSearchModule.__testables.shouldSurfaceCodexLensFtsCompatibilityWarning({
|
|
||||||
compatibilityTriggeredThisQuery: true,
|
|
||||||
skipExactDueToCompatibility: false,
|
|
||||||
ripgrepResultCount: 0,
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
smartSearchModule.__testables.shouldSurfaceCodexLensFtsCompatibilityWarning({
|
|
||||||
compatibilityTriggeredThisQuery: false,
|
|
||||||
skipExactDueToCompatibility: true,
|
|
||||||
ripgrepResultCount: 0,
|
|
||||||
}),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('builds actionable index suggestions for unhealthy index states', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const suggestions = smartSearchModule.__testables.buildIndexSuggestions(
|
|
||||||
{
|
|
||||||
indexed: true,
|
|
||||||
has_embeddings: false,
|
|
||||||
embeddings_coverage_percent: 0,
|
|
||||||
warning: 'Index exists but no embeddings generated. Run smart_search(action="embed") to build the vector index.',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
workingDirectory: 'D:/tmp/demo',
|
|
||||||
searchPaths: ['.'],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.equal(Array.isArray(suggestions), true);
|
|
||||||
assert.match(suggestions[0].command, /smart_search\(action="embed"/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('surfaces backend failure details when fuzzy search fully fails', async () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const missingPath = join(createWorkspace(), 'missing-folder', 'missing.ts');
|
|
||||||
const toolResult = await smartSearchModule.handler({
|
|
||||||
action: 'search',
|
|
||||||
query: 'TARGET_TOKEN',
|
|
||||||
path: missingPath,
|
|
||||||
output_mode: 'full',
|
|
||||||
regex: false,
|
|
||||||
tokenize: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(toolResult.success, false);
|
|
||||||
assert.match(toolResult.error, /Both search backends failed:/);
|
|
||||||
assert.match(toolResult.error, /(FTS|Ripgrep)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns structured semantic results after local init and embed without JSON parse warnings', async () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
|
|
||||||
const codexLensModule = await import(new URL(`../dist/tools/codex-lens.js?smart-semantic=${Date.now()}`, import.meta.url).href);
|
|
||||||
const ready = await codexLensModule.checkVenvStatus(true);
|
|
||||||
if (!ready.ready) {
|
|
||||||
console.log('Skipping: CodexLens not ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const semantic = await codexLensModule.checkSemanticStatus();
|
|
||||||
if (!semantic.available) {
|
|
||||||
console.log('Skipping: semantic dependencies not ready');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dir = createWorkspace();
|
|
||||||
writeFileSync(
|
|
||||||
join(dir, 'sample.ts'),
|
|
||||||
'export function parseCodexLensOutput() { return stripAnsiOutput(); }\nexport const sum = (a, b) => a + b;\n',
|
|
||||||
);
|
|
||||||
|
|
||||||
const init = await smartSearchModule.handler({ action: 'init', path: dir });
|
|
||||||
assert.equal(init.success, true, init.error ?? 'Expected init to succeed');
|
|
||||||
|
|
||||||
const embed = await smartSearchModule.handler({
|
|
||||||
action: 'embed',
|
|
||||||
path: dir,
|
|
||||||
embeddingBackend: 'local',
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
assert.equal(embed.success, true, embed.error ?? 'Expected local embed to succeed');
|
|
||||||
|
|
||||||
const search = await smartSearchModule.handler({
|
|
||||||
action: 'search',
|
|
||||||
mode: 'semantic',
|
|
||||||
path: dir,
|
|
||||||
query: 'parse CodexLens output strip ANSI',
|
|
||||||
limit: 5,
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.equal(search.success, true, search.error ?? 'Expected semantic search to succeed');
|
|
||||||
assert.equal(search.result.success, true);
|
|
||||||
assert.equal(search.result.results.format, 'ace');
|
|
||||||
assert.ok(search.result.results.total >= 1, 'Expected at least one structured semantic match');
|
|
||||||
assert.doesNotMatch(search.result.metadata?.warning ?? '', /Failed to parse JSON output/i);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
/**
|
|
||||||
* TypeScript parity tests for query intent detection + adaptive RRF weights.
|
|
||||||
*
|
|
||||||
* Notes:
|
|
||||||
* - These tests target the runtime implementation shipped in `ccw/dist`.
|
|
||||||
* - Keep logic aligned with Python: `codex-lens/src/codexlens/search/ranking.py`.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { before, describe, it } from 'node:test';
|
|
||||||
import assert from 'node:assert';
|
|
||||||
|
|
||||||
const smartSearchPath = new URL('../dist/tools/smart-search.js', import.meta.url).href;
|
|
||||||
|
|
||||||
describe('Smart Search (TS) - Query Intent + RRF Weights', async () => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
let smartSearchModule: any;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
try {
|
|
||||||
smartSearchModule = await import(smartSearchPath);
|
|
||||||
} catch (err: any) {
|
|
||||||
// Keep tests non-blocking for environments that haven't built `ccw/dist` yet.
|
|
||||||
console.log('Note: smart-search module import skipped:', err?.message ?? String(err));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('detectQueryIntent parity (10 cases)', () => {
|
|
||||||
const cases: Array<[string, 'keyword' | 'semantic' | 'mixed']> = [
|
|
||||||
['def authenticate', 'keyword'],
|
|
||||||
['MyClass', 'keyword'],
|
|
||||||
['user_id', 'keyword'],
|
|
||||||
['UserService::authenticate', 'keyword'],
|
|
||||||
['ptr->next', 'keyword'],
|
|
||||||
['how to handle user login', 'semantic'],
|
|
||||||
['what is authentication?', 'semantic'],
|
|
||||||
['where is this used?', 'semantic'],
|
|
||||||
['why does FooBar crash?', 'mixed'],
|
|
||||||
['how to use user_id in query', 'mixed'],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [query, expected] of cases) {
|
|
||||||
it(`classifies ${JSON.stringify(query)} as ${expected}`, () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
assert.strictEqual(smartSearchModule.detectQueryIntent(query), expected);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('adaptive weights (Python parity thresholds)', () => {
|
|
||||||
it('uses exact-heavy weights for code-like queries (exact > 0.4)', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
const weights = smartSearchModule.getRRFWeights('def authenticate', {
|
|
||||||
exact: 0.3,
|
|
||||||
fuzzy: 0.1,
|
|
||||||
vector: 0.6,
|
|
||||||
});
|
|
||||||
assert.ok(weights.exact > 0.4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses vector-heavy weights for NL queries (vector > 0.6)', () => {
|
|
||||||
if (!smartSearchModule) return;
|
|
||||||
const weights = smartSearchModule.getRRFWeights('how to handle user login', {
|
|
||||||
exact: 0.3,
|
|
||||||
fuzzy: 0.1,
|
|
||||||
vector: 0.6,
|
|
||||||
});
|
|
||||||
assert.ok(weights.vector > 0.6);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { after, beforeEach, describe, it } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { EventEmitter } from 'node:events';
|
|
||||||
import { createRequire } from 'node:module';
|
|
||||||
import { mkdtempSync, rmSync } from 'node:fs';
|
|
||||||
import { tmpdir } from 'node:os';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
const fs = require('node:fs') as typeof import('node:fs');
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
||||||
const childProcess = require('node:child_process') as typeof import('node:child_process');
|
|
||||||
|
|
||||||
class FakeChildProcess extends EventEmitter {
|
|
||||||
stdout = new EventEmitter();
|
|
||||||
stderr = new EventEmitter();
|
|
||||||
stdinChunks: string[] = [];
|
|
||||||
stdin = {
|
|
||||||
write: (chunk: string | Buffer) => {
|
|
||||||
this.stdinChunks.push(String(chunk));
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
end: () => undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type SpawnCall = {
|
|
||||||
command: string;
|
|
||||||
args: string[];
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
options: any;
|
|
||||||
child: FakeChildProcess;
|
|
||||||
};
|
|
||||||
|
|
||||||
const spawnCalls: SpawnCall[] = [];
|
|
||||||
const tempDirs: string[] = [];
|
|
||||||
let embedderAvailable = true;
|
|
||||||
|
|
||||||
const originalExistsSync = fs.existsSync;
|
|
||||||
const originalSpawn = childProcess.spawn;
|
|
||||||
|
|
||||||
fs.existsSync = ((..._args: unknown[]) => embedderAvailable) as typeof fs.existsSync;
|
|
||||||
|
|
||||||
childProcess.spawn = ((command: string, args: string[] = [], options: unknown = {}) => {
|
|
||||||
const child = new FakeChildProcess();
|
|
||||||
spawnCalls.push({ command: String(command), args: args.map(String), options, child });
|
|
||||||
|
|
||||||
queueMicrotask(() => {
|
|
||||||
child.stdout.emit('data', JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
total_chunks: 4,
|
|
||||||
hnsw_available: true,
|
|
||||||
hnsw_count: 4,
|
|
||||||
dimension: 384,
|
|
||||||
}));
|
|
||||||
child.emit('close', 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
return child as unknown as ReturnType<typeof childProcess.spawn>;
|
|
||||||
}) as typeof childProcess.spawn;
|
|
||||||
|
|
||||||
after(() => {
|
|
||||||
fs.existsSync = originalExistsSync;
|
|
||||||
childProcess.spawn = originalSpawn;
|
|
||||||
while (tempDirs.length > 0) {
|
|
||||||
rmSync(tempDirs.pop() as string, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('unified-vector-index', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
embedderAvailable = true;
|
|
||||||
spawnCalls.length = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('spawns CodexLens venv python with hidden window options', async () => {
|
|
||||||
const projectDir = mkdtempSync(join(tmpdir(), 'ccw-unified-vector-index-'));
|
|
||||||
tempDirs.push(projectDir);
|
|
||||||
|
|
||||||
const moduleUrl = new URL('../dist/core/unified-vector-index.js', import.meta.url);
|
|
||||||
moduleUrl.searchParams.set('t', String(Date.now()));
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const mod: any = await import(moduleUrl.href);
|
|
||||||
|
|
||||||
const index = new mod.UnifiedVectorIndex(projectDir);
|
|
||||||
const status = await index.getStatus();
|
|
||||||
|
|
||||||
assert.equal(status.success, true);
|
|
||||||
assert.equal(spawnCalls.length, 1);
|
|
||||||
assert.equal(spawnCalls[0].options.shell, false);
|
|
||||||
assert.equal(spawnCalls[0].options.windowsHide, true);
|
|
||||||
assert.equal(spawnCalls[0].options.env.PYTHONIOENCODING, 'utf-8');
|
|
||||||
assert.deepEqual(spawnCalls[0].options.stdio, ['pipe', 'pipe', 'pipe']);
|
|
||||||
assert.match(spawnCalls[0].child.stdinChunks.join(''), /"operation":"status"/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user