cortex-local 2.0.1__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,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: cortex-local
3
+ Version: 2.0.1
4
+ Summary: Local semantic search MCP server — notes + code, embedded Qdrant, fastembed
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: click>=8.0.0
7
+ Requires-Dist: fastembed>=0.4.0
8
+ Requires-Dist: mcp[cli]>=1.0.0
9
+ Requires-Dist: networkx>=3.0
10
+ Requires-Dist: qdrant-client>=1.17.0
File without changes
@@ -0,0 +1,62 @@
1
+ """cortex CLI — index <path> and serve (stdio MCP)."""
2
+
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+
10
+ @click.group()
11
+ def cli() -> None:
12
+ """cortex: local semantic search MCP server."""
13
+
14
+
15
+ @cli.command()
16
+ @click.argument("path", type=click.Path(exists=True, file_okay=False, resolve_path=True))
17
+ def index(path: str) -> None:
18
+ """Index PATH (notes and/or code) into ~/.cortex."""
19
+ from .indexer import index_path
20
+ index_path(Path(path))
21
+
22
+
23
+ @cli.command()
24
+ def serve() -> None:
25
+ """Run cortex MCP server on stdio."""
26
+ from .mcp_server import run
27
+ run()
28
+
29
+
30
+ @cli.command()
31
+ def pull_models() -> None:
32
+ """Pre-download fastembed models."""
33
+ from .embedder import pull_models as _pull
34
+ _pull()
35
+
36
+
37
+ @cli.command()
38
+ def stats() -> None:
39
+ """Show cortex efficiency stats."""
40
+ from .state import _stats
41
+ from .mcp_server import _fmt_stats
42
+ from .config import VERSION
43
+ click.echo(_fmt_stats(VERSION, _stats, current=True))
44
+
45
+
46
+ @cli.command("install")
47
+ def install_cmd() -> None:
48
+ """Download embedding models and register cortex MCP in Claude Code."""
49
+ from .embedder import pull_models as _pull
50
+ click.echo("Downloading embedding models...")
51
+ _pull()
52
+ click.echo("Registering MCP server in Claude Code...")
53
+ result = subprocess.run(
54
+ ["claude", "mcp", "add", "cortex", "--transport", "stdio", "cortex", "serve"],
55
+ capture_output=True, text=True,
56
+ )
57
+ if result.returncode == 0:
58
+ click.echo("Done. cortex is ready — restart Claude Code to activate.")
59
+ else:
60
+ click.echo(f"MCP registration failed:\n{result.stderr}")
61
+ click.echo("Run manually: claude mcp add cortex --transport stdio cortex serve")
62
+ sys.exit(1)
@@ -0,0 +1,18 @@
1
+ """Paths and constants for cortex local mode."""
2
+
3
+ from pathlib import Path
4
+
5
+ CORTEX_DIR = Path.home() / ".cortex"
6
+ QDRANT_PATH = str(CORTEX_DIR / "qdrant")
7
+ DATA_DIR = CORTEX_DIR
8
+ STATS_FILE = CORTEX_DIR / "stats.json"
9
+ CACHE_FILE = CORTEX_DIR / "embed_cache.json"
10
+
11
+ EMBED_MODEL = "nomic-ai/nomic-embed-text-v1.5"
12
+ VECTOR_SIZE = 768
13
+
14
+ try:
15
+ from importlib.metadata import version
16
+ VERSION = version("cortex")
17
+ except Exception:
18
+ VERSION = "dev"
@@ -0,0 +1,33 @@
1
+ """fastembed-based dense + sparse embeddings for cortex local mode."""
2
+
3
+ from .config import EMBED_MODEL
4
+
5
+ _dense = None
6
+ _sparse = None
7
+
8
+
9
+ def embed(text: str) -> list:
10
+ """Dense embedding via fastembed nomic-embed-text (768 dims)."""
11
+ global _dense
12
+ if _dense is None:
13
+ from fastembed import TextEmbedding
14
+ _dense = TextEmbedding(EMBED_MODEL)
15
+ return list(_dense.embed([text[:4000]]))[0].tolist()
16
+
17
+
18
+ def sparse_embed(text: str) -> tuple:
19
+ """BM25 sparse embedding via fastembed (indices, values)."""
20
+ global _sparse
21
+ if _sparse is None:
22
+ from fastembed import SparseTextEmbedding
23
+ _sparse = SparseTextEmbedding("Qdrant/bm25")
24
+ e = list(_sparse.embed([text[:4000]]))[0]
25
+ return e.indices.tolist(), e.values.tolist()
26
+
27
+
28
+ def pull_models() -> None:
29
+ """Pre-download embedding models (run at install time)."""
30
+ print("Pulling fastembed models...")
31
+ embed("warmup")
32
+ sparse_embed("warmup")
33
+ print("Models ready.")
@@ -0,0 +1,261 @@
1
+ """Index notes and code from a local path into embedded Qdrant."""
2
+
3
+ import hashlib
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from qdrant_client import QdrantClient
9
+ from qdrant_client.models import (
10
+ Distance, FieldCondition, Filter, FilterSelector,
11
+ MatchValue, PointStruct, SparseVector, SparseVectorParams, VectorParams,
12
+ )
13
+
14
+ from .config import QDRANT_PATH, DATA_DIR, CACHE_FILE, VECTOR_SIZE
15
+ from .embedder import embed, sparse_embed
16
+
17
+ # Reuse chunking/graph logic from core
18
+ import sys as _sys
19
+ from pathlib import Path as _Path
20
+ _core = _Path(__file__).parent.parent.parent / "core"
21
+ if str(_core) not in _sys.path:
22
+ _sys.path.insert(0, str(_core))
23
+
24
+ from chunker import chunk_file, CODE_EXTS, SKIP_DIRS # noqa: E402
25
+ from cache import load_cache, save_cache # noqa: E402
26
+ import graph as _graph # noqa: E402
27
+
28
+
29
+ def _qdrant() -> QdrantClient:
30
+ return QdrantClient(path=QDRANT_PATH)
31
+
32
+
33
+ def _ensure_collection(client: QdrantClient, name: str) -> bool:
34
+ existing = [c.name for c in client.get_collections().collections]
35
+ if name not in existing:
36
+ client.create_collection(
37
+ name,
38
+ vectors_config=VectorParams(size=VECTOR_SIZE, distance=Distance.COSINE),
39
+ sparse_vectors_config={"sparse": SparseVectorParams()},
40
+ )
41
+ print(f"Created collection '{name}'")
42
+ return True
43
+
44
+
45
+ def _sparse_available() -> bool:
46
+ try:
47
+ sparse_embed("warmup")
48
+ return True
49
+ except Exception as e:
50
+ print(f"warn: sparse unavailable: {e}")
51
+ return False
52
+
53
+
54
+ def _make_point(text: str, pid: int, payload: dict, use_sparse: bool) -> PointStruct:
55
+ dense = embed(text)
56
+ if use_sparse:
57
+ idx, vals = sparse_embed(text)
58
+ vec = {"": dense, "sparse": SparseVector(indices=idx, values=vals)}
59
+ else:
60
+ vec = dense
61
+ return PointStruct(id=pid, vector=vec, payload=payload)
62
+
63
+
64
+ # ── Notes ───────────────────────────────────────────────────────────────────
65
+
66
+ def _parse_frontmatter(text: str) -> tuple:
67
+ m = re.match(r"^---\n(.*?)\n---\n", text, flags=re.DOTALL)
68
+ if not m:
69
+ return text, []
70
+ fm = m.group(1)
71
+ tags = []
72
+ tm = re.search(r"^tags:\s*\[([^\]]+)\]", fm, re.MULTILINE)
73
+ if tm:
74
+ tags = [t.strip().strip("\"'") for t in tm.group(1).split(",") if t.strip()]
75
+ else:
76
+ tm2 = re.search(r"^tags:\s*\n((?:[ \t]*-[ \t]*.+\n?)+)", fm, re.MULTILINE)
77
+ if tm2:
78
+ tags = [re.sub(r"^[ \t]*-[ \t]*", "", l).strip()
79
+ for l in tm2.group(1).splitlines() if l.strip()]
80
+ return text[m.end():], tags
81
+
82
+
83
+ def _chunk_markdown(path: Path, notes_dir: Path) -> list:
84
+ raw = path.read_text(encoding="utf-8")
85
+ rel = str(path.relative_to(notes_dir))
86
+ modified_at = int(path.stat().st_mtime)
87
+ text, tags = _parse_frontmatter(raw)
88
+
89
+ chunks, current_heading, current_lines = [], path.stem, []
90
+ for line in text.splitlines():
91
+ if re.match(r"^#{1,3} ", line):
92
+ if current_lines:
93
+ body = "\n".join(current_lines).strip()
94
+ if body:
95
+ chunks.append({"heading": current_heading, "file": rel, "tags": tags,
96
+ "modified_at": modified_at,
97
+ "text": f"{current_heading}\n\n{body}"})
98
+ current_heading = line.lstrip("#").strip()
99
+ current_lines = []
100
+ else:
101
+ current_lines.append(line)
102
+ if current_lines:
103
+ body = "\n".join(current_lines).strip()
104
+ if body:
105
+ chunks.append({"heading": current_heading, "file": rel, "tags": tags,
106
+ "modified_at": modified_at, "text": f"{current_heading}\n\n{body}"})
107
+ if not chunks:
108
+ body = text.strip()
109
+ if body:
110
+ chunks.append({"heading": path.stem, "file": rel, "tags": tags,
111
+ "modified_at": modified_at, "text": body})
112
+ return chunks
113
+
114
+
115
+ def _chunk_id(file: str, heading: str) -> int:
116
+ return int(hashlib.md5(f"{file}:{heading}".encode()).hexdigest()[:8], 16)
117
+
118
+
119
+ def index_notes(notes_dir: Path) -> int:
120
+ """Index all .md files in notes_dir into the 'notes' collection. Returns chunk count."""
121
+ client = _qdrant()
122
+ _ensure_collection(client, "notes")
123
+ use_sparse = _sparse_available()
124
+
125
+ md_files = [p for p in notes_dir.rglob("*.md")
126
+ if ".obsidian" not in p.parts and ".git" not in p.parts]
127
+
128
+ cache = load_cache("notes", CACHE_FILE)
129
+ if client.get_collection("notes").points_count == 0:
130
+ cache = {}
131
+ new_cache: dict = {}
132
+ total = 0
133
+ cached = 0
134
+
135
+ for path in md_files:
136
+ rel = str(path.relative_to(notes_dir))
137
+ mtime = path.stat().st_mtime
138
+ if cache.get(rel) == mtime:
139
+ new_cache[rel] = mtime
140
+ cached += 1
141
+ continue
142
+ chunks = _chunk_markdown(path, notes_dir)
143
+ points = [_make_point(c["text"], _chunk_id(c["file"], c["heading"]), c, use_sparse)
144
+ for c in chunks]
145
+ if points:
146
+ client.upsert("notes", points)
147
+ total += len(points)
148
+ print(f" {rel}: {len(points)} chunk(s)", flush=True)
149
+ new_cache[rel] = mtime
150
+
151
+ save_cache("notes", new_cache, CACHE_FILE)
152
+ print(f"Done. {total} chunks indexed, {cached}/{len(md_files)} files cached.", flush=True)
153
+
154
+ try:
155
+ G = _graph.build_notes_graph(notes_dir)
156
+ _graph.persist_notes_graph(G, DATA_DIR)
157
+ except Exception as e:
158
+ print(f" notes graph failed: {e}")
159
+
160
+ return total
161
+
162
+
163
+ # ── Code ────────────────────────────────────────────────────────────────────
164
+
165
+ def _code_chunk_id(repo: str, file: str, start: int) -> int:
166
+ return int(hashlib.md5(f"{repo}:{file}:{start}".encode()).hexdigest()[:8], 16)
167
+
168
+
169
+ def index_code(code_dir: Path) -> int:
170
+ """Index all code files in code_dir into the 'code' collection. Returns chunk count."""
171
+ repo_name = code_dir.name
172
+ client = _qdrant()
173
+ _ensure_collection(client, "code")
174
+ use_sparse = _sparse_available()
175
+
176
+ code_files = [
177
+ p for p in code_dir.rglob("*")
178
+ if p.is_file() and p.suffix in CODE_EXTS
179
+ and not any(s in p.parts for s in SKIP_DIRS)
180
+ ]
181
+
182
+ cache = load_cache("code", CACHE_FILE)
183
+ if client.get_collection("code").points_count == 0:
184
+ cache = {}
185
+ new_cache = dict(cache)
186
+ total = 0
187
+ cached = 0
188
+ file_point_ids: dict = {}
189
+
190
+ for i, path in enumerate(code_files):
191
+ if i % 10 == 0 and i > 0:
192
+ print(f" {repo_name}: {i}/{len(code_files)} files, {total} chunks...", flush=True)
193
+
194
+ cache_key = f"{repo_name}/{str(path.relative_to(code_dir))}"
195
+ mtime = path.stat().st_mtime
196
+ if cache.get(cache_key) == mtime:
197
+ cached += 1
198
+ continue
199
+
200
+ points = []
201
+ for chunk in chunk_file(path, repo_name, base_dir=code_dir):
202
+ pid = _code_chunk_id(repo_name, chunk["file"], chunk["start_line"])
203
+ points.append(_make_point(chunk["text"], pid, chunk, use_sparse))
204
+ file_point_ids.setdefault(chunk["file"], []).append(pid)
205
+ if points:
206
+ client.upsert("code", points)
207
+ total += len(points)
208
+ new_cache[cache_key] = mtime
209
+
210
+ save_cache("code", new_cache, CACHE_FILE)
211
+ print(f" {repo_name}: {total} chunks from {len(code_files) - cached} files ({cached} cached)", flush=True)
212
+
213
+ all_files = {
214
+ str(p.relative_to(code_dir)) for p in code_files
215
+ }
216
+ try:
217
+ G = _graph.build_code_graph(code_dir, all_files)
218
+ if G is not None:
219
+ metadata = _graph.compute_code_metadata(G)
220
+ _graph.persist_code_graph(G, metadata, repo_name, DATA_DIR)
221
+ for file_rel, meta in metadata.items():
222
+ pids = file_point_ids.get(file_rel, [])
223
+ if pids:
224
+ client.set_payload(
225
+ collection_name="code",
226
+ payload={
227
+ "centrality": meta["centrality"],
228
+ "community_id": meta["community_id"],
229
+ "imports": meta["imports"],
230
+ "imported_by": meta["imported_by"],
231
+ },
232
+ points=pids,
233
+ )
234
+ except Exception as e:
235
+ print(f" graph failed: {e}")
236
+
237
+ from .state import invalidate_graph_cache
238
+ invalidate_graph_cache(repo_name)
239
+ return total
240
+
241
+
242
+ def index_path(path: Path) -> None:
243
+ """Index a path — .md files as notes, code files as code."""
244
+ path = path.resolve()
245
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
246
+
247
+ md_files = list(path.rglob("*.md"))
248
+ code_files = [p for p in path.rglob("*")
249
+ if p.is_file() and p.suffix in CODE_EXTS
250
+ and not any(s in p.parts for s in SKIP_DIRS)]
251
+
252
+ if md_files:
253
+ print(f"\nIndexing notes from {path} ({len(md_files)} .md files)...")
254
+ index_notes(path)
255
+
256
+ if code_files:
257
+ print(f"\nIndexing code from {path} ({len(code_files)} files)...")
258
+ index_code(path)
259
+
260
+ if not md_files and not code_files:
261
+ print(f"No indexable files found in {path}")
@@ -0,0 +1,389 @@
1
+ """Cortex local MCP server — stdio transport, all 8 tools, embedded Qdrant."""
2
+
3
+ import json
4
+ import threading
5
+ import time
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from mcp.server.fastmcp import FastMCP
10
+ from qdrant_client import QdrantClient
11
+ from qdrant_client.models import Prefetch, Fusion, SparseVector
12
+
13
+ from .config import QDRANT_PATH, DATA_DIR, VERSION, VECTOR_SIZE
14
+ from .embedder import embed, sparse_embed
15
+ from .state import _stats, get_graph_meta, save_stats
16
+ from .indexer import index_path
17
+
18
+ mcp = FastMCP("cortex")
19
+
20
+ _reindex_state: dict = {
21
+ "running": False, "started_at": None, "output": [], "error": None, "done": False,
22
+ }
23
+ _reindex_path: str = ""
24
+
25
+
26
+ def _qdrant() -> QdrantClient:
27
+ return QdrantClient(path=QDRANT_PATH)
28
+
29
+
30
+ # ── Reindex ─────────────────────────────────────────────────────────────────
31
+
32
+ def _run_reindex(path_str: str) -> None:
33
+ _reindex_state.update(running=True, started_at=time.time(), output=[], error=None, done=False)
34
+ import io
35
+ from contextlib import redirect_stdout
36
+ buf = io.StringIO()
37
+ try:
38
+ with redirect_stdout(buf):
39
+ index_path(Path(path_str))
40
+ _reindex_state["output"] = buf.getvalue().splitlines()
41
+ _stats["reindex_count"] += 1
42
+ except Exception as e:
43
+ _reindex_state["error"] = str(e)
44
+ finally:
45
+ _reindex_state["running"] = False
46
+ _reindex_state["done"] = True
47
+ save_stats()
48
+
49
+
50
+ # ── PPR helper ───────────────────────────────────────────────────────────────
51
+
52
+ def _ppr_block(matched_files: list, matched_scores: list) -> str | None:
53
+ try:
54
+ from core.notes_graph import ppr_augment
55
+ except ImportError:
56
+ try:
57
+ import sys
58
+ from pathlib import Path as _P
59
+ _core = _P(__file__).parent.parent.parent / "core"
60
+ if str(_core) not in sys.path:
61
+ sys.path.insert(0, str(_core))
62
+ from notes_graph import ppr_augment
63
+ except Exception:
64
+ return None
65
+ graph_path = DATA_DIR / "graph_notes.json"
66
+ extras, reason = ppr_augment(matched_files, matched_scores, graph_path, _return_reason=True)
67
+ if extras:
68
+ _stats["ppr_fires"] += 1
69
+ _stats["ppr_results_added"] += len(extras)
70
+ lines = ["**Related via wikilinks (PPR):**"]
71
+ for e in extras:
72
+ lines.append(f" -> {e['file']} (ppr: {e['ppr_score']})")
73
+ return "\n".join(lines)
74
+ if reason == "exception":
75
+ _stats["ppr_exception"] += 1
76
+ else:
77
+ _stats["ppr_below_threshold"] += 1
78
+ return None
79
+
80
+
81
+ # ── Tools ────────────────────────────────────────────────────────────────────
82
+
83
+ @mcp.tool()
84
+ def search_notes(query: str, limit: int = 5) -> str:
85
+ """Search the user's personal knowledge base semantically.
86
+
87
+ Call this proactively whenever the user asks about:
88
+ - Their projects, plans, ideas, or roadmap items
89
+ - Personal context, goals, or decisions they may have documented
90
+ - How something in their setup works
91
+ - Anything that sounds like it could be in personal notes
92
+
93
+ Returns matching note sections with file path, heading, score, and tags.
94
+ PPR over wikilinks surfaces related notes beyond direct vector matches.
95
+ """
96
+ _stats["search_notes_calls"] += 1
97
+ client = _qdrant()
98
+ vector = embed(query)
99
+ try:
100
+ idx, vals = sparse_embed(query)
101
+ results = client.query_points(
102
+ "notes",
103
+ prefetch=[
104
+ Prefetch(query=vector, using=None, limit=limit * 2),
105
+ Prefetch(query=SparseVector(indices=idx, values=vals), using="sparse", limit=limit * 2),
106
+ ],
107
+ query=Fusion.RRF,
108
+ limit=limit,
109
+ with_payload=True,
110
+ ).points
111
+ except Exception:
112
+ results = client.query_points("notes", query=vector, limit=limit, with_payload=True).points
113
+ if not results:
114
+ return "No results found."
115
+ _stats["total_results_notes"] += len(results)
116
+ parts = []
117
+ matched_files = []
118
+ matched_scores = []
119
+ for r in results:
120
+ p = r.payload
121
+ tags = p.get("tags", [])
122
+ tag_str = f" `{'`, `'.join(tags)}`" if tags else ""
123
+ parts.append(f"**{p['file']} > {p['heading']}** (score: {round(r.score, 3)}){tag_str}\n\n{p['text']}")
124
+ matched_files.append(p.get("file", ""))
125
+ matched_scores.append(r.score)
126
+ ppr = _ppr_block(matched_files, matched_scores)
127
+ if ppr:
128
+ parts.append(ppr)
129
+ out = "\n\n---\n\n".join(parts)
130
+ _stats["context_tokens_notes"] += len(out) // 4
131
+ return out
132
+
133
+
134
+ @mcp.tool()
135
+ def search_code(query: str, limit: int = 5) -> str:
136
+ """Search source code across indexed directories semantically.
137
+
138
+ Results are re-ranked by centrality (highly-imported files score higher).
139
+ Returns code chunks with file path, line numbers, language, score, centrality, and community.
140
+ """
141
+ _stats["search_code_calls"] += 1
142
+ client = _qdrant()
143
+ vector = embed(query)
144
+ fetch_limit = min(limit * 3, 50)
145
+ try:
146
+ idx, vals = sparse_embed(query)
147
+ results = client.query_points(
148
+ "code",
149
+ prefetch=[
150
+ Prefetch(query=vector, using=None, limit=fetch_limit),
151
+ Prefetch(query=SparseVector(indices=idx, values=vals), using="sparse", limit=fetch_limit),
152
+ ],
153
+ query=Fusion.RRF,
154
+ limit=fetch_limit,
155
+ with_payload=True,
156
+ ).points
157
+ except Exception:
158
+ results = client.query_points("code", query=vector, limit=fetch_limit, with_payload=True).points
159
+ if not results:
160
+ return "No results found."
161
+ scored = []
162
+ for r in results:
163
+ p = r.payload
164
+ file_meta = get_graph_meta(p.get("repo", "")).get(p.get("file", ""), {})
165
+ centrality = file_meta.get("centrality", 0.0)
166
+ boosted = r.score * (1.0 + 0.2 * centrality)
167
+ if centrality > 0:
168
+ _stats["centrality_lift_total"] += round(boosted - r.score, 4)
169
+ _stats["centrality_lift_count"] += 1
170
+ scored.append((boosted, r, file_meta))
171
+ scored.sort(key=lambda x: -x[0])
172
+ seen: set = set()
173
+ deduped = []
174
+ for item in scored:
175
+ key = f"{item[1].payload.get('repo', '')}/{item[1].payload.get('file', '')}"
176
+ if key not in seen:
177
+ seen.add(key)
178
+ deduped.append(item)
179
+ if len(deduped) >= limit:
180
+ break
181
+ _stats["total_results_code"] += len(deduped)
182
+ parts = []
183
+ for boosted_score, r, file_meta in deduped:
184
+ p = r.payload
185
+ centrality = file_meta.get("centrality")
186
+ community = file_meta.get("community_id")
187
+ header = (
188
+ f"**{p['repo']}/{p['file']}** "
189
+ f"lines {p['start_line']}-{p['end_line']} "
190
+ f"({p.get('language', '')}) — score: {round(boosted_score, 3)}"
191
+ )
192
+ if centrality is not None:
193
+ header += f" · centrality: {centrality}"
194
+ if community is not None:
195
+ header += f" · community: {community}"
196
+ url = p.get("github_url", "")
197
+ parts.append(f"{header}\n{url}\n\n{p['text']}" if url else f"{header}\n\n{p['text']}")
198
+ out = "\n\n---\n\n".join(parts)
199
+ _stats["context_tokens_code"] += len(out) // 4
200
+ return out
201
+
202
+
203
+ @mcp.tool()
204
+ def reindex(path: str = "") -> str:
205
+ """Re-index a path into the local Qdrant database.
206
+
207
+ Runs async — returns immediately. Call reindex_status() to check progress.
208
+ path: directory to index (defaults to last indexed path).
209
+ """
210
+ global _reindex_path
211
+ target = path or _reindex_path
212
+ if not target:
213
+ return "Error: no path specified and no previous path recorded. Pass a path to index."
214
+ _reindex_path = target
215
+ if _reindex_state["running"]:
216
+ return "Reindex already running. Call reindex_status() to check progress."
217
+ threading.Thread(target=_run_reindex, args=(target,), daemon=True).start()
218
+ return f"Reindex queued for '{target}'. Use reindex_status() to check progress."
219
+
220
+
221
+ @mcp.tool()
222
+ def reindex_status() -> str:
223
+ """Check whether a reindex is running or finished, and see its output log."""
224
+ if _reindex_state["started_at"] is None:
225
+ return "No reindex has been run yet. Call reindex(path='/your/path') to start."
226
+ elapsed = time.time() - _reindex_state["started_at"]
227
+ status = "running" if _reindex_state["running"] else "done"
228
+ lines = [f"Status: {status} ({elapsed:.0f}s elapsed)"]
229
+ if _reindex_state["output"]:
230
+ lines.append("\n".join(_reindex_state["output"]))
231
+ if _reindex_state["error"]:
232
+ lines.append(f"Error: {_reindex_state['error']}")
233
+ return "\n".join(lines)
234
+
235
+
236
+ @mcp.tool()
237
+ def get_stats(all: bool = False) -> str:
238
+ """Return cortex efficiency metrics.
239
+
240
+ all=False (default): current session stats.
241
+ all=True: all persisted versions side-by-side.
242
+ """
243
+ from .config import STATS_FILE
244
+ if all:
245
+ try:
246
+ versions = json.loads(STATS_FILE.read_text()).get("versions", {}) if STATS_FILE.exists() else {}
247
+ except Exception:
248
+ versions = {}
249
+ if not versions:
250
+ return "No persisted stats found."
251
+ blocks = []
252
+ for v in sorted(versions):
253
+ s = versions[v]
254
+ blocks.append(_fmt_stats(v, s, current=(v == VERSION)))
255
+ return "\n\n".join(blocks)
256
+ return _fmt_stats(VERSION, _stats, current=True)
257
+
258
+
259
+ def _fmt_stats(v: str, s: dict, current: bool = False) -> str:
260
+ total = s.get("search_code_calls", 0) + s.get("search_notes_calls", 0)
261
+ ppr_rate = f"{s['ppr_fires'] / s['search_notes_calls'] * 100:.1f}%" if s.get("search_notes_calls") else "—"
262
+ lift_avg = round(s["centrality_lift_total"] / s["centrality_lift_count"], 4) if s.get("centrality_lift_count") else 0
263
+ label = f"{v} (current)" if current else v
264
+ lines = [label]
265
+ started = s.get("started_at", "")
266
+ if current and started:
267
+ try:
268
+ delta = datetime.now(timezone.utc) - datetime.fromisoformat(started)
269
+ h, rem = divmod(int(delta.total_seconds()), 3600)
270
+ m = rem // 60
271
+ lines.append(f" uptime: {h}h {m}m")
272
+ except Exception:
273
+ pass
274
+ lines += [
275
+ f" searches: code={s.get('search_code_calls', 0)} notes={s.get('search_notes_calls', 0)} total={total}",
276
+ f" centrality lift avg: {lift_avg} (across {s.get('centrality_lift_count', 0)} results)",
277
+ f" PPR: {s.get('ppr_fires', 0)} fires ({ppr_rate} of note searches) · +{s.get('ppr_results_added', 0)} results",
278
+ f" reindexes: {s.get('reindex_count', 0)}",
279
+ ]
280
+ return "\n".join(lines)
281
+
282
+
283
+ @mcp.tool()
284
+ def get_neighbors(file: str, repo: str) -> str:
285
+ """Show what a code file imports and what imports it (direct graph neighbors).
286
+
287
+ Use when you want to explore structural connections around a file.
288
+ Example: get_neighbors("src/lib/api/sonarr.ts", "arr-client")
289
+
290
+ Requires graph data — run reindex first if empty.
291
+ """
292
+ meta = get_graph_meta(repo)
293
+ if not meta:
294
+ return f"No graph data for '{repo}'. Run reindex() first."
295
+ file_meta = meta.get(file)
296
+ if file_meta is None:
297
+ matches = [f for f in meta if file in f]
298
+ if not matches:
299
+ return f"File '{file}' not found in graph for '{repo}'."
300
+ file = matches[0]
301
+ file_meta = meta[file]
302
+ imports = file_meta.get("imports", [])
303
+ imported_by = file_meta.get("imported_by", [])
304
+ lines = [
305
+ f"**{file}**",
306
+ f"centrality: {file_meta.get('centrality', 0)} · community: {file_meta.get('community_id', '?')}",
307
+ ]
308
+ if imports:
309
+ lines.append(f"\nimports ({len(imports)}):")
310
+ lines.extend(f" -> {f}" for f in imports)
311
+ if imported_by:
312
+ lines.append(f"\nimported by ({len(imported_by)}):")
313
+ lines.extend(f" <- {f}" for f in imported_by)
314
+ if not imports and not imported_by:
315
+ lines.append("\nNo connections found (isolated file).")
316
+ return "\n".join(lines)
317
+
318
+
319
+ @mcp.tool()
320
+ def get_community(repo: str, community_id: int) -> str:
321
+ """List all files in the same structural community/cluster.
322
+
323
+ Use when you want to find everything structurally related to a file.
324
+ Example: get_community("my-project", 0)
325
+
326
+ Requires graph data — run reindex first if empty.
327
+ """
328
+ meta = get_graph_meta(repo)
329
+ if not meta:
330
+ return f"No graph data for '{repo}'. Run reindex() first."
331
+ members = [
332
+ (f, m.get("centrality", 0))
333
+ for f, m in meta.items()
334
+ if m.get("community_id") == community_id
335
+ ]
336
+ if not members:
337
+ return f"Community {community_id} not found in '{repo}'."
338
+ members.sort(key=lambda x: -x[1])
339
+ lines = [f"**Community {community_id}** in '{repo}' — {len(members)} files:"]
340
+ for f, centrality in members:
341
+ star = "*" if centrality > 0.1 else " "
342
+ lines.append(f" {star} {f} (centrality: {centrality})")
343
+ return "\n".join(lines)
344
+
345
+
346
+ @mcp.tool()
347
+ def get_onboarding(existing_content: str = "") -> str:
348
+ """Get a CLAUDE.md template with Cortex search instructions pre-filled.
349
+
350
+ No args: returns full template with placeholder sections.
351
+ With existing_content: appends Cortex section if not already present.
352
+ """
353
+ cortex_section = """## Cortex (semantic search)
354
+
355
+ cortex is available as an MCP tool. Use it proactively:
356
+ - `search_notes(query)` — search personal knowledge base
357
+ - `search_code(query)` — search indexed code repositories
358
+ - `get_neighbors(file, repo)` — explore import graph for a file
359
+ - `get_community(repo, community_id)` — list files in a structural cluster
360
+ - `reindex(path)` — re-index a directory after changes
361
+ - `reindex_status()` — check reindex progress
362
+ """
363
+ if existing_content.strip():
364
+ if "cortex" in existing_content.lower() or "search_notes" in existing_content:
365
+ return existing_content
366
+ return existing_content.rstrip() + "\n\n" + cortex_section
367
+ return f"""# Project CLAUDE.md
368
+
369
+ ## Stack
370
+ <!-- fill in: language, framework, key dependencies -->
371
+
372
+ ## Commands
373
+ <!-- fill in: build, test, lint, run -->
374
+
375
+ ## Structure
376
+ <!-- fill in: key directories and what they contain -->
377
+
378
+ ## Conventions
379
+ <!-- fill in: naming, patterns, style -->
380
+
381
+ ## Don't
382
+ <!-- fill in: things to avoid -->
383
+
384
+ {cortex_section}"""
385
+
386
+
387
+ def run() -> None:
388
+ """Entry point for stdio MCP server."""
389
+ mcp.run(transport="stdio")
@@ -0,0 +1,86 @@
1
+ """Runtime state and graph cache for cortex local mode."""
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+ from .config import DATA_DIR, STATS_FILE, VERSION
8
+
9
+ _graph_cache: dict = {}
10
+
11
+ _stats: dict = {}
12
+
13
+
14
+ def _default_stats() -> dict:
15
+ return {
16
+ "search_code_calls": 0,
17
+ "centrality_lift_total": 0.0,
18
+ "centrality_lift_count": 0,
19
+ "search_notes_calls": 0,
20
+ "ppr_fires": 0,
21
+ "ppr_results_added": 0,
22
+ "graph_cache_hits": 0,
23
+ "graph_cache_misses": 0,
24
+ "reindex_count": 0,
25
+ "total_results_code": 0,
26
+ "total_results_notes": 0,
27
+ "context_tokens_notes": 0,
28
+ "context_tokens_code": 0,
29
+ "ppr_below_threshold": 0,
30
+ "ppr_exception": 0,
31
+ "started_at": datetime.now(timezone.utc).isoformat(),
32
+ }
33
+
34
+
35
+ def _load_stats() -> dict:
36
+ defaults = _default_stats()
37
+ try:
38
+ if STATS_FILE.exists():
39
+ data = json.loads(STATS_FILE.read_text())
40
+ saved = data.get("versions", {}).get(VERSION, {})
41
+ if saved:
42
+ return {**defaults, **saved}
43
+ except Exception:
44
+ pass
45
+ return defaults
46
+
47
+
48
+ def save_stats() -> None:
49
+ try:
50
+ existing: dict = {}
51
+ if STATS_FILE.exists():
52
+ existing = json.loads(STATS_FILE.read_text()).get("versions", {})
53
+ existing[VERSION] = {**_stats, "last_saved": datetime.now(timezone.utc).isoformat()}
54
+ STATS_FILE.parent.mkdir(parents=True, exist_ok=True)
55
+ STATS_FILE.write_text(json.dumps({"versions": existing}, indent=2))
56
+ except Exception:
57
+ pass
58
+
59
+
60
+ def get_graph_meta(repo_name: str) -> dict:
61
+ if repo_name in _graph_cache:
62
+ _stats["graph_cache_hits"] += 1
63
+ return _graph_cache[repo_name]
64
+ path = DATA_DIR / f"graph_{repo_name}.json"
65
+ if not path.exists():
66
+ _stats["graph_cache_misses"] += 1
67
+ return {}
68
+ try:
69
+ data = json.loads(path.read_text())
70
+ meta = {n["id"]: n for n in data.get("nodes", []) if "id" in n}
71
+ _graph_cache[repo_name] = meta
72
+ _stats["graph_cache_misses"] += 1
73
+ return meta
74
+ except Exception:
75
+ _stats["graph_cache_misses"] += 1
76
+ return {}
77
+
78
+
79
+ def invalidate_graph_cache(repo_name: str = "") -> None:
80
+ if repo_name:
81
+ _graph_cache.pop(repo_name, None)
82
+ else:
83
+ _graph_cache.clear()
84
+
85
+
86
+ _stats = _load_stats()
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "cortex-local"
7
+ version = "2.0.1"
8
+ description = "Local semantic search MCP server — notes + code, embedded Qdrant, fastembed"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "mcp[cli]>=1.0.0",
12
+ "qdrant-client>=1.17.0",
13
+ "fastembed>=0.4.0",
14
+ "click>=8.0.0",
15
+ "networkx>=3.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ cortex = "cortex.cli:cli"
20
+
21
+ [project.entry-points."mcp.servers"]
22
+ cortex = "cortex.mcp_server:run"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["cortex"]