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.
- cortex_local-2.0.1/PKG-INFO +10 -0
- cortex_local-2.0.1/cortex/__init__.py +0 -0
- cortex_local-2.0.1/cortex/cli.py +62 -0
- cortex_local-2.0.1/cortex/config.py +18 -0
- cortex_local-2.0.1/cortex/embedder.py +33 -0
- cortex_local-2.0.1/cortex/indexer.py +261 -0
- cortex_local-2.0.1/cortex/mcp_server.py +389 -0
- cortex_local-2.0.1/cortex/state.py +86 -0
- cortex_local-2.0.1/pyproject.toml +25 -0
|
@@ -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"]
|