crabcode 0.1.0__py3-none-any.whl

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.
Files changed (64) hide show
  1. crabcode-0.1.0.dist-info/METADATA +23 -0
  2. crabcode-0.1.0.dist-info/RECORD +64 -0
  3. crabcode-0.1.0.dist-info/WHEEL +4 -0
  4. crabcode-0.1.0.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +3 -0
  6. crabcode_cli/app.py +142 -0
  7. crabcode_cli/banner.py +42 -0
  8. crabcode_cli/pipe.py +58 -0
  9. crabcode_cli/render/__init__.py +0 -0
  10. crabcode_cli/render/messages.py +19 -0
  11. crabcode_cli/repl.py +1263 -0
  12. crabcode_core/__init__.py +3 -0
  13. crabcode_core/api/__init__.py +6 -0
  14. crabcode_core/api/anthropic_adapter.py +250 -0
  15. crabcode_core/api/base.py +66 -0
  16. crabcode_core/api/codex_adapter.py +390 -0
  17. crabcode_core/api/openai_adapter.py +221 -0
  18. crabcode_core/api/registry.py +40 -0
  19. crabcode_core/compact/__init__.py +7 -0
  20. crabcode_core/compact/compact.py +150 -0
  21. crabcode_core/config/__init__.py +3 -0
  22. crabcode_core/config/claudemd.py +53 -0
  23. crabcode_core/config/manager.py +152 -0
  24. crabcode_core/events.py +392 -0
  25. crabcode_core/mcp/__init__.py +4 -0
  26. crabcode_core/mcp/client.py +163 -0
  27. crabcode_core/mcp/config.py +52 -0
  28. crabcode_core/permissions/__init__.py +3 -0
  29. crabcode_core/permissions/manager.py +106 -0
  30. crabcode_core/prompts/__init__.py +0 -0
  31. crabcode_core/prompts/context.py +209 -0
  32. crabcode_core/prompts/profile.py +66 -0
  33. crabcode_core/prompts/system.py +405 -0
  34. crabcode_core/prompts/templates.py +60 -0
  35. crabcode_core/query/__init__.py +0 -0
  36. crabcode_core/query/loop.py +442 -0
  37. crabcode_core/session/__init__.py +3 -0
  38. crabcode_core/session/storage.py +153 -0
  39. crabcode_core/skills/__init__.py +5 -0
  40. crabcode_core/skills/loader.py +147 -0
  41. crabcode_core/tools/__init__.py +29 -0
  42. crabcode_core/tools/agent.py +160 -0
  43. crabcode_core/tools/bash.py +127 -0
  44. crabcode_core/tools/diff_utils.py +120 -0
  45. crabcode_core/tools/file_edit.py +145 -0
  46. crabcode_core/tools/file_read.py +123 -0
  47. crabcode_core/tools/file_write.py +102 -0
  48. crabcode_core/tools/glob.py +109 -0
  49. crabcode_core/tools/grep.py +130 -0
  50. crabcode_core/tools/lint.py +368 -0
  51. crabcode_core/tools/memory.py +236 -0
  52. crabcode_core/tools/skill.py +112 -0
  53. crabcode_core/types/__init__.py +73 -0
  54. crabcode_core/types/config.py +103 -0
  55. crabcode_core/types/event.py +105 -0
  56. crabcode_core/types/message.py +177 -0
  57. crabcode_core/types/tool.py +138 -0
  58. crabcode_search/__init__.py +11 -0
  59. crabcode_search/background.py +371 -0
  60. crabcode_search/chunker.py +517 -0
  61. crabcode_search/embedder.py +447 -0
  62. crabcode_search/indexer.py +212 -0
  63. crabcode_search/store.py +222 -0
  64. crabcode_search/tool.py +232 -0
@@ -0,0 +1,447 @@
1
+ """Embedding backends — API and local model support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from abc import ABC, abstractmethod
8
+ from typing import Any
9
+
10
+ import numpy as np
11
+
12
+
13
+ class Embedder(ABC):
14
+ """Abstract embedding backend."""
15
+
16
+ @abstractmethod
17
+ async def embed(self, texts: list[str], task_type: str | None = None) -> np.ndarray:
18
+ """Embed a batch of texts. Returns (N, dimension) float32 array, L2-normalized.
19
+
20
+ Args:
21
+ texts: List of texts to embed.
22
+ task_type: Optional hint for the embedding model (e.g. "RETRIEVAL_DOCUMENT",
23
+ "CODE_RETRIEVAL_QUERY"). Non-Gemini backends ignore this.
24
+ """
25
+ ...
26
+
27
+ @property
28
+ @abstractmethod
29
+ def dimension(self) -> int: ...
30
+
31
+ async def preload(self) -> None:
32
+ """Pre-load the model. Override for backends with deferred initialization."""
33
+
34
+ def _normalize(self, vecs: np.ndarray) -> np.ndarray:
35
+ norms = np.linalg.norm(vecs, axis=1, keepdims=True)
36
+ norms = np.where(norms == 0, 1, norms)
37
+ return (vecs / norms).astype(np.float32)
38
+
39
+
40
+ class OllamaEmbedder(Embedder):
41
+ """Ollama local HTTP API — no extra Python deps."""
42
+
43
+ def __init__(
44
+ self,
45
+ model: str = "nomic-embed-text",
46
+ base_url: str = "http://localhost:11434",
47
+ dim: int | None = None,
48
+ ):
49
+ self._model = model
50
+ self._base_url = base_url.rstrip("/")
51
+ self._dim = dim
52
+ self._resolved_dim: int | None = None
53
+
54
+ @property
55
+ def dimension(self) -> int:
56
+ if self._dim:
57
+ return self._dim
58
+ if self._resolved_dim:
59
+ return self._resolved_dim
60
+ return 768
61
+
62
+ async def embed(self, texts: list[str], task_type: str | None = None) -> np.ndarray:
63
+ import httpx
64
+
65
+ async with httpx.AsyncClient(timeout=120) as client:
66
+ resp = await client.post(
67
+ f"{self._base_url}/api/embed",
68
+ json={"model": self._model, "input": texts},
69
+ )
70
+ resp.raise_for_status()
71
+ data = resp.json()
72
+
73
+ embeddings = data["embeddings"]
74
+ vecs = np.array(embeddings, dtype=np.float32)
75
+ if self._resolved_dim is None:
76
+ self._resolved_dim = vecs.shape[1]
77
+ return self._normalize(vecs)
78
+
79
+
80
+ class GeminiEmbedder(Embedder):
81
+ """Google Gemini Embedding API — zero extra deps (pure httpx).
82
+
83
+ Uses batchEmbedContents for efficiency (one HTTP round-trip per batch).
84
+ Supports task_type for domain-optimised embeddings:
85
+ - "RETRIEVAL_DOCUMENT" — for code chunks being indexed
86
+ - "CODE_RETRIEVAL_QUERY" — for natural-language search queries
87
+ """
88
+
89
+ BATCH_SIZE = 100 # max items per batchEmbedContents call
90
+ _BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
91
+
92
+ def __init__(
93
+ self,
94
+ model: str = "gemini-embedding-2-preview",
95
+ dim: int = 768,
96
+ api_key: str | None = None,
97
+ default_task_type: str = "RETRIEVAL_DOCUMENT",
98
+ ):
99
+ # Strip leading "models/" if user mistakenly includes it
100
+ self._model = model.removeprefix("models/")
101
+ self._dim = dim
102
+ self._api_key = api_key or os.environ.get("GEMINI_API_KEY", "")
103
+ self._default_task_type = default_task_type
104
+
105
+ @property
106
+ def dimension(self) -> int:
107
+ return self._dim
108
+
109
+ async def embed(self, texts: list[str], task_type: str | None = None) -> np.ndarray:
110
+ import httpx
111
+
112
+ effective_task_type = task_type or self._default_task_type
113
+ url = f"{self._BASE_URL}/models/{self._model}:batchEmbedContents"
114
+ headers = {
115
+ "Content-Type": "application/json",
116
+ "x-goog-api-key": self._api_key,
117
+ }
118
+
119
+ all_vecs: list[list[float]] = []
120
+ async with httpx.AsyncClient(timeout=120) as client:
121
+ for i in range(0, len(texts), self.BATCH_SIZE):
122
+ batch = texts[i : i + self.BATCH_SIZE]
123
+ requests: list[dict[str, Any]] = []
124
+ for text in batch:
125
+ req: dict[str, Any] = {
126
+ "model": f"models/{self._model}",
127
+ "content": {"parts": [{"text": text}]},
128
+ "taskType": effective_task_type,
129
+ }
130
+ if self._dim:
131
+ req["outputDimensionality"] = self._dim
132
+ requests.append(req)
133
+
134
+ resp = await client.post(
135
+ url, headers=headers, json={"requests": requests}
136
+ )
137
+ resp.raise_for_status()
138
+ data = resp.json()
139
+ for emb in data["embeddings"]:
140
+ all_vecs.append(emb["values"])
141
+
142
+ vecs = np.array(all_vecs, dtype=np.float32)
143
+ return self._normalize(vecs)
144
+
145
+
146
+ class OpenAIEmbedder(Embedder):
147
+ """OpenAI-compatible embedding API."""
148
+
149
+ def __init__(
150
+ self,
151
+ model: str = "text-embedding-3-small",
152
+ base_url: str = "https://api.openai.com/v1",
153
+ api_key: str | None = None,
154
+ dim: int = 1536,
155
+ ):
156
+ self._model = model
157
+ self._base_url = base_url.rstrip("/")
158
+ self._api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
159
+ self._dim = dim
160
+
161
+ @property
162
+ def dimension(self) -> int:
163
+ return self._dim
164
+
165
+ async def embed(self, texts: list[str], task_type: str | None = None) -> np.ndarray:
166
+ import httpx
167
+
168
+ headers = {
169
+ "Content-Type": "application/json",
170
+ "Authorization": f"Bearer {self._api_key}",
171
+ }
172
+ body: dict[str, Any] = {
173
+ "model": self._model,
174
+ "input": texts,
175
+ }
176
+ if self._dim:
177
+ body["dimensions"] = self._dim
178
+
179
+ async with httpx.AsyncClient(timeout=120) as client:
180
+ resp = await client.post(
181
+ f"{self._base_url}/embeddings",
182
+ headers=headers,
183
+ json=body,
184
+ )
185
+ resp.raise_for_status()
186
+ data = resp.json()
187
+
188
+ embeddings = [item["embedding"] for item in data["data"]]
189
+ vecs = np.array(embeddings, dtype=np.float32)
190
+ return self._normalize(vecs)
191
+
192
+
193
+ def _detect_best_device() -> str:
194
+ """Auto-detect the best available compute device: cuda > mps > cpu."""
195
+ try:
196
+ import torch
197
+
198
+ if torch.cuda.is_available():
199
+ return "cuda"
200
+ if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
201
+ return "mps"
202
+ except ImportError:
203
+ pass
204
+ return "cpu"
205
+
206
+
207
+ _GPU_PER_SAMPLE_MB = 80.0
208
+ _CPU_PER_SAMPLE_MB = 50.0
209
+ _MAX_BATCH = {"cuda": 64, "mps": 32, "cpu": 8}
210
+
211
+
212
+ def _auto_batch_size(device: str = "cpu") -> int:
213
+ """Compute a safe encode batch_size from available device memory.
214
+
215
+ Transformer attention memory scales quadratically with sequence length,
216
+ so per-sample cost on GPU is much higher than the raw embedding size.
217
+ We use ~80 MB/sample for GPU/MPS and ~8 MB/sample for CPU.
218
+
219
+ For CUDA, uses free VRAM (40% budget). For MPS and CPU, uses available
220
+ system RAM — on Apple Silicon the two share unified memory.
221
+ """
222
+ gpu = device in ("cuda", "mps")
223
+ per_sample = _GPU_PER_SAMPLE_MB if gpu else _CPU_PER_SAMPLE_MB
224
+ cap = _MAX_BATCH.get(device, 32)
225
+
226
+ try:
227
+ if device == "cuda":
228
+ import torch
229
+
230
+ free, _ = torch.cuda.mem_get_info()
231
+ available_mb = free / (1024**2)
232
+ budget_ratio = 0.4
233
+ else:
234
+ import psutil
235
+
236
+ available_mb = psutil.virtual_memory().available / (1024**2)
237
+ budget_ratio = 0.2 if gpu else 0.3
238
+
239
+ size = max(1, int(available_mb * budget_ratio / per_sample))
240
+ return min(size, cap)
241
+ except Exception:
242
+ return 4 if gpu else 8
243
+
244
+
245
+ def _is_oom_error(exc: Exception) -> bool:
246
+ """Detect GPU/MPS out-of-memory errors (including MPS buffer limits)."""
247
+ try:
248
+ import torch
249
+
250
+ if isinstance(exc, torch.cuda.OutOfMemoryError):
251
+ return True
252
+ except (ImportError, AttributeError):
253
+ pass
254
+ msg = str(exc).lower()
255
+ return (
256
+ "out of memory" in msg
257
+ or "oom" in msg
258
+ or "invalid buffer size" in msg
259
+ )
260
+
261
+
262
+ class HuggingFaceEmbedder(Embedder):
263
+ """Local embedding via sentence-transformers (HuggingFace models).
264
+
265
+ Model loading is deferred to a background thread on first use to avoid
266
+ blocking the event loop (loading a 0.6B model can take 10+ seconds).
267
+
268
+ device: ``None`` (default) auto-detects the best device (CUDA > MPS > CPU).
269
+ Pass ``"cpu"`` / ``"cuda"`` / ``"mps"`` to force a specific device.
270
+
271
+ batch_size: ``None`` (default) computes dynamically from available memory
272
+ (VRAM for CUDA, unified memory for MPS/CPU).
273
+
274
+ If an OOM error occurs on GPU/MPS, the model is moved to CPU
275
+ transparently and the batch is retried.
276
+ """
277
+
278
+ def __init__(
279
+ self,
280
+ model: str = "BAAI/bge-small-en-v1.5",
281
+ batch_size: int | None = None,
282
+ device: str | None = None,
283
+ sync: bool = False,
284
+ ):
285
+ try:
286
+ import sentence_transformers # noqa: F401
287
+ except ImportError as exc:
288
+ raise ImportError(
289
+ "HuggingFace backend requires sentence-transformers: "
290
+ "pip install crabcode-search[huggingface]"
291
+ ) from exc
292
+ self._model_name = model
293
+ self._batch_size = batch_size
294
+ self._requested_device = device
295
+ self._device: str | None = None
296
+ self._sync_mode = sync
297
+ self._model: Any = None
298
+ self._dim: int | None = None
299
+
300
+ async def _ensure_loaded(self) -> None:
301
+ if self._model is not None:
302
+ return
303
+ from sentence_transformers import SentenceTransformer
304
+
305
+ if self._requested_device is not None:
306
+ self._device = self._requested_device
307
+ else:
308
+ self._device = _detect_best_device()
309
+
310
+ import sys
311
+ print(f"[embedder] using device: {self._device}", file=sys.stderr)
312
+
313
+ if self._sync_mode:
314
+ self._model = SentenceTransformer(self._model_name, device=self._device)
315
+ else:
316
+ self._model = await asyncio.to_thread(
317
+ SentenceTransformer, self._model_name, device=self._device,
318
+ )
319
+ self._dim = self._model.get_sentence_embedding_dimension()
320
+
321
+ @property
322
+ def dimension(self) -> int:
323
+ if self._dim is not None:
324
+ return self._dim
325
+ return 1024
326
+
327
+ async def preload(self) -> None:
328
+ await self._ensure_loaded()
329
+
330
+ async def embed(self, texts: list[str], task_type: str | None = None) -> np.ndarray:
331
+ await self._ensure_loaded()
332
+ assert self._device is not None
333
+ bs = self._batch_size if self._batch_size is not None else _auto_batch_size(self._device)
334
+
335
+ def _encode() -> np.ndarray:
336
+ return self._model.encode(texts, normalize_embeddings=True, batch_size=bs)
337
+
338
+ try:
339
+ if self._sync_mode:
340
+ vecs = _encode()
341
+ else:
342
+ vecs = await asyncio.to_thread(_encode)
343
+ except RuntimeError as exc:
344
+ if not _is_oom_error(exc):
345
+ raise
346
+ vecs = await self._oom_retry(texts, bs, exc)
347
+ return np.asarray(vecs, dtype=np.float32)
348
+
349
+ async def _oom_retry(
350
+ self, texts: list[str], failed_bs: int, original_exc: Exception,
351
+ ) -> np.ndarray:
352
+ """Progressive OOM recovery: halve batch on same device, then CPU."""
353
+ import sys
354
+
355
+ if self._device != "cpu" and failed_bs > 1:
356
+ new_bs = max(1, failed_bs // 2)
357
+ print(
358
+ f"[embedder] OOM on {self._device} (batch={failed_bs}), "
359
+ f"retrying with batch={new_bs}",
360
+ file=sys.stderr,
361
+ )
362
+ try:
363
+ vecs = await asyncio.to_thread(
364
+ self._model.encode, texts, normalize_embeddings=True,
365
+ batch_size=new_bs,
366
+ )
367
+ self._batch_size = new_bs
368
+ return vecs
369
+ except RuntimeError as exc2:
370
+ if not _is_oom_error(exc2):
371
+ raise
372
+
373
+ if self._device == "cpu":
374
+ raise original_exc
375
+
376
+ print(
377
+ f"[embedder] still OOM on {self._device}, falling back to CPU",
378
+ file=sys.stderr,
379
+ )
380
+ self._model = self._model.to("cpu")
381
+ self._device = "cpu"
382
+ bs = _auto_batch_size("cpu")
383
+ self._batch_size = bs
384
+ return await asyncio.to_thread(
385
+ self._model.encode, texts, normalize_embeddings=True, batch_size=bs,
386
+ )
387
+
388
+
389
+ class ModelScopeEmbedder(Embedder):
390
+ """Local embedding via ModelScope SDK."""
391
+
392
+ def __init__(self, model: str = "iic/nlp_gte_sentence-embedding_chinese-base"):
393
+ try:
394
+ from modelscope.models import Model
395
+ from modelscope.pipelines import pipeline
396
+ except ImportError as exc:
397
+ raise ImportError(
398
+ "ModelScope backend requires modelscope: "
399
+ "pip install crabcode-search[modelscope]"
400
+ ) from exc
401
+ self._pipeline = pipeline("sentence-embedding", model=model)
402
+ self._dim: int = 0
403
+
404
+ @property
405
+ def dimension(self) -> int:
406
+ return self._dim
407
+
408
+ async def embed(self, texts: list[str], task_type: str | None = None) -> np.ndarray:
409
+ result = await asyncio.to_thread(
410
+ self._pipeline, {"source_sentence": texts}
411
+ )
412
+ vecs = np.array(result["text_embedding"], dtype=np.float32)
413
+ if self._dim == 0:
414
+ self._dim = vecs.shape[1]
415
+ return self._normalize(vecs)
416
+
417
+
418
+ _EMBEDDER_REGISTRY: dict[str, type[Embedder]] = {
419
+ "ollama": OllamaEmbedder,
420
+ "gemini": GeminiEmbedder,
421
+ "openai": OpenAIEmbedder,
422
+ "huggingface": HuggingFaceEmbedder,
423
+ "modelscope": ModelScopeEmbedder,
424
+ }
425
+
426
+
427
+ def create_embedder(
428
+ backend: str = "ollama",
429
+ model: str | None = None,
430
+ dimension: int | None = None,
431
+ **kwargs: Any,
432
+ ) -> Embedder:
433
+ """Factory: create an embedder from config."""
434
+ cls = _EMBEDDER_REGISTRY.get(backend)
435
+ if cls is None:
436
+ raise ValueError(
437
+ f"Unknown embedder backend: {backend!r}. "
438
+ f"Available: {', '.join(_EMBEDDER_REGISTRY)}"
439
+ )
440
+
441
+ init_kwargs: dict[str, Any] = {k: v for k, v in kwargs.items() if v is not None}
442
+ if model:
443
+ init_kwargs["model"] = model
444
+ if dimension is not None:
445
+ init_kwargs["dim"] = dimension
446
+
447
+ return cls(**init_kwargs)
@@ -0,0 +1,212 @@
1
+ """Codebase indexer — orchestrates scanning, chunking, embedding, and storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import AsyncGenerator
10
+
11
+ from crabcode_search.chunker import ChunkMeta, chunk_file, is_indexable
12
+ from crabcode_search.embedder import Embedder
13
+ from crabcode_search.store import VectorStore
14
+
15
+ BATCH_SIZE = 5
16
+ INDEX_DIR_NAME = ".crabcode/search"
17
+
18
+
19
+ @dataclass
20
+ class IndexProgress:
21
+ done: int
22
+ total: int
23
+
24
+
25
+ class CodebaseIndexer:
26
+ """Coordinates file scanning, chunking, embedding, and vector storage."""
27
+
28
+ def __init__(self, cwd: str, embedder: Embedder):
29
+ self.cwd = Path(cwd).resolve()
30
+ self.embedder = embedder
31
+ self._index_dir = self.cwd / INDEX_DIR_NAME
32
+ self.store = VectorStore(self._index_dir, dimension=embedder.dimension)
33
+ self._file_index: dict[str, float] = {}
34
+ self._file_index_path = self._index_dir / "file_index.json"
35
+ self.total_files: int = 0
36
+
37
+ self._load_file_index()
38
+ self.store.load()
39
+
40
+ @property
41
+ def progress(self) -> IndexProgress:
42
+ indexed = len(self._file_index)
43
+ return IndexProgress(done=indexed, total=max(self.total_files, indexed))
44
+
45
+ async def build_or_update(self) -> AsyncGenerator[IndexProgress, None]:
46
+ """Full/incremental index build, yielding progress after each batch."""
47
+ all_files = self._scan_files()
48
+ self.total_files = len(all_files)
49
+ changed = self._detect_changes(all_files)
50
+ deleted = self._detect_deleted(all_files)
51
+
52
+ await self._purge_deleted(deleted)
53
+
54
+ if not changed:
55
+ yield IndexProgress(done=self.total_files, total=self.total_files)
56
+ return
57
+
58
+ total_changed = len(changed)
59
+ done = 0
60
+
61
+ for batch in _batched(changed, BATCH_SIZE):
62
+ all_chunks: list[ChunkMeta] = []
63
+ for file_path in batch:
64
+ rel = str(file_path.relative_to(self.cwd))
65
+ await self.store.remove_by_file(rel)
66
+ try:
67
+ content = file_path.read_text(errors="replace")
68
+ except OSError:
69
+ continue
70
+ chunks = chunk_file(rel, content)
71
+ all_chunks.extend(chunks)
72
+
73
+ if all_chunks:
74
+ texts = [c.content for c in all_chunks]
75
+ vectors = await self.embedder.embed(texts, task_type="RETRIEVAL_DOCUMENT")
76
+ await self.store.add(vectors, all_chunks)
77
+
78
+ for file_path in batch:
79
+ rel = str(file_path.relative_to(self.cwd))
80
+ try:
81
+ self._file_index[rel] = file_path.stat().st_mtime
82
+ except OSError:
83
+ pass
84
+
85
+ done += len(batch)
86
+ yield IndexProgress(done=done, total=total_changed)
87
+
88
+ self._save_file_index()
89
+ self.store.save()
90
+
91
+ async def incremental_update(self) -> None:
92
+ """Quick mtime-based update for changed files (typically < 1s)."""
93
+ all_files = self._scan_files()
94
+ changed = self._detect_changes(all_files)
95
+ deleted = self._detect_deleted(all_files)
96
+
97
+ await self._purge_deleted(deleted)
98
+
99
+ if not changed:
100
+ if deleted:
101
+ self._save_file_index()
102
+ self.store.save()
103
+ return
104
+
105
+ for file_path in changed:
106
+ rel = str(file_path.relative_to(self.cwd))
107
+ await self.store.remove_by_file(rel)
108
+
109
+ all_chunks: list[ChunkMeta] = []
110
+ for file_path in changed:
111
+ rel = str(file_path.relative_to(self.cwd))
112
+ try:
113
+ content = file_path.read_text(errors="replace")
114
+ except OSError:
115
+ continue
116
+ chunks = chunk_file(rel, content)
117
+ all_chunks.extend(chunks)
118
+
119
+ if all_chunks:
120
+ texts = [c.content for c in all_chunks]
121
+ vectors = await self.embedder.embed(texts, task_type="RETRIEVAL_DOCUMENT")
122
+ await self.store.add(vectors, all_chunks)
123
+
124
+ for file_path in changed:
125
+ rel = str(file_path.relative_to(self.cwd))
126
+ try:
127
+ self._file_index[rel] = file_path.stat().st_mtime
128
+ except OSError:
129
+ pass
130
+
131
+ self._save_file_index()
132
+ self.store.save()
133
+
134
+ def _scan_files(self) -> list[Path]:
135
+ """Scan the codebase for indexable files, respecting .gitignore."""
136
+ tracked = self._git_tracked_files()
137
+ if tracked is not None:
138
+ return [f for f in tracked if is_indexable(f)]
139
+
140
+ files: list[Path] = []
141
+ skip_dirs = {".git", "node_modules", "__pycache__", ".venv", "venv",
142
+ ".tox", ".mypy_cache", ".ruff_cache", "dist", "build",
143
+ ".crabcode", ".cursor"}
144
+ for item in self.cwd.rglob("*"):
145
+ if any(part in skip_dirs for part in item.parts):
146
+ continue
147
+ if item.is_file() and is_indexable(item):
148
+ files.append(item)
149
+ return files
150
+
151
+ def _git_tracked_files(self) -> list[Path] | None:
152
+ """Use git ls-files to get tracked files (respects .gitignore)."""
153
+ try:
154
+ result = subprocess.run(
155
+ ["git", "ls-files", "--cached", "--others", "--exclude-standard"],
156
+ cwd=self.cwd,
157
+ capture_output=True,
158
+ text=True,
159
+ timeout=10,
160
+ )
161
+ if result.returncode != 0:
162
+ return None
163
+ files: list[Path] = []
164
+ for line in result.stdout.splitlines():
165
+ line = line.strip()
166
+ if line:
167
+ files.append(self.cwd / line)
168
+ return files
169
+ except (OSError, subprocess.TimeoutExpired):
170
+ return None
171
+
172
+ def _detect_changes(self, files: list[Path]) -> list[Path]:
173
+ """Return files whose mtime changed since last index."""
174
+ changed: list[Path] = []
175
+ for f in files:
176
+ rel = str(f.relative_to(self.cwd))
177
+ try:
178
+ mtime = f.stat().st_mtime
179
+ except OSError:
180
+ continue
181
+ if rel not in self._file_index or self._file_index[rel] != mtime:
182
+ changed.append(f)
183
+ return changed
184
+
185
+ def _detect_deleted(self, current_files: list[Path]) -> list[str]:
186
+ """Return relative paths that are in file_index but no longer on disk."""
187
+ current_rels = {str(f.relative_to(self.cwd)) for f in current_files}
188
+ return [rel for rel in self._file_index if rel not in current_rels]
189
+
190
+ async def _purge_deleted(self, deleted: list[str]) -> None:
191
+ """Remove vectors and file_index entries for deleted files."""
192
+ for rel in deleted:
193
+ await self.store.remove_by_file(rel)
194
+ del self._file_index[rel]
195
+
196
+ def _load_file_index(self) -> None:
197
+ if self._file_index_path.exists():
198
+ try:
199
+ with open(self._file_index_path) as f:
200
+ self._file_index = json.load(f)
201
+ except (json.JSONDecodeError, OSError):
202
+ self._file_index = {}
203
+
204
+ def _save_file_index(self) -> None:
205
+ self._index_dir.mkdir(parents=True, exist_ok=True)
206
+ with open(self._file_index_path, "w") as f:
207
+ json.dump(self._file_index, f)
208
+
209
+
210
+ def _batched(items: list, size: int):
211
+ for i in range(0, len(items), size):
212
+ yield items[i : i + size]