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,222 @@
1
+ """USearch-backed vector store with async-safe concurrent access.
2
+
3
+ Uses USearch HNSW for all dataset sizes:
4
+ - count < HNSW_THRESHOLD → exact brute-force search (exact=True, O(N))
5
+ - count >= HNSW_THRESHOLD → approximate HNSW search (exact=False, O(log N))
6
+
7
+ USearch uses SimSIMD instead of OpenMP, which avoids the libomp conflict with
8
+ PyTorch on macOS that made FAISS unusable in the same process.
9
+
10
+ Persistence:
11
+ index.usearch — USearch HNSW index
12
+ hnsw_state.json — key→metadata-index mapping and next-key counter
13
+ metadata.json — list of chunk dicts
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import asyncio
19
+ import json
20
+ import logging
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+
27
+ from crabcode_search.chunker import ChunkMeta
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ HNSW_THRESHOLD = 100_000
32
+ HNSW_CONNECTIVITY = 16
33
+ HNSW_EXPANSION_ADD = 128
34
+ HNSW_EXPANSION_SEARCH = 64
35
+
36
+
37
+ @dataclass
38
+ class SearchResult:
39
+ chunk: ChunkMeta
40
+ score: float
41
+
42
+
43
+ class VectorStore:
44
+ """USearch-backed vector store with metadata tracking.
45
+
46
+ Small codebases (count < HNSW_THRESHOLD) use exact inner-product search;
47
+ large codebases use approximate HNSW — both via the same USearch index.
48
+ """
49
+
50
+ def __init__(self, index_dir: Path, dimension: int = 768):
51
+ self._index_dir = index_dir
52
+ self._dimension = dimension
53
+
54
+ self._index: Any = None # usearch.index.Index (lazy init)
55
+ self._metadata: list[dict[str, Any]] = []
56
+ self._key_to_idx: dict[int, int] = {} # usearch key → metadata list index
57
+ self._next_key: int = 0
58
+ self._lock = asyncio.Lock()
59
+
60
+ @property
61
+ def count(self) -> int:
62
+ if self._index is None:
63
+ return 0
64
+ return len(self._index)
65
+
66
+ def _get_or_create_index(self) -> Any:
67
+ if self._index is None:
68
+ from usearch.index import Index
69
+
70
+ self._index = Index(
71
+ ndim=self._dimension,
72
+ metric="ip",
73
+ dtype="f32",
74
+ connectivity=HNSW_CONNECTIVITY,
75
+ expansion_add=HNSW_EXPANSION_ADD,
76
+ expansion_search=HNSW_EXPANSION_SEARCH,
77
+ )
78
+ return self._index
79
+
80
+ async def add(self, vectors: np.ndarray, chunks: list[ChunkMeta]) -> None:
81
+ if vectors.shape[0] == 0:
82
+ return
83
+ async with self._lock:
84
+ idx = self._get_or_create_index()
85
+ base = len(self._metadata)
86
+ keys = np.arange(
87
+ self._next_key, self._next_key + vectors.shape[0], dtype=np.int64
88
+ )
89
+ idx.add(keys, vectors)
90
+ for i, chunk in enumerate(chunks):
91
+ self._key_to_idx[int(keys[i])] = base + i
92
+ self._metadata.append({
93
+ "file_path": chunk.file_path,
94
+ "start_line": chunk.start_line,
95
+ "end_line": chunk.end_line,
96
+ "signature": chunk.signature,
97
+ "content": chunk.content,
98
+ })
99
+ self._next_key += vectors.shape[0]
100
+
101
+ async def remove_by_file(self, file_path: str) -> None:
102
+ async with self._lock:
103
+ if not self._metadata or self._index is None:
104
+ return
105
+
106
+ remove_set = {
107
+ i for i, m in enumerate(self._metadata) if m["file_path"] == file_path
108
+ }
109
+ if not remove_set:
110
+ return
111
+
112
+ keys_to_remove = [k for k, idx in self._key_to_idx.items() if idx in remove_set]
113
+ for k in keys_to_remove:
114
+ self._index.remove(k)
115
+ del self._key_to_idx[k]
116
+
117
+ keep = [i for i in range(len(self._metadata)) if i not in remove_set]
118
+ new_meta = [self._metadata[i] for i in keep]
119
+ old_to_new = {old: new for new, old in enumerate(keep)}
120
+ self._key_to_idx = {
121
+ k: old_to_new[v]
122
+ for k, v in self._key_to_idx.items()
123
+ if v in old_to_new
124
+ }
125
+ self._metadata = new_meta
126
+
127
+ async def search(self, query_vec: np.ndarray, top_k: int = 10) -> list[SearchResult]:
128
+ async with self._lock:
129
+ if self._index is None or len(self._index) == 0:
130
+ return []
131
+ if query_vec.ndim == 1:
132
+ query_vec = query_vec.reshape(1, -1)
133
+ k = min(top_k, len(self._index))
134
+ exact = len(self._index) < HNSW_THRESHOLD
135
+ matches = self._index.search(query_vec, k, exact=exact)
136
+ keys = matches.keys.ravel()
137
+ distances = matches.distances.ravel()
138
+
139
+ results: list[SearchResult] = []
140
+ for key, dist in zip(keys, distances):
141
+ key = int(key)
142
+ if key not in self._key_to_idx:
143
+ continue
144
+ meta_idx = self._key_to_idx[key]
145
+ if meta_idx >= len(self._metadata):
146
+ continue
147
+ meta = self._metadata[meta_idx]
148
+ results.append(SearchResult(
149
+ chunk=ChunkMeta(**meta),
150
+ score=1.0 - float(dist),
151
+ ))
152
+ return results
153
+
154
+ def save(self) -> None:
155
+ self._index_dir.mkdir(parents=True, exist_ok=True)
156
+ if self._index is not None:
157
+ self._index.save(str(self._index_dir / "index.usearch"))
158
+ state = {
159
+ "key_to_idx": {str(k): v for k, v in self._key_to_idx.items()},
160
+ "next_key": self._next_key,
161
+ }
162
+ with open(self._index_dir / "hnsw_state.json", "w") as f:
163
+ json.dump(state, f)
164
+ with open(self._index_dir / "metadata.json", "w") as f:
165
+ json.dump(self._metadata, f)
166
+ (self._index_dir / "index.npy").unlink(missing_ok=True)
167
+
168
+ def load(self) -> bool:
169
+ meta_path = self._index_dir / "metadata.json"
170
+ if not meta_path.exists():
171
+ return False
172
+ try:
173
+ with open(meta_path) as f:
174
+ self._metadata = json.load(f)
175
+
176
+ usearch_path = self._index_dir / "index.usearch"
177
+ if usearch_path.exists():
178
+ from usearch.index import Index
179
+
180
+ idx = Index(
181
+ ndim=self._dimension,
182
+ metric="ip",
183
+ dtype="f32",
184
+ connectivity=HNSW_CONNECTIVITY,
185
+ expansion_add=HNSW_EXPANSION_ADD,
186
+ expansion_search=HNSW_EXPANSION_SEARCH,
187
+ )
188
+ idx.load(str(usearch_path))
189
+ self._index = idx
190
+ if len(idx) > 0:
191
+ self._dimension = idx.ndim
192
+
193
+ state_path = self._index_dir / "hnsw_state.json"
194
+ if state_path.exists():
195
+ with open(state_path) as f:
196
+ state = json.load(f)
197
+ self._key_to_idx = {int(k): v for k, v in state["key_to_idx"].items()}
198
+ self._next_key = state["next_key"]
199
+ else:
200
+ n = len(idx)
201
+ self._key_to_idx = {k: k for k in range(n)}
202
+ self._next_key = n
203
+ return True
204
+
205
+ # Legacy: plain numpy index — migrate to usearch on next save
206
+ npy_path = self._index_dir / "index.npy"
207
+ if npy_path.exists():
208
+ vectors = np.load(npy_path).astype(np.float32)
209
+ if vectors.shape[0] > 0:
210
+ self._dimension = vectors.shape[1]
211
+ idx = self._get_or_create_index()
212
+ n = vectors.shape[0]
213
+ keys = np.arange(n, dtype=np.int64)
214
+ idx.add(keys, vectors)
215
+ self._key_to_idx = {k: k for k in range(n)}
216
+ self._next_key = n
217
+ return True
218
+
219
+ return False
220
+ except Exception:
221
+ logger.exception("Failed to load vector store")
222
+ return False
@@ -0,0 +1,232 @@
1
+ """CodebaseSearchTool — semantic code search as a CrabCode extra tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ import json
8
+
9
+ # Must be set before ANY native library (FAISS / PyTorch) loads libomp.
10
+ os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
11
+ os.environ.setdefault("OMP_NUM_THREADS", "1")
12
+ os.environ.setdefault("MKL_NUM_THREADS", "1")
13
+
14
+ import asyncio
15
+ from typing import Any
16
+
17
+ from crabcode_core.types.tool import Tool, ToolContext, ToolResult
18
+
19
+ from crabcode_search.background import STATUS_FILE_NAME
20
+ from crabcode_search.embedder import create_embedder
21
+ from crabcode_search.indexer import CodebaseIndexer
22
+
23
+
24
+ class CodebaseSearchTool(Tool):
25
+ """Semantic codebase search powered by embeddings and FAISS."""
26
+
27
+ name = "CodebaseSearch"
28
+ description = "Search the codebase using natural language queries. Returns semantically relevant code snippets with file paths and line numbers. Use this when you need to find code by meaning rather than exact text. For exact text/regex matching, use Grep instead."
29
+ is_read_only = True
30
+ is_concurrency_safe = True
31
+ input_schema = {
32
+ "type": "object",
33
+ "properties": {
34
+ "query": {
35
+ "type": "string",
36
+ "description": "Natural language search query about the codebase.",
37
+ },
38
+ "target_directory": {
39
+ "type": "string",
40
+ "description": "Optional subdirectory to limit search scope.",
41
+ },
42
+ "num_results": {
43
+ "type": "integer",
44
+ "description": "Number of results to return (default 10).",
45
+ "default": 10,
46
+ },
47
+ },
48
+ "required": ["query"],
49
+ }
50
+
51
+ def __init__(self) -> None:
52
+ self._indexer: CodebaseIndexer | None = None
53
+ self._tool_config: dict[str, Any] = {}
54
+ self._init_lock = asyncio.Lock()
55
+ self._status_path: Path | None = None
56
+
57
+ async def setup(self, context: ToolContext) -> None:
58
+ await super().setup(context)
59
+ self._tool_config = context.tool_config
60
+ self._status_path = Path(context.cwd) / ".crabcode" / "search" / STATUS_FILE_NAME
61
+
62
+ status = self._read_background_status()
63
+ if status:
64
+ state = status.get("state")
65
+ if state == "ready":
66
+ chunks = status.get("chunks")
67
+ message = "Background index ready"
68
+ if chunks is not None:
69
+ message += f" ({chunks} chunks)"
70
+ self.emit_event("ready", {"message": message})
71
+ return
72
+ if state in {"starting", "preloading", "loading_index", "indexing"}:
73
+ done = status.get("done")
74
+ total = status.get("total")
75
+ percent = None
76
+ if isinstance(done, int) and isinstance(total, int) and total > 0:
77
+ percent = done / total
78
+ self.emit_event(
79
+ "progress",
80
+ {
81
+ "message": f"Background {state}...",
82
+ "percent": percent,
83
+ },
84
+ )
85
+ return
86
+
87
+ await self._ensure_initialized()
88
+
89
+ async def _ensure_initialized(self) -> None:
90
+ if self._indexer is not None:
91
+ return
92
+
93
+ async with self._init_lock:
94
+ if self._indexer is not None:
95
+ return
96
+
97
+ context = self._setup_context
98
+ if context is None:
99
+ return
100
+
101
+ config = self._tool_config
102
+ embedder = create_embedder(
103
+ backend=config.get("embedder", "ollama"),
104
+ model=config.get("model"),
105
+ dimension=config.get("dimension"),
106
+ batch_size=config.get("batch_size"),
107
+ device=config.get("device"),
108
+ )
109
+ await embedder.preload()
110
+ self._indexer = CodebaseIndexer(context.cwd, embedder=embedder)
111
+
112
+ threads = config.get("threads")
113
+ if threads is not None:
114
+ threads = int(threads)
115
+ os.environ["OMP_NUM_THREADS"] = str(threads)
116
+ os.environ["MKL_NUM_THREADS"] = str(threads)
117
+ try:
118
+ import torch
119
+ torch.set_num_threads(threads)
120
+ except Exception:
121
+ pass
122
+
123
+ all_files = self._indexer._scan_files()
124
+ self._indexer.total_files = len(all_files)
125
+ if self._indexer.store.count > 0 and not self._indexer._detect_changes(
126
+ all_files
127
+ ):
128
+ self.emit_event("ready", {
129
+ "message": f"Index loaded ({self._indexer.store.count} chunks)",
130
+ })
131
+ return
132
+
133
+ self.emit_event("progress", {"message": "Starting indexing...", "percent": 0})
134
+ self._background_task = asyncio.create_task(self._build_index())
135
+
136
+ def _read_background_status(self) -> dict[str, Any] | None:
137
+ if self._status_path is None or not self._status_path.exists():
138
+ return None
139
+ try:
140
+ data = json.loads(self._status_path.read_text(encoding="utf-8"))
141
+ except (OSError, json.JSONDecodeError):
142
+ return None
143
+ return data if isinstance(data, dict) else None
144
+
145
+ async def _build_index(self) -> None:
146
+ assert self._indexer is not None
147
+ async for progress in self._indexer.build_or_update():
148
+ if progress.total > 0:
149
+ pct = progress.done / progress.total
150
+ else:
151
+ pct = 1.0
152
+ self.emit_event("progress", {
153
+ "message": f"Indexing: {progress.done}/{progress.total} files",
154
+ "percent": pct,
155
+ })
156
+ self.emit_event("ready", {
157
+ "message": f"Index ready ({self._indexer.store.count} chunks, {self._indexer.total_files} files)",
158
+ })
159
+
160
+ async def get_prompt(self, **kwargs: Any) -> str:
161
+ return (
162
+ "Search the codebase using natural language queries. "
163
+ "Returns semantically relevant code snippets with file paths and line numbers. "
164
+ "Use this when you need to find code by meaning rather than exact text. "
165
+ "For example: 'where is authentication handled', 'how does the build system work', "
166
+ "'find the payment processing logic'. "
167
+ "For exact text/regex matching, use Grep instead. "
168
+ "For finding files by name, use Glob instead."
169
+ )
170
+
171
+ async def call(
172
+ self,
173
+ tool_input: dict[str, Any],
174
+ context: ToolContext,
175
+ ) -> ToolResult:
176
+ if self._indexer is None:
177
+ await self._ensure_initialized()
178
+ if self._indexer is None:
179
+ return ToolResult(
180
+ result_for_model="CodebaseSearch is not initialized. Use Grep or Glob instead.",
181
+ is_error=True,
182
+ )
183
+
184
+ query = tool_input.get("query", "")
185
+ if not query:
186
+ return ToolResult(result_for_model="query is required.", is_error=True)
187
+
188
+ num_results = tool_input.get("num_results", 10)
189
+ target_dir = tool_input.get("target_directory")
190
+
191
+ building = self._background_task and not self._background_task.done()
192
+
193
+ if not building:
194
+ await self._indexer.incremental_update()
195
+
196
+ query_vec = await self._indexer.embedder.embed([query], task_type="CODE_RETRIEVAL_QUERY")
197
+ results = await self._indexer.store.search(query_vec[0], top_k=num_results * 2)
198
+
199
+ if target_dir:
200
+ results = [r for r in results if r.chunk.file_path.startswith(target_dir)]
201
+
202
+ results = results[:num_results]
203
+
204
+ if not results:
205
+ msg = "No results found."
206
+ if building:
207
+ progress = self._indexer.progress
208
+ msg += (
209
+ f"\n\n[Note: Index still building ({progress.done}/{progress.total} files). "
210
+ "Results may be incomplete. Use Grep for precise searches.]"
211
+ )
212
+ return ToolResult(result_for_model=msg)
213
+
214
+ output_parts: list[str] = []
215
+ for i, r in enumerate(results, 1):
216
+ output_parts.append(
217
+ f"[{i}] {r.chunk.file_path}:{r.chunk.start_line}-{r.chunk.end_line} "
218
+ f"(score: {r.score:.3f})\n"
219
+ f" {r.chunk.signature}\n"
220
+ f"```\n{r.chunk.content}\n```"
221
+ )
222
+
223
+ output = "\n\n".join(output_parts)
224
+
225
+ if building:
226
+ progress = self._indexer.progress
227
+ output += (
228
+ f"\n\n[Note: Index still building ({progress.done}/{progress.total} files). "
229
+ "Results may be incomplete. Use Grep for precise searches.]"
230
+ )
231
+
232
+ return ToolResult(result_for_model=output)