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,186 @@
1
+ """Find a repository on disk (cloning it first if it's a URL) and list the files worth indexing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ LANGUAGES = {
12
+ ".py": "python",
13
+ ".pyi": "python",
14
+ ".md": "markdown",
15
+ ".mdx": "markdown",
16
+ ".rst": "text",
17
+ ".txt": "text",
18
+ ".js": "javascript",
19
+ ".jsx": "javascript",
20
+ ".mjs": "javascript",
21
+ ".cjs": "javascript",
22
+ ".ts": "typescript",
23
+ ".tsx": "typescript",
24
+ ".go": "go",
25
+ ".rs": "rust",
26
+ ".java": "java",
27
+ ".kt": "kotlin",
28
+ ".rb": "ruby",
29
+ ".php": "php",
30
+ ".c": "c",
31
+ ".h": "c",
32
+ ".cc": "cpp",
33
+ ".cpp": "cpp",
34
+ ".hpp": "cpp",
35
+ ".cs": "csharp",
36
+ ".swift": "swift",
37
+ ".sh": "shell",
38
+ ".bash": "shell",
39
+ ".sql": "sql",
40
+ ".html": "html",
41
+ ".css": "css",
42
+ ".scss": "css",
43
+ ".toml": "config",
44
+ ".yaml": "config",
45
+ ".yml": "config",
46
+ ".json": "config",
47
+ ".ini": "config",
48
+ ".cfg": "config",
49
+ }
50
+ NAMED_FILES = {"Dockerfile": "config", "Makefile": "config", "README": "text", "LICENSE": "text"}
51
+ SKIP_DIRS = {
52
+ ".git",
53
+ "node_modules",
54
+ ".venv",
55
+ "venv",
56
+ "__pycache__",
57
+ "dist",
58
+ "build",
59
+ ".mypy_cache",
60
+ ".pytest_cache",
61
+ ".tox",
62
+ ".next",
63
+ "target",
64
+ "vendor",
65
+ ".idea",
66
+ ".vscode",
67
+ }
68
+ SKIP_FILES = {"package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock", "uv.lock", "Cargo.lock"}
69
+ GIT_TIMEOUT_SECONDS = 300
70
+ _URL = re.compile(r"^https://[A-Za-z0-9.-]+/[A-Za-z0-9._/-]+?(\.git)?/?$")
71
+
72
+
73
+ class SourceError(ValueError):
74
+ """The source can't be used: missing path, bad URL, or a failed clone."""
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class SourceFile:
79
+ path: str
80
+ """Path relative to the repository root, always with forward slashes."""
81
+ language: str
82
+ text: str
83
+
84
+
85
+ def is_url(source: str) -> bool:
86
+ return source.startswith(("https://", "http://", "git@", "ssh://"))
87
+
88
+
89
+ def default_name(source: str) -> str:
90
+ """``https://github.com/tiangolo/fastapi.git`` and ``~/code/fastapi`` both become ``fastapi``."""
91
+ tail = source.rstrip("/").rsplit("/", 1)[-1]
92
+ tail = tail.removesuffix(".git")
93
+ name = re.sub(r"[^A-Za-z0-9._-]", "-", tail).strip("-.")
94
+ if not name:
95
+ raise SourceError(f"Can't work out a name from {source!r}; pass one explicitly.")
96
+ return name
97
+
98
+
99
+ def resolve(source: str, clone_dir: Path) -> Path:
100
+ """The repository's root on disk. URLs are shallow-cloned (or refreshed) under ``clone_dir``."""
101
+ if not is_url(source):
102
+ root = Path(source).expanduser().resolve()
103
+ if not root.is_dir():
104
+ raise SourceError(f"{source!r} is not a directory.")
105
+ return root
106
+ if not _URL.match(source):
107
+ # Only plain https URLs: no ssh, no git's ext:: transports, no options smuggled into the URL.
108
+ raise SourceError("Only https:// repository URLs are supported, e.g. https://github.com/owner/repo.")
109
+ target = clone_dir / default_name(source)
110
+ # Blobless clones keep the full commit history (needed for explain_history) but fetch file
111
+ # contents only for the checkout and for the commits we actually inspect.
112
+ if (target / ".git").is_dir():
113
+ if run_git(target, "rev-parse", "--is-shallow-repository").strip() == "true":
114
+ run_git(target, "fetch", "--unshallow", "--filter=blob:none", "origin")
115
+ else:
116
+ run_git(target, "fetch", "--filter=blob:none", "origin")
117
+ run_git(target, "reset", "--hard", "FETCH_HEAD")
118
+ else:
119
+ clone_dir.mkdir(parents=True, exist_ok=True)
120
+ run_git(clone_dir, "clone", "--filter=blob:none", "--", source, str(target))
121
+ return target
122
+
123
+
124
+ def head_commit(root: Path) -> str | None:
125
+ try:
126
+ return run_git(root, "rev-parse", "HEAD").strip() or None
127
+ except SourceError:
128
+ return None
129
+
130
+
131
+ def iter_files(root: Path, max_bytes: int) -> list[SourceFile]:
132
+ """Text files worth indexing, sorted by path. Honours .gitignore when ``root`` is a git checkout."""
133
+ files = []
134
+ for rel in _candidate_paths(root):
135
+ language = language_for(rel)
136
+ if language is None:
137
+ continue
138
+ full = root / rel
139
+ try:
140
+ if not full.is_file() or full.is_symlink() or full.stat().st_size > max_bytes:
141
+ continue
142
+ raw = full.read_bytes()
143
+ except OSError:
144
+ continue
145
+ if b"\x00" in raw[:8192]:
146
+ continue # binary
147
+ files.append(SourceFile(path=rel, language=language, text=raw.decode("utf-8", errors="replace")))
148
+ return sorted(files, key=lambda f: f.path)
149
+
150
+
151
+ def language_for(rel: str) -> str | None:
152
+ name = rel.rsplit("/", 1)[-1]
153
+ if name in SKIP_FILES or name.endswith((".min.js", ".min.css", ".map")):
154
+ return None
155
+ if name in NAMED_FILES:
156
+ return NAMED_FILES[name]
157
+ return LANGUAGES.get(os.path.splitext(name)[1].lower())
158
+
159
+
160
+ def _candidate_paths(root: Path) -> list[str]:
161
+ try:
162
+ out = run_git(root, "ls-files", "-z", "--cached", "--others", "--exclude-standard")
163
+ paths = [p for p in out.split("\0") if p]
164
+ return [p for p in paths if not SKIP_DIRS.intersection(p.split("/")[:-1])]
165
+ except SourceError:
166
+ pass
167
+ paths = []
168
+ for dirpath, dirnames, filenames in os.walk(root):
169
+ dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith("."))
170
+ rel_dir = Path(dirpath).relative_to(root).as_posix()
171
+ for filename in filenames:
172
+ paths.append(filename if rel_dir == "." else f"{rel_dir}/{filename}")
173
+ return paths
174
+
175
+
176
+ def run_git(cwd: Path, *args: str) -> str:
177
+ env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ALLOW_PROTOCOL": "https:file"}
178
+ try:
179
+ done = subprocess.run(
180
+ ["git", *args], cwd=cwd, env=env, capture_output=True, text=True, timeout=GIT_TIMEOUT_SECONDS, check=False
181
+ )
182
+ except (OSError, subprocess.TimeoutExpired) as exc:
183
+ raise SourceError(f"git {args[0]} failed: {exc}") from exc
184
+ if done.returncode != 0:
185
+ raise SourceError(f"git {args[0]} failed: {done.stderr.strip()[:300]}")
186
+ return done.stdout
@@ -0,0 +1,349 @@
1
+ """One SQLite file per indexed repository: files, chunks, their vectors, and an FTS5 keyword index."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import sqlite3
8
+ import threading
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+ import numpy as np
13
+
14
+ from .chunking import Chunk
15
+ from .history import Commit, PullRequest
16
+
17
+ SCHEMA = """
18
+ CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
19
+ CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, sha TEXT NOT NULL, language TEXT NOT NULL);
20
+ CREATE TABLE IF NOT EXISTS chunks (
21
+ id INTEGER PRIMARY KEY,
22
+ path TEXT NOT NULL,
23
+ start_line INTEGER NOT NULL,
24
+ end_line INTEGER NOT NULL,
25
+ kind TEXT NOT NULL,
26
+ symbol TEXT,
27
+ text TEXT NOT NULL,
28
+ vector BLOB NOT NULL,
29
+ params TEXT,
30
+ accepts_any_keyword INTEGER NOT NULL DEFAULT 0
31
+ );
32
+ CREATE INDEX IF NOT EXISTS chunks_path ON chunks (path);
33
+ CREATE INDEX IF NOT EXISTS chunks_symbol ON chunks (symbol);
34
+ CREATE TABLE IF NOT EXISTS refs (chunk_id INTEGER NOT NULL, name TEXT NOT NULL);
35
+ CREATE INDEX IF NOT EXISTS refs_name ON refs (name);
36
+ CREATE INDEX IF NOT EXISTS refs_chunk ON refs (chunk_id);
37
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5 (body, symbol, path, tokenize = 'porter unicode61');
38
+ CREATE TABLE IF NOT EXISTS commits (
39
+ sha TEXT PRIMARY KEY,
40
+ author TEXT NOT NULL,
41
+ date TEXT NOT NULL,
42
+ subject TEXT NOT NULL,
43
+ body TEXT NOT NULL,
44
+ prs TEXT NOT NULL
45
+ );
46
+ CREATE VIRTUAL TABLE IF NOT EXISTS commits_fts USING fts5 (subject, body, sha UNINDEXED, tokenize = 'porter unicode61');
47
+ CREATE TABLE IF NOT EXISTS pull_requests (number INTEGER PRIMARY KEY, data TEXT NOT NULL);
48
+ """
49
+ SCHEMA_VERSION = "3"
50
+ """Bump when the index layout changes; older indexes are rebuilt automatically."""
51
+ _WORD = re.compile(r"[A-Za-z0-9_]+")
52
+ _CAMEL = re.compile(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|\d+")
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class StoredChunk:
57
+ id: int
58
+ path: str
59
+ start_line: int
60
+ end_line: int
61
+ kind: str
62
+ symbol: str | None
63
+ text: str
64
+
65
+ @property
66
+ def citation(self) -> str:
67
+ return f"{self.path}:{self.start_line}-{self.end_line}"
68
+
69
+
70
+ def identifier_terms(text: str) -> str:
71
+ """``getUserById`` and ``get_user_by_id`` both also yield ``get user by id``, so keywords match either style."""
72
+ extra = []
73
+ for word in _WORD.findall(text):
74
+ parts = [p.lower() for piece in word.split("_") for p in _CAMEL.findall(piece)]
75
+ if len(parts) > 1:
76
+ extra.extend(parts)
77
+ return " ".join(extra)
78
+
79
+
80
+ def _glob_literal(text: str) -> str:
81
+ """Escape GLOB wildcards. GLOB, unlike LIKE, is case-sensitive, which symbol names need."""
82
+ return re.sub(r"([*?\[])", r"[\1]", text)
83
+
84
+
85
+ def _match_expression(query: str) -> str:
86
+ """An FTS5 OR-query of the query's words and identifier parts, each quoted so nothing is FTS syntax."""
87
+ terms = {t.lower() for t in _WORD.findall(query)} | set(identifier_terms(query).split())
88
+ return " OR ".join(f'"{t}"' for t in sorted(t for t in terms if len(t) > 1))
89
+
90
+
91
+ class Store:
92
+ def __init__(self, path: Path):
93
+ path.parent.mkdir(parents=True, exist_ok=True)
94
+ self.path = path
95
+ self._db = sqlite3.connect(path, check_same_thread=False)
96
+ self._db.row_factory = sqlite3.Row
97
+ self._upgrade()
98
+ self._db.executescript(SCHEMA)
99
+ self._lock = threading.Lock()
100
+ self._matrix: tuple[np.ndarray, np.ndarray] | None = None
101
+
102
+ def close(self) -> None:
103
+ self._db.close()
104
+
105
+ def _upgrade(self) -> None:
106
+ """Indexes from older versions are dropped (they're rebuilt on the next index run)."""
107
+ tables = {r[0] for r in self._db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
108
+ if "meta" not in tables:
109
+ return
110
+ row = self._db.execute("SELECT value FROM meta WHERE key = 'schema_version'").fetchone()
111
+ if row and row[0] == SCHEMA_VERSION:
112
+ return
113
+ with self._db:
114
+ for table in ("chunks_fts", "chunks", "files", "refs", "commits_fts", "commits"):
115
+ self._db.execute(f"DROP TABLE IF EXISTS {table}")
116
+ self._db.execute("DELETE FROM meta WHERE key IN ('embed_model', 'history_head')")
117
+
118
+ @property
119
+ def schema_version(self) -> str:
120
+ return SCHEMA_VERSION
121
+
122
+ # ---- metadata -------------------------------------------------------------------------
123
+ def get_meta(self) -> dict[str, str]:
124
+ return {row["key"]: row["value"] for row in self._db.execute("SELECT key, value FROM meta")}
125
+
126
+ def set_meta(self, **values: str) -> None:
127
+ with self._lock, self._db:
128
+ self._db.executemany("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", values.items())
129
+
130
+ # ---- files and chunks -----------------------------------------------------------------
131
+ def file_hashes(self) -> dict[str, str]:
132
+ return {row["path"]: row["sha"] for row in self._db.execute("SELECT path, sha FROM files")}
133
+
134
+ def replace_file(self, path: str, sha: str, language: str, chunks: list[Chunk], vectors: np.ndarray) -> None:
135
+ with self._lock, self._db:
136
+ self._delete_chunks(path)
137
+ for chunk, vector in zip(chunks, vectors, strict=True):
138
+ cur = self._db.execute(
139
+ "INSERT INTO chunks (path, start_line, end_line, kind, symbol, text, vector, params,"
140
+ " accepts_any_keyword) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
141
+ (
142
+ chunk.path,
143
+ chunk.start_line,
144
+ chunk.end_line,
145
+ chunk.kind,
146
+ chunk.symbol,
147
+ chunk.text,
148
+ np.asarray(vector, dtype=np.float32).tobytes(),
149
+ None if chunk.params is None else json.dumps(list(chunk.params)),
150
+ int(chunk.accepts_any_keyword),
151
+ ),
152
+ )
153
+ self._db.executemany(
154
+ "INSERT INTO refs (chunk_id, name) VALUES (?, ?)", [(cur.lastrowid, name) for name in chunk.refs]
155
+ )
156
+ body = f"{chunk.text}\n{identifier_terms(chunk.text)}"
157
+ self._db.execute(
158
+ "INSERT INTO chunks_fts (rowid, body, symbol, path) VALUES (?, ?, ?, ?)",
159
+ (cur.lastrowid, body, chunk.symbol or "", f"{path} {identifier_terms(path)}"),
160
+ )
161
+ self._db.execute(
162
+ "INSERT OR REPLACE INTO files (path, sha, language) VALUES (?, ?, ?)", (path, sha, language)
163
+ )
164
+ self._matrix = None
165
+
166
+ def remove_file(self, path: str) -> None:
167
+ with self._lock, self._db:
168
+ self._delete_chunks(path)
169
+ self._db.execute("DELETE FROM files WHERE path = ?", (path,))
170
+ self._matrix = None
171
+
172
+ def clear(self) -> None:
173
+ with self._lock, self._db:
174
+ self._db.execute("DELETE FROM chunks_fts")
175
+ self._db.execute("DELETE FROM refs")
176
+ self._db.execute("DELETE FROM chunks")
177
+ self._db.execute("DELETE FROM files")
178
+ self._db.execute("DELETE FROM commits_fts")
179
+ self._db.execute("DELETE FROM commits")
180
+ self._matrix = None
181
+
182
+ def _delete_chunks(self, path: str) -> None:
183
+ self._db.execute("DELETE FROM chunks_fts WHERE rowid IN (SELECT id FROM chunks WHERE path = ?)", (path,))
184
+ self._db.execute("DELETE FROM refs WHERE chunk_id IN (SELECT id FROM chunks WHERE path = ?)", (path,))
185
+ self._db.execute("DELETE FROM chunks WHERE path = ?", (path,))
186
+
187
+ def counts(self) -> dict[str, int]:
188
+ files = self._db.execute("SELECT COUNT(*) FROM files").fetchone()[0]
189
+ chunks = self._db.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
190
+ return {"files": files, "chunks": chunks}
191
+
192
+ def languages(self) -> dict[str, int]:
193
+ rows = self._db.execute("SELECT language, COUNT(*) AS n FROM files GROUP BY language ORDER BY n DESC")
194
+ return {row["language"]: row["n"] for row in rows}
195
+
196
+ def get_chunks(self, ids: list[int]) -> dict[int, StoredChunk]:
197
+ if not ids:
198
+ return {}
199
+ marks = ",".join("?" * len(ids))
200
+ rows = self._db.execute(
201
+ f"SELECT id, path, start_line, end_line, kind, symbol, text FROM chunks WHERE id IN ({marks})", # noqa: S608 (only "?" placeholders)
202
+ ids,
203
+ )
204
+ return {row["id"]: StoredChunk(**dict(row)) for row in rows}
205
+
206
+ def find_symbol(self, symbol: str, path: str | None = None) -> list[StoredChunk]:
207
+ """Chunks whose symbol is ``symbol`` or ends with ``.symbol`` (so ``unsign`` finds ``Signer.unsign``)."""
208
+ query = (
209
+ "SELECT id, path, start_line, end_line, kind, symbol, text FROM chunks" # noqa: S608 (fixed SQL)
210
+ " WHERE (symbol = ? OR symbol GLOB ?)" + (" AND path = ?" if path else "") + " ORDER BY start_line"
211
+ )
212
+ params = [symbol, f"*.{_glob_literal(symbol)}"] + ([path] if path else [])
213
+ return [StoredChunk(**dict(row)) for row in self._db.execute(query, params)]
214
+
215
+ # ---- structure (impact and drift) -----------------------------------------------------
216
+ def chunks_referencing(self, name: str, limit: int = 200) -> list[StoredChunk]:
217
+ rows = self._db.execute(
218
+ "SELECT DISTINCT c.id, c.path, c.start_line, c.end_line, c.kind, c.symbol, c.text FROM refs r"
219
+ " JOIN chunks c ON c.id = r.chunk_id WHERE r.name = ? ORDER BY c.path, c.start_line LIMIT ?",
220
+ (name, limit),
221
+ )
222
+ return [StoredChunk(**dict(row)) for row in rows]
223
+
224
+ def symbols(self) -> list[tuple[str, str]]:
225
+ """Every defined (symbol, path) pair."""
226
+ rows = self._db.execute(
227
+ "SELECT DISTINCT symbol, path FROM chunks WHERE symbol IS NOT NULL"
228
+ " AND kind IN ('function', 'class', 'method')"
229
+ )
230
+ return [(row["symbol"], row["path"]) for row in rows]
231
+
232
+ def signatures(self, short_name: str) -> list[tuple[str, str, list[str], bool]]:
233
+ """(symbol, path, params, accepts_any_keyword) for functions named ``short_name``."""
234
+ rows = self._db.execute(
235
+ "SELECT symbol, path, params, accepts_any_keyword FROM chunks WHERE params IS NOT NULL"
236
+ " AND (symbol = ? OR symbol GLOB ?)",
237
+ (short_name, f"*.{_glob_literal(short_name)}"),
238
+ )
239
+ return [(r["symbol"], r["path"], json.loads(r["params"]), bool(r["accepts_any_keyword"])) for r in rows]
240
+
241
+ def file_paths(self) -> set[str]:
242
+ return {row["path"] for row in self._db.execute("SELECT path FROM files")}
243
+
244
+ def doc_chunks(self, path_prefix: str | None = None) -> list[StoredChunk]:
245
+ query = (
246
+ "SELECT c.id, c.path, c.start_line, c.end_line, c.kind, c.symbol, c.text FROM chunks c"
247
+ " JOIN files f ON f.path = c.path WHERE f.language IN ('markdown', 'text')"
248
+ )
249
+ params: list[str] = []
250
+ if path_prefix:
251
+ query += " AND c.path LIKE ?"
252
+ params.append(path_prefix.replace("%", "") + "%")
253
+ return [StoredChunk(**dict(row)) for row in self._db.execute(query + " ORDER BY c.path, c.start_line", params)]
254
+
255
+ def code_mentions(self, term: str) -> bool:
256
+ """Whether ``term`` appears anywhere in non-documentation files (a plain substring check)."""
257
+ row = self._db.execute(
258
+ "SELECT 1 FROM chunks c JOIN files f ON f.path = c.path"
259
+ " WHERE f.language NOT IN ('markdown', 'text') AND instr(c.text, ?) > 0 LIMIT 1",
260
+ (term,),
261
+ ).fetchone()
262
+ return row is not None
263
+
264
+ # ---- history --------------------------------------------------------------------------
265
+ def add_commits(self, commits: list[Commit]) -> int:
266
+ added = 0
267
+ with self._lock, self._db:
268
+ for commit in commits:
269
+ cur = self._db.execute(
270
+ "INSERT OR IGNORE INTO commits (sha, author, date, subject, body, prs) VALUES (?, ?, ?, ?, ?, ?)",
271
+ (commit.sha, commit.author, commit.date, commit.subject, commit.body, json.dumps(commit.prs)),
272
+ )
273
+ if cur.rowcount:
274
+ added += 1
275
+ self._db.execute(
276
+ "INSERT INTO commits_fts (subject, body, sha) VALUES (?, ?, ?)",
277
+ (f"{commit.subject} {identifier_terms(commit.subject)}", commit.body, commit.sha),
278
+ )
279
+ return added
280
+
281
+ def commit_count(self) -> int:
282
+ return self._db.execute("SELECT COUNT(*) FROM commits").fetchone()[0]
283
+
284
+ def search_commits(self, query: str, limit: int) -> list[Commit]:
285
+ match = _match_expression(query)
286
+ if not match:
287
+ return []
288
+ rows = self._db.execute(
289
+ "SELECT c.sha, c.author, c.date, c.subject, c.body, c.prs FROM commits_fts f"
290
+ " JOIN commits c ON c.sha = f.sha WHERE commits_fts MATCH ?"
291
+ " ORDER BY bm25(commits_fts, 3.0, 1.0) LIMIT ?",
292
+ (match, limit),
293
+ )
294
+ return [
295
+ Commit(
296
+ sha=r["sha"],
297
+ author=r["author"],
298
+ date=r["date"],
299
+ subject=r["subject"],
300
+ body=r["body"],
301
+ prs=tuple(json.loads(r["prs"])),
302
+ )
303
+ for r in rows
304
+ ]
305
+
306
+ def cached_pull_request(self, number: int) -> PullRequest | None:
307
+ row = self._db.execute("SELECT data FROM pull_requests WHERE number = ?", (number,)).fetchone()
308
+ return PullRequest(**json.loads(row["data"])) if row else None
309
+
310
+ def cache_pull_request(self, pr: PullRequest) -> None:
311
+ if pr.title is None:
312
+ return # don't cache failed lookups; they may work next time
313
+ with self._lock, self._db:
314
+ self._db.execute(
315
+ "INSERT OR REPLACE INTO pull_requests (number, data) VALUES (?, ?)",
316
+ (pr.number, json.dumps(pr.__dict__)),
317
+ )
318
+
319
+ # ---- retrieval ------------------------------------------------------------------------
320
+ def keyword_search(self, query: str, limit: int) -> list[int]:
321
+ """Chunk ids ranked by BM25. Symbol and path matches count more than body matches."""
322
+ match = _match_expression(query)
323
+ if not match:
324
+ return []
325
+ rows = self._db.execute(
326
+ "SELECT rowid FROM chunks_fts WHERE chunks_fts MATCH ? ORDER BY bm25(chunks_fts, 1.0, 4.0, 2.0) LIMIT ?",
327
+ (match, limit),
328
+ )
329
+ return [row[0] for row in rows]
330
+
331
+ def vector_search(self, query_vector: np.ndarray, limit: int) -> list[int]:
332
+ """Chunk ids ranked by cosine similarity (vectors are normalised, so a dot product)."""
333
+ ids, matrix = self._vectors()
334
+ if not len(ids):
335
+ return []
336
+ scores = matrix @ query_vector.astype(np.float32)
337
+ top = np.argsort(-scores)[:limit]
338
+ return [int(ids[i]) for i in top]
339
+
340
+ def _vectors(self) -> tuple[np.ndarray, np.ndarray]:
341
+ if self._matrix is None:
342
+ rows = self._db.execute("SELECT id, vector FROM chunks ORDER BY id").fetchall()
343
+ if rows:
344
+ ids = np.array([row["id"] for row in rows])
345
+ matrix = np.stack([np.frombuffer(row["vector"], dtype=np.float32) for row in rows])
346
+ else:
347
+ ids, matrix = np.array([], dtype=int), np.zeros((0, 0), dtype=np.float32)
348
+ self._matrix = (ids, matrix)
349
+ return self._matrix