codebase-brain-mcp 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.
- codebase_brain_mcp/__init__.py +8 -0
- codebase_brain_mcp/__main__.py +118 -0
- codebase_brain_mcp/analysis.py +427 -0
- codebase_brain_mcp/brain.py +395 -0
- codebase_brain_mcp/chunking.py +196 -0
- codebase_brain_mcp/config.py +58 -0
- codebase_brain_mcp/embeddings.py +79 -0
- codebase_brain_mcp/evals.py +372 -0
- codebase_brain_mcp/history.py +214 -0
- codebase_brain_mcp/py.typed +0 -0
- codebase_brain_mcp/remote.py +112 -0
- codebase_brain_mcp/server.py +415 -0
- codebase_brain_mcp/sources.py +186 -0
- codebase_brain_mcp/store.py +349 -0
- codebase_brain_mcp-0.1.0.dist-info/METADATA +293 -0
- codebase_brain_mcp-0.1.0.dist-info/RECORD +19 -0
- codebase_brain_mcp-0.1.0.dist-info/WHEEL +4 -0
- codebase_brain_mcp-0.1.0.dist-info/entry_points.txt +4 -0
- codebase_brain_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Serving over Streamable HTTP: bearer-token auth, a health check, and safe defaults for a public host."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hmac
|
|
6
|
+
import ipaddress
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from mcp.server.mcpserver import MCPServer
|
|
11
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
12
|
+
from starlette.requests import Request
|
|
13
|
+
from starlette.responses import JSONResponse
|
|
14
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
15
|
+
|
|
16
|
+
HEALTH_PATH = "/healthz"
|
|
17
|
+
MCP_PATH = "/mcp"
|
|
18
|
+
MIN_TOKEN_LENGTH = 24
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RemoteConfigError(ValueError):
|
|
22
|
+
"""The HTTP settings are unsafe or incomplete."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class RemoteConfig:
|
|
27
|
+
host: str
|
|
28
|
+
port: int
|
|
29
|
+
token: str | None
|
|
30
|
+
allowed_hosts: tuple[str, ...] = ()
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def is_loopback(self) -> bool:
|
|
34
|
+
if self.host == "localhost":
|
|
35
|
+
return True
|
|
36
|
+
try:
|
|
37
|
+
return ipaddress.ip_address(self.host).is_loopback
|
|
38
|
+
except ValueError:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
def validate(self) -> None:
|
|
42
|
+
"""Refuse to expose the server beyond this machine without a strong token."""
|
|
43
|
+
if self.token is not None and len(self.token) < MIN_TOKEN_LENGTH:
|
|
44
|
+
raise RemoteConfigError(
|
|
45
|
+
f"CODEBASE_BRAIN_TOKEN must be at least {MIN_TOKEN_LENGTH} characters; "
|
|
46
|
+
'generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
|
47
|
+
)
|
|
48
|
+
if not self.is_loopback and not self.token:
|
|
49
|
+
raise RemoteConfigError(
|
|
50
|
+
f"Refusing to listen on {self.host} without CODEBASE_BRAIN_TOKEN. Anyone who can reach the port "
|
|
51
|
+
"could use the server. Set a token, or bind to 127.0.0.1."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def transport_security(self) -> TransportSecuritySettings:
|
|
55
|
+
"""DNS-rebinding protection: on for localhost (where it matters most) and for explicit host lists.
|
|
56
|
+
A public, token-protected server behind a platform's router has no fixed Host to check against."""
|
|
57
|
+
if self.allowed_hosts:
|
|
58
|
+
return TransportSecuritySettings(allowed_hosts=list(self.allowed_hosts))
|
|
59
|
+
if self.is_loopback:
|
|
60
|
+
hosts = [f"{name}:{self.port}" for name in ("127.0.0.1", "localhost", "[::1]")]
|
|
61
|
+
return TransportSecuritySettings(allowed_hosts=hosts)
|
|
62
|
+
return TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class BearerTokenMiddleware:
|
|
66
|
+
"""Requires ``Authorization: Bearer <token>`` on every HTTP request except the health check."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, app: ASGIApp, token: str | None):
|
|
69
|
+
self.app = app
|
|
70
|
+
self.token = token.encode() if token else None
|
|
71
|
+
|
|
72
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
73
|
+
if scope["type"] != "http" or self.token is None or scope["path"] == HEALTH_PATH:
|
|
74
|
+
await self.app(scope, receive, send)
|
|
75
|
+
return
|
|
76
|
+
header = dict(scope.get("headers") or []).get(b"authorization", b"")
|
|
77
|
+
scheme, _, presented = header.partition(b" ")
|
|
78
|
+
if scheme.lower() == b"bearer" and hmac.compare_digest(presented.strip(), self.token):
|
|
79
|
+
await self.app(scope, receive, send)
|
|
80
|
+
return
|
|
81
|
+
body = json.dumps({"error": "unauthorized", "message": "Missing or invalid bearer token."}).encode()
|
|
82
|
+
await send(
|
|
83
|
+
{
|
|
84
|
+
"type": "http.response.start",
|
|
85
|
+
"status": 401,
|
|
86
|
+
"headers": [
|
|
87
|
+
(b"content-type", b"application/json"),
|
|
88
|
+
(b"www-authenticate", b'Bearer realm="codebase-brain"'),
|
|
89
|
+
(b"content-length", str(len(body)).encode()),
|
|
90
|
+
],
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
await send({"type": "http.response.body", "body": body})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_app(server: MCPServer, config: RemoteConfig) -> ASGIApp:
|
|
97
|
+
config.validate()
|
|
98
|
+
|
|
99
|
+
@server.custom_route(HEALTH_PATH, methods=["GET"], include_in_schema=False)
|
|
100
|
+
async def health(_: Request) -> JSONResponse:
|
|
101
|
+
return JSONResponse({"status": "ok"})
|
|
102
|
+
|
|
103
|
+
app = server.streamable_http_app(
|
|
104
|
+
streamable_http_path=MCP_PATH, transport_security=config.transport_security(), host=config.host
|
|
105
|
+
)
|
|
106
|
+
return BearerTokenMiddleware(app, config.token)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def serve(server: MCPServer, config: RemoteConfig) -> None:
|
|
110
|
+
import uvicorn
|
|
111
|
+
|
|
112
|
+
uvicorn.run(build_app(server, config), host=config.host, port=config.port, log_level="info")
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""The MCP server: tools, a resource and a prompt over the Brain."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import anyio
|
|
6
|
+
import anyio.from_thread
|
|
7
|
+
import anyio.to_thread
|
|
8
|
+
from mcp.server.mcpserver import Context, MCPServer
|
|
9
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
10
|
+
from mcp.types import ToolAnnotations
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .analysis import DriftReport, ImpactReport
|
|
15
|
+
from .brain import Brain, BrainError, HistoryReport, Hit
|
|
16
|
+
from .config import load_settings
|
|
17
|
+
from .history import Commit
|
|
18
|
+
from .sources import SourceError
|
|
19
|
+
|
|
20
|
+
INSTRUCTIONS = """\
|
|
21
|
+
Codebase Brain indexes code repositories and answers questions about them with citations.
|
|
22
|
+
Start with index_repo (a local path or an https git URL). Then use ask_codebase for questions,
|
|
23
|
+
search for raw hybrid search, and read_lines to open the exact lines a citation points at.
|
|
24
|
+
For "why is it like this?" questions, use explain_history on a symbol or line range, and
|
|
25
|
+
search_history to find commits by topic. Before changing a function, run impact_of_change to see
|
|
26
|
+
what depends on it. find_doc_drift lists documentation that no longer matches the code.
|
|
27
|
+
Cite code as path:start-end and commits by short sha."""
|
|
28
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)
|
|
29
|
+
MAX_RESULTS = 25
|
|
30
|
+
SNIPPET_CHARS = 1_800
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class IndexResult(BaseModel):
|
|
34
|
+
repo: str
|
|
35
|
+
root: str
|
|
36
|
+
commit: str | None
|
|
37
|
+
files_seen: int
|
|
38
|
+
files_indexed: int = Field(description="Files embedded this run (new or changed).")
|
|
39
|
+
files_unchanged: int
|
|
40
|
+
files_removed: int
|
|
41
|
+
chunks_total: int
|
|
42
|
+
commits_added: int
|
|
43
|
+
commits_total: int
|
|
44
|
+
seconds: float
|
|
45
|
+
full_rebuild: bool
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Source(BaseModel):
|
|
49
|
+
citation: str = Field(description="path:start-end, ready to quote.")
|
|
50
|
+
path: str
|
|
51
|
+
start_line: int
|
|
52
|
+
end_line: int
|
|
53
|
+
kind: str
|
|
54
|
+
symbol: str | None
|
|
55
|
+
score: float
|
|
56
|
+
matched_by: list[str] = Field(description="Which retrievers found it: keyword, vector, or both.")
|
|
57
|
+
snippet: str
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SearchResult(BaseModel):
|
|
61
|
+
repo: str
|
|
62
|
+
query: str
|
|
63
|
+
results: list[Source]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CommitInfo(BaseModel):
|
|
67
|
+
sha: str = Field(description="Short sha, ready to cite.")
|
|
68
|
+
author: str
|
|
69
|
+
date: str
|
|
70
|
+
subject: str
|
|
71
|
+
body: str
|
|
72
|
+
pull_requests: list[int]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class AskResult(BaseModel):
|
|
76
|
+
repo: str
|
|
77
|
+
question: str
|
|
78
|
+
instructions: str
|
|
79
|
+
sources: list[Source]
|
|
80
|
+
related_commits: list[CommitInfo] = Field(description="Commits whose messages match the question.")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class Change(BaseModel):
|
|
84
|
+
commit: CommitInfo
|
|
85
|
+
diff: str = Field(description="The part of the commit's diff that touched the range.")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Authorship(BaseModel):
|
|
89
|
+
sha: str
|
|
90
|
+
author: str
|
|
91
|
+
date: str
|
|
92
|
+
subject: str
|
|
93
|
+
lines: int = Field(description="How many of the current lines come from this commit.")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class PullRequestInfo(BaseModel):
|
|
97
|
+
number: int
|
|
98
|
+
url: str
|
|
99
|
+
title: str | None
|
|
100
|
+
body: str | None
|
|
101
|
+
state: str | None
|
|
102
|
+
merged_at: str | None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class HistoryResult(BaseModel):
|
|
106
|
+
repo: str
|
|
107
|
+
citation: str
|
|
108
|
+
symbol: str | None
|
|
109
|
+
instructions: str
|
|
110
|
+
authorship: list[Authorship]
|
|
111
|
+
changes: list[Change] = Field(description="Commits that changed these lines, newest first.")
|
|
112
|
+
pull_requests: list[PullRequestInfo]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class DependentInfo(BaseModel):
|
|
116
|
+
citation: str
|
|
117
|
+
symbol: str | None
|
|
118
|
+
kind: str
|
|
119
|
+
via: str = Field(description="The name through which it depends on the level above.")
|
|
120
|
+
level: int = Field(description="1 = uses the target directly, 2 = uses something that does.")
|
|
121
|
+
is_test: bool
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ImpactResult(BaseModel):
|
|
125
|
+
target: str = Field(description="path:start-end of the definition analysed.")
|
|
126
|
+
symbol: str
|
|
127
|
+
summary: str
|
|
128
|
+
direct: list[DependentInfo]
|
|
129
|
+
indirect: list[DependentInfo]
|
|
130
|
+
tests: list[str] = Field(description="Citations of tests that exercise the target, directly or not.")
|
|
131
|
+
same_name_definitions: list[str]
|
|
132
|
+
notes: list[str]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class FindingInfo(BaseModel):
|
|
136
|
+
severity: str = Field(description="high (almost certainly wrong), medium (probably wrong), low (worth a look).")
|
|
137
|
+
kind: str
|
|
138
|
+
doc: str = Field(description="path:start-end of the doc section.")
|
|
139
|
+
claim: str
|
|
140
|
+
detail: str
|
|
141
|
+
suggestion: str | None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class DriftResult(BaseModel):
|
|
145
|
+
repo: str
|
|
146
|
+
summary: str
|
|
147
|
+
sections_checked: int
|
|
148
|
+
references_checked: int
|
|
149
|
+
findings: list[FindingInfo]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class RepoSummary(BaseModel):
|
|
153
|
+
name: str
|
|
154
|
+
source: str
|
|
155
|
+
commit: str | None
|
|
156
|
+
indexed_at: str
|
|
157
|
+
files: int
|
|
158
|
+
chunks: int
|
|
159
|
+
commits: int
|
|
160
|
+
languages: dict[str, int]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def build_server(brain: Brain | None = None) -> MCPServer:
|
|
164
|
+
brain = brain or Brain(load_settings())
|
|
165
|
+
server = MCPServer("codebase-brain", instructions=INSTRUCTIONS, version=__version__)
|
|
166
|
+
|
|
167
|
+
@server.tool(
|
|
168
|
+
annotations=ToolAnnotations(
|
|
169
|
+
read_only_hint=False, destructive_hint=False, idempotent_hint=True, open_world_hint=True
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
async def index_repo(source: str, ctx: Context, name: str | None = None) -> IndexResult:
|
|
173
|
+
"""Index a repository so it can be searched. `source` is a local directory or an https git URL
|
|
174
|
+
(cloned shallowly). Re-running only re-embeds files that changed since the last run."""
|
|
175
|
+
|
|
176
|
+
def progress(done: int, total: int, message: str) -> None:
|
|
177
|
+
anyio.from_thread.run(ctx.report_progress, done, total, message)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
report = await anyio.to_thread.run_sync(lambda: brain.index(source, name=name, progress=progress))
|
|
181
|
+
except (BrainError, SourceError) as exc:
|
|
182
|
+
raise ToolError(str(exc)) from exc
|
|
183
|
+
return IndexResult(**report.__dict__)
|
|
184
|
+
|
|
185
|
+
@server.tool(annotations=READ_ONLY)
|
|
186
|
+
async def search(
|
|
187
|
+
query: str, repo: str | None = None, k: int = 8, path_prefix: str | None = None, kinds: list[str] | None = None
|
|
188
|
+
) -> SearchResult:
|
|
189
|
+
"""Hybrid search (BM25 keywords + embeddings, fused by rank). Filter by `path_prefix`
|
|
190
|
+
(e.g. "src/auth/") or `kinds` (function, class, method, module, section, code)."""
|
|
191
|
+
hits = await _search(brain, query, repo, k, path_prefix, kinds)
|
|
192
|
+
return SearchResult(repo=brain.resolve_repo(repo), query=query, results=[_source(h) for h in hits])
|
|
193
|
+
|
|
194
|
+
@server.tool(annotations=READ_ONLY)
|
|
195
|
+
async def ask_codebase(question: str, repo: str | None = None, k: int = 8) -> AskResult:
|
|
196
|
+
"""Gather the code and docs most relevant to a question, as numbered, cited sources.
|
|
197
|
+
Answer from these sources only, citing each claim as path:start-end."""
|
|
198
|
+
hits = await _search(brain, question, repo, k, None, None)
|
|
199
|
+
commits = await _call(lambda: brain.search_history(question, repo, 3))
|
|
200
|
+
instructions = (
|
|
201
|
+
"Answer the question using only these sources. Cite every claim as path:start-end. "
|
|
202
|
+
"If the sources don't contain the answer, say so and suggest a better search or a file to read "
|
|
203
|
+
"with read_lines."
|
|
204
|
+
if hits
|
|
205
|
+
else "Nothing relevant was found. Try search with different keywords, or check the repo is indexed."
|
|
206
|
+
)
|
|
207
|
+
return AskResult(
|
|
208
|
+
repo=brain.resolve_repo(repo),
|
|
209
|
+
question=question,
|
|
210
|
+
instructions=instructions,
|
|
211
|
+
sources=[_source(h) for h in hits],
|
|
212
|
+
related_commits=[_commit(c) for c in commits],
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
@server.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=True))
|
|
216
|
+
async def explain_history(
|
|
217
|
+
repo: str | None = None,
|
|
218
|
+
symbol: str | None = None,
|
|
219
|
+
path: str | None = None,
|
|
220
|
+
start_line: int | None = None,
|
|
221
|
+
end_line: int | None = None,
|
|
222
|
+
max_commits: int = 8,
|
|
223
|
+
) -> HistoryResult:
|
|
224
|
+
"""Why code looks the way it does. Give a `symbol` (e.g. "unsign" or "Signer.sign"), or a `path`
|
|
225
|
+
with optional line range. Returns who wrote the current lines, every commit that changed them
|
|
226
|
+
(with the relevant diff), and the pull requests behind those commits."""
|
|
227
|
+
report = await _call(lambda: brain.explain_history(repo, path, symbol, start_line, end_line, max_commits))
|
|
228
|
+
return _history(report)
|
|
229
|
+
|
|
230
|
+
@server.tool(annotations=READ_ONLY)
|
|
231
|
+
async def impact_of_change(
|
|
232
|
+
symbol: str, repo: str | None = None, path: str | None = None, depth: int = 2
|
|
233
|
+
) -> ImpactResult:
|
|
234
|
+
"""What breaks if `symbol` changes: code that uses it directly, code that uses that code
|
|
235
|
+
(up to `depth`, max 3), and the tests involved. Python is analysed structurally."""
|
|
236
|
+
report = await _call(lambda: brain.impact_of_change(repo, symbol, path, depth))
|
|
237
|
+
return _impact(report)
|
|
238
|
+
|
|
239
|
+
@server.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
|
|
240
|
+
async def find_doc_drift(
|
|
241
|
+
repo: str | None = None, path_prefix: str | None = None, check_history: bool = True, limit: int = 50
|
|
242
|
+
) -> DriftResult:
|
|
243
|
+
"""Documentation that no longer matches the code: files, functions, settings and flags that don't
|
|
244
|
+
exist; examples passing parameters a function doesn't take; imports that would fail; and docs last
|
|
245
|
+
edited long before the code they describe changed. Each finding cites the doc section."""
|
|
246
|
+
report = await _call(lambda: brain.find_doc_drift(repo, path_prefix, check_history, max(1, min(limit, 200))))
|
|
247
|
+
return _drift(brain.resolve_repo(repo), report)
|
|
248
|
+
|
|
249
|
+
@server.tool(annotations=READ_ONLY)
|
|
250
|
+
async def search_history(query: str, repo: str | None = None, k: int = 5) -> list[CommitInfo]:
|
|
251
|
+
"""Find commits by what their messages say, e.g. "fix timezone" or "drop python 3.8"."""
|
|
252
|
+
commits = await _call(lambda: brain.search_history(query, repo, max(1, min(k, MAX_RESULTS))))
|
|
253
|
+
return [_commit(c) for c in commits]
|
|
254
|
+
|
|
255
|
+
@server.tool(annotations=READ_ONLY)
|
|
256
|
+
async def read_lines(path: str, start: int, end: int, repo: str | None = None) -> str:
|
|
257
|
+
"""Exact, numbered lines from a file in an indexed repo, e.g. to open a citation in full."""
|
|
258
|
+
try:
|
|
259
|
+
return brain.read_lines(repo, path, start, end)
|
|
260
|
+
except BrainError as exc:
|
|
261
|
+
raise ToolError(str(exc)) from exc
|
|
262
|
+
|
|
263
|
+
@server.tool(annotations=READ_ONLY)
|
|
264
|
+
async def list_repos() -> list[RepoSummary]:
|
|
265
|
+
"""Repositories indexed so far, with size, languages and the commit they were indexed at."""
|
|
266
|
+
return [_summary(info) for info in brain.repos()]
|
|
267
|
+
|
|
268
|
+
@server.resource(
|
|
269
|
+
"brain://repos",
|
|
270
|
+
name="indexed-repos",
|
|
271
|
+
mime_type="application/json",
|
|
272
|
+
description="Every indexed repository and its stats.",
|
|
273
|
+
)
|
|
274
|
+
def repos_resource() -> str:
|
|
275
|
+
return "[" + ",".join(_summary(info).model_dump_json() for info in brain.repos()) + "]"
|
|
276
|
+
|
|
277
|
+
@server.prompt(description="Answer a question about a codebase with strict citations.")
|
|
278
|
+
def answer_with_citations(question: str, repo: str = "") -> str:
|
|
279
|
+
target = f" in the {repo} repo" if repo else ""
|
|
280
|
+
return (
|
|
281
|
+
f"Question about the codebase{target}: {question}\n\n"
|
|
282
|
+
"1. Call ask_codebase with this question" + (f' and repo="{repo}"' if repo else "") + ".\n"
|
|
283
|
+
"2. If a source looks relevant but is cut off, open it with read_lines.\n"
|
|
284
|
+
"3. Answer in a few short paragraphs. Cite every claim as path:start-end.\n"
|
|
285
|
+
"4. If the sources don't answer it, say what's missing instead of guessing."
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return server
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def _search(
|
|
292
|
+
brain: Brain, query: str, repo: str | None, k: int, path_prefix: str | None, kinds: list[str] | None
|
|
293
|
+
) -> list[Hit]:
|
|
294
|
+
k = max(1, min(k, MAX_RESULTS))
|
|
295
|
+
return await _call(lambda: brain.search(query, repo, k, path_prefix, kinds))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
async def _call(fn):
|
|
299
|
+
"""Run blocking engine work off the event loop; user-facing errors become tool errors."""
|
|
300
|
+
try:
|
|
301
|
+
return await anyio.to_thread.run_sync(fn)
|
|
302
|
+
except BrainError as exc:
|
|
303
|
+
raise ToolError(str(exc)) from exc
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _commit(commit: Commit) -> CommitInfo:
|
|
307
|
+
return CommitInfo(
|
|
308
|
+
sha=commit.sha[:10],
|
|
309
|
+
author=commit.author,
|
|
310
|
+
date=commit.date,
|
|
311
|
+
subject=commit.subject,
|
|
312
|
+
body=commit.body,
|
|
313
|
+
pull_requests=list(commit.prs),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _impact(report: ImpactReport) -> ImpactResult:
|
|
318
|
+
def info(d) -> DependentInfo:
|
|
319
|
+
return DependentInfo(
|
|
320
|
+
citation=d.chunk.citation,
|
|
321
|
+
symbol=d.chunk.symbol,
|
|
322
|
+
kind=d.chunk.kind,
|
|
323
|
+
via=d.via,
|
|
324
|
+
level=d.level,
|
|
325
|
+
is_test=d.is_test,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
direct = [info(d) for d in report.dependents if d.level == 1]
|
|
329
|
+
indirect = [info(d) for d in report.dependents if d.level > 1]
|
|
330
|
+
tests = [d.chunk.citation for d in report.dependents if d.is_test]
|
|
331
|
+
files = len({d.chunk.path for d in report.dependents})
|
|
332
|
+
summary = (
|
|
333
|
+
f"{report.symbol} is used directly in {len(direct)} place(s) and indirectly in {len(indirect)}, "
|
|
334
|
+
f"across {files} file(s); {len(tests)} test chunk(s) exercise it."
|
|
335
|
+
)
|
|
336
|
+
return ImpactResult(
|
|
337
|
+
target=f"{report.path}:{report.start_line}-{report.end_line}",
|
|
338
|
+
symbol=report.symbol,
|
|
339
|
+
summary=summary,
|
|
340
|
+
direct=direct,
|
|
341
|
+
indirect=indirect,
|
|
342
|
+
tests=tests,
|
|
343
|
+
same_name_definitions=report.same_name_definitions,
|
|
344
|
+
notes=report.notes,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _drift(repo: str, report: DriftReport) -> DriftResult:
|
|
349
|
+
counts = {level: sum(f.severity == level for f in report.findings) for level in ("high", "medium", "low")}
|
|
350
|
+
summary = (
|
|
351
|
+
f"{len(report.findings)} finding(s) in {report.sections_checked} doc section(s): "
|
|
352
|
+
f"{counts['high']} high, {counts['medium']} medium, {counts['low']} low."
|
|
353
|
+
if report.findings
|
|
354
|
+
else f"No drift found in {report.sections_checked} doc section(s)."
|
|
355
|
+
)
|
|
356
|
+
return DriftResult(
|
|
357
|
+
repo=repo,
|
|
358
|
+
summary=summary,
|
|
359
|
+
sections_checked=report.sections_checked,
|
|
360
|
+
references_checked=report.references_checked,
|
|
361
|
+
findings=[FindingInfo(**f.__dict__) for f in report.findings],
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _history(report: HistoryReport) -> HistoryResult:
|
|
366
|
+
citation = f"{report.path}:{report.start_line}-{report.end_line}"
|
|
367
|
+
instructions = (
|
|
368
|
+
"Explain why this code looks the way it does, oldest change first: what each commit changed and the "
|
|
369
|
+
"reason its message or PR gives. Cite commits by short sha and code as path:start-end. If the messages "
|
|
370
|
+
"don't say why, say so rather than guessing."
|
|
371
|
+
if report.changes
|
|
372
|
+
else "No commits changed these lines (they may be uncommitted)."
|
|
373
|
+
)
|
|
374
|
+
return HistoryResult(
|
|
375
|
+
repo=report.repo,
|
|
376
|
+
citation=citation,
|
|
377
|
+
symbol=report.symbol,
|
|
378
|
+
instructions=instructions,
|
|
379
|
+
authorship=[
|
|
380
|
+
Authorship(sha=b.sha[:10], author=b.author, date=b.date, subject=b.subject, lines=b.lines)
|
|
381
|
+
for b in report.blame
|
|
382
|
+
],
|
|
383
|
+
changes=[Change(commit=_commit(c.commit), diff=c.diff) for c in report.changes],
|
|
384
|
+
pull_requests=[PullRequestInfo(**pr.__dict__) for pr in report.pull_requests],
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _source(hit: Hit) -> Source:
|
|
389
|
+
chunk = hit.chunk
|
|
390
|
+
matched = [name for name, rank in (("keyword", hit.keyword_rank), ("vector", hit.vector_rank)) if rank]
|
|
391
|
+
snippet = chunk.text if len(chunk.text) <= SNIPPET_CHARS else chunk.text[:SNIPPET_CHARS] + "\n…"
|
|
392
|
+
return Source(
|
|
393
|
+
citation=chunk.citation,
|
|
394
|
+
path=chunk.path,
|
|
395
|
+
start_line=chunk.start_line,
|
|
396
|
+
end_line=chunk.end_line,
|
|
397
|
+
kind=chunk.kind,
|
|
398
|
+
symbol=chunk.symbol,
|
|
399
|
+
score=hit.score,
|
|
400
|
+
matched_by=matched,
|
|
401
|
+
snippet=snippet,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _summary(info) -> RepoSummary:
|
|
406
|
+
return RepoSummary(
|
|
407
|
+
name=info.name,
|
|
408
|
+
source=info.source,
|
|
409
|
+
commit=info.commit,
|
|
410
|
+
indexed_at=info.indexed_at,
|
|
411
|
+
files=info.files,
|
|
412
|
+
chunks=info.chunks,
|
|
413
|
+
commits=info.commits,
|
|
414
|
+
languages=info.languages,
|
|
415
|
+
)
|