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,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]
@@ -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