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.
- crabcode-0.1.6.dist-info/METADATA +63 -0
- crabcode-0.1.6.dist-info/RECORD +205 -0
- crabcode-0.1.6.dist-info/WHEEL +4 -0
- crabcode-0.1.6.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +7 -0
- crabcode_cli/__main__.py +4 -0
- crabcode_cli/_native/crabcode-computer-use-host.exe +0 -0
- crabcode_cli/_native/runtime.json +6 -0
- crabcode_cli/app.py +776 -0
- crabcode_cli/banner.py +39 -0
- crabcode_cli/computer_use.py +286 -0
- crabcode_cli/model_manager.py +649 -0
- crabcode_cli/model_picker.py +168 -0
- crabcode_cli/pipe.py +148 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +4841 -0
- crabcode_cli/session_picker.py +239 -0
- crabcode_core/__init__.py +7 -0
- crabcode_core/_version.py +3 -0
- crabcode_core/_windows_process.py +68 -0
- crabcode_core/agent_manager.py +1731 -0
- crabcode_core/api/__init__.py +20 -0
- crabcode_core/api/anthropic_adapter.py +648 -0
- crabcode_core/api/azure_adapter.py +69 -0
- crabcode_core/api/base.py +233 -0
- crabcode_core/api/codex_adapter.py +1327 -0
- crabcode_core/api/error_logging.py +132 -0
- crabcode_core/api/gemini_adapter.py +26 -0
- crabcode_core/api/model_info.py +257 -0
- crabcode_core/api/network.py +62 -0
- crabcode_core/api/ollama_adapter.py +27 -0
- crabcode_core/api/openai_adapter.py +323 -0
- crabcode_core/api/registry.py +58 -0
- crabcode_core/command_line.py +18 -0
- crabcode_core/compact/__init__.py +17 -0
- crabcode_core/compact/compact.py +514 -0
- crabcode_core/compact/context_tokens.py +165 -0
- crabcode_core/computer_use_mode.py +38 -0
- crabcode_core/computer_use_observation.py +144 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +61 -0
- crabcode_core/config/manager.py +237 -0
- crabcode_core/config/model_catalog.py +256 -0
- crabcode_core/events.py +4896 -0
- crabcode_core/file_lock.py +81 -0
- crabcode_core/filesystem.py +22 -0
- crabcode_core/goal.py +149 -0
- crabcode_core/hooks/__init__.py +5 -0
- crabcode_core/hooks/manager.py +290 -0
- crabcode_core/image_io.py +111 -0
- crabcode_core/io_worker.py +211 -0
- crabcode_core/logging_utils.py +168 -0
- crabcode_core/lsp/__init__.py +6 -0
- crabcode_core/lsp/client.py +786 -0
- crabcode_core/lsp/diagnostics.py +150 -0
- crabcode_core/lsp/manager.py +583 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +169 -0
- crabcode_core/mcp/config.py +57 -0
- crabcode_core/path_validation.py +34 -0
- crabcode_core/paths.py +24 -0
- crabcode_core/peer/__init__.py +10 -0
- crabcode_core/peer/runtime.py +518 -0
- crabcode_core/permissions/__init__.py +7 -0
- crabcode_core/permissions/ai_reviewer.py +184 -0
- crabcode_core/permissions/manager.py +187 -0
- crabcode_core/plan/__init__.py +5 -0
- crabcode_core/plan/executor.py +270 -0
- crabcode_core/plan/types.py +169 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/blocks.py +10 -0
- crabcode_core/prompts/context.py +229 -0
- crabcode_core/prompts/library.py +92 -0
- crabcode_core/prompts/profile.py +79 -0
- crabcode_core/prompts/system.py +356 -0
- crabcode_core/prompts/templates.py +104 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/computer_use_history.py +83 -0
- crabcode_core/query/loop.py +2446 -0
- crabcode_core/query/retry.py +116 -0
- crabcode_core/schedule/__init__.py +13 -0
- crabcode_core/schedule/manager.py +631 -0
- crabcode_core/schedule/models.py +204 -0
- crabcode_core/schedule/store.py +558 -0
- crabcode_core/schedule/timing.py +287 -0
- crabcode_core/session/__init__.py +12 -0
- crabcode_core/session/export.py +141 -0
- crabcode_core/session/meta_db.py +684 -0
- crabcode_core/session/storage.py +2032 -0
- crabcode_core/session/title_gen.py +52 -0
- crabcode_core/skills/__init__.py +6 -0
- crabcode_core/skills/context.py +23 -0
- crabcode_core/skills/loader.py +214 -0
- crabcode_core/skills/matcher.py +203 -0
- crabcode_core/snapshot/__init__.py +21 -0
- crabcode_core/snapshot/snapshot.py +436 -0
- crabcode_core/snapshot/tracker.py +359 -0
- crabcode_core/subprocess_utils.py +260 -0
- crabcode_core/team/__init__.py +27 -0
- crabcode_core/team/bridge.py +66 -0
- crabcode_core/team/inbox.py +237 -0
- crabcode_core/team/manager.py +1660 -0
- crabcode_core/team/message_bus.py +821 -0
- crabcode_core/team/models.py +191 -0
- crabcode_core/team/recovery.py +192 -0
- crabcode_core/text_io.py +52 -0
- crabcode_core/tools/__init__.py +112 -0
- crabcode_core/tools/_input_helpers.py +39 -0
- crabcode_core/tools/_input_schema.py +130 -0
- crabcode_core/tools/agent.py +413 -0
- crabcode_core/tools/apply_patch.py +587 -0
- crabcode_core/tools/ask_user.py +122 -0
- crabcode_core/tools/bash.py +154 -0
- crabcode_core/tools/browser.py +678 -0
- crabcode_core/tools/checklist.py +241 -0
- crabcode_core/tools/checkpoint.py +78 -0
- crabcode_core/tools/computer_use.py +599 -0
- crabcode_core/tools/diff_utils.py +123 -0
- crabcode_core/tools/file_edit.py +240 -0
- crabcode_core/tools/file_read.py +187 -0
- crabcode_core/tools/file_write.py +154 -0
- crabcode_core/tools/glob.py +123 -0
- crabcode_core/tools/goal.py +134 -0
- crabcode_core/tools/grep.py +247 -0
- crabcode_core/tools/image.py +173 -0
- crabcode_core/tools/image_generate.py +115 -0
- crabcode_core/tools/lint.py +388 -0
- crabcode_core/tools/loading.py +187 -0
- crabcode_core/tools/memory.py +456 -0
- crabcode_core/tools/monitor.py +677 -0
- crabcode_core/tools/peer.py +103 -0
- crabcode_core/tools/revert.py +90 -0
- crabcode_core/tools/schedule.py +683 -0
- crabcode_core/tools/skill.py +108 -0
- crabcode_core/tools/switch_mode.py +213 -0
- crabcode_core/tools/team.py +329 -0
- crabcode_core/tools/tool_search.py +64 -0
- crabcode_core/tools/web_search.py +444 -0
- crabcode_core/turn_details.py +64 -0
- crabcode_core/types/__init__.py +95 -0
- crabcode_core/types/config.py +434 -0
- crabcode_core/types/event.py +334 -0
- crabcode_core/types/message.py +263 -0
- crabcode_core/types/tool.py +208 -0
- crabcode_core/usage.py +289 -0
- crabcode_core/utf8_sanitize.py +29 -0
- crabcode_debugger/__init__.py +5 -0
- crabcode_debugger/adapters.py +332 -0
- crabcode_debugger/dap.py +304 -0
- crabcode_debugger/memory.py +1806 -0
- crabcode_debugger/process.py +707 -0
- crabcode_debugger/runtime.py +58 -0
- crabcode_debugger/sessions.py +387 -0
- crabcode_debugger/tool.py +731 -0
- crabcode_gateway/__init__.py +18 -0
- crabcode_gateway/acp/__init__.py +13 -0
- crabcode_gateway/acp/agent.py +1581 -0
- crabcode_gateway/acp/session.py +114 -0
- crabcode_gateway/acp/transport.py +39 -0
- crabcode_gateway/acp/types.py +90 -0
- crabcode_gateway/adapter.py +30 -0
- crabcode_gateway/auth.py +139 -0
- crabcode_gateway/computer_use.py +364 -0
- crabcode_gateway/document_engine.py +637 -0
- crabcode_gateway/document_worker.py +301 -0
- crabcode_gateway/event_bus.py +655 -0
- crabcode_gateway/grpc/__init__.py +0 -0
- crabcode_gateway/grpc/crabcode_pb2.py +70 -0
- crabcode_gateway/grpc/crabcode_pb2_grpc.py +474 -0
- crabcode_gateway/grpc/proto/crabcode.proto +160 -0
- crabcode_gateway/grpc/server.py +765 -0
- crabcode_gateway/middleware.py +344 -0
- crabcode_gateway/protocol.py +17 -0
- crabcode_gateway/routes/__init__.py +0 -0
- crabcode_gateway/routes/agent.py +392 -0
- crabcode_gateway/routes/auth.py +150 -0
- crabcode_gateway/routes/computer_use.py +101 -0
- crabcode_gateway/routes/config.py +1698 -0
- crabcode_gateway/routes/document.py +1810 -0
- crabcode_gateway/routes/event.py +3801 -0
- crabcode_gateway/routes/git_review.py +232 -0
- crabcode_gateway/routes/health.py +14 -0
- crabcode_gateway/routes/peer.py +78 -0
- crabcode_gateway/routes/permission.py +121 -0
- crabcode_gateway/routes/schedule.py +300 -0
- crabcode_gateway/routes/session.py +1496 -0
- crabcode_gateway/routes/snapshot.py +211 -0
- crabcode_gateway/routes/tasks.py +318 -0
- crabcode_gateway/routes/team.py +392 -0
- crabcode_gateway/routes/usage.py +49 -0
- crabcode_gateway/routes/workspace.py +299 -0
- crabcode_gateway/safe_file.py +63 -0
- crabcode_gateway/schemas.py +1820 -0
- crabcode_gateway/server.py +514 -0
- crabcode_gateway/session_registry.py +36 -0
- crabcode_gateway/task_registry.py +708 -0
- crabcode_gateway/turn_details.py +60 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +368 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +497 -0
- crabcode_search/indexer.py +215 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +268 -0
|
@@ -0,0 +1,215 @@
|
|
|
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_core.subprocess_utils import subprocess_group_options
|
|
12
|
+
from crabcode_search.chunker import ChunkMeta, chunk_file, is_indexable
|
|
13
|
+
from crabcode_search.embedder import Embedder
|
|
14
|
+
from crabcode_search.store import VectorStore
|
|
15
|
+
|
|
16
|
+
BATCH_SIZE = 5
|
|
17
|
+
INDEX_DIR_NAME = ".crabcode/search"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class IndexProgress:
|
|
22
|
+
done: int
|
|
23
|
+
total: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CodebaseIndexer:
|
|
27
|
+
"""Coordinates file scanning, chunking, embedding, and vector storage."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, cwd: str, embedder: Embedder):
|
|
30
|
+
self.cwd = Path(cwd).resolve()
|
|
31
|
+
self.embedder = embedder
|
|
32
|
+
self._index_dir = self.cwd / INDEX_DIR_NAME
|
|
33
|
+
self.store = VectorStore(self._index_dir, dimension=embedder.dimension)
|
|
34
|
+
self._file_index: dict[str, float] = {}
|
|
35
|
+
self._file_index_path = self._index_dir / "file_index.json"
|
|
36
|
+
self.total_files: int = 0
|
|
37
|
+
|
|
38
|
+
self._load_file_index()
|
|
39
|
+
self.store.load()
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def progress(self) -> IndexProgress:
|
|
43
|
+
indexed = len(self._file_index)
|
|
44
|
+
return IndexProgress(done=indexed, total=max(self.total_files, indexed))
|
|
45
|
+
|
|
46
|
+
async def build_or_update(self) -> AsyncGenerator[IndexProgress, None]:
|
|
47
|
+
"""Full/incremental index build, yielding progress after each batch."""
|
|
48
|
+
all_files = self._scan_files()
|
|
49
|
+
self.total_files = len(all_files)
|
|
50
|
+
changed = self._detect_changes(all_files)
|
|
51
|
+
deleted = self._detect_deleted(all_files)
|
|
52
|
+
|
|
53
|
+
await self._purge_deleted(deleted)
|
|
54
|
+
|
|
55
|
+
if not changed:
|
|
56
|
+
yield IndexProgress(done=self.total_files, total=self.total_files)
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
total_changed = len(changed)
|
|
60
|
+
done = 0
|
|
61
|
+
|
|
62
|
+
for batch in _batched(changed, BATCH_SIZE):
|
|
63
|
+
all_chunks: list[ChunkMeta] = []
|
|
64
|
+
for file_path in batch:
|
|
65
|
+
rel = str(file_path.relative_to(self.cwd))
|
|
66
|
+
await self.store.remove_by_file(rel)
|
|
67
|
+
try:
|
|
68
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
69
|
+
except OSError:
|
|
70
|
+
continue
|
|
71
|
+
chunks = chunk_file(rel, content)
|
|
72
|
+
all_chunks.extend(chunks)
|
|
73
|
+
|
|
74
|
+
if all_chunks:
|
|
75
|
+
texts = [c.content for c in all_chunks]
|
|
76
|
+
vectors = await self.embedder.embed(texts, task_type="RETRIEVAL_DOCUMENT")
|
|
77
|
+
await self.store.add(vectors, all_chunks)
|
|
78
|
+
|
|
79
|
+
for file_path in batch:
|
|
80
|
+
rel = str(file_path.relative_to(self.cwd))
|
|
81
|
+
try:
|
|
82
|
+
self._file_index[rel] = file_path.stat().st_mtime
|
|
83
|
+
except OSError:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
done += len(batch)
|
|
87
|
+
yield IndexProgress(done=done, total=total_changed)
|
|
88
|
+
|
|
89
|
+
self._save_file_index()
|
|
90
|
+
self.store.save()
|
|
91
|
+
|
|
92
|
+
async def incremental_update(self) -> None:
|
|
93
|
+
"""Quick mtime-based update for changed files (typically < 1s)."""
|
|
94
|
+
all_files = self._scan_files()
|
|
95
|
+
changed = self._detect_changes(all_files)
|
|
96
|
+
deleted = self._detect_deleted(all_files)
|
|
97
|
+
|
|
98
|
+
await self._purge_deleted(deleted)
|
|
99
|
+
|
|
100
|
+
if not changed:
|
|
101
|
+
if deleted:
|
|
102
|
+
self._save_file_index()
|
|
103
|
+
self.store.save()
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
for file_path in changed:
|
|
107
|
+
rel = str(file_path.relative_to(self.cwd))
|
|
108
|
+
await self.store.remove_by_file(rel)
|
|
109
|
+
|
|
110
|
+
all_chunks: list[ChunkMeta] = []
|
|
111
|
+
for file_path in changed:
|
|
112
|
+
rel = str(file_path.relative_to(self.cwd))
|
|
113
|
+
try:
|
|
114
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
115
|
+
except OSError:
|
|
116
|
+
continue
|
|
117
|
+
chunks = chunk_file(rel, content)
|
|
118
|
+
all_chunks.extend(chunks)
|
|
119
|
+
|
|
120
|
+
if all_chunks:
|
|
121
|
+
texts = [c.content for c in all_chunks]
|
|
122
|
+
vectors = await self.embedder.embed(texts, task_type="RETRIEVAL_DOCUMENT")
|
|
123
|
+
await self.store.add(vectors, all_chunks)
|
|
124
|
+
|
|
125
|
+
for file_path in changed:
|
|
126
|
+
rel = str(file_path.relative_to(self.cwd))
|
|
127
|
+
try:
|
|
128
|
+
self._file_index[rel] = file_path.stat().st_mtime
|
|
129
|
+
except OSError:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
self._save_file_index()
|
|
133
|
+
self.store.save()
|
|
134
|
+
|
|
135
|
+
def _scan_files(self) -> list[Path]:
|
|
136
|
+
"""Scan the codebase for indexable files, respecting .gitignore."""
|
|
137
|
+
tracked = self._git_tracked_files()
|
|
138
|
+
if tracked is not None:
|
|
139
|
+
return [f for f in tracked if is_indexable(f)]
|
|
140
|
+
|
|
141
|
+
files: list[Path] = []
|
|
142
|
+
skip_dirs = {".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
143
|
+
".tox", ".mypy_cache", ".ruff_cache", "dist", "build",
|
|
144
|
+
".crabcode", ".cursor"}
|
|
145
|
+
for item in self.cwd.rglob("*"):
|
|
146
|
+
if any(part in skip_dirs for part in item.parts):
|
|
147
|
+
continue
|
|
148
|
+
if item.is_file() and is_indexable(item):
|
|
149
|
+
files.append(item)
|
|
150
|
+
return files
|
|
151
|
+
|
|
152
|
+
def _git_tracked_files(self) -> list[Path] | None:
|
|
153
|
+
"""Use git ls-files to get tracked files (respects .gitignore)."""
|
|
154
|
+
try:
|
|
155
|
+
result = subprocess.run(
|
|
156
|
+
["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard"],
|
|
157
|
+
cwd=self.cwd,
|
|
158
|
+
capture_output=True,
|
|
159
|
+
text=True,
|
|
160
|
+
encoding="utf-8",
|
|
161
|
+
errors="replace",
|
|
162
|
+
timeout=10,
|
|
163
|
+
**subprocess_group_options(),
|
|
164
|
+
)
|
|
165
|
+
if result.returncode != 0:
|
|
166
|
+
return None
|
|
167
|
+
files: list[Path] = []
|
|
168
|
+
for line in result.stdout.split("\0"):
|
|
169
|
+
if line:
|
|
170
|
+
files.append(self.cwd / line)
|
|
171
|
+
return files
|
|
172
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
def _detect_changes(self, files: list[Path]) -> list[Path]:
|
|
176
|
+
"""Return files whose mtime changed since last index."""
|
|
177
|
+
changed: list[Path] = []
|
|
178
|
+
for f in files:
|
|
179
|
+
rel = str(f.relative_to(self.cwd))
|
|
180
|
+
try:
|
|
181
|
+
mtime = f.stat().st_mtime
|
|
182
|
+
except OSError:
|
|
183
|
+
continue
|
|
184
|
+
if rel not in self._file_index or self._file_index[rel] != mtime:
|
|
185
|
+
changed.append(f)
|
|
186
|
+
return changed
|
|
187
|
+
|
|
188
|
+
def _detect_deleted(self, current_files: list[Path]) -> list[str]:
|
|
189
|
+
"""Return relative paths that are in file_index but no longer on disk."""
|
|
190
|
+
current_rels = {str(f.relative_to(self.cwd)) for f in current_files}
|
|
191
|
+
return [rel for rel in self._file_index if rel not in current_rels]
|
|
192
|
+
|
|
193
|
+
async def _purge_deleted(self, deleted: list[str]) -> None:
|
|
194
|
+
"""Remove vectors and file_index entries for deleted files."""
|
|
195
|
+
for rel in deleted:
|
|
196
|
+
await self.store.remove_by_file(rel)
|
|
197
|
+
del self._file_index[rel]
|
|
198
|
+
|
|
199
|
+
def _load_file_index(self) -> None:
|
|
200
|
+
if self._file_index_path.exists():
|
|
201
|
+
try:
|
|
202
|
+
with open(self._file_index_path, encoding="utf-8") as f:
|
|
203
|
+
self._file_index = json.load(f)
|
|
204
|
+
except (json.JSONDecodeError, OSError):
|
|
205
|
+
self._file_index = {}
|
|
206
|
+
|
|
207
|
+
def _save_file_index(self) -> None:
|
|
208
|
+
self._index_dir.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
with open(self._file_index_path, "w", encoding="utf-8") as f:
|
|
210
|
+
json.dump(self._file_index, f)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _batched(items: list, size: int):
|
|
214
|
+
for i in range(0, len(items), size):
|
|
215
|
+
yield items[i : i + size]
|
crabcode_search/store.py
ADDED
|
@@ -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", encoding="utf-8") as f:
|
|
163
|
+
json.dump(state, f)
|
|
164
|
+
with open(self._index_dir / "metadata.json", "w", encoding="utf-8") 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, encoding="utf-8") 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, encoding="utf-8") 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
|