claude-code-workflow 6.2.6 → 6.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -98,15 +98,17 @@ async function loadCodexLensStatus() {
98
98
  }
99
99
  window.cliToolsStatus.codexlens = {
100
100
  installed: data.ready || false,
101
- version: data.version || null
101
+ version: data.version || null,
102
+ installedModels: [] // Will be populated by loadSemanticStatus
102
103
  };
103
104
 
104
105
  // Update CodexLens badge
105
106
  updateCodexLensBadge();
106
107
 
107
- // If CodexLens is ready, also check semantic status
108
+ // If CodexLens is ready, also check semantic status and models
108
109
  if (data.ready) {
109
110
  await loadSemanticStatus();
111
+ await loadInstalledModels();
110
112
  }
111
113
 
112
114
  return data;
@@ -132,6 +134,37 @@ async function loadSemanticStatus() {
132
134
  }
133
135
  }
134
136
 
137
+ /**
138
+ * Load installed embedding models
139
+ */
140
+ async function loadInstalledModels() {
141
+ try {
142
+ const response = await fetch('/api/codexlens/models');
143
+ if (!response.ok) throw new Error('Failed to load models');
144
+ const data = await response.json();
145
+
146
+ if (data.success && data.result && data.result.models) {
147
+ // Filter to only installed models
148
+ const installedModels = data.result.models
149
+ .filter(m => m.installed)
150
+ .map(m => m.profile);
151
+
152
+ // Update window.cliToolsStatus
153
+ if (window.cliToolsStatus && window.cliToolsStatus.codexlens) {
154
+ window.cliToolsStatus.codexlens.installedModels = installedModels;
155
+ window.cliToolsStatus.codexlens.allModels = data.result.models;
156
+ }
157
+
158
+ console.log('[CLI Status] Installed models:', installedModels);
159
+ return installedModels;
160
+ }
161
+ return [];
162
+ } catch (err) {
163
+ console.error('Failed to load installed models:', err);
164
+ return [];
165
+ }
166
+ }
167
+
135
168
  // ========== Badge Update ==========
136
169
  function updateCliBadge() {
137
170
  const badge = document.getElementById('badgeCliTools');
@@ -349,6 +349,50 @@ function getSelectedModel() {
349
349
  return select ? select.value : 'code';
350
350
  }
351
351
 
352
+ /**
353
+ * Build model select options HTML, showing only installed models
354
+ * @returns {string} HTML string for select options
355
+ */
356
+ function buildModelSelectOptions() {
357
+ var installedModels = window.cliToolsStatus?.codexlens?.installedModels || [];
358
+ var allModels = window.cliToolsStatus?.codexlens?.allModels || [];
359
+
360
+ // Model display configuration
361
+ var modelConfig = {
362
+ 'code': { label: t('index.modelCode') || 'Code (768d)', star: true },
363
+ 'base': { label: t('index.modelBase') || 'Base (768d)', star: false },
364
+ 'fast': { label: t('index.modelFast') || 'Fast (384d)', star: false },
365
+ 'minilm': { label: t('index.modelMinilm') || 'MiniLM (384d)', star: false },
366
+ 'multilingual': { label: t('index.modelMultilingual') || 'Multilingual (1024d)', warn: true },
367
+ 'balanced': { label: t('index.modelBalanced') || 'Balanced (1024d)', warn: true }
368
+ };
369
+
370
+ // If no models installed, show placeholder
371
+ if (installedModels.length === 0) {
372
+ return '<option value="" disabled selected>' + (t('index.noModelsInstalled') || 'No models installed') + '</option>';
373
+ }
374
+
375
+ // Build options for installed models only
376
+ var options = '';
377
+ var firstInstalled = null;
378
+
379
+ // Preferred order: code, fast, minilm, base, multilingual, balanced
380
+ var preferredOrder = ['code', 'fast', 'minilm', 'base', 'multilingual', 'balanced'];
381
+
382
+ preferredOrder.forEach(function(profile) {
383
+ if (installedModels.includes(profile) && modelConfig[profile]) {
384
+ var config = modelConfig[profile];
385
+ var style = config.warn ? ' style="color: var(--muted-foreground)"' : '';
386
+ var suffix = config.star ? ' ⭐' : (config.warn ? ' ⚠️' : '');
387
+ var selected = !firstInstalled ? ' selected' : '';
388
+ if (!firstInstalled) firstInstalled = profile;
389
+ options += '<option value="' + profile + '"' + style + selected + '>' + config.label + suffix + '</option>';
390
+ }
391
+ });
392
+
393
+ return options;
394
+ }
395
+
352
396
  // ========== Tools Section (Left Column) ==========
353
397
  function renderToolsSection() {
354
398
  var container = document.getElementById('tools-section');
@@ -404,12 +448,7 @@ function renderToolsSection() {
404
448
  (codexLensStatus.ready
405
449
  ? '<span class="tool-status-text success"><i data-lucide="check-circle" class="w-3.5 h-3.5"></i> v' + (codexLensStatus.version || 'installed') + '</span>' +
406
450
  '<select id="codexlensModelSelect" class="btn-sm bg-muted border border-border rounded text-xs" onclick="event.stopPropagation()" title="' + (t('index.selectModel') || 'Select embedding model') + '">' +
407
- '<option value="code">' + (t('index.modelCode') || 'Code (768d)') + ' ⭐</option>' +
408
- '<option value="base">' + (t('index.modelBase') || 'Base (768d)') + '</option>' +
409
- '<option value="fast">' + (t('index.modelFast') || 'Fast (384d)') + '</option>' +
410
- '<option value="minilm">' + (t('index.modelMinilm') || 'MiniLM (384d)') + '</option>' +
411
- '<option value="multilingual" style="color: var(--muted-foreground)">' + (t('index.modelMultilingual') || 'Multilingual (1024d)') + ' ⚠️</option>' +
412
- '<option value="balanced" style="color: var(--muted-foreground)">' + (t('index.modelBalanced') || 'Balanced (1024d)') + ' ⚠️</option>' +
451
+ buildModelSelectOptions() +
413
452
  '</select>' +
414
453
  '<button class="btn-sm btn-primary" onclick="event.stopPropagation(); initCodexLensIndex(\'full\', getSelectedModel())" title="' + (t('index.fullDesc') || 'FTS + Semantic search (recommended)') + '"><i data-lucide="layers" class="w-3 h-3"></i> ' + (t('index.fullIndex') || '全部索引') + '</button>' +
415
454
  '<button class="btn-sm btn-outline" onclick="event.stopPropagation(); initCodexLensIndex(\'vector\', getSelectedModel())" title="' + (t('index.vectorDesc') || 'Semantic search with embeddings') + '"><i data-lucide="sparkles" class="w-3 h-3"></i> ' + (t('index.vectorIndex') || '向量索引') + '</button>' +
@@ -35,8 +35,17 @@ from .output import (
35
35
  app = typer.Typer(help="CodexLens CLI — local code indexing and search.")
36
36
 
37
37
 
38
- def _configure_logging(verbose: bool) -> None:
39
- level = logging.DEBUG if verbose else logging.INFO
38
+ def _configure_logging(verbose: bool, json_mode: bool = False) -> None:
39
+ """Configure logging level.
40
+
41
+ In JSON mode, suppress INFO logs to keep stderr clean for error parsing.
42
+ Only WARNING and above are shown to avoid mixing logs with JSON output.
43
+ """
44
+ if json_mode and not verbose:
45
+ # In JSON mode, suppress INFO logs to keep stderr clean
46
+ level = logging.WARNING
47
+ else:
48
+ level = logging.DEBUG if verbose else logging.INFO
40
49
  logging.basicConfig(level=level, format="%(levelname)s %(message)s")
41
50
 
42
51
 
@@ -95,7 +104,7 @@ def init(
95
104
  If semantic search dependencies are installed, automatically generates embeddings
96
105
  after indexing completes. Use --no-embeddings to skip this step.
97
106
  """
98
- _configure_logging(verbose)
107
+ _configure_logging(verbose, json_mode)
99
108
  config = Config()
100
109
  languages = _parse_languages(language)
101
110
  base_path = path.expanduser().resolve()
@@ -314,7 +323,7 @@ def search(
314
323
  # Force hybrid mode
315
324
  codexlens search "authentication" --mode hybrid
316
325
  """
317
- _configure_logging(verbose)
326
+ _configure_logging(verbose, json_mode)
318
327
  search_path = path.expanduser().resolve()
319
328
 
320
329
  # Validate mode
@@ -487,7 +496,7 @@ def symbol(
487
496
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."),
488
497
  ) -> None:
489
498
  """Look up symbols by name and optional kind."""
490
- _configure_logging(verbose)
499
+ _configure_logging(verbose, json_mode)
491
500
  search_path = path.expanduser().resolve()
492
501
 
493
502
  registry: RegistryStore | None = None
@@ -538,7 +547,7 @@ def inspect(
538
547
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."),
539
548
  ) -> None:
540
549
  """Analyze a single file and display symbols."""
541
- _configure_logging(verbose)
550
+ _configure_logging(verbose, json_mode)
542
551
  config = Config()
543
552
  factory = ParserFactory(config)
544
553
 
@@ -588,7 +597,7 @@ def status(
588
597
  verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable debug logging."),
589
598
  ) -> None:
590
599
  """Show index status and configuration."""
591
- _configure_logging(verbose)
600
+ _configure_logging(verbose, json_mode)
592
601
 
593
602
  registry: RegistryStore | None = None
594
603
  try:
@@ -648,7 +657,7 @@ def status(
648
657
  # Embedding manager not available
649
658
  pass
650
659
  except Exception as e:
651
- logger.debug(f"Failed to get embeddings status: {e}")
660
+ logging.debug(f"Failed to get embeddings status: {e}")
652
661
 
653
662
  stats = {
654
663
  "index_root": str(index_root),
@@ -737,7 +746,7 @@ def projects(
737
746
  - show <path>: Show details for a specific project
738
747
  - remove <path>: Remove a project from the registry
739
748
  """
740
- _configure_logging(verbose)
749
+ _configure_logging(verbose, json_mode)
741
750
 
742
751
  registry: RegistryStore | None = None
743
752
  try:
@@ -892,7 +901,7 @@ def config(
892
901
  Config keys:
893
902
  - index_dir: Directory to store indexes (default: ~/.codexlens/indexes)
894
903
  """
895
- _configure_logging(verbose)
904
+ _configure_logging(verbose, json_mode)
896
905
 
897
906
  config_file = Path.home() / ".codexlens" / "config.json"
898
907
 
@@ -1057,7 +1066,7 @@ def migrate(
1057
1066
  This is a safe operation that preserves all existing data.
1058
1067
  Progress is shown during migration.
1059
1068
  """
1060
- _configure_logging(verbose)
1069
+ _configure_logging(verbose, json_mode)
1061
1070
  base_path = path.expanduser().resolve()
1062
1071
 
1063
1072
  registry: RegistryStore | None = None
@@ -1183,7 +1192,7 @@ def clean(
1183
1192
  With path, removes that project's indexes.
1184
1193
  With --all, removes all indexes (use with caution).
1185
1194
  """
1186
- _configure_logging(verbose)
1195
+ _configure_logging(verbose, json_mode)
1187
1196
 
1188
1197
  try:
1189
1198
  mapper = PathMapper()
@@ -1329,7 +1338,7 @@ def semantic_list(
1329
1338
  Shows files that have LLM-generated summaries and keywords.
1330
1339
  Results are aggregated from all index databases in the project.
1331
1340
  """
1332
- _configure_logging(verbose)
1341
+ _configure_logging(verbose, json_mode)
1333
1342
  base_path = path.expanduser().resolve()
1334
1343
 
1335
1344
  registry: Optional[RegistryStore] = None
@@ -1798,7 +1807,7 @@ def embeddings_generate(
1798
1807
  codexlens embeddings-generate ~/.codexlens/indexes/project/_index.db # Specific index
1799
1808
  codexlens embeddings-generate ~/projects/my-app --model fast --force # Regenerate with fast model
1800
1809
  """
1801
- _configure_logging(verbose)
1810
+ _configure_logging(verbose, json_mode)
1802
1811
 
1803
1812
  from codexlens.cli.embedding_manager import generate_embeddings, generate_embeddings_recursive
1804
1813
 
@@ -279,6 +279,21 @@ def generate_embeddings(
279
279
 
280
280
  try:
281
281
  with VectorStore(index_path) as vector_store:
282
+ # Check model compatibility with existing embeddings
283
+ if not force:
284
+ is_compatible, warning = vector_store.check_model_compatibility(
285
+ model_profile, embedder.model_name, embedder.embedding_dim
286
+ )
287
+ if not is_compatible:
288
+ return {
289
+ "success": False,
290
+ "error": warning,
291
+ }
292
+
293
+ # Set/update model configuration for this index
294
+ vector_store.set_model_config(
295
+ model_profile, embedder.model_name, embedder.embedding_dim
296
+ )
282
297
  # Use bulk insert mode for efficient batch ANN index building
283
298
  # This defers ANN updates until end_bulk_insert() is called
284
299
  with vector_store.bulk_insert():
@@ -1,311 +1,337 @@
1
- """Model Manager - Manage fastembed models for semantic search."""
2
-
3
- import json
4
- import os
5
- import shutil
6
- from pathlib import Path
7
- from typing import Dict, List, Optional
8
-
9
- try:
10
- from fastembed import TextEmbedding
11
- FASTEMBED_AVAILABLE = True
12
- except ImportError:
13
- FASTEMBED_AVAILABLE = False
14
-
15
-
16
- # Model profiles with metadata
17
- # Note: 768d is max recommended dimension for optimal performance/quality balance
18
- # 1024d models are available but not recommended due to higher resource usage
19
- MODEL_PROFILES = {
20
- "fast": {
21
- "model_name": "BAAI/bge-small-en-v1.5",
22
- "dimensions": 384,
23
- "size_mb": 80,
24
- "description": "Fast, lightweight, English-optimized",
25
- "use_case": "Quick prototyping, resource-constrained environments",
26
- "recommended": True,
27
- },
28
- "base": {
29
- "model_name": "BAAI/bge-base-en-v1.5",
30
- "dimensions": 768,
31
- "size_mb": 220,
32
- "description": "General purpose, good balance of speed and quality",
33
- "use_case": "General text search, documentation",
34
- "recommended": True,
35
- },
36
- "code": {
37
- "model_name": "jinaai/jina-embeddings-v2-base-code",
38
- "dimensions": 768,
39
- "size_mb": 150,
40
- "description": "Code-optimized, best for programming languages",
41
- "use_case": "Open source projects, code semantic search",
42
- "recommended": True,
43
- },
44
- "minilm": {
45
- "model_name": "sentence-transformers/all-MiniLM-L6-v2",
46
- "dimensions": 384,
47
- "size_mb": 90,
48
- "description": "Popular lightweight model, good quality",
49
- "use_case": "General purpose, low resource environments",
50
- "recommended": True,
51
- },
52
- "multilingual": {
53
- "model_name": "intfloat/multilingual-e5-large",
54
- "dimensions": 1024,
55
- "size_mb": 1000,
56
- "description": "Multilingual + code support (high resource usage)",
57
- "use_case": "Enterprise multilingual projects",
58
- "recommended": False, # 1024d not recommended
59
- },
60
- "balanced": {
61
- "model_name": "mixedbread-ai/mxbai-embed-large-v1",
62
- "dimensions": 1024,
63
- "size_mb": 600,
64
- "description": "High accuracy, general purpose (high resource usage)",
65
- "use_case": "High-quality semantic search, balanced performance",
66
- "recommended": False, # 1024d not recommended
67
- },
68
- }
69
-
70
-
71
- def get_cache_dir() -> Path:
72
- """Get fastembed cache directory.
73
-
74
- Returns:
75
- Path to cache directory (usually ~/.cache/fastembed or %LOCALAPPDATA%\\Temp\\fastembed_cache)
76
- """
77
- # Check HF_HOME environment variable first
78
- if "HF_HOME" in os.environ:
79
- return Path(os.environ["HF_HOME"])
80
-
81
- # Default cache locations
82
- if os.name == "nt": # Windows
83
- cache_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "Temp" / "fastembed_cache"
84
- else: # Unix-like
85
- cache_dir = Path.home() / ".cache" / "fastembed"
86
-
87
- return cache_dir
88
-
89
-
90
- def list_models() -> Dict[str, any]:
91
- """List available model profiles and their installation status.
92
-
93
- Returns:
94
- Dictionary with model profiles, installed status, and cache info
95
- """
96
- if not FASTEMBED_AVAILABLE:
97
- return {
98
- "success": False,
99
- "error": "fastembed not installed. Install with: pip install codexlens[semantic]",
100
- }
101
-
102
- cache_dir = get_cache_dir()
103
- cache_exists = cache_dir.exists()
104
-
105
- models = []
106
- for profile, info in MODEL_PROFILES.items():
107
- model_name = info["model_name"]
108
-
109
- # Check if model is cached
110
- installed = False
111
- cache_size_mb = 0
112
-
113
- if cache_exists:
114
- # Check for model directory in cache
115
- model_cache_path = cache_dir / f"models--{model_name.replace('/', '--')}"
116
- if model_cache_path.exists():
117
- installed = True
118
- # Calculate cache size
119
- total_size = sum(
120
- f.stat().st_size
121
- for f in model_cache_path.rglob("*")
122
- if f.is_file()
123
- )
124
- cache_size_mb = round(total_size / (1024 * 1024), 1)
125
-
126
- models.append({
127
- "profile": profile,
128
- "model_name": model_name,
129
- "dimensions": info["dimensions"],
130
- "estimated_size_mb": info["size_mb"],
131
- "actual_size_mb": cache_size_mb if installed else None,
132
- "description": info["description"],
133
- "use_case": info["use_case"],
134
- "installed": installed,
135
- })
136
-
137
- return {
138
- "success": True,
139
- "result": {
140
- "models": models,
141
- "cache_dir": str(cache_dir),
142
- "cache_exists": cache_exists,
143
- },
144
- }
145
-
146
-
147
- def download_model(profile: str, progress_callback: Optional[callable] = None) -> Dict[str, any]:
148
- """Download a model by profile name.
149
-
150
- Args:
151
- profile: Model profile name (fast, code, multilingual, balanced)
152
- progress_callback: Optional callback function to report progress
153
-
154
- Returns:
155
- Result dictionary with success status
156
- """
157
- if not FASTEMBED_AVAILABLE:
158
- return {
159
- "success": False,
160
- "error": "fastembed not installed. Install with: pip install codexlens[semantic]",
161
- }
162
-
163
- if profile not in MODEL_PROFILES:
164
- return {
165
- "success": False,
166
- "error": f"Unknown profile: {profile}. Available: {', '.join(MODEL_PROFILES.keys())}",
167
- }
168
-
169
- model_name = MODEL_PROFILES[profile]["model_name"]
170
-
171
- try:
172
- # Download model by instantiating TextEmbedding
173
- # This will automatically download to cache if not present
174
- if progress_callback:
175
- progress_callback(f"Downloading {model_name}...")
176
-
177
- embedder = TextEmbedding(model_name=model_name)
178
-
179
- if progress_callback:
180
- progress_callback(f"Model {model_name} downloaded successfully")
181
-
182
- # Get cache info
183
- cache_dir = get_cache_dir()
184
- model_cache_path = cache_dir / f"models--{model_name.replace('/', '--')}"
185
-
186
- cache_size = 0
187
- if model_cache_path.exists():
188
- total_size = sum(
189
- f.stat().st_size
190
- for f in model_cache_path.rglob("*")
191
- if f.is_file()
192
- )
193
- cache_size = round(total_size / (1024 * 1024), 1)
194
-
195
- return {
196
- "success": True,
197
- "result": {
198
- "profile": profile,
199
- "model_name": model_name,
200
- "cache_size_mb": cache_size,
201
- "cache_path": str(model_cache_path),
202
- },
203
- }
204
-
205
- except Exception as e:
206
- return {
207
- "success": False,
208
- "error": f"Failed to download model: {str(e)}",
209
- }
210
-
211
-
212
- def delete_model(profile: str) -> Dict[str, any]:
213
- """Delete a downloaded model from cache.
214
-
215
- Args:
216
- profile: Model profile name to delete
217
-
218
- Returns:
219
- Result dictionary with success status
220
- """
221
- if profile not in MODEL_PROFILES:
222
- return {
223
- "success": False,
224
- "error": f"Unknown profile: {profile}. Available: {', '.join(MODEL_PROFILES.keys())}",
225
- }
226
-
227
- model_name = MODEL_PROFILES[profile]["model_name"]
228
- cache_dir = get_cache_dir()
229
- model_cache_path = cache_dir / f"models--{model_name.replace('/', '--')}"
230
-
231
- if not model_cache_path.exists():
232
- return {
233
- "success": False,
234
- "error": f"Model {profile} ({model_name}) is not installed",
235
- }
236
-
237
- try:
238
- # Calculate size before deletion
239
- total_size = sum(
240
- f.stat().st_size
241
- for f in model_cache_path.rglob("*")
242
- if f.is_file()
243
- )
244
- size_mb = round(total_size / (1024 * 1024), 1)
245
-
246
- # Delete model directory
247
- shutil.rmtree(model_cache_path)
248
-
249
- return {
250
- "success": True,
251
- "result": {
252
- "profile": profile,
253
- "model_name": model_name,
254
- "deleted_size_mb": size_mb,
255
- "cache_path": str(model_cache_path),
256
- },
257
- }
258
-
259
- except Exception as e:
260
- return {
261
- "success": False,
262
- "error": f"Failed to delete model: {str(e)}",
263
- }
264
-
265
-
266
- def get_model_info(profile: str) -> Dict[str, any]:
267
- """Get detailed information about a model profile.
268
-
269
- Args:
270
- profile: Model profile name
271
-
272
- Returns:
273
- Result dictionary with model information
274
- """
275
- if profile not in MODEL_PROFILES:
276
- return {
277
- "success": False,
278
- "error": f"Unknown profile: {profile}. Available: {', '.join(MODEL_PROFILES.keys())}",
279
- }
280
-
281
- info = MODEL_PROFILES[profile]
282
- model_name = info["model_name"]
283
-
284
- # Check installation status
285
- cache_dir = get_cache_dir()
286
- model_cache_path = cache_dir / f"models--{model_name.replace('/', '--')}"
287
- installed = model_cache_path.exists()
288
-
289
- cache_size_mb = None
290
- if installed:
291
- total_size = sum(
292
- f.stat().st_size
293
- for f in model_cache_path.rglob("*")
294
- if f.is_file()
295
- )
296
- cache_size_mb = round(total_size / (1024 * 1024), 1)
297
-
298
- return {
299
- "success": True,
300
- "result": {
301
- "profile": profile,
302
- "model_name": model_name,
303
- "dimensions": info["dimensions"],
304
- "estimated_size_mb": info["size_mb"],
305
- "actual_size_mb": cache_size_mb,
306
- "description": info["description"],
307
- "use_case": info["use_case"],
308
- "installed": installed,
309
- "cache_path": str(model_cache_path) if installed else None,
310
- },
311
- }
1
+ """Model Manager - Manage fastembed models for semantic search."""
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Dict, List, Optional
8
+
9
+ try:
10
+ from fastembed import TextEmbedding
11
+ FASTEMBED_AVAILABLE = True
12
+ except ImportError:
13
+ FASTEMBED_AVAILABLE = False
14
+
15
+
16
+ # Model profiles with metadata
17
+ # Note: 768d is max recommended dimension for optimal performance/quality balance
18
+ # 1024d models are available but not recommended due to higher resource usage
19
+ # cache_name: The actual Hugging Face repo name used by fastembed for ONNX caching
20
+ MODEL_PROFILES = {
21
+ "fast": {
22
+ "model_name": "BAAI/bge-small-en-v1.5",
23
+ "cache_name": "qdrant/bge-small-en-v1.5-onnx-q", # fastembed uses ONNX version
24
+ "dimensions": 384,
25
+ "size_mb": 80,
26
+ "description": "Fast, lightweight, English-optimized",
27
+ "use_case": "Quick prototyping, resource-constrained environments",
28
+ "recommended": True,
29
+ },
30
+ "base": {
31
+ "model_name": "BAAI/bge-base-en-v1.5",
32
+ "cache_name": "qdrant/bge-base-en-v1.5-onnx-q", # fastembed uses ONNX version
33
+ "dimensions": 768,
34
+ "size_mb": 220,
35
+ "description": "General purpose, good balance of speed and quality",
36
+ "use_case": "General text search, documentation",
37
+ "recommended": True,
38
+ },
39
+ "code": {
40
+ "model_name": "jinaai/jina-embeddings-v2-base-code",
41
+ "cache_name": "jinaai/jina-embeddings-v2-base-code", # Uses original name
42
+ "dimensions": 768,
43
+ "size_mb": 150,
44
+ "description": "Code-optimized, best for programming languages",
45
+ "use_case": "Open source projects, code semantic search",
46
+ "recommended": True,
47
+ },
48
+ "minilm": {
49
+ "model_name": "sentence-transformers/all-MiniLM-L6-v2",
50
+ "cache_name": "qdrant/all-MiniLM-L6-v2-onnx", # fastembed uses ONNX version
51
+ "dimensions": 384,
52
+ "size_mb": 90,
53
+ "description": "Popular lightweight model, good quality",
54
+ "use_case": "General purpose, low resource environments",
55
+ "recommended": True,
56
+ },
57
+ "multilingual": {
58
+ "model_name": "intfloat/multilingual-e5-large",
59
+ "cache_name": "qdrant/multilingual-e5-large-onnx", # fastembed uses ONNX version
60
+ "dimensions": 1024,
61
+ "size_mb": 1000,
62
+ "description": "Multilingual + code support (high resource usage)",
63
+ "use_case": "Enterprise multilingual projects",
64
+ "recommended": False, # 1024d not recommended
65
+ },
66
+ "balanced": {
67
+ "model_name": "mixedbread-ai/mxbai-embed-large-v1",
68
+ "cache_name": "mixedbread-ai/mxbai-embed-large-v1", # Uses original name
69
+ "dimensions": 1024,
70
+ "size_mb": 600,
71
+ "description": "High accuracy, general purpose (high resource usage)",
72
+ "use_case": "High-quality semantic search, balanced performance",
73
+ "recommended": False, # 1024d not recommended
74
+ },
75
+ }
76
+
77
+
78
+ def get_cache_dir() -> Path:
79
+ """Get fastembed cache directory.
80
+
81
+ Returns:
82
+ Path to cache directory (usually ~/.cache/fastembed or %LOCALAPPDATA%\\Temp\\fastembed_cache)
83
+ """
84
+ # Check HF_HOME environment variable first
85
+ if "HF_HOME" in os.environ:
86
+ return Path(os.environ["HF_HOME"])
87
+
88
+ # Default cache locations
89
+ if os.name == "nt": # Windows
90
+ cache_dir = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local")) / "Temp" / "fastembed_cache"
91
+ else: # Unix-like
92
+ cache_dir = Path.home() / ".cache" / "fastembed"
93
+
94
+ return cache_dir
95
+
96
+
97
+ def _get_model_cache_path(cache_dir: Path, info: Dict) -> Path:
98
+ """Get the actual cache path for a model.
99
+
100
+ fastembed uses ONNX versions of models with different names than the original.
101
+ This function returns the correct path based on the cache_name field.
102
+
103
+ Args:
104
+ cache_dir: The fastembed cache directory
105
+ info: Model profile info dictionary
106
+
107
+ Returns:
108
+ Path to the model cache directory
109
+ """
110
+ cache_name = info.get("cache_name", info["model_name"])
111
+ return cache_dir / f"models--{cache_name.replace('/', '--')}"
112
+
113
+
114
+ def list_models() -> Dict[str, any]:
115
+ """List available model profiles and their installation status.
116
+
117
+ Returns:
118
+ Dictionary with model profiles, installed status, and cache info
119
+ """
120
+ if not FASTEMBED_AVAILABLE:
121
+ return {
122
+ "success": False,
123
+ "error": "fastembed not installed. Install with: pip install codexlens[semantic]",
124
+ }
125
+
126
+ cache_dir = get_cache_dir()
127
+ cache_exists = cache_dir.exists()
128
+
129
+ models = []
130
+ for profile, info in MODEL_PROFILES.items():
131
+ model_name = info["model_name"]
132
+
133
+ # Check if model is cached using the actual cache name
134
+ installed = False
135
+ cache_size_mb = 0
136
+
137
+ if cache_exists:
138
+ # Check for model directory in cache using correct cache_name
139
+ model_cache_path = _get_model_cache_path(cache_dir, info)
140
+ if model_cache_path.exists():
141
+ installed = True
142
+ # Calculate cache size
143
+ total_size = sum(
144
+ f.stat().st_size
145
+ for f in model_cache_path.rglob("*")
146
+ if f.is_file()
147
+ )
148
+ cache_size_mb = round(total_size / (1024 * 1024), 1)
149
+
150
+ models.append({
151
+ "profile": profile,
152
+ "model_name": model_name,
153
+ "dimensions": info["dimensions"],
154
+ "estimated_size_mb": info["size_mb"],
155
+ "actual_size_mb": cache_size_mb if installed else None,
156
+ "description": info["description"],
157
+ "use_case": info["use_case"],
158
+ "installed": installed,
159
+ })
160
+
161
+ return {
162
+ "success": True,
163
+ "result": {
164
+ "models": models,
165
+ "cache_dir": str(cache_dir),
166
+ "cache_exists": cache_exists,
167
+ },
168
+ }
169
+
170
+
171
+ def download_model(profile: str, progress_callback: Optional[callable] = None) -> Dict[str, any]:
172
+ """Download a model by profile name.
173
+
174
+ Args:
175
+ profile: Model profile name (fast, code, multilingual, balanced)
176
+ progress_callback: Optional callback function to report progress
177
+
178
+ Returns:
179
+ Result dictionary with success status
180
+ """
181
+ if not FASTEMBED_AVAILABLE:
182
+ return {
183
+ "success": False,
184
+ "error": "fastembed not installed. Install with: pip install codexlens[semantic]",
185
+ }
186
+
187
+ if profile not in MODEL_PROFILES:
188
+ return {
189
+ "success": False,
190
+ "error": f"Unknown profile: {profile}. Available: {', '.join(MODEL_PROFILES.keys())}",
191
+ }
192
+
193
+ info = MODEL_PROFILES[profile]
194
+ model_name = info["model_name"]
195
+
196
+ try:
197
+ # Download model by instantiating TextEmbedding
198
+ # This will automatically download to cache if not present
199
+ if progress_callback:
200
+ progress_callback(f"Downloading {model_name}...")
201
+
202
+ embedder = TextEmbedding(model_name=model_name)
203
+
204
+ if progress_callback:
205
+ progress_callback(f"Model {model_name} downloaded successfully")
206
+
207
+ # Get cache info using correct cache_name
208
+ cache_dir = get_cache_dir()
209
+ model_cache_path = _get_model_cache_path(cache_dir, info)
210
+
211
+ cache_size = 0
212
+ if model_cache_path.exists():
213
+ total_size = sum(
214
+ f.stat().st_size
215
+ for f in model_cache_path.rglob("*")
216
+ if f.is_file()
217
+ )
218
+ cache_size = round(total_size / (1024 * 1024), 1)
219
+
220
+ return {
221
+ "success": True,
222
+ "result": {
223
+ "profile": profile,
224
+ "model_name": model_name,
225
+ "cache_size_mb": cache_size,
226
+ "cache_path": str(model_cache_path),
227
+ },
228
+ }
229
+
230
+ except Exception as e:
231
+ return {
232
+ "success": False,
233
+ "error": f"Failed to download model: {str(e)}",
234
+ }
235
+
236
+
237
+ def delete_model(profile: str) -> Dict[str, any]:
238
+ """Delete a downloaded model from cache.
239
+
240
+ Args:
241
+ profile: Model profile name to delete
242
+
243
+ Returns:
244
+ Result dictionary with success status
245
+ """
246
+ if profile not in MODEL_PROFILES:
247
+ return {
248
+ "success": False,
249
+ "error": f"Unknown profile: {profile}. Available: {', '.join(MODEL_PROFILES.keys())}",
250
+ }
251
+
252
+ info = MODEL_PROFILES[profile]
253
+ model_name = info["model_name"]
254
+ cache_dir = get_cache_dir()
255
+ model_cache_path = _get_model_cache_path(cache_dir, info)
256
+
257
+ if not model_cache_path.exists():
258
+ return {
259
+ "success": False,
260
+ "error": f"Model {profile} ({model_name}) is not installed",
261
+ }
262
+
263
+ try:
264
+ # Calculate size before deletion
265
+ total_size = sum(
266
+ f.stat().st_size
267
+ for f in model_cache_path.rglob("*")
268
+ if f.is_file()
269
+ )
270
+ size_mb = round(total_size / (1024 * 1024), 1)
271
+
272
+ # Delete model directory
273
+ shutil.rmtree(model_cache_path)
274
+
275
+ return {
276
+ "success": True,
277
+ "result": {
278
+ "profile": profile,
279
+ "model_name": model_name,
280
+ "deleted_size_mb": size_mb,
281
+ "cache_path": str(model_cache_path),
282
+ },
283
+ }
284
+
285
+ except Exception as e:
286
+ return {
287
+ "success": False,
288
+ "error": f"Failed to delete model: {str(e)}",
289
+ }
290
+
291
+
292
+ def get_model_info(profile: str) -> Dict[str, any]:
293
+ """Get detailed information about a model profile.
294
+
295
+ Args:
296
+ profile: Model profile name
297
+
298
+ Returns:
299
+ Result dictionary with model information
300
+ """
301
+ if profile not in MODEL_PROFILES:
302
+ return {
303
+ "success": False,
304
+ "error": f"Unknown profile: {profile}. Available: {', '.join(MODEL_PROFILES.keys())}",
305
+ }
306
+
307
+ info = MODEL_PROFILES[profile]
308
+ model_name = info["model_name"]
309
+
310
+ # Check installation status using correct cache_name
311
+ cache_dir = get_cache_dir()
312
+ model_cache_path = _get_model_cache_path(cache_dir, info)
313
+ installed = model_cache_path.exists()
314
+
315
+ cache_size_mb = None
316
+ if installed:
317
+ total_size = sum(
318
+ f.stat().st_size
319
+ for f in model_cache_path.rglob("*")
320
+ if f.is_file()
321
+ )
322
+ cache_size_mb = round(total_size / (1024 * 1024), 1)
323
+
324
+ return {
325
+ "success": True,
326
+ "result": {
327
+ "profile": profile,
328
+ "model_name": model_name,
329
+ "dimensions": info["dimensions"],
330
+ "estimated_size_mb": info["size_mb"],
331
+ "actual_size_mb": cache_size_mb,
332
+ "description": info["description"],
333
+ "use_case": info["use_case"],
334
+ "installed": installed,
335
+ "cache_path": str(model_cache_path) if installed else None,
336
+ },
337
+ }
@@ -396,7 +396,20 @@ class ChainSearchEngine:
396
396
  all_results = []
397
397
  stats = SearchStats()
398
398
 
399
- executor = self._get_executor(options.max_workers)
399
+ # Force single-threaded execution for vector/hybrid search to avoid GPU crashes
400
+ # DirectML/ONNX have threading issues when multiple threads access GPU resources
401
+ effective_workers = options.max_workers
402
+ if options.enable_vector or options.hybrid_mode:
403
+ effective_workers = 1
404
+ self.logger.debug("Using single-threaded mode for vector search (GPU safety)")
405
+ # Pre-load embedder to avoid initialization overhead per-search
406
+ try:
407
+ from codexlens.semantic.embedder import get_embedder
408
+ get_embedder(profile="code", use_gpu=True)
409
+ except Exception:
410
+ pass # Ignore pre-load failures
411
+
412
+ executor = self._get_executor(effective_workers)
400
413
  # Submit all search tasks
401
414
  future_to_path = {
402
415
  executor.submit(
@@ -274,19 +274,32 @@ class HybridSearchEngine:
274
274
  )
275
275
  return []
276
276
 
277
- # Auto-detect embedding dimension and select appropriate profile
278
- detected_dim = vector_store.dimension
279
- if detected_dim is None:
280
- self.logger.info("Vector store dimension unknown, using default profile")
281
- profile = "code" # Default fallback
282
- elif detected_dim == 384:
283
- profile = "fast"
284
- elif detected_dim == 768:
285
- profile = "code"
286
- elif detected_dim == 1024:
287
- profile = "multilingual" # or balanced, both are 1024
277
+ # Get stored model configuration (preferred) or auto-detect from dimension
278
+ model_config = vector_store.get_model_config()
279
+ if model_config:
280
+ profile = model_config["model_profile"]
281
+ self.logger.debug(
282
+ "Using stored model config: %s (%s, %dd)",
283
+ profile, model_config["model_name"], model_config["embedding_dim"]
284
+ )
288
285
  else:
289
- profile = "code" # Default fallback
286
+ # Fallback: auto-detect from embedding dimension
287
+ detected_dim = vector_store.dimension
288
+ if detected_dim is None:
289
+ self.logger.info("Vector store dimension unknown, using default profile")
290
+ profile = "code" # Default fallback
291
+ elif detected_dim == 384:
292
+ profile = "fast"
293
+ elif detected_dim == 768:
294
+ profile = "code"
295
+ elif detected_dim == 1024:
296
+ profile = "multilingual" # or balanced, both are 1024
297
+ else:
298
+ profile = "code" # Default fallback
299
+ self.logger.debug(
300
+ "No stored model config, auto-detected profile '%s' from dimension %s",
301
+ profile, detected_dim
302
+ )
290
303
 
291
304
  # Use cached embedder (singleton) for performance
292
305
  embedder = get_embedder(profile=profile)
@@ -116,6 +116,17 @@ class VectorStore:
116
116
  CREATE INDEX IF NOT EXISTS idx_chunks_file
117
117
  ON semantic_chunks(file_path)
118
118
  """)
119
+ # Model configuration table - tracks which model generated the embeddings
120
+ conn.execute("""
121
+ CREATE TABLE IF NOT EXISTS embeddings_config (
122
+ id INTEGER PRIMARY KEY CHECK (id = 1),
123
+ model_profile TEXT NOT NULL,
124
+ model_name TEXT NOT NULL,
125
+ embedding_dim INTEGER NOT NULL,
126
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
127
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
128
+ )
129
+ """)
119
130
  conn.commit()
120
131
 
121
132
  def _init_ann_index(self) -> None:
@@ -932,6 +943,92 @@ class VectorStore:
932
943
  return self._ann_index.count()
933
944
  return 0
934
945
 
946
+ def get_model_config(self) -> Optional[Dict[str, Any]]:
947
+ """Get the model configuration used for embeddings in this store.
948
+
949
+ Returns:
950
+ Dictionary with model_profile, model_name, embedding_dim, or None if not set.
951
+ """
952
+ with sqlite3.connect(self.db_path) as conn:
953
+ row = conn.execute(
954
+ "SELECT model_profile, model_name, embedding_dim, created_at, updated_at "
955
+ "FROM embeddings_config WHERE id = 1"
956
+ ).fetchone()
957
+ if row:
958
+ return {
959
+ "model_profile": row[0],
960
+ "model_name": row[1],
961
+ "embedding_dim": row[2],
962
+ "created_at": row[3],
963
+ "updated_at": row[4],
964
+ }
965
+ return None
966
+
967
+ def set_model_config(
968
+ self, model_profile: str, model_name: str, embedding_dim: int
969
+ ) -> None:
970
+ """Set the model configuration for embeddings in this store.
971
+
972
+ This should be called when generating new embeddings. If a different
973
+ model was previously used, this will update the configuration.
974
+
975
+ Args:
976
+ model_profile: Model profile name (fast, code, minilm, etc.)
977
+ model_name: Full model name (e.g., jinaai/jina-embeddings-v2-base-code)
978
+ embedding_dim: Embedding dimension (e.g., 768)
979
+ """
980
+ with sqlite3.connect(self.db_path) as conn:
981
+ conn.execute(
982
+ """
983
+ INSERT INTO embeddings_config (id, model_profile, model_name, embedding_dim)
984
+ VALUES (1, ?, ?, ?)
985
+ ON CONFLICT(id) DO UPDATE SET
986
+ model_profile = excluded.model_profile,
987
+ model_name = excluded.model_name,
988
+ embedding_dim = excluded.embedding_dim,
989
+ updated_at = CURRENT_TIMESTAMP
990
+ """,
991
+ (model_profile, model_name, embedding_dim)
992
+ )
993
+ conn.commit()
994
+
995
+ def check_model_compatibility(
996
+ self, model_profile: str, model_name: str, embedding_dim: int
997
+ ) -> Tuple[bool, Optional[str]]:
998
+ """Check if the given model is compatible with existing embeddings.
999
+
1000
+ Args:
1001
+ model_profile: Model profile to check
1002
+ model_name: Model name to check
1003
+ embedding_dim: Embedding dimension to check
1004
+
1005
+ Returns:
1006
+ Tuple of (is_compatible, warning_message).
1007
+ is_compatible is True if no existing config or configs match.
1008
+ warning_message is a user-friendly message if incompatible.
1009
+ """
1010
+ existing = self.get_model_config()
1011
+ if existing is None:
1012
+ return True, None
1013
+
1014
+ # Check dimension first (most critical)
1015
+ if existing["embedding_dim"] != embedding_dim:
1016
+ return False, (
1017
+ f"Dimension mismatch: existing embeddings use {existing['embedding_dim']}d "
1018
+ f"({existing['model_profile']}), but requested model uses {embedding_dim}d "
1019
+ f"({model_profile}). Use --force to regenerate all embeddings."
1020
+ )
1021
+
1022
+ # Check model (different models with same dimension may have different semantic spaces)
1023
+ if existing["model_profile"] != model_profile:
1024
+ return False, (
1025
+ f"Model mismatch: existing embeddings use '{existing['model_profile']}' "
1026
+ f"({existing['model_name']}), but requested '{model_profile}' "
1027
+ f"({model_name}). Use --force to regenerate all embeddings."
1028
+ )
1029
+
1030
+ return True, None
1031
+
935
1032
  def close(self) -> None:
936
1033
  """Close the vector store and release resources.
937
1034
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-workflow",
3
- "version": "6.2.6",
3
+ "version": "6.2.7",
4
4
  "description": "JSON-driven multi-agent development framework with intelligent CLI orchestration (Gemini/Qwen/Codex), context-first architecture, and automated workflow execution",
5
5
  "type": "module",
6
6
  "main": "ccw/src/index.js",