crabcode 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crabcode-0.1.0.dist-info/METADATA +23 -0
- crabcode-0.1.0.dist-info/RECORD +64 -0
- crabcode-0.1.0.dist-info/WHEEL +4 -0
- crabcode-0.1.0.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +3 -0
- crabcode_cli/app.py +142 -0
- crabcode_cli/banner.py +42 -0
- crabcode_cli/pipe.py +58 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +1263 -0
- crabcode_core/__init__.py +3 -0
- crabcode_core/api/__init__.py +6 -0
- crabcode_core/api/anthropic_adapter.py +250 -0
- crabcode_core/api/base.py +66 -0
- crabcode_core/api/codex_adapter.py +390 -0
- crabcode_core/api/openai_adapter.py +221 -0
- crabcode_core/api/registry.py +40 -0
- crabcode_core/compact/__init__.py +7 -0
- crabcode_core/compact/compact.py +150 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +53 -0
- crabcode_core/config/manager.py +152 -0
- crabcode_core/events.py +392 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +163 -0
- crabcode_core/mcp/config.py +52 -0
- crabcode_core/permissions/__init__.py +3 -0
- crabcode_core/permissions/manager.py +106 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/context.py +209 -0
- crabcode_core/prompts/profile.py +66 -0
- crabcode_core/prompts/system.py +405 -0
- crabcode_core/prompts/templates.py +60 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/loop.py +442 -0
- crabcode_core/session/__init__.py +3 -0
- crabcode_core/session/storage.py +153 -0
- crabcode_core/skills/__init__.py +5 -0
- crabcode_core/skills/loader.py +147 -0
- crabcode_core/tools/__init__.py +29 -0
- crabcode_core/tools/agent.py +160 -0
- crabcode_core/tools/bash.py +127 -0
- crabcode_core/tools/diff_utils.py +120 -0
- crabcode_core/tools/file_edit.py +145 -0
- crabcode_core/tools/file_read.py +123 -0
- crabcode_core/tools/file_write.py +102 -0
- crabcode_core/tools/glob.py +109 -0
- crabcode_core/tools/grep.py +130 -0
- crabcode_core/tools/lint.py +368 -0
- crabcode_core/tools/memory.py +236 -0
- crabcode_core/tools/skill.py +112 -0
- crabcode_core/types/__init__.py +73 -0
- crabcode_core/types/config.py +103 -0
- crabcode_core/types/event.py +105 -0
- crabcode_core/types/message.py +177 -0
- crabcode_core/types/tool.py +138 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +371 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +447 -0
- crabcode_search/indexer.py +212 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +232 -0
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") 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
|
crabcode_search/tool.py
ADDED
|
@@ -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)
|