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.
@@ -0,0 +1,395 @@
1
+ """The engine behind the MCP tools: index repositories, search them, and gather cited evidence for questions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import re
7
+ import time
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass, field
10
+ from datetime import UTC, datetime
11
+ from pathlib import Path
12
+ from typing import Literal
13
+
14
+ from . import analysis, history, sources
15
+ from .analysis import DriftReport, ImpactReport, definition_span
16
+ from .chunking import chunk_file
17
+ from .config import Settings
18
+ from .embeddings import Embedder, make_embedder
19
+ from .history import BlameShare, Commit, LineChange, PullRequest
20
+ from .store import Store, StoredChunk
21
+
22
+ RRF_K = 60
23
+ """Reciprocal-rank-fusion constant; 60 is the value from the original paper and works well untuned."""
24
+ CANDIDATES = 50
25
+ EMBED_BATCH_FILES = 16
26
+ HISTORY_LIMIT = 5_000
27
+ """Commits read per indexing run; enough for the history that matters, bounded for huge repos."""
28
+ MAX_PULL_REQUESTS = 5
29
+ _NAME = re.compile(r"^[A-Za-z0-9._-]{1,100}$")
30
+
31
+ ProgressFn = Callable[[int, int, str], None]
32
+ SearchMode = Literal["hybrid", "keyword", "vector"]
33
+ SEARCH_MODES: tuple[SearchMode, ...] = ("hybrid", "keyword", "vector")
34
+
35
+
36
+ class BrainError(ValueError):
37
+ """A request the brain can't serve, with a message meant for the user."""
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class IndexReport:
42
+ repo: str
43
+ root: str
44
+ commit: str | None
45
+ files_seen: int
46
+ files_indexed: int
47
+ files_unchanged: int
48
+ files_removed: int
49
+ chunks_total: int
50
+ commits_added: int
51
+ commits_total: int
52
+ seconds: float
53
+ full_rebuild: bool
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class Hit:
58
+ chunk: StoredChunk
59
+ score: float
60
+ keyword_rank: int | None
61
+ vector_rank: int | None
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class HistoryReport:
66
+ repo: str
67
+ path: str
68
+ start_line: int
69
+ end_line: int
70
+ symbol: str | None
71
+ blame: list[BlameShare]
72
+ changes: list[LineChange]
73
+ pull_requests: list[PullRequest]
74
+ github: str | None
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class RepoInfo:
79
+ name: str
80
+ source: str
81
+ root: str
82
+ commit: str | None
83
+ indexed_at: str
84
+ files: int
85
+ chunks: int
86
+ commits: int = 0
87
+ languages: dict[str, int] = field(default_factory=dict)
88
+
89
+
90
+ class Brain:
91
+ def __init__(self, settings: Settings, embedder: Embedder | None = None):
92
+ self.settings = settings
93
+ self.embedder = embedder or make_embedder(settings)
94
+ self._stores: dict[str, Store] = {}
95
+
96
+ # ---- indexing -------------------------------------------------------------------------
97
+ def index(self, source: str, name: str | None = None, progress: ProgressFn | None = None) -> IndexReport:
98
+ """Index (or re-index) a local directory or an https git URL. Unchanged files are skipped."""
99
+ started = time.monotonic()
100
+ name = self._valid_name(name or sources.default_name(source))
101
+ if not self.settings.allow_local_paths and not sources.is_url(source):
102
+ raise BrainError("This server only indexes public https git URLs, not local paths.")
103
+ root = sources.resolve(source, self.settings.clone_dir)
104
+ files = sources.iter_files(root, self.settings.max_file_bytes)
105
+ if len(files) > self.settings.max_files:
106
+ raise BrainError(
107
+ f"{source} has {len(files)} indexable files, over this server's limit of {self.settings.max_files}."
108
+ )
109
+ store = self._store(name)
110
+
111
+ meta = store.get_meta()
112
+ full_rebuild = (
113
+ meta.get("embed_model") != self.embedder.model_id or meta.get("schema_version") != store.schema_version
114
+ )
115
+ if full_rebuild:
116
+ store.clear()
117
+ known = store.file_hashes()
118
+ current = {f.path: f for f in files}
119
+
120
+ removed = [path for path in known if path not in current]
121
+ for path in removed:
122
+ store.remove_file(path)
123
+
124
+ changed = [f for f in files if known.get(f.path) != _sha(f.text)]
125
+ notify = progress or (lambda done, total, message: None)
126
+ notify(0, len(changed), f"{len(changed)} of {len(files)} files need indexing")
127
+ for start in range(0, len(changed), EMBED_BATCH_FILES):
128
+ batch = changed[start : start + EMBED_BATCH_FILES]
129
+ per_file = [chunk_file(f.path, f.language, f.text) for f in batch]
130
+ vectors = self.embedder.embed_documents([_embedding_text(c) for chunks in per_file for c in chunks])
131
+ offset = 0
132
+ for file, chunks in zip(batch, per_file, strict=True):
133
+ store.replace_file(
134
+ file.path, _sha(file.text), file.language, chunks, vectors[offset : offset + len(chunks)]
135
+ )
136
+ offset += len(chunks)
137
+ notify(min(start + len(batch), len(changed)), len(changed), batch[-1].path)
138
+
139
+ commit = sources.head_commit(root)
140
+ commits_added = 0
141
+ if history.is_git_repo(root):
142
+ notify(len(changed), len(changed), "reading git history")
143
+ since = None if full_rebuild else meta.get("history_head") or None
144
+ commits_added = store.add_commits(history.commits(root, since, HISTORY_LIMIT))
145
+ store.set_meta(
146
+ source=source,
147
+ root=str(root),
148
+ commit=commit or "",
149
+ embed_model=self.embedder.model_id,
150
+ history_head=commit or "",
151
+ schema_version=store.schema_version,
152
+ indexed_at=datetime.now(UTC).isoformat(timespec="seconds"),
153
+ )
154
+ return IndexReport(
155
+ repo=name,
156
+ root=str(root),
157
+ commit=commit,
158
+ files_seen=len(files),
159
+ files_indexed=len(changed),
160
+ files_unchanged=len(files) - len(changed),
161
+ files_removed=len(removed),
162
+ chunks_total=store.counts()["chunks"],
163
+ commits_added=commits_added,
164
+ commits_total=store.commit_count(),
165
+ seconds=round(time.monotonic() - started, 2),
166
+ full_rebuild=full_rebuild,
167
+ )
168
+
169
+ # ---- retrieval ------------------------------------------------------------------------
170
+ def search(
171
+ self,
172
+ query: str,
173
+ repo: str | None = None,
174
+ k: int = 8,
175
+ path_prefix: str | None = None,
176
+ kinds: list[str] | None = None,
177
+ mode: SearchMode = "hybrid",
178
+ ) -> list[Hit]:
179
+ """Hybrid search: BM25 keywords and vector similarity, merged with reciprocal-rank fusion.
180
+ ``mode`` can switch to one retriever alone; the evals use that to measure what each adds."""
181
+ query = query.strip()
182
+ if not query:
183
+ raise BrainError("The query is empty.")
184
+ if mode not in SEARCH_MODES:
185
+ raise BrainError(f"mode must be one of {', '.join(SEARCH_MODES)}.")
186
+ store = self._store(self.resolve_repo(repo))
187
+ keyword_ids = store.keyword_search(query, CANDIDATES) if mode != "vector" else []
188
+ vector_ids = store.vector_search(self.embedder.embed_query(query), CANDIDATES) if mode != "keyword" else []
189
+
190
+ scores: dict[int, float] = {}
191
+ for ranked in (keyword_ids, vector_ids):
192
+ for rank, chunk_id in enumerate(ranked, start=1):
193
+ scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (RRF_K + rank)
194
+ chunks = store.get_chunks(list(scores))
195
+ keyword_rank = {cid: r for r, cid in enumerate(keyword_ids, start=1)}
196
+ vector_rank = {cid: r for r, cid in enumerate(vector_ids, start=1)}
197
+
198
+ hits = []
199
+ for chunk_id in sorted(scores, key=scores.__getitem__, reverse=True):
200
+ chunk = chunks[chunk_id]
201
+ if path_prefix and not chunk.path.startswith(path_prefix):
202
+ continue
203
+ if kinds and chunk.kind not in kinds:
204
+ continue
205
+ hits.append(
206
+ Hit(
207
+ chunk=chunk,
208
+ score=round(scores[chunk_id], 5),
209
+ keyword_rank=keyword_rank.get(chunk_id),
210
+ vector_rank=vector_rank.get(chunk_id),
211
+ )
212
+ )
213
+ if len(hits) == k:
214
+ break
215
+ return hits
216
+
217
+ # ---- history --------------------------------------------------------------------------
218
+ def explain_history(
219
+ self,
220
+ repo: str | None,
221
+ path: str | None = None,
222
+ symbol: str | None = None,
223
+ start: int | None = None,
224
+ end: int | None = None,
225
+ max_commits: int = 8,
226
+ ) -> HistoryReport:
227
+ """How a function, a line range or a whole file came to be: blame shares, the commits that
228
+ changed it (with the relevant diff), and the pull requests those commits came from."""
229
+ name = self.resolve_repo(repo)
230
+ store = self._store(name)
231
+ root = Path(store.get_meta()["root"])
232
+ if symbol:
233
+ matches = store.find_symbol(symbol, path)
234
+ if not matches:
235
+ where = f" in {path}" if path else ""
236
+ raise BrainError(f"No function, class or section called {symbol!r}{where}. Try search first.")
237
+ path, start, end, symbol = definition_span(matches)
238
+ if not path:
239
+ raise BrainError("Give a path (optionally with start and end lines) or a symbol.")
240
+ target = self._file_in(root, path)
241
+ if not history.is_git_repo(root):
242
+ raise BrainError(f"{name} isn't a git repository, so it has no history to explain.")
243
+ total = len(target.read_text(encoding="utf-8", errors="replace").splitlines())
244
+ start, end = max(1, start or 1), min(total, end or total)
245
+ if start > end:
246
+ raise BrainError(f"{path} has {total} lines.")
247
+ try:
248
+ changes = history.line_history(root, path, start, end, max(1, min(max_commits, 30)))
249
+ shares = history.blame(root, path, start, end)[:5]
250
+ except sources.SourceError as exc:
251
+ raise BrainError(f"git couldn't read the history of {path}: {exc}") from exc
252
+ slug = history.github_slug(root)
253
+ return HistoryReport(
254
+ repo=name,
255
+ path=path,
256
+ start_line=start,
257
+ end_line=end,
258
+ symbol=symbol,
259
+ blame=shares,
260
+ changes=changes,
261
+ pull_requests=self._pull_requests(store, slug, changes),
262
+ github=slug,
263
+ )
264
+
265
+ def search_history(self, query: str, repo: str | None = None, k: int = 5) -> list[Commit]:
266
+ """Commits whose message matches the query (BM25)."""
267
+ if not query.strip():
268
+ raise BrainError("The query is empty.")
269
+ return self._store(self.resolve_repo(repo)).search_commits(query, k)
270
+
271
+ def _pull_requests(self, store: Store, slug: str | None, changes: list[LineChange]) -> list[PullRequest]:
272
+ numbers = list(dict.fromkeys(n for change in changes for n in change.commit.prs))[:MAX_PULL_REQUESTS]
273
+ if not slug:
274
+ return []
275
+ prs = []
276
+ for number in numbers:
277
+ pr = store.cached_pull_request(number)
278
+ if pr is None:
279
+ if self.settings.fetch_pull_requests:
280
+ pr = history.fetch_pull_request(slug, number, self.settings.github_token)
281
+ store.cache_pull_request(pr)
282
+ else:
283
+ pr = PullRequest(number=number, url=f"https://github.com/{slug}/pull/{number}")
284
+ prs.append(pr)
285
+ return prs
286
+
287
+ # ---- structure ------------------------------------------------------------------------
288
+ def impact_of_change(self, repo: str | None, symbol: str, path: str | None = None, depth: int = 2) -> ImpactReport:
289
+ """What uses ``symbol``, directly and transitively, including the tests that exercise it."""
290
+ store = self._store(self.resolve_repo(repo))
291
+ matches = store.find_symbol(symbol, path)
292
+ if not matches:
293
+ where = f" in {path}" if path else ""
294
+ raise BrainError(f"No function, class or method called {symbol!r}{where}. Try search first.")
295
+ target_path, start, end, _ = definition_span(matches)
296
+ target = next(c for c in matches if c.path == target_path)
297
+ return analysis.impact_of_change(store, target, (start, end), depth)
298
+
299
+ def find_doc_drift(
300
+ self, repo: str | None, path_prefix: str | None = None, check_history: bool = True, limit: int = 50
301
+ ) -> DriftReport:
302
+ """Documentation that no longer matches the code."""
303
+ store = self._store(self.resolve_repo(repo))
304
+ return analysis.find_doc_drift(store, Path(store.get_meta()["root"]), path_prefix, check_history, limit)
305
+
306
+ # ---- repositories ---------------------------------------------------------------------
307
+ def repos(self) -> list[RepoInfo]:
308
+ index_dir = self.settings.index_dir
309
+ names = sorted(p.stem for p in index_dir.glob("*.db")) if index_dir.is_dir() else []
310
+ return [info for info in (self.repo_info(n) for n in names) if info is not None]
311
+
312
+ def repo_info(self, name: str) -> RepoInfo | None:
313
+ store = self._store(name)
314
+ meta = store.get_meta()
315
+ if "indexed_at" not in meta:
316
+ return None
317
+ counts = store.counts()
318
+ return RepoInfo(
319
+ name=name,
320
+ source=meta.get("source", ""),
321
+ root=meta.get("root", ""),
322
+ commit=meta.get("commit") or None,
323
+ indexed_at=meta["indexed_at"],
324
+ files=counts["files"],
325
+ chunks=counts["chunks"],
326
+ commits=store.commit_count(),
327
+ languages=store.languages(),
328
+ )
329
+
330
+ def resolve_repo(self, repo: str | None) -> str:
331
+ """The repo to use: the one named, or the only one indexed."""
332
+ if repo:
333
+ name = self._valid_name(repo)
334
+ if not (self.settings.index_dir / f"{name}.db").exists():
335
+ raise BrainError(f"No index named {name!r}. Run index_repo first.")
336
+ return name
337
+ names = [info.name for info in self.repos()]
338
+ if len(names) == 1:
339
+ return names[0]
340
+ if not names:
341
+ raise BrainError("Nothing is indexed yet. Run index_repo with a local path or an https git URL.")
342
+ raise BrainError(f"Several repos are indexed ({', '.join(names)}); say which one with repo=.")
343
+
344
+ def read_lines(self, repo: str | None, path: str, start: int, end: int) -> str:
345
+ """Exact lines from an indexed file, for following up on a citation."""
346
+ info = self.repo_info(self.resolve_repo(repo))
347
+ if info is None:
348
+ raise BrainError("That repo isn't indexed.")
349
+ target = self._file_in(Path(info.root), path)
350
+ lines = target.read_text(encoding="utf-8", errors="replace").splitlines()
351
+ start, end = max(1, start), min(len(lines), end)
352
+ if start > end:
353
+ raise BrainError(f"{path} has {len(lines)} lines.")
354
+ return "\n".join(f"{n:>5} {lines[n - 1]}" for n in range(start, end + 1))
355
+
356
+ @staticmethod
357
+ def _file_in(root: Path, path: str) -> Path:
358
+ """``path`` inside ``root``, refusing anything that escapes it."""
359
+ root = root.resolve()
360
+ target = (root / path).resolve()
361
+ if not target.is_relative_to(root) or not target.is_file():
362
+ raise BrainError(f"{path!r} isn't a file in this repository.")
363
+ return target
364
+
365
+ def close(self) -> None:
366
+ for store in self._stores.values():
367
+ store.close()
368
+ self._stores.clear()
369
+
370
+ def store_for(self, repo: str | None) -> Store:
371
+ """The index of an indexed repo, for read-only analysis such as evals."""
372
+ return self._store(self.resolve_repo(repo))
373
+
374
+ def _store(self, name: str) -> Store:
375
+ if name not in self._stores:
376
+ self._stores[name] = Store(self.settings.index_dir / f"{name}.db")
377
+ return self._stores[name]
378
+
379
+ @staticmethod
380
+ def _valid_name(name: str) -> str:
381
+ if not _NAME.match(name) or name.startswith("."):
382
+ raise BrainError("Repo names may use letters, digits, '.', '_' and '-' only.")
383
+ return name
384
+
385
+
386
+ def _sha(text: str) -> str:
387
+ return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
388
+
389
+
390
+ def _embedding_text(chunk) -> str:
391
+ """What the model sees: where the chunk lives and what it's called, then the code itself."""
392
+ header = f"File: {chunk.path}"
393
+ if chunk.symbol:
394
+ header += f"\n{chunk.kind.capitalize()}: {chunk.symbol}"
395
+ return f"{header}\n\n{chunk.text}"
@@ -0,0 +1,196 @@
1
+ """Split files into chunks that follow the code's own structure.
2
+
3
+ Python is split by function and class (with exact line numbers, from ``ast``), Markdown by heading,
4
+ and everything else into overlapping line windows. Chunks that are still too long are windowed
5
+ again but keep their symbol, so a citation always says which function it came from.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import builtins
12
+ import re
13
+ from dataclasses import dataclass, replace
14
+
15
+ WINDOW_LINES = 60
16
+ WINDOW_OVERLAP = 10
17
+ MAX_CHUNK_LINES = 120
18
+ _HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
19
+ _IGNORED_NAMES = set(dir(builtins)) | {"self", "cls", "super", "typing", "t", "_"}
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Chunk:
24
+ path: str
25
+ start_line: int
26
+ """1-based, inclusive."""
27
+ end_line: int
28
+ """1-based, inclusive."""
29
+ kind: str
30
+ """function, class, method, module, section, or code."""
31
+ symbol: str | None
32
+ text: str
33
+ refs: tuple[str, ...] = ()
34
+ """Names this chunk uses: calls, attributes and imports (Python only). Feeds impact_of_change."""
35
+ params: tuple[str, ...] | None = None
36
+ """Parameter names when the chunk is a function or method; None otherwise. Feeds find_doc_drift."""
37
+ accepts_any_keyword: bool = False
38
+ """True when the function takes ``**kwargs``, so any keyword argument is valid."""
39
+
40
+ @property
41
+ def citation(self) -> str:
42
+ return f"{self.path}:{self.start_line}-{self.end_line}"
43
+
44
+
45
+ def chunk_file(path: str, language: str, text: str) -> list[Chunk]:
46
+ lines = text.splitlines()
47
+ if not any(line.strip() for line in lines):
48
+ return []
49
+ if language == "python":
50
+ try:
51
+ tree = ast.parse(text)
52
+ except (SyntaxError, ValueError):
53
+ return _windows(path, lines, 1, len(lines), "code", None)
54
+ chunks = [piece for chunk in _python_chunks(path, lines, tree) for piece in _split_long(chunk, lines)]
55
+ return _with_python_facts(chunks, tree)
56
+ if language == "markdown":
57
+ chunks = _markdown_chunks(path, lines)
58
+ else:
59
+ chunks = _windows(path, lines, 1, len(lines), "code", None)
60
+ return [piece for chunk in chunks for piece in _split_long(chunk, lines)]
61
+
62
+
63
+ def _python_chunks(path: str, lines: list[str], tree: ast.Module) -> list[Chunk]:
64
+ chunks: list[Chunk] = []
65
+ covered: list[tuple[int, int]] = []
66
+ for node in tree.body:
67
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
68
+ continue
69
+ start, end = _span(node)
70
+ covered.append((start, end))
71
+ if isinstance(node, ast.ClassDef):
72
+ chunks.extend(_class_chunks(path, lines, node, start, end))
73
+ else:
74
+ chunks.append(_make(path, lines, start, end, "function", node.name))
75
+ # Whatever sits between definitions (imports, constants, module docstring) is one "module" chunk each.
76
+ cursor = 1
77
+ for start, end in [*sorted(covered), (len(lines) + 1, len(lines) + 1)]:
78
+ gap_start, gap_end = cursor, start - 1
79
+ while gap_start <= gap_end and not lines[gap_start - 1].strip():
80
+ gap_start += 1
81
+ while gap_end >= gap_start and not lines[gap_end - 1].strip():
82
+ gap_end -= 1
83
+ if gap_start <= gap_end:
84
+ chunks.append(_make(path, lines, gap_start, gap_end, "module", None))
85
+ cursor = max(cursor, end + 1)
86
+ return sorted(chunks, key=lambda c: c.start_line)
87
+
88
+
89
+ def _with_python_facts(chunks: list[Chunk], tree: ast.Module) -> list[Chunk]:
90
+ """Attach the names each chunk uses, and the parameters of each function chunk."""
91
+ uses: list[tuple[int, str]] = []
92
+ signatures: dict[int, tuple[tuple[str, ...], bool]] = {}
93
+ for node in ast.walk(tree):
94
+ if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
95
+ uses.append((node.lineno, node.id))
96
+ elif isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load):
97
+ uses.append((node.lineno, node.attr))
98
+ elif isinstance(node, ast.ImportFrom):
99
+ uses.extend((node.lineno, alias.name) for alias in node.names)
100
+ if node.module:
101
+ uses.extend((node.lineno, part) for part in node.module.split("."))
102
+ elif isinstance(node, ast.Import):
103
+ uses.extend((node.lineno, part) for alias in node.names for part in alias.name.split("."))
104
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
105
+ args = node.args
106
+ names = [a.arg for a in (*args.posonlyargs, *args.args, *args.kwonlyargs)]
107
+ signatures[_span(node)[0]] = (tuple(n for n in names if n not in ("self", "cls")), args.kwarg is not None)
108
+ result = []
109
+ for chunk in chunks:
110
+ own = chunk.symbol.rsplit(".", 1)[-1] if chunk.symbol else None
111
+ refs = sorted(
112
+ {
113
+ name
114
+ for line, name in uses
115
+ if chunk.start_line <= line <= chunk.end_line
116
+ and name not in _IGNORED_NAMES
117
+ and name != own
118
+ and len(name) > 2
119
+ }
120
+ )
121
+ signature = signatures.get(chunk.start_line) if chunk.kind in ("function", "method") else None
122
+ result.append(
123
+ replace(
124
+ chunk,
125
+ refs=tuple(refs),
126
+ params=signature[0] if signature else None,
127
+ accepts_any_keyword=bool(signature and signature[1]),
128
+ )
129
+ )
130
+ return result
131
+
132
+
133
+ def _class_chunks(path: str, lines: list[str], node: ast.ClassDef, start: int, end: int) -> list[Chunk]:
134
+ if end - start + 1 <= MAX_CHUNK_LINES:
135
+ return [_make(path, lines, start, end, "class", node.name)]
136
+ # A big class: its header (up to the first method) plus one chunk per method.
137
+ methods = [m for m in node.body if isinstance(m, (ast.FunctionDef, ast.AsyncFunctionDef))]
138
+ header_end = (_span(methods[0])[0] - 1) if methods else end
139
+ chunks = [_make(path, lines, start, header_end, "class", node.name)]
140
+ for method in methods:
141
+ m_start, m_end = _span(method)
142
+ chunks.append(_make(path, lines, m_start, m_end, "method", f"{node.name}.{method.name}"))
143
+ return chunks
144
+
145
+
146
+ def _span(node: ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef) -> tuple[int, int]:
147
+ decorators = node.decorator_list
148
+ start = min([node.lineno, *(d.lineno for d in decorators)])
149
+ return start, node.end_lineno or node.lineno
150
+
151
+
152
+ def _markdown_chunks(path: str, lines: list[str]) -> list[Chunk]:
153
+ chunks: list[Chunk] = []
154
+ trail: list[str] = []
155
+ section_start, section_symbol = 1, None
156
+ in_fence = False
157
+ for number, line in enumerate(lines, start=1):
158
+ if line.lstrip().startswith(("```", "~~~")):
159
+ in_fence = not in_fence
160
+ match = None if in_fence else _HEADING.match(line)
161
+ if not match:
162
+ continue
163
+ if number > section_start:
164
+ chunks.append(_make(path, lines, section_start, number - 1, "section", section_symbol))
165
+ level = len(match.group(1))
166
+ trail = [*trail[: level - 1], match.group(2)]
167
+ section_start, section_symbol = number, " > ".join(trail)
168
+ chunks.append(_make(path, lines, section_start, len(lines), "section", section_symbol))
169
+ return [c for c in chunks if c.text.strip()]
170
+
171
+
172
+ def _windows(path: str, lines: list[str], start: int, end: int, kind: str, symbol: str | None) -> list[Chunk]:
173
+ chunks = []
174
+ step = WINDOW_LINES - WINDOW_OVERLAP
175
+ cursor = start
176
+ while cursor <= end:
177
+ stop = min(cursor + WINDOW_LINES - 1, end)
178
+ chunk = _make(path, lines, cursor, stop, kind, symbol)
179
+ if chunk.text.strip():
180
+ chunks.append(chunk)
181
+ if stop == end:
182
+ break
183
+ cursor += step
184
+ return chunks
185
+
186
+
187
+ def _split_long(chunk: Chunk, lines: list[str]) -> list[Chunk]:
188
+ if chunk.end_line - chunk.start_line + 1 <= MAX_CHUNK_LINES:
189
+ return [chunk]
190
+ return _windows(chunk.path, lines, chunk.start_line, chunk.end_line, chunk.kind, chunk.symbol)
191
+
192
+
193
+ def _make(path: str, lines: list[str], start: int, end: int, kind: str, symbol: str | None) -> Chunk:
194
+ return Chunk(
195
+ path=path, start_line=start, end_line=end, kind=kind, symbol=symbol, text="\n".join(lines[start - 1 : end])
196
+ )
@@ -0,0 +1,58 @@
1
+ """Settings, read once from the environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ DEFAULT_EMBED_MODEL = "BAAI/bge-small-en-v1.5"
10
+ DEFAULT_MAX_FILE_BYTES = 512_000
11
+ DEFAULT_MAX_FILES = 20_000
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Settings:
16
+ home: Path
17
+ """Where indexes and cloned repositories live."""
18
+
19
+ embedder: str
20
+ """``fastembed`` (real local model) or ``hash`` (offline, for tests)."""
21
+
22
+ embed_model: str
23
+ max_file_bytes: int
24
+ """Files larger than this are skipped; they are almost always generated or data."""
25
+
26
+ github_token: str | None = None
27
+ """Optional. Only used to read PR titles and descriptions (raises the API rate limit)."""
28
+
29
+ fetch_pull_requests: bool = True
30
+
31
+ allow_local_paths: bool = True
32
+ """Whether index_repo may read local directories. Off by default when serving over HTTP, so remote
33
+ clients can only index public git URLs, never the server's own files."""
34
+
35
+ max_files: int = DEFAULT_MAX_FILES
36
+ """Repositories with more indexable files than this are refused, to bound time and disk."""
37
+
38
+ @property
39
+ def index_dir(self) -> Path:
40
+ return self.home / "indexes"
41
+
42
+ @property
43
+ def clone_dir(self) -> Path:
44
+ return self.home / "clones"
45
+
46
+
47
+ def load_settings() -> Settings:
48
+ home = Path(os.environ.get("CODEBASE_BRAIN_HOME", Path.home() / ".codebase-brain")).expanduser()
49
+ return Settings(
50
+ home=home,
51
+ embedder=os.environ.get("CODEBASE_BRAIN_EMBEDDER", "fastembed"),
52
+ embed_model=os.environ.get("CODEBASE_BRAIN_EMBED_MODEL", DEFAULT_EMBED_MODEL),
53
+ max_file_bytes=int(os.environ.get("CODEBASE_BRAIN_MAX_FILE_BYTES", DEFAULT_MAX_FILE_BYTES)),
54
+ github_token=os.environ.get("GITHUB_TOKEN") or None,
55
+ fetch_pull_requests=os.environ.get("CODEBASE_BRAIN_FETCH_PRS", "1") != "0",
56
+ allow_local_paths=os.environ.get("CODEBASE_BRAIN_ALLOW_LOCAL_PATHS", "1") != "0",
57
+ max_files=int(os.environ.get("CODEBASE_BRAIN_MAX_FILES", DEFAULT_MAX_FILES)),
58
+ )