vault-index 0.2.0__tar.gz

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,12 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .export-stamp
7
+ # Your own MCP config (absolute paths); start from .mcp.json.example
8
+ .mcp.json
9
+ # Raw pre-compaction snapshots contain your prompts
10
+ docs/handoffs/auto/
11
+ .claude/settings.local.json
12
+ dist/
@@ -0,0 +1,48 @@
1
+ Metadata-Version: 2.5
2
+ Name: vault-index
3
+ Version: 0.2.0
4
+ Summary: Local hybrid (FTS5 + sqlite-vec) search MCP server for markdown vaults and codebases
5
+ Project-URL: Homepage, https://github.com/GitGudatCodeing/claude-second-brain
6
+ Project-URL: Issues, https://github.com/GitGudatCodeing/claude-second-brain/issues
7
+ License-Expression: MIT
8
+ Keywords: claude,markdown,mcp,obsidian,search,sqlite-vec
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Text Processing :: Indexing
12
+ Requires-Python: <3.14,>=3.10
13
+ Requires-Dist: fastembed>=0.4
14
+ Requires-Dist: mcp<2,>=1.2
15
+ Requires-Dist: sqlite-vec>=0.1.6
16
+ Description-Content-Type: text/markdown
17
+
18
+ # vault-index
19
+
20
+ <!-- mcp-name: io.github.GitGudatCodeing/vault-index -->
21
+
22
+ Local hybrid search for markdown vaults and codebases, served as an MCP server. Keyword search (SQLite FTS5) and vector search (sqlite-vec, `bge-small-en-v1.5` on CPU) are merged with reciprocal rank fusion. Everything lives in one SQLite file on your machine; no cloud services.
23
+
24
+ Part of [claude-second-brain](https://github.com/GitGudatCodeing/claude-second-brain), where every search change is measured against golden question sets and compared with naive grep and basic-memory (see its README for the numbers).
25
+
26
+ ## Use with Claude Code
27
+
28
+ ```bash
29
+ claude mcp add vault-index -- uvx --python 3.12 vault-index
30
+ ```
31
+
32
+ Then ask Claude to index a folder, for example: "index_paths ~/notes". After that, `search_notes` finds paraphrased answers with file path and heading. The first run downloads the embedding model (about 65 MB).
33
+
34
+ ## Tools
35
+ - `index_paths(paths, reindex=False)`: add folders or files as sources and sync them. With no paths, re-syncs every source (new, changed and deleted files).
36
+ - `search_notes(query, limit=8, path_prefix=None)`: hybrid search; returns chunks with file path and heading path.
37
+ - `get_note(path)`: full text of an indexed file.
38
+ - `list_sources()`, `remove_source(path)`.
39
+
40
+ Markdown is chunked by heading. A `.indexignore` in a source folder lists relative paths to skip.
41
+
42
+ ## Settings
43
+ | Env var | Default |
44
+ |---|---|
45
+ | `VAULT_INDEX_DB` | `~/.mcp-memory/vault-index.db` |
46
+ | `VAULT_MODEL_CACHE` | `~/.mcp-memory/models` |
47
+
48
+ Python 3.10 to 3.13 (`--python 3.12` keeps uvx off newer Pythons that onnxruntime does not support yet). MIT license.
@@ -0,0 +1,31 @@
1
+ # vault-index
2
+
3
+ <!-- mcp-name: io.github.GitGudatCodeing/vault-index -->
4
+
5
+ Local hybrid search for markdown vaults and codebases, served as an MCP server. Keyword search (SQLite FTS5) and vector search (sqlite-vec, `bge-small-en-v1.5` on CPU) are merged with reciprocal rank fusion. Everything lives in one SQLite file on your machine; no cloud services.
6
+
7
+ Part of [claude-second-brain](https://github.com/GitGudatCodeing/claude-second-brain), where every search change is measured against golden question sets and compared with naive grep and basic-memory (see its README for the numbers).
8
+
9
+ ## Use with Claude Code
10
+
11
+ ```bash
12
+ claude mcp add vault-index -- uvx --python 3.12 vault-index
13
+ ```
14
+
15
+ Then ask Claude to index a folder, for example: "index_paths ~/notes". After that, `search_notes` finds paraphrased answers with file path and heading. The first run downloads the embedding model (about 65 MB).
16
+
17
+ ## Tools
18
+ - `index_paths(paths, reindex=False)`: add folders or files as sources and sync them. With no paths, re-syncs every source (new, changed and deleted files).
19
+ - `search_notes(query, limit=8, path_prefix=None)`: hybrid search; returns chunks with file path and heading path.
20
+ - `get_note(path)`: full text of an indexed file.
21
+ - `list_sources()`, `remove_source(path)`.
22
+
23
+ Markdown is chunked by heading. A `.indexignore` in a source folder lists relative paths to skip.
24
+
25
+ ## Settings
26
+ | Env var | Default |
27
+ |---|---|
28
+ | `VAULT_INDEX_DB` | `~/.mcp-memory/vault-index.db` |
29
+ | `VAULT_MODEL_CACHE` | `~/.mcp-memory/models` |
30
+
31
+ Python 3.10 to 3.13 (`--python 3.12` keeps uvx off newer Pythons that onnxruntime does not support yet). MIT license.
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "vault-index"
3
+ version = "0.2.0"
4
+ description = "Local hybrid (FTS5 + sqlite-vec) search MCP server for markdown vaults and codebases"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10,<3.14"
8
+ keywords = ["mcp", "claude", "search", "sqlite-vec", "markdown", "obsidian"]
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Operating System :: OS Independent",
12
+ "Topic :: Text Processing :: Indexing",
13
+ ]
14
+ dependencies = [
15
+ "mcp>=1.2,<2",
16
+ "sqlite-vec>=0.1.6",
17
+ "fastembed>=0.4",
18
+ ]
19
+
20
+ [project.urls]
21
+ Homepage = "https://github.com/GitGudatCodeing/claude-second-brain"
22
+ Issues = "https://github.com/GitGudatCodeing/claude-second-brain/issues"
23
+
24
+ [project.scripts]
25
+ vault-index = "vault_index:main"
26
+
27
+ [build-system]
28
+ requires = ["hatchling>=1.27"]
29
+ build-backend = "hatchling.build"
30
+
31
+ # The repo runs server.py in place; the wheel ships it as the module vault_index.
32
+ [tool.hatch.build.targets.wheel]
33
+ bypass-selection = true
34
+
35
+ [tool.hatch.build.targets.wheel.force-include]
36
+ "server.py" = "vault_index.py"
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ include = ["server.py", "README.md", "pyproject.toml"]
40
+
41
+ [tool.uv]
42
+ package = false # `uv run --project mcp` keeps using the source file, not an installed copy
@@ -0,0 +1,403 @@
1
+ """Vault index MCP server: hybrid keyword (FTS5) + vector (sqlite-vec) search.
2
+
3
+ Embeddings come from fastembed (ONNX, CPU). Everything lives in one SQLite file.
4
+ Env:
5
+ VAULT_INDEX_DB path to the SQLite file (default ~/.mcp-memory/vault-index.db)
6
+ VAULT_MODEL_CACHE fastembed model cache dir (default ~/.mcp-memory/models)
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ import re
14
+ import sqlite3
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ import sqlite_vec
19
+ from mcp.server.fastmcp import FastMCP
20
+
21
+ MODEL_NAME = "BAAI/bge-small-en-v1.5"
22
+ DIM = 384
23
+ HOME = Path.home() / ".mcp-memory"
24
+ DB_PATH = Path(os.environ.get("VAULT_INDEX_DB", HOME / "vault-index.db"))
25
+ MODEL_CACHE = Path(os.environ.get("VAULT_MODEL_CACHE", HOME / "models"))
26
+
27
+ EXTENSIONS = {
28
+ ".md", ".markdown", ".txt", ".rst",
29
+ ".py", ".js", ".ts", ".tsx", ".jsx", ".cs", ".java", ".go", ".rs", ".sql",
30
+ ".json", ".yaml", ".yml", ".toml", ".ps1", ".sh",
31
+ }
32
+ SKIP_DIRS = {
33
+ ".git", "node_modules", ".venv", "venv", "__pycache__", "bin", "obj",
34
+ "dist", "build", ".idea", ".vs", ".next", ".obsidian", ".trash",
35
+ }
36
+ MAX_FILE_BYTES = 1_000_000
37
+ CHUNK_CHARS = 1200
38
+
39
+ mcp = FastMCP("vault-index")
40
+ _embedder = None
41
+ _db: sqlite3.Connection | None = None
42
+
43
+
44
+ def log(msg: str) -> None:
45
+ print(msg, file=sys.stderr, flush=True)
46
+
47
+
48
+ def embedder():
49
+ global _embedder
50
+ if _embedder is None:
51
+ from fastembed import TextEmbedding
52
+
53
+ MODEL_CACHE.mkdir(parents=True, exist_ok=True)
54
+ _embedder = TextEmbedding(MODEL_NAME, cache_dir=str(MODEL_CACHE))
55
+ return _embedder
56
+
57
+
58
+ def embed(texts: list[str]) -> list[bytes]:
59
+ return [sqlite_vec.serialize_float32(v.tolist()) for v in embedder().embed(texts)]
60
+
61
+
62
+ def db() -> sqlite3.Connection:
63
+ global _db
64
+ if _db is None:
65
+ DB_PATH.parent.mkdir(parents=True, exist_ok=True)
66
+ con = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=30)
67
+ con.execute("PRAGMA journal_mode=WAL") # the PostToolUse hook writes concurrently
68
+ con.enable_load_extension(True)
69
+ sqlite_vec.load(con)
70
+ con.enable_load_extension(False)
71
+ con.executescript(
72
+ f"""
73
+ CREATE TABLE IF NOT EXISTS sources(path TEXT PRIMARY KEY);
74
+ CREATE TABLE IF NOT EXISTS files(
75
+ id INTEGER PRIMARY KEY, path TEXT UNIQUE NOT NULL,
76
+ mtime REAL NOT NULL, hash TEXT NOT NULL);
77
+ CREATE TABLE IF NOT EXISTS chunks(
78
+ id INTEGER PRIMARY KEY, file_id INTEGER NOT NULL,
79
+ ord INTEGER NOT NULL, heading TEXT NOT NULL, text TEXT NOT NULL);
80
+ CREATE INDEX IF NOT EXISTS chunks_file ON chunks(file_id);
81
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(text, heading);
82
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks_vec USING vec0(embedding float[{DIM}]);
83
+ """
84
+ )
85
+ _db = con
86
+ return _db
87
+
88
+
89
+ # ---------- chunking ----------
90
+
91
+ _HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
92
+
93
+
94
+ def chunk_text(text: str, is_markdown: bool) -> list[tuple[str, str]]:
95
+ """Return (heading_path, chunk_text) pairs."""
96
+ sections: list[tuple[str, list[str]]] = []
97
+ stack: list[tuple[int, str]] = []
98
+ heading, buf = "", []
99
+ in_fence = False
100
+ for line in text.splitlines():
101
+ if line.lstrip().startswith("```"):
102
+ in_fence = not in_fence
103
+ m = None if (in_fence or not is_markdown) else _HEADING.match(line)
104
+ if m:
105
+ sections.append((heading, buf))
106
+ level = len(m.group(1))
107
+ while stack and stack[-1][0] >= level:
108
+ stack.pop()
109
+ stack.append((level, m.group(2).strip()))
110
+ heading, buf = " > ".join(h for _, h in stack), []
111
+ else:
112
+ buf.append(line)
113
+ sections.append((heading, buf))
114
+
115
+ out: list[tuple[str, str]] = []
116
+ for head, lines in sections:
117
+ body = "\n".join(lines).strip()
118
+ if not body:
119
+ continue
120
+ cur = ""
121
+ for para in re.split(r"\n\s*\n", body):
122
+ while len(para) > CHUNK_CHARS * 1.5: # very long paragraph/code block
123
+ cut = para.rfind("\n", 0, CHUNK_CHARS)
124
+ cut = cut if cut > CHUNK_CHARS // 2 else CHUNK_CHARS
125
+ if cur:
126
+ out.append((head, cur))
127
+ cur = ""
128
+ out.append((head, para[:cut].strip()))
129
+ para = para[cut:]
130
+ if cur and len(cur) + len(para) > CHUNK_CHARS:
131
+ out.append((head, cur))
132
+ cur = ""
133
+ cur = f"{cur}\n\n{para}" if cur else para
134
+ if cur.strip():
135
+ out.append((head, cur.strip()))
136
+ return [(h, t) for h, t in out if t.strip()]
137
+
138
+
139
+ # ---------- indexing ----------
140
+
141
+ def load_ignores(root: Path) -> list[str]:
142
+ """Source-relative paths (files or folders) listed in <source>/.indexignore."""
143
+ f = root / ".indexignore"
144
+ if not root.is_dir() or not f.is_file():
145
+ return []
146
+ lines = (l.split("#", 1)[0].strip().replace("\\", "/").strip("/") for l in f.read_text(encoding="utf-8").splitlines())
147
+ return [l.lower() for l in lines if l]
148
+
149
+
150
+ def is_ignored(p: Path, root: Path, ignores: list[str] | None = None) -> bool:
151
+ ignores = load_ignores(root) if ignores is None else ignores
152
+ try:
153
+ rel = p.relative_to(root).as_posix().lower()
154
+ except ValueError:
155
+ return False
156
+ return any(rel == ig or rel.startswith(ig + "/") for ig in ignores)
157
+
158
+
159
+ def iter_files(root: Path):
160
+ if root.is_file():
161
+ yield root
162
+ return
163
+ ignores = load_ignores(root)
164
+ for dirpath, dirnames, filenames in os.walk(root):
165
+ dirnames[:] = [
166
+ d for d in dirnames if d not in SKIP_DIRS and not is_ignored(Path(dirpath) / d, root, ignores)
167
+ ]
168
+ for name in filenames:
169
+ p = Path(dirpath) / name
170
+ if p.suffix.lower() in EXTENSIONS and not is_ignored(p, root, ignores):
171
+ yield p
172
+
173
+
174
+ def drop_file(con: sqlite3.Connection, file_id: int) -> None:
175
+ ids = [r[0] for r in con.execute("SELECT id FROM chunks WHERE file_id=?", (file_id,))]
176
+ for cid in ids:
177
+ con.execute("DELETE FROM chunks_fts WHERE rowid=?", (cid,))
178
+ con.execute("DELETE FROM chunks_vec WHERE rowid=?", (cid,))
179
+ con.execute("DELETE FROM chunks WHERE file_id=?", (file_id,))
180
+ con.execute("DELETE FROM files WHERE id=?", (file_id,))
181
+
182
+
183
+ def index_file(con: sqlite3.Connection, p: Path, force: bool) -> str:
184
+ key = str(p)
185
+ st = p.stat()
186
+ row = con.execute("SELECT id, mtime, hash FROM files WHERE path=?", (key,)).fetchone()
187
+ if row and not force and abs(row[1] - st.st_mtime) < 1e-6:
188
+ return "unchanged"
189
+ try:
190
+ text = p.read_text(encoding="utf-8") if st.st_size <= MAX_FILE_BYTES else None
191
+ except (UnicodeDecodeError, OSError):
192
+ text = None
193
+ if text is None: # too big or unreadable now: never keep serving its old chunks
194
+ if row:
195
+ drop_file(con, row[0])
196
+ return "skipped"
197
+ digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
198
+ if row and not force and row[2] == digest:
199
+ con.execute("UPDATE files SET mtime=? WHERE id=?", (st.st_mtime, row[0]))
200
+ return "unchanged"
201
+ chunks = chunk_text(text, p.suffix.lower() in {".md", ".markdown"})
202
+ # Embed before touching the index, so a failure leaves the old version in place.
203
+ vecs = embed([f"{h}\n{t}" if h else t for h, t in chunks]) if chunks else []
204
+ if row:
205
+ drop_file(con, row[0])
206
+ cur = con.execute("INSERT INTO files(path, mtime, hash) VALUES(?,?,?)", (key, st.st_mtime, digest))
207
+ file_id = cur.lastrowid
208
+ if chunks:
209
+ for i, ((h, t), v) in enumerate(zip(chunks, vecs, strict=True)):
210
+ cid = con.execute(
211
+ "INSERT INTO chunks(file_id, ord, heading, text) VALUES(?,?,?,?)", (file_id, i, h, t)
212
+ ).lastrowid
213
+ con.execute("INSERT INTO chunks_fts(rowid, text, heading) VALUES(?,?,?)", (cid, t, h))
214
+ con.execute("INSERT INTO chunks_vec(rowid, embedding) VALUES(?,?)", (cid, v))
215
+ return "updated" if row else "added"
216
+
217
+
218
+ def sync_source(con: sqlite3.Connection, root: Path, force: bool) -> dict:
219
+ stats = {"added": 0, "updated": 0, "unchanged": 0, "skipped": 0, "removed": 0}
220
+ seen: set[str] = set()
221
+ try:
222
+ for p in iter_files(root):
223
+ seen.add(str(p))
224
+ stats[index_file(con, p, force)] += 1
225
+ for fid, path in files_under(con, str(root)):
226
+ if path not in seen:
227
+ drop_file(con, fid)
228
+ stats["removed"] += 1
229
+ except BaseException:
230
+ con.rollback() # the connection is long-lived; a later commit must never keep half a file
231
+ raise
232
+ con.commit()
233
+ return stats
234
+
235
+
236
+ def files_under(con: sqlite3.Connection, source: str) -> list[tuple[int, str]]:
237
+ """(id, path) of indexed files at or below `source`. A plain string match: LIKE treats _ and % as wildcards."""
238
+ prefix = source.rstrip("\\/")
239
+ return [(fid, path) for fid, path in con.execute("SELECT id, path FROM files").fetchall()
240
+ if path == prefix or path.startswith(prefix + os.sep)]
241
+
242
+
243
+ # ---------- search ----------
244
+
245
+ STOPWORDS = frozenset("""
246
+ a about after again all am an and any are as at be because been before being both but by can could
247
+ did do does doing don't down during each few for from further had has have having he her here hers
248
+ him his how i if in into is it its itself just let me more most my no nor not of off on once only or
249
+ other our ours out over own same she should so some such than that the their theirs them then there
250
+ these they this those through to too under until up very was we were what when where which while who
251
+ whom why will with would you your yours
252
+ """.split())
253
+
254
+
255
+ def fts_query(q: str) -> str | None:
256
+ toks = re.findall(r"\w+", q)
257
+ content = [t for t in toks if t.lower() not in STOPWORDS] or toks
258
+ return " OR ".join(f'"{t}"' for t in content) if content else None
259
+
260
+
261
+ @mcp.tool()
262
+ def index_paths(paths: list[str] | None = None, reindex: bool = False) -> str:
263
+ """Add folders/files to the index (remembered as sources) and sync them.
264
+
265
+ With no `paths`, re-syncs every registered source (picks up new, changed and
266
+ deleted files). `reindex=True` forces re-embedding of everything.
267
+ """
268
+ con = db()
269
+ for raw in paths or []:
270
+ p = Path(raw).expanduser().resolve()
271
+ if not p.exists():
272
+ return f"Path does not exist: {p}"
273
+ con.execute("INSERT OR IGNORE INTO sources(path) VALUES(?)", (str(p),))
274
+ con.commit()
275
+ sources = [r[0] for r in con.execute("SELECT path FROM sources ORDER BY path")]
276
+ if not sources:
277
+ return "No sources registered. Call index_paths with one or more paths."
278
+ targets = [str(Path(x).expanduser().resolve()) for x in paths] if paths else sources
279
+ lines = []
280
+ for s in targets:
281
+ lines.append(f"{s}: {sync_source(con, Path(s), reindex)}")
282
+ return "\n".join(lines)
283
+
284
+
285
+ RRF_K = 60
286
+ # Keyword ranks count at a tenth of vector ranks: a tie-breaker and jargon booster, not an equal vote.
287
+ # Beat equal weights on both golden sets (docs/adr/2026-10-23-fts-weight-in-fusion.md).
288
+ FTS_WEIGHT = 0.1
289
+
290
+
291
+ def hybrid_search(
292
+ query: str, limit: int, path_prefix: str | None = None, mode: str = "hybrid"
293
+ ) -> list[tuple[str, str, str, float]]:
294
+ """Ranked (path, heading, text, score). `mode` is hybrid, fts or vec; the
295
+ single-sided modes exist so eval/run_eval.py can measure each side's contribution."""
296
+ con = db()
297
+ pool = limit * 4
298
+ ranks: dict[int, float] = {}
299
+ # With a prefix, both sides rank only chunks under it, so a filtered search never comes back
300
+ # short just because other folders filled the candidate pool.
301
+ scope = json.dumps(prefix_chunk_ids(con, path_prefix)) if path_prefix else None
302
+
303
+ fq = fts_query(query)
304
+ if fq and mode in ("hybrid", "fts"):
305
+ sql = "SELECT rowid FROM chunks_fts WHERE chunks_fts MATCH ?"
306
+ args: tuple = (fq,)
307
+ if scope:
308
+ sql, args = sql + " AND rowid IN (SELECT value FROM json_each(?))", (fq, scope)
309
+ for rank, (cid,) in enumerate(con.execute(sql + " ORDER BY bm25(chunks_fts) LIMIT ?", (*args, pool))):
310
+ ranks[cid] = ranks.get(cid, 0) + (FTS_WEIGHT if mode == "hybrid" else 1) / (RRF_K + rank)
311
+ if mode in ("hybrid", "vec"):
312
+ qv = embed([query])[0]
313
+ if scope: # exact distances over the scoped chunks (vec0's kNN index cannot filter by path)
314
+ rows = con.execute(
315
+ "SELECT rowid FROM chunks_vec WHERE rowid IN (SELECT value FROM json_each(?))"
316
+ " ORDER BY vec_distance_l2(embedding, ?) LIMIT ?", (scope, qv, pool))
317
+ else:
318
+ rows = con.execute("SELECT rowid FROM chunks_vec WHERE embedding MATCH ? AND k=? ORDER BY distance", (qv, pool))
319
+ for rank, (cid,) in enumerate(rows):
320
+ ranks[cid] = ranks.get(cid, 0) + 1 / (RRF_K + rank)
321
+
322
+ results = []
323
+ for cid, score in sorted(ranks.items(), key=lambda kv: -kv[1])[:limit]:
324
+ path, heading, text = con.execute(
325
+ "SELECT f.path, c.heading, c.text FROM chunks c JOIN files f ON f.id=c.file_id WHERE c.id=?", (cid,)
326
+ ).fetchone()
327
+ results.append((path, heading, text, score))
328
+ return results
329
+
330
+
331
+ def prefix_chunk_ids(con: sqlite3.Connection, path_prefix: str) -> list[int]:
332
+ """Ids of chunks whose file path starts with `path_prefix`: an absolute path, or one relative
333
+ to any registered source (e.g. "docs/lessons.md"). Case-insensitive; / and \\ are the same."""
334
+ def norm(s: str) -> str:
335
+ return s.replace("\\", "/").lower()
336
+
337
+ p = Path(path_prefix).expanduser()
338
+ if p.is_absolute():
339
+ bases = [norm(str(p))]
340
+ else:
341
+ rel = norm(path_prefix).removeprefix("./")
342
+ bases = [norm(s).rstrip("/") + "/" + rel for (s,) in con.execute("SELECT path FROM sources")]
343
+ rows = con.execute("SELECT c.id, f.path FROM chunks c JOIN files f ON f.id=c.file_id")
344
+ return [cid for cid, path in rows if norm(path).startswith(tuple(bases))]
345
+
346
+
347
+ @mcp.tool()
348
+ def search_notes(query: str, limit: int = 8, path_prefix: str | None = None) -> str:
349
+ """Hybrid search (keyword + semantic, merged with reciprocal rank fusion).
350
+
351
+ Returns the best-matching chunks with file path and heading. Optionally restrict
352
+ to files whose path starts with `path_prefix`.
353
+ """
354
+ limit = max(1, min(limit, 25))
355
+ results = []
356
+ for i, (path, heading, text, score) in enumerate(hybrid_search(query, limit, path_prefix), 1):
357
+ snippet = text if len(text) <= 700 else text[:700] + "..."
358
+ results.append(f"[{i}] {path}" + (f" # {heading}" if heading else "") + f" (score {score:.4f})\n{snippet}")
359
+ return "\n\n".join(results) or "No matches. (Has index_paths been run?)"
360
+
361
+
362
+ @mcp.tool()
363
+ def get_note(path: str) -> str:
364
+ """Return the full text of an indexed file (read from disk)."""
365
+ p = Path(path).expanduser().resolve()
366
+ row = db().execute("SELECT 1 FROM files WHERE path=?", (str(p),)).fetchone()
367
+ if not row:
368
+ return f"Not in index: {p}. Only indexed files can be read with this tool."
369
+ return p.read_text(encoding="utf-8")
370
+
371
+
372
+ @mcp.tool()
373
+ def list_sources() -> str:
374
+ """List registered source paths with file and chunk counts."""
375
+ con = db()
376
+ out = []
377
+ for (s,) in con.execute("SELECT path FROM sources ORDER BY path").fetchall():
378
+ ids = [fid for fid, _ in files_under(con, s)]
379
+ nc = sum(con.execute("SELECT COUNT(*) FROM chunks WHERE file_id=?", (fid,)).fetchone()[0] for fid in ids)
380
+ out.append(f"{s}: {len(ids)} files, {nc} chunks")
381
+ return "\n".join(out) or "No sources registered."
382
+
383
+
384
+ @mcp.tool()
385
+ def remove_source(path: str) -> str:
386
+ """Unregister a source and delete its files/chunks from the index (files on disk are untouched)."""
387
+ con = db()
388
+ s = str(Path(path).expanduser().resolve())
389
+ con.execute("DELETE FROM sources WHERE path=?", (s,))
390
+ ids = [fid for fid, _ in files_under(con, s)]
391
+ for fid in ids:
392
+ drop_file(con, fid)
393
+ con.commit()
394
+ return f"Removed {s} ({len(ids)} files dropped from index)."
395
+
396
+
397
+ def main() -> None:
398
+ """Console entry point (`uvx vault-index`): serve over stdio."""
399
+ mcp.run()
400
+
401
+
402
+ if __name__ == "__main__":
403
+ main()