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.
- crabcode-0.1.0.dist-info/METADATA +23 -0
- crabcode-0.1.0.dist-info/RECORD +64 -0
- crabcode-0.1.0.dist-info/WHEEL +4 -0
- crabcode-0.1.0.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +3 -0
- crabcode_cli/app.py +142 -0
- crabcode_cli/banner.py +42 -0
- crabcode_cli/pipe.py +58 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +1263 -0
- crabcode_core/__init__.py +3 -0
- crabcode_core/api/__init__.py +6 -0
- crabcode_core/api/anthropic_adapter.py +250 -0
- crabcode_core/api/base.py +66 -0
- crabcode_core/api/codex_adapter.py +390 -0
- crabcode_core/api/openai_adapter.py +221 -0
- crabcode_core/api/registry.py +40 -0
- crabcode_core/compact/__init__.py +7 -0
- crabcode_core/compact/compact.py +150 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +53 -0
- crabcode_core/config/manager.py +152 -0
- crabcode_core/events.py +392 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +163 -0
- crabcode_core/mcp/config.py +52 -0
- crabcode_core/permissions/__init__.py +3 -0
- crabcode_core/permissions/manager.py +106 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/context.py +209 -0
- crabcode_core/prompts/profile.py +66 -0
- crabcode_core/prompts/system.py +405 -0
- crabcode_core/prompts/templates.py +60 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/loop.py +442 -0
- crabcode_core/session/__init__.py +3 -0
- crabcode_core/session/storage.py +153 -0
- crabcode_core/skills/__init__.py +5 -0
- crabcode_core/skills/loader.py +147 -0
- crabcode_core/tools/__init__.py +29 -0
- crabcode_core/tools/agent.py +160 -0
- crabcode_core/tools/bash.py +127 -0
- crabcode_core/tools/diff_utils.py +120 -0
- crabcode_core/tools/file_edit.py +145 -0
- crabcode_core/tools/file_read.py +123 -0
- crabcode_core/tools/file_write.py +102 -0
- crabcode_core/tools/glob.py +109 -0
- crabcode_core/tools/grep.py +130 -0
- crabcode_core/tools/lint.py +368 -0
- crabcode_core/tools/memory.py +236 -0
- crabcode_core/tools/skill.py +112 -0
- crabcode_core/types/__init__.py +73 -0
- crabcode_core/types/config.py +103 -0
- crabcode_core/types/event.py +105 -0
- crabcode_core/types/message.py +177 -0
- crabcode_core/types/tool.py +138 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +371 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +447 -0
- crabcode_search/indexer.py +212 -0
- crabcode_search/store.py +222 -0
- 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]
|