crabcode 0.1.6__py3-none-win_amd64.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 (205) hide show
  1. crabcode-0.1.6.dist-info/METADATA +63 -0
  2. crabcode-0.1.6.dist-info/RECORD +205 -0
  3. crabcode-0.1.6.dist-info/WHEEL +4 -0
  4. crabcode-0.1.6.dist-info/entry_points.txt +2 -0
  5. crabcode_cli/__init__.py +7 -0
  6. crabcode_cli/__main__.py +4 -0
  7. crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
  8. crabcode_cli/_native/runtime.json +6 -0
  9. crabcode_cli/app.py +776 -0
  10. crabcode_cli/banner.py +39 -0
  11. crabcode_cli/computer_use.py +286 -0
  12. crabcode_cli/model_manager.py +649 -0
  13. crabcode_cli/model_picker.py +168 -0
  14. crabcode_cli/pipe.py +148 -0
  15. crabcode_cli/render/__init__.py +0 -0
  16. crabcode_cli/render/messages.py +19 -0
  17. crabcode_cli/repl.py +4841 -0
  18. crabcode_cli/session_picker.py +239 -0
  19. crabcode_core/__init__.py +7 -0
  20. crabcode_core/_version.py +3 -0
  21. crabcode_core/_windows_process.py +68 -0
  22. crabcode_core/agent_manager.py +1731 -0
  23. crabcode_core/api/__init__.py +20 -0
  24. crabcode_core/api/anthropic_adapter.py +648 -0
  25. crabcode_core/api/azure_adapter.py +69 -0
  26. crabcode_core/api/base.py +233 -0
  27. crabcode_core/api/codex_adapter.py +1327 -0
  28. crabcode_core/api/error_logging.py +132 -0
  29. crabcode_core/api/gemini_adapter.py +26 -0
  30. crabcode_core/api/model_info.py +257 -0
  31. crabcode_core/api/network.py +62 -0
  32. crabcode_core/api/ollama_adapter.py +27 -0
  33. crabcode_core/api/openai_adapter.py +323 -0
  34. crabcode_core/api/registry.py +58 -0
  35. crabcode_core/command_line.py +18 -0
  36. crabcode_core/compact/__init__.py +17 -0
  37. crabcode_core/compact/compact.py +514 -0
  38. crabcode_core/compact/context_tokens.py +165 -0
  39. crabcode_core/computer_use_mode.py +38 -0
  40. crabcode_core/computer_use_observation.py +144 -0
  41. crabcode_core/config/__init__.py +3 -0
  42. crabcode_core/config/claudemd.py +61 -0
  43. crabcode_core/config/manager.py +237 -0
  44. crabcode_core/config/model_catalog.py +256 -0
  45. crabcode_core/events.py +4896 -0
  46. crabcode_core/file_lock.py +81 -0
  47. crabcode_core/filesystem.py +22 -0
  48. crabcode_core/goal.py +149 -0
  49. crabcode_core/hooks/__init__.py +5 -0
  50. crabcode_core/hooks/manager.py +290 -0
  51. crabcode_core/image_io.py +111 -0
  52. crabcode_core/io_worker.py +211 -0
  53. crabcode_core/logging_utils.py +168 -0
  54. crabcode_core/lsp/__init__.py +6 -0
  55. crabcode_core/lsp/client.py +786 -0
  56. crabcode_core/lsp/diagnostics.py +150 -0
  57. crabcode_core/lsp/manager.py +583 -0
  58. crabcode_core/mcp/__init__.py +4 -0
  59. crabcode_core/mcp/client.py +169 -0
  60. crabcode_core/mcp/config.py +57 -0
  61. crabcode_core/path_validation.py +34 -0
  62. crabcode_core/paths.py +24 -0
  63. crabcode_core/peer/__init__.py +10 -0
  64. crabcode_core/peer/runtime.py +518 -0
  65. crabcode_core/permissions/__init__.py +7 -0
  66. crabcode_core/permissions/ai_reviewer.py +184 -0
  67. crabcode_core/permissions/manager.py +187 -0
  68. crabcode_core/plan/__init__.py +5 -0
  69. crabcode_core/plan/executor.py +270 -0
  70. crabcode_core/plan/types.py +169 -0
  71. crabcode_core/prompts/__init__.py +0 -0
  72. crabcode_core/prompts/blocks.py +10 -0
  73. crabcode_core/prompts/context.py +229 -0
  74. crabcode_core/prompts/library.py +92 -0
  75. crabcode_core/prompts/profile.py +79 -0
  76. crabcode_core/prompts/system.py +356 -0
  77. crabcode_core/prompts/templates.py +104 -0
  78. crabcode_core/query/__init__.py +0 -0
  79. crabcode_core/query/computer_use_history.py +83 -0
  80. crabcode_core/query/loop.py +2446 -0
  81. crabcode_core/query/retry.py +116 -0
  82. crabcode_core/schedule/__init__.py +13 -0
  83. crabcode_core/schedule/manager.py +631 -0
  84. crabcode_core/schedule/models.py +204 -0
  85. crabcode_core/schedule/store.py +558 -0
  86. crabcode_core/schedule/timing.py +287 -0
  87. crabcode_core/session/__init__.py +12 -0
  88. crabcode_core/session/export.py +141 -0
  89. crabcode_core/session/meta_db.py +684 -0
  90. crabcode_core/session/storage.py +2032 -0
  91. crabcode_core/session/title_gen.py +52 -0
  92. crabcode_core/skills/__init__.py +6 -0
  93. crabcode_core/skills/context.py +23 -0
  94. crabcode_core/skills/loader.py +214 -0
  95. crabcode_core/skills/matcher.py +203 -0
  96. crabcode_core/snapshot/__init__.py +21 -0
  97. crabcode_core/snapshot/snapshot.py +436 -0
  98. crabcode_core/snapshot/tracker.py +359 -0
  99. crabcode_core/subprocess_utils.py +260 -0
  100. crabcode_core/team/__init__.py +27 -0
  101. crabcode_core/team/bridge.py +66 -0
  102. crabcode_core/team/inbox.py +237 -0
  103. crabcode_core/team/manager.py +1660 -0
  104. crabcode_core/team/message_bus.py +821 -0
  105. crabcode_core/team/models.py +191 -0
  106. crabcode_core/team/recovery.py +192 -0
  107. crabcode_core/text_io.py +52 -0
  108. crabcode_core/tools/__init__.py +112 -0
  109. crabcode_core/tools/_input_helpers.py +39 -0
  110. crabcode_core/tools/_input_schema.py +130 -0
  111. crabcode_core/tools/agent.py +413 -0
  112. crabcode_core/tools/apply_patch.py +587 -0
  113. crabcode_core/tools/ask_user.py +122 -0
  114. crabcode_core/tools/bash.py +154 -0
  115. crabcode_core/tools/browser.py +678 -0
  116. crabcode_core/tools/checklist.py +241 -0
  117. crabcode_core/tools/checkpoint.py +78 -0
  118. crabcode_core/tools/computer_use.py +599 -0
  119. crabcode_core/tools/diff_utils.py +123 -0
  120. crabcode_core/tools/file_edit.py +240 -0
  121. crabcode_core/tools/file_read.py +187 -0
  122. crabcode_core/tools/file_write.py +154 -0
  123. crabcode_core/tools/glob.py +123 -0
  124. crabcode_core/tools/goal.py +134 -0
  125. crabcode_core/tools/grep.py +247 -0
  126. crabcode_core/tools/image.py +173 -0
  127. crabcode_core/tools/image_generate.py +115 -0
  128. crabcode_core/tools/lint.py +388 -0
  129. crabcode_core/tools/loading.py +187 -0
  130. crabcode_core/tools/memory.py +456 -0
  131. crabcode_core/tools/monitor.py +677 -0
  132. crabcode_core/tools/peer.py +103 -0
  133. crabcode_core/tools/revert.py +90 -0
  134. crabcode_core/tools/schedule.py +683 -0
  135. crabcode_core/tools/skill.py +108 -0
  136. crabcode_core/tools/switch_mode.py +213 -0
  137. crabcode_core/tools/team.py +329 -0
  138. crabcode_core/tools/tool_search.py +64 -0
  139. crabcode_core/tools/web_search.py +444 -0
  140. crabcode_core/turn_details.py +64 -0
  141. crabcode_core/types/__init__.py +95 -0
  142. crabcode_core/types/config.py +434 -0
  143. crabcode_core/types/event.py +334 -0
  144. crabcode_core/types/message.py +263 -0
  145. crabcode_core/types/tool.py +208 -0
  146. crabcode_core/usage.py +289 -0
  147. crabcode_core/utf8_sanitize.py +29 -0
  148. crabcode_debugger/__init__.py +5 -0
  149. crabcode_debugger/adapters.py +332 -0
  150. crabcode_debugger/dap.py +304 -0
  151. crabcode_debugger/memory.py +1806 -0
  152. crabcode_debugger/process.py +707 -0
  153. crabcode_debugger/runtime.py +58 -0
  154. crabcode_debugger/sessions.py +387 -0
  155. crabcode_debugger/tool.py +731 -0
  156. crabcode_gateway/__init__.py +18 -0
  157. crabcode_gateway/acp/__init__.py +13 -0
  158. crabcode_gateway/acp/agent.py +1581 -0
  159. crabcode_gateway/acp/session.py +114 -0
  160. crabcode_gateway/acp/transport.py +39 -0
  161. crabcode_gateway/acp/types.py +90 -0
  162. crabcode_gateway/adapter.py +30 -0
  163. crabcode_gateway/auth.py +139 -0
  164. crabcode_gateway/computer_use.py +364 -0
  165. crabcode_gateway/document_engine.py +637 -0
  166. crabcode_gateway/document_worker.py +301 -0
  167. crabcode_gateway/event_bus.py +655 -0
  168. crabcode_gateway/grpc/__init__.py +0 -0
  169. crabcode_gateway/grpc/crabcode_pb2.py +70 -0
  170. crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
  171. crabcode_gateway/grpc/proto/crabcode.proto +160 -0
  172. crabcode_gateway/grpc/server.py +765 -0
  173. crabcode_gateway/middleware.py +344 -0
  174. crabcode_gateway/protocol.py +17 -0
  175. crabcode_gateway/routes/__init__.py +0 -0
  176. crabcode_gateway/routes/agent.py +392 -0
  177. crabcode_gateway/routes/auth.py +150 -0
  178. crabcode_gateway/routes/computer_use.py +101 -0
  179. crabcode_gateway/routes/config.py +1698 -0
  180. crabcode_gateway/routes/document.py +1810 -0
  181. crabcode_gateway/routes/event.py +3801 -0
  182. crabcode_gateway/routes/git_review.py +232 -0
  183. crabcode_gateway/routes/health.py +14 -0
  184. crabcode_gateway/routes/peer.py +78 -0
  185. crabcode_gateway/routes/permission.py +121 -0
  186. crabcode_gateway/routes/schedule.py +300 -0
  187. crabcode_gateway/routes/session.py +1496 -0
  188. crabcode_gateway/routes/snapshot.py +211 -0
  189. crabcode_gateway/routes/tasks.py +318 -0
  190. crabcode_gateway/routes/team.py +392 -0
  191. crabcode_gateway/routes/usage.py +49 -0
  192. crabcode_gateway/routes/workspace.py +299 -0
  193. crabcode_gateway/safe_file.py +63 -0
  194. crabcode_gateway/schemas.py +1820 -0
  195. crabcode_gateway/server.py +514 -0
  196. crabcode_gateway/session_registry.py +36 -0
  197. crabcode_gateway/task_registry.py +708 -0
  198. crabcode_gateway/turn_details.py +60 -0
  199. crabcode_search/__init__.py +11 -0
  200. crabcode_search/background.py +368 -0
  201. crabcode_search/chunker.py +517 -0
  202. crabcode_search/embedder.py +497 -0
  203. crabcode_search/indexer.py +215 -0
  204. crabcode_search/store.py +222 -0
  205. crabcode_search/tool.py +268 -0
@@ -0,0 +1,497 @@
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
+ _MPS_PER_SAMPLE_MB = 150.0 # MPS has higher overhead than CUDA due to shared memory
209
+ _CPU_PER_SAMPLE_MB = 50.0
210
+ _MAX_BATCH = {"cuda": 64, "mps": 8, "cpu": 8} # MPS capped low: Metal buffer
211
+ # allocation failures are fatal (abort, not catchable)
212
+
213
+
214
+ def _auto_batch_size(device: str = "cpu") -> int:
215
+ """Compute a safe encode batch_size from available device memory.
216
+
217
+ Transformer attention memory scales quadratically with sequence length,
218
+ so per-sample cost on GPU is much higher than the raw embedding size.
219
+
220
+ For CUDA, uses free VRAM (40% budget). For MPS and CPU, uses available
221
+ system RAM — on Apple Silicon the two share unified memory.
222
+
223
+ NOTE: MPS batch sizes are deliberately conservative because Metal buffer
224
+ allocation failures trigger a hard abort (C++ assertion) that kills the
225
+ process — they cannot be caught as Python exceptions.
226
+ """
227
+ if device == "mps":
228
+ per_sample = _MPS_PER_SAMPLE_MB
229
+ elif device == "cuda":
230
+ per_sample = _GPU_PER_SAMPLE_MB
231
+ else:
232
+ per_sample = _CPU_PER_SAMPLE_MB
233
+ cap = _MAX_BATCH.get(device, 8)
234
+
235
+ try:
236
+ if device == "cuda":
237
+ import torch
238
+
239
+ free, _ = torch.cuda.mem_get_info()
240
+ available_mb = free / (1024**2)
241
+ budget_ratio = 0.4
242
+ else:
243
+ import psutil
244
+
245
+ available_mb = psutil.virtual_memory().available / (1024**2)
246
+ # MPS is very conservative — Metal buffer alloc failures are fatal
247
+ budget_ratio = 0.1 if device == "mps" else (0.2 if device != "cpu" else 0.3)
248
+
249
+ size = max(1, int(available_mb * budget_ratio / per_sample))
250
+ return min(size, cap)
251
+ except Exception:
252
+ return 4 if device in ("cuda", "mps") else 8
253
+
254
+
255
+ def _is_oom_error(exc: Exception) -> bool:
256
+ """Detect GPU/MPS out-of-memory errors (including MPS buffer limits)."""
257
+ try:
258
+ import torch
259
+
260
+ if isinstance(exc, torch.cuda.OutOfMemoryError):
261
+ return True
262
+ except (ImportError, AttributeError):
263
+ pass
264
+ msg = str(exc).lower()
265
+ return (
266
+ "out of memory" in msg
267
+ or "oom" in msg
268
+ or "invalid buffer size" in msg
269
+ or "failed to allocate" in msg
270
+ )
271
+
272
+
273
+ # Minimum available memory (MB) to safely use MPS for embedding inference.
274
+ # Metal buffer allocation failures are fatal (hard abort, uncatchable), so we
275
+ # need a generous safety margin. 0.6B embedding models + batch overhead
276
+ # typically need ~2-3 GB; we require at least 4 GB free to proceed on MPS.
277
+ _MPS_MIN_AVAILABLE_MB = 4096
278
+
279
+
280
+ def _safe_mps_device() -> str:
281
+ """Return "mps" if safe to use, otherwise "cpu".
282
+
283
+ Metal buffer allocation failures on Apple Silicon trigger a C++ assertion
284
+ (abort) that kills the entire process — they cannot be caught as Python
285
+ exceptions. This function checks available memory and downgrades to CPU
286
+ proactively when memory is too tight.
287
+ """
288
+ try:
289
+ import psutil
290
+
291
+ available_mb = psutil.virtual_memory().available / (1024**2)
292
+ if available_mb < _MPS_MIN_AVAILABLE_MB:
293
+ import sys
294
+ print(
295
+ f"[embedder] available memory {available_mb:.0f} MB < "
296
+ f"{_MPS_MIN_AVAILABLE_MB} MB safety threshold, "
297
+ f"falling back to CPU (MPS buffer alloc failures are fatal)",
298
+ file=sys.stderr,
299
+ )
300
+ return "cpu"
301
+ except Exception:
302
+ return "cpu"
303
+ return "mps"
304
+
305
+
306
+ class HuggingFaceEmbedder(Embedder):
307
+ """Local embedding via sentence-transformers (HuggingFace models).
308
+
309
+ Model loading is deferred to a background thread on first use to avoid
310
+ blocking the event loop (loading a 0.6B model can take 10+ seconds).
311
+
312
+ device: ``None`` (default) auto-detects the best device (CUDA > MPS > CPU).
313
+ Pass ``"cpu"`` / ``"cuda"`` / ``"mps"`` to force a specific device.
314
+
315
+ batch_size: ``None`` (default) computes dynamically from available memory
316
+ (VRAM for CUDA, unified memory for MPS/CPU).
317
+
318
+ If an OOM error occurs on GPU/MPS, the model is moved to CPU
319
+ transparently and the batch is retried.
320
+ """
321
+
322
+ def __init__(
323
+ self,
324
+ model: str = "BAAI/bge-small-en-v1.5",
325
+ batch_size: int | None = None,
326
+ device: str | None = None,
327
+ sync: bool = False,
328
+ ):
329
+ try:
330
+ import sentence_transformers # noqa: F401
331
+ except ImportError as exc:
332
+ raise ImportError(
333
+ "HuggingFace backend requires sentence-transformers: "
334
+ "pip install crabcode-search[huggingface]"
335
+ ) from exc
336
+ self._model_name = model
337
+ self._batch_size = batch_size
338
+ self._requested_device = device
339
+ self._device: str | None = None
340
+ self._sync_mode = sync
341
+ self._model: Any = None
342
+ self._dim: int | None = None
343
+
344
+ async def _ensure_loaded(self) -> None:
345
+ if self._model is not None:
346
+ return
347
+ from sentence_transformers import SentenceTransformer
348
+
349
+ if self._requested_device is not None:
350
+ self._device = self._requested_device
351
+ else:
352
+ self._device = _detect_best_device()
353
+
354
+ # MPS pre-flight: Metal buffer allocation failures trigger a hard C++
355
+ # assertion that aborts the process — they cannot be caught in Python.
356
+ # Downgrade to CPU proactively when available memory is tight.
357
+ if self._device == "mps":
358
+ self._device = _safe_mps_device()
359
+
360
+ import sys
361
+ print(f"[embedder] using device: {self._device}", file=sys.stderr)
362
+
363
+ if self._sync_mode:
364
+ self._model = SentenceTransformer(self._model_name, device=self._device)
365
+ else:
366
+ self._model = await asyncio.to_thread(
367
+ SentenceTransformer, self._model_name, device=self._device,
368
+ )
369
+ self._dim = self._model.get_sentence_embedding_dimension()
370
+
371
+ @property
372
+ def dimension(self) -> int:
373
+ if self._dim is not None:
374
+ return self._dim
375
+ return 1024
376
+
377
+ async def preload(self) -> None:
378
+ await self._ensure_loaded()
379
+
380
+ async def embed(self, texts: list[str], task_type: str | None = None) -> np.ndarray:
381
+ await self._ensure_loaded()
382
+ assert self._device is not None
383
+ bs = self._batch_size if self._batch_size is not None else _auto_batch_size(self._device)
384
+
385
+ def _encode() -> np.ndarray:
386
+ return self._model.encode(texts, normalize_embeddings=True, batch_size=bs)
387
+
388
+ try:
389
+ if self._sync_mode:
390
+ vecs = _encode()
391
+ else:
392
+ vecs = await asyncio.to_thread(_encode)
393
+ except RuntimeError as exc:
394
+ if not _is_oom_error(exc):
395
+ raise
396
+ vecs = await self._oom_retry(texts, bs, exc)
397
+ return np.asarray(vecs, dtype=np.float32)
398
+
399
+ async def _oom_retry(
400
+ self, texts: list[str], failed_bs: int, original_exc: Exception,
401
+ ) -> np.ndarray:
402
+ """Progressive OOM recovery: halve batch on same device, then CPU."""
403
+ import sys
404
+
405
+ if self._device != "cpu" and failed_bs > 1:
406
+ new_bs = max(1, failed_bs // 2)
407
+ print(
408
+ f"[embedder] OOM on {self._device} (batch={failed_bs}), "
409
+ f"retrying with batch={new_bs}",
410
+ file=sys.stderr,
411
+ )
412
+ try:
413
+ vecs = await asyncio.to_thread(
414
+ self._model.encode, texts, normalize_embeddings=True,
415
+ batch_size=new_bs,
416
+ )
417
+ self._batch_size = new_bs
418
+ return vecs
419
+ except RuntimeError as exc2:
420
+ if not _is_oom_error(exc2):
421
+ raise
422
+
423
+ if self._device == "cpu":
424
+ raise original_exc
425
+
426
+ print(
427
+ f"[embedder] still OOM on {self._device}, falling back to CPU",
428
+ file=sys.stderr,
429
+ )
430
+ self._model = self._model.to("cpu")
431
+ self._device = "cpu"
432
+ bs = _auto_batch_size("cpu")
433
+ self._batch_size = bs
434
+ return await asyncio.to_thread(
435
+ self._model.encode, texts, normalize_embeddings=True, batch_size=bs,
436
+ )
437
+
438
+
439
+ class ModelScopeEmbedder(Embedder):
440
+ """Local embedding via ModelScope SDK."""
441
+
442
+ def __init__(self, model: str = "iic/nlp_gte_sentence-embedding_chinese-base"):
443
+ try:
444
+ from modelscope.models import Model
445
+ from modelscope.pipelines import pipeline
446
+ except ImportError as exc:
447
+ raise ImportError(
448
+ "ModelScope backend requires modelscope: "
449
+ "pip install crabcode-search[modelscope]"
450
+ ) from exc
451
+ self._pipeline = pipeline("sentence-embedding", model=model)
452
+ self._dim: int = 0
453
+
454
+ @property
455
+ def dimension(self) -> int:
456
+ return self._dim
457
+
458
+ async def embed(self, texts: list[str], task_type: str | None = None) -> np.ndarray:
459
+ result = await asyncio.to_thread(
460
+ self._pipeline, {"source_sentence": texts}
461
+ )
462
+ vecs = np.array(result["text_embedding"], dtype=np.float32)
463
+ if self._dim == 0:
464
+ self._dim = vecs.shape[1]
465
+ return self._normalize(vecs)
466
+
467
+
468
+ _EMBEDDER_REGISTRY: dict[str, type[Embedder]] = {
469
+ "ollama": OllamaEmbedder,
470
+ "gemini": GeminiEmbedder,
471
+ "openai": OpenAIEmbedder,
472
+ "huggingface": HuggingFaceEmbedder,
473
+ "modelscope": ModelScopeEmbedder,
474
+ }
475
+
476
+
477
+ def create_embedder(
478
+ backend: str = "ollama",
479
+ model: str | None = None,
480
+ dimension: int | None = None,
481
+ **kwargs: Any,
482
+ ) -> Embedder:
483
+ """Factory: create an embedder from config."""
484
+ cls = _EMBEDDER_REGISTRY.get(backend)
485
+ if cls is None:
486
+ raise ValueError(
487
+ f"Unknown embedder backend: {backend!r}. "
488
+ f"Available: {', '.join(_EMBEDDER_REGISTRY)}"
489
+ )
490
+
491
+ init_kwargs: dict[str, Any] = {k: v for k, v in kwargs.items() if v is not None}
492
+ if model:
493
+ init_kwargs["model"] = model
494
+ if dimension is not None:
495
+ init_kwargs["dim"] = dimension
496
+
497
+ return cls(**init_kwargs)