cortex-local 2.4.5__tar.gz → 2.5.3__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.4.5 → cortex_local-2.5.3}/PKG-INFO +7 -1
- cortex_local-2.5.3/cortex/__init__.py +10 -0
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/cli.py +48 -8
- cortex_local-2.5.3/cortex/config.py +184 -0
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/core/cache.py +4 -1
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/core/chunker.py +1 -1
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/core/code_graph.py +53 -4
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/core/formatters.py +53 -3
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/core/global_graph.py +4 -1
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/core/notes_graph.py +5 -2
- cortex_local-2.5.3/cortex/db.py +79 -0
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/indexer.py +152 -85
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/mcp_server.py +138 -52
- cortex_local-2.5.3/cortex/watcher.py +108 -0
- cortex_local-2.5.3/hatch_build.py +16 -0
- {cortex_local-2.4.5 → cortex_local-2.5.3}/pyproject.toml +15 -0
- cortex_local-2.5.3/tests/test_stdio_init.py +69 -0
- cortex_local-2.4.5/cortex/config.py +0 -110
- cortex_local-2.4.5/cortex/core/__init__.py +0 -0
- cortex_local-2.4.5/cortex/watcher.py +0 -60
- {cortex_local-2.4.5 → cortex_local-2.5.3}/.gitignore +0 -0
- {cortex_local-2.4.5 → cortex_local-2.5.3}/README.md +0 -0
- {cortex_local-2.4.5/cortex → cortex_local-2.5.3/cortex/core}/__init__.py +0 -0
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/core/graph.py +0 -0
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/core/roslyn_analyzer.py +0 -0
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/core/roslyn_tool/CortexAnalyzer.csproj +0 -0
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/core/roslyn_tool/Program.cs +0 -0
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/embedder.py +0 -0
- {cortex_local-2.4.5 → cortex_local-2.5.3}/cortex/state.py +0 -0
- {cortex_local-2.4.5 → cortex_local-2.5.3}/tests/test_imports.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cortex-local
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.5.3
|
|
4
4
|
Summary: Local semantic search MCP server — notes + code, embedded Qdrant, fastembed
|
|
5
5
|
Requires-Python: >=3.11
|
|
6
6
|
Requires-Dist: click>=8.0.0
|
|
@@ -9,6 +9,12 @@ Requires-Dist: mcp[cli]>=2.0.0
|
|
|
9
9
|
Requires-Dist: networkx>=3.0
|
|
10
10
|
Requires-Dist: qdrant-client>=1.17.0
|
|
11
11
|
Requires-Dist: tqdm>=4.0
|
|
12
|
+
Requires-Dist: tree-sitter-c-sharp
|
|
13
|
+
Requires-Dist: tree-sitter-javascript
|
|
14
|
+
Requires-Dist: tree-sitter-kotlin
|
|
15
|
+
Requires-Dist: tree-sitter-python
|
|
16
|
+
Requires-Dist: tree-sitter-typescript
|
|
17
|
+
Requires-Dist: tree-sitter>=0.22
|
|
12
18
|
Requires-Dist: watchdog>=3.0
|
|
13
19
|
Description-Content-Type: text/markdown
|
|
14
20
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""cortex local package."""
|
|
2
|
+
|
|
3
|
+
import os as _os
|
|
4
|
+
|
|
5
|
+
# Installed wheels bundle core/ as cortex/core (hatch force-include). In a source
|
|
6
|
+
# checkout (pip install -e, tests) it lives at <repo>/core — expose it as cortex.core.
|
|
7
|
+
_here = _os.path.dirname(__file__)
|
|
8
|
+
_repo_root = _os.path.normpath(_os.path.join(_here, "..", ".."))
|
|
9
|
+
if not _os.path.isdir(_os.path.join(_here, "core")) and _os.path.isdir(_os.path.join(_repo_root, "core")):
|
|
10
|
+
__path__.append(_repo_root) # type: ignore[name-defined]
|
|
@@ -19,14 +19,25 @@ from .config import (
|
|
|
19
19
|
@click.version_option(VERSION, prog_name="cortex")
|
|
20
20
|
def cli() -> None:
|
|
21
21
|
"""cortex: local semantic search MCP server."""
|
|
22
|
+
# Windows consoles default to cp1252; never crash on non-ASCII output (e.g. `claude mcp get`).
|
|
23
|
+
for stream in (sys.stdout, sys.stderr):
|
|
24
|
+
try:
|
|
25
|
+
stream.reconfigure(errors="replace")
|
|
26
|
+
except Exception:
|
|
27
|
+
pass
|
|
22
28
|
|
|
23
29
|
|
|
24
30
|
@cli.command()
|
|
25
31
|
@click.argument("path", type=click.Path(exists=True, file_okay=False, resolve_path=True))
|
|
26
|
-
@click.option("--progress/--no-progress", default=
|
|
32
|
+
@click.option("--progress/--no-progress", default=None,
|
|
33
|
+
help="Show tqdm progress bar (default: only when stderr is a terminal).")
|
|
27
34
|
def index(path: str, progress: bool) -> None:
|
|
28
35
|
"""Index PATH (notes and/or code) into ~/.cortex."""
|
|
36
|
+
import logging
|
|
29
37
|
from .indexer import index_path
|
|
38
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout, force=True)
|
|
39
|
+
if progress is None:
|
|
40
|
+
progress = sys.stderr.isatty()
|
|
30
41
|
index_path(Path(path), show_progress=progress)
|
|
31
42
|
save_last_indexed_path(path)
|
|
32
43
|
|
|
@@ -34,8 +45,25 @@ def index(path: str, progress: bool) -> None:
|
|
|
34
45
|
@cli.command()
|
|
35
46
|
def serve() -> None:
|
|
36
47
|
"""Run cortex MCP server on stdio."""
|
|
37
|
-
|
|
38
|
-
|
|
48
|
+
# stdout is the MCP channel: startup failures go to stderr (shown by `claude mcp get` / logs)
|
|
49
|
+
# instead of dying silently as "Connection closed".
|
|
50
|
+
try:
|
|
51
|
+
from .mcp_server import run
|
|
52
|
+
except Exception:
|
|
53
|
+
import traceback
|
|
54
|
+
sys.stderr.write(f"cortex {VERSION}: failed to start (import error):\n{traceback.format_exc()}")
|
|
55
|
+
sys.stderr.write("Try reinstalling: pipx install --force cortex-local\n")
|
|
56
|
+
sys.exit(1)
|
|
57
|
+
try:
|
|
58
|
+
run()
|
|
59
|
+
except KeyboardInterrupt:
|
|
60
|
+
pass
|
|
61
|
+
except Exception:
|
|
62
|
+
import logging
|
|
63
|
+
import traceback
|
|
64
|
+
logging.getLogger("cortex").exception("server crashed")
|
|
65
|
+
sys.stderr.write(f"cortex {VERSION}: server error:\n{traceback.format_exc()}")
|
|
66
|
+
sys.exit(1)
|
|
39
67
|
|
|
40
68
|
|
|
41
69
|
@cli.command()
|
|
@@ -199,7 +227,7 @@ def install_cmd(scope: str) -> None:
|
|
|
199
227
|
capture_output=True, text=True,
|
|
200
228
|
)
|
|
201
229
|
if result.returncode == 0:
|
|
202
|
-
click.echo(f"Done. cortex is ready (scope: {scope})
|
|
230
|
+
click.echo(f"Done. cortex is ready (scope: {scope}) - restart Claude Code to activate.")
|
|
203
231
|
else:
|
|
204
232
|
click.echo(f"MCP registration failed. Run manually:\n {mcp_cmd}")
|
|
205
233
|
click.echo(result.stderr.strip())
|
|
@@ -231,17 +259,21 @@ def doctor_cmd() -> None:
|
|
|
231
259
|
click.echo(f"version {VERSION}")
|
|
232
260
|
|
|
233
261
|
cache_ok = MODELS_DIR.exists() and any(MODELS_DIR.iterdir())
|
|
234
|
-
click.echo(f"model cache {'ok' if cache_ok else 'missing
|
|
262
|
+
click.echo(f"model cache {'ok' if cache_ok else 'missing - run: cortex pull-models'}")
|
|
235
263
|
|
|
236
264
|
claude = shutil.which("claude")
|
|
237
265
|
mcp_status = "claude not found in PATH"
|
|
238
266
|
if claude:
|
|
239
267
|
r = subprocess.run([claude, "mcp", "get", "cortex"], capture_output=True, text=True)
|
|
240
268
|
if r.returncode == 0:
|
|
241
|
-
|
|
242
|
-
|
|
269
|
+
fields = {}
|
|
270
|
+
for line in r.stdout.splitlines():
|
|
271
|
+
key, sep, val = line.strip().partition(":")
|
|
272
|
+
if sep and key in ("Scope", "Status"):
|
|
273
|
+
fields[key] = val.strip()
|
|
274
|
+
mcp_status = ", ".join(f"{k.lower()}: {v}" for k, v in fields.items()) or "registered"
|
|
243
275
|
else:
|
|
244
|
-
mcp_status = "not registered
|
|
276
|
+
mcp_status = "not registered - run: cortex install"
|
|
245
277
|
click.echo(f"MCP {mcp_status}")
|
|
246
278
|
|
|
247
279
|
ws = get_active_workspace()
|
|
@@ -250,6 +282,14 @@ def doctor_cmd() -> None:
|
|
|
250
282
|
|
|
251
283
|
last = get_config()["index"].get("last_path") or "(none)"
|
|
252
284
|
click.echo(f"last indexed {last}")
|
|
285
|
+
from .config import read_index_meta
|
|
286
|
+
from .core.formatters import fmt_index_roots
|
|
287
|
+
roots = read_index_meta(ws).get("roots", {})
|
|
288
|
+
for line in fmt_index_roots(roots):
|
|
289
|
+
root = line.split(" ", 1)[0]
|
|
290
|
+
click.echo(f"root {line}{'' if Path(root).exists() else ' (missing on disk)'}")
|
|
291
|
+
if indexed and not roots:
|
|
292
|
+
click.echo("note index has no recorded roots - run: cortex index <path>")
|
|
253
293
|
|
|
254
294
|
claude_json = Path.home() / ".claude.json"
|
|
255
295
|
if claude_json.exists():
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Paths and constants for cortex local mode."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import tomllib
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
CORTEX_DIR = Path.home() / ".cortex"
|
|
10
|
+
MODELS_DIR = CORTEX_DIR / "models"
|
|
11
|
+
WORKSPACES_DIR = CORTEX_DIR / "workspaces"
|
|
12
|
+
ACTIVE_WORKSPACE_FILE = CORTEX_DIR / "active_workspace"
|
|
13
|
+
CONFIG_FILE = CORTEX_DIR / "config.toml"
|
|
14
|
+
|
|
15
|
+
_CONFIG_DEFAULTS: dict = {
|
|
16
|
+
"watch": {"enabled": True, "debounce_seconds": 2.0},
|
|
17
|
+
"index": {"last_path": "", "exclude_patterns": []},
|
|
18
|
+
}
|
|
19
|
+
_config_cache: dict | None = None
|
|
20
|
+
|
|
21
|
+
EMBED_MODEL = "nomic-ai/nomic-embed-text-v1.5"
|
|
22
|
+
VECTOR_SIZE = 768
|
|
23
|
+
PPR_THRESHOLD = float(os.environ.get("PPR_THRESHOLD", "0.03"))
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from importlib.metadata import version
|
|
27
|
+
VERSION = version("cortex-local")
|
|
28
|
+
except Exception:
|
|
29
|
+
VERSION = "dev"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_active_workspace() -> str:
|
|
33
|
+
if ACTIVE_WORKSPACE_FILE.exists():
|
|
34
|
+
return ACTIVE_WORKSPACE_FILE.read_text().strip() or "default"
|
|
35
|
+
return "default"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def set_active_workspace(name: str) -> None:
|
|
39
|
+
CORTEX_DIR.mkdir(parents=True, exist_ok=True)
|
|
40
|
+
ACTIVE_WORKSPACE_FILE.write_text(name)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def get_workspace_dir(name: str | None = None) -> Path:
|
|
44
|
+
return WORKSPACES_DIR / (name or get_active_workspace())
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def qdrant_path(ws: str | None = None) -> str:
|
|
48
|
+
return str(get_workspace_dir(ws) / "qdrant")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def data_dir(ws: str | None = None) -> Path:
|
|
52
|
+
return get_workspace_dir(ws)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cache_file(ws: str | None = None) -> Path:
|
|
56
|
+
return get_workspace_dir(ws) / "embed_cache.json"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def stats_file(ws: str | None = None) -> Path:
|
|
60
|
+
return get_workspace_dir(ws) / "stats.json"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Bump when stored payloads change incompatibly; older indexes are wiped and rebuilt.
|
|
64
|
+
# 2: POSIX payload paths (2.4.5 stored backslashes on Windows).
|
|
65
|
+
# 3: `root` payload + root-scoped point ids / cache keys (incremental deletes, multi-root).
|
|
66
|
+
INDEX_SCHEMA = 3
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def index_meta_file(ws: str | None = None) -> Path:
|
|
70
|
+
return get_workspace_dir(ws) / "index_meta.json"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_index_meta(ws: str | None = None) -> dict:
|
|
74
|
+
try:
|
|
75
|
+
return json.loads(index_meta_file(ws).read_text(encoding="utf-8"))
|
|
76
|
+
except Exception:
|
|
77
|
+
return {}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def write_index_meta(meta: dict, ws: str | None = None) -> None:
|
|
81
|
+
f = index_meta_file(ws)
|
|
82
|
+
f.parent.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
f.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def ensure_index_schema(ws: str | None = None) -> bool:
|
|
87
|
+
"""Wipe an index built with an older schema. Returns True if a wipe happened.
|
|
88
|
+
|
|
89
|
+
Must run before any QdrantClient opens the workspace.
|
|
90
|
+
"""
|
|
91
|
+
meta = read_index_meta(ws)
|
|
92
|
+
if meta.get("schema") == INDEX_SCHEMA:
|
|
93
|
+
return False
|
|
94
|
+
ws_dir = get_workspace_dir(ws)
|
|
95
|
+
wiped = False
|
|
96
|
+
qdir = ws_dir / "qdrant"
|
|
97
|
+
if qdir.exists():
|
|
98
|
+
shutil.rmtree(qdir, ignore_errors=True)
|
|
99
|
+
wiped = True
|
|
100
|
+
for f in [ws_dir / "embed_cache.json", *ws_dir.glob("graph_*.json")]:
|
|
101
|
+
if f.exists():
|
|
102
|
+
f.unlink()
|
|
103
|
+
wiped = True
|
|
104
|
+
meta = {"schema": INDEX_SCHEMA, "roots": {}}
|
|
105
|
+
write_index_meta(meta, ws)
|
|
106
|
+
if wiped:
|
|
107
|
+
import logging
|
|
108
|
+
logging.getLogger("cortex.config").warning(
|
|
109
|
+
"index schema changed (now v%d): old index wiped - run: cortex index <path>", INDEX_SCHEMA)
|
|
110
|
+
return wiped
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_config() -> dict:
|
|
114
|
+
global _config_cache
|
|
115
|
+
if _config_cache is not None:
|
|
116
|
+
return _config_cache
|
|
117
|
+
cfg: dict = {k: dict(v) for k, v in _CONFIG_DEFAULTS.items()}
|
|
118
|
+
if CONFIG_FILE.exists():
|
|
119
|
+
try:
|
|
120
|
+
loaded = tomllib.loads(CONFIG_FILE.read_text())
|
|
121
|
+
for section, vals in loaded.items():
|
|
122
|
+
if section in cfg and isinstance(vals, dict):
|
|
123
|
+
cfg[section].update(vals)
|
|
124
|
+
except Exception as e:
|
|
125
|
+
import logging
|
|
126
|
+
logging.getLogger("cortex.config").warning("config.toml unreadable (%s): %s", CONFIG_FILE, e)
|
|
127
|
+
_config_cache = cfg
|
|
128
|
+
return cfg
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _toml_value(v) -> str:
|
|
132
|
+
if isinstance(v, bool):
|
|
133
|
+
return "true" if v else "false"
|
|
134
|
+
if isinstance(v, (int, float)):
|
|
135
|
+
return repr(v)
|
|
136
|
+
if isinstance(v, (list, tuple)):
|
|
137
|
+
return "[" + ", ".join(_toml_value(x) for x in v) + "]"
|
|
138
|
+
# JSON string escaping is valid TOML basic-string escaping (handles \\ and quotes).
|
|
139
|
+
return json.dumps(str(v))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def save_config(cfg: dict) -> None:
|
|
143
|
+
"""Write config.toml from a {section: {key: value}} dict."""
|
|
144
|
+
global _config_cache
|
|
145
|
+
CORTEX_DIR.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
lines: list = []
|
|
147
|
+
for section, vals in cfg.items():
|
|
148
|
+
if not isinstance(vals, dict):
|
|
149
|
+
continue
|
|
150
|
+
lines.append(f"[{section}]")
|
|
151
|
+
lines.extend(f"{k} = {_toml_value(v)}" for k, v in vals.items())
|
|
152
|
+
lines.append("")
|
|
153
|
+
CONFIG_FILE.write_text("\n".join(lines), encoding="utf-8")
|
|
154
|
+
_config_cache = None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def save_last_indexed_path(path: str) -> None:
|
|
158
|
+
"""Record PATH as last indexed (stored as POSIX)."""
|
|
159
|
+
cfg = {k: dict(v) for k, v in get_config().items()}
|
|
160
|
+
cfg["index"]["last_path"] = Path(path).resolve().as_posix()
|
|
161
|
+
cfg["index"].pop("roots", None) # 2.4.6 kept roots here; now per workspace in index_meta.json
|
|
162
|
+
save_config(cfg)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def get_roots(ws: str | None = None) -> list:
|
|
166
|
+
"""Index roots of a workspace that still exist on disk (from index_meta.json)."""
|
|
167
|
+
roots = list(read_index_meta(ws).get("roots", {}))
|
|
168
|
+
return [r for r in roots if Path(r).exists()]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def migrate_legacy() -> None:
|
|
172
|
+
"""Move old flat ~/.cortex/ layout into ~/.cortex/workspaces/default/ on first run."""
|
|
173
|
+
old_qdrant = CORTEX_DIR / "qdrant"
|
|
174
|
+
default_dir = WORKSPACES_DIR / "default"
|
|
175
|
+
if not old_qdrant.exists() or WORKSPACES_DIR.exists():
|
|
176
|
+
return
|
|
177
|
+
default_dir.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
shutil.move(str(old_qdrant), str(default_dir / "qdrant"))
|
|
179
|
+
for fname in ("embed_cache.json", "stats.json"):
|
|
180
|
+
src = CORTEX_DIR / fname
|
|
181
|
+
if src.exists():
|
|
182
|
+
shutil.move(str(src), str(default_dir / fname))
|
|
183
|
+
for f in CORTEX_DIR.glob("graph_*.json"):
|
|
184
|
+
shutil.move(str(f), str(default_dir / f.name))
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Mtime-based embedding cache — skip re-embedding unchanged files on full reindex."""
|
|
3
3
|
|
|
4
|
+
import logging
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
9
|
+
log = logging.getLogger("cortex.cache")
|
|
10
|
+
|
|
8
11
|
|
|
9
12
|
def _default_cache_file() -> Path:
|
|
10
13
|
repos_config = os.environ.get("REPOS_CONFIG", "/app/data/repos.json")
|
|
@@ -33,4 +36,4 @@ def save_cache(section: str, data: dict, cache_file: Path | None = None) -> None
|
|
|
33
36
|
existing[section] = data
|
|
34
37
|
cache_file.write_text(json.dumps(existing))
|
|
35
38
|
except Exception as e:
|
|
36
|
-
|
|
39
|
+
log.warning(f"warn: cache save failed: {e}")
|
|
@@ -261,7 +261,7 @@ def chunk_file(path: Path, repo_name: str, base_dir: Path | None = None, github_
|
|
|
261
261
|
ext = path.suffix
|
|
262
262
|
base = base_dir if base_dir else path.parent
|
|
263
263
|
try:
|
|
264
|
-
rel =
|
|
264
|
+
rel = path.relative_to(base).as_posix()
|
|
265
265
|
except ValueError:
|
|
266
266
|
rel = path.name
|
|
267
267
|
language = LANG_MAP.get(ext, ext.lstrip("."))
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Code graph extraction — import/call/inheritance edges, centrality, Louvain communities."""
|
|
3
3
|
|
|
4
|
+
import logging
|
|
4
5
|
import json
|
|
5
6
|
import re
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
9
|
+
log = logging.getLogger("cortex.graph")
|
|
10
|
+
|
|
8
11
|
try:
|
|
9
12
|
import networkx as nx
|
|
10
13
|
_NX_AVAILABLE = True
|
|
11
14
|
except ImportError:
|
|
12
15
|
_NX_AVAILABLE = False
|
|
13
|
-
|
|
16
|
+
log.warning("Warning: networkx not installed - graph features disabled")
|
|
14
17
|
|
|
15
18
|
_IMPORT_RE: dict = {
|
|
16
19
|
"py": re.compile(r'^(?:from|import)\s+([\w.]+)', re.MULTILINE),
|
|
@@ -170,7 +173,53 @@ def _extract_named_imports_map(src: str) -> dict:
|
|
|
170
173
|
return result
|
|
171
174
|
|
|
172
175
|
|
|
173
|
-
|
|
176
|
+
_KT_IMPORT_RE = re.compile(r'^import\s+([\w.]+?)(?:\s+as\s+(\w+))?\s*$', re.MULTILINE)
|
|
177
|
+
_KT_QUALIFIED_RE = re.compile(r'\b([A-Z]\w*)\s*(?:\.|::)')
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _extract_kotlin_call_targets(src: str, all_files: set, rel: str, symbol_to_file: dict) -> list:
|
|
181
|
+
"""Kotlin call edges: imported names that are used, plus same-package types that are used.
|
|
182
|
+
|
|
183
|
+
"Used" = called (`Foo(`, `bar(`) or referenced as a receiver (`Foo.bar`, `Foo::class`).
|
|
184
|
+
Same-package (same directory) resolution only considers capitalized names — classes and
|
|
185
|
+
objects — because lowercase method names collide too easily.
|
|
186
|
+
"""
|
|
187
|
+
called = {m.group(1) for m in _CALL_RE.finditer(src)}
|
|
188
|
+
called |= {m.group(1) for m in _KT_QUALIFIED_RE.finditer(src)}
|
|
189
|
+
targets: set = set()
|
|
190
|
+
imported: set = set()
|
|
191
|
+
for m in _KT_IMPORT_RE.finditer(src):
|
|
192
|
+
fq, alias = m.group(1), m.group(2)
|
|
193
|
+
if fq.endswith("."):
|
|
194
|
+
continue
|
|
195
|
+
simple = fq.rsplit(".", 1)[-1]
|
|
196
|
+
imported.add(simple)
|
|
197
|
+
if (alias or simple) not in called:
|
|
198
|
+
continue
|
|
199
|
+
resolved = _resolve_import(rel, fq, all_files, Path(rel).suffix)
|
|
200
|
+
if not resolved:
|
|
201
|
+
# top-level function or nested symbol: resolve by declared name
|
|
202
|
+
resolved = symbol_to_file.get(simple)
|
|
203
|
+
if resolved and resolved != rel:
|
|
204
|
+
targets.add(resolved)
|
|
205
|
+
rel_dir = Path(rel).parent
|
|
206
|
+
for name in called:
|
|
207
|
+
if name in imported or not name[:1].isupper():
|
|
208
|
+
continue
|
|
209
|
+
target = symbol_to_file.get(name)
|
|
210
|
+
if target and target != rel and Path(target).parent == rel_dir and Path(target).suffix in (".kt", ".kts"):
|
|
211
|
+
targets.add(target)
|
|
212
|
+
return list(targets)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _extract_call_targets(path: Path, ext: str, all_files: set, rel: str,
|
|
216
|
+
symbol_to_file: dict | None = None) -> list:
|
|
217
|
+
if ext in (".kt", ".kts"):
|
|
218
|
+
try:
|
|
219
|
+
src = path.read_text(encoding="utf-8", errors="replace")
|
|
220
|
+
except Exception:
|
|
221
|
+
return []
|
|
222
|
+
return _extract_kotlin_call_targets(src, all_files, rel, symbol_to_file or {})
|
|
174
223
|
if ext not in (".js", ".jsx", ".ts", ".tsx", ".svelte"):
|
|
175
224
|
return []
|
|
176
225
|
try:
|
|
@@ -283,7 +332,7 @@ def build_code_graph(repo_path: Path, all_files: set):
|
|
|
283
332
|
resolved = _resolve_import(rel, imp, all_files, ext)
|
|
284
333
|
if resolved and resolved != rel:
|
|
285
334
|
_add_edge(G, rel, resolved, "import")
|
|
286
|
-
for target in _extract_call_targets(path, ext, all_files, rel):
|
|
335
|
+
for target in _extract_call_targets(path, ext, all_files, rel, symbol_to_file):
|
|
287
336
|
_add_edge(G, rel, target, "call")
|
|
288
337
|
for target in _extract_inheritance_targets(path, ext, all_files, rel, symbol_to_file):
|
|
289
338
|
_add_edge(G, rel, target, "inherits")
|
|
@@ -333,7 +382,7 @@ def persist_code_graph(G, metadata: dict, repo_name: str, data_dir: Path) -> Non
|
|
|
333
382
|
}
|
|
334
383
|
out = data_dir / f"graph_{repo_name}.json"
|
|
335
384
|
out.write_text(json.dumps(data))
|
|
336
|
-
|
|
385
|
+
log.info(
|
|
337
386
|
f" graph: {len(data['nodes'])} nodes, {len(data['edges'])} edges "
|
|
338
387
|
f"(import:{edge_counts['import']} call:{edge_counts['call']} "
|
|
339
388
|
f"inherits:{edge_counts['inherits']}) -> {out.name}"
|
|
@@ -1,8 +1,53 @@
|
|
|
1
1
|
"""Shared result formatters for MCP search tools — used by server and local modes."""
|
|
2
2
|
|
|
3
|
+
import time
|
|
3
4
|
from datetime import datetime, timezone
|
|
4
5
|
|
|
5
6
|
|
|
7
|
+
def strip_embed_header(text: str) -> str:
|
|
8
|
+
"""Drop the synthetic `# repo/path (lines)` / path-token / framework lines the chunker
|
|
9
|
+
prepends for embedding — they only cost tokens in a result."""
|
|
10
|
+
lines = text.split("\n")
|
|
11
|
+
i = 0
|
|
12
|
+
while i < len(lines) and lines[i].startswith("# "):
|
|
13
|
+
i += 1
|
|
14
|
+
if i == 0:
|
|
15
|
+
return text
|
|
16
|
+
while i < len(lines) and not lines[i].strip():
|
|
17
|
+
i += 1
|
|
18
|
+
return "\n".join(lines[i:])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _display_path(p: dict, fallback_prefix: str = "") -> str:
|
|
22
|
+
"""Absolute POSIX path when the index recorded a root (local mode), else repo-relative."""
|
|
23
|
+
root = p.get("root")
|
|
24
|
+
if root:
|
|
25
|
+
return f"{root}/{p['file']}"
|
|
26
|
+
return f"{fallback_prefix}/{p['file']}" if fallback_prefix else p["file"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def fmt_age(seconds: float) -> str:
|
|
30
|
+
seconds = max(0, int(seconds))
|
|
31
|
+
if seconds < 60:
|
|
32
|
+
return f"{seconds}s"
|
|
33
|
+
if seconds < 3600:
|
|
34
|
+
return f"{seconds // 60}m"
|
|
35
|
+
if seconds < 86400:
|
|
36
|
+
return f"{seconds // 3600}h"
|
|
37
|
+
return f"{seconds // 86400}d"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fmt_index_roots(roots: dict, now: float | None = None) -> list:
|
|
41
|
+
"""One line per index root: `path @head (age ago) notes=N code=M`."""
|
|
42
|
+
now = now or time.time()
|
|
43
|
+
out = []
|
|
44
|
+
for root, m in sorted(roots.items()):
|
|
45
|
+
head = f" @{m['git_head']}" if m.get("git_head") else ""
|
|
46
|
+
age = f" ({fmt_age(now - m['indexed_at'])} ago)" if m.get("indexed_at") else ""
|
|
47
|
+
out.append(f"{root}{head}{age} notes={m.get('notes', 0)} code={m.get('code', 0)}")
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
6
51
|
def format_notes_parts(results) -> tuple:
|
|
7
52
|
"""Build parts list from note search results.
|
|
8
53
|
|
|
@@ -18,8 +63,12 @@ def format_notes_parts(results) -> tuple:
|
|
|
18
63
|
p = r.payload
|
|
19
64
|
tags = p.get("tags", [])
|
|
20
65
|
tag_str = f" `{'`, `'.join(tags)}`" if tags else ""
|
|
66
|
+
text = p["text"]
|
|
67
|
+
heading = p.get("heading", "")
|
|
68
|
+
if heading and text.startswith(heading + "\n"):
|
|
69
|
+
text = text[len(heading):].lstrip("\n") # chunk text repeats the heading
|
|
21
70
|
parts.append(
|
|
22
|
-
f"**{p
|
|
71
|
+
f"**{_display_path(p)} > {heading}** (score: {round(r.score, 3)}){tag_str}\n\n{text}"
|
|
23
72
|
)
|
|
24
73
|
matched_files.append(p.get("file", ""))
|
|
25
74
|
matched_scores.append(r.score)
|
|
@@ -38,7 +87,7 @@ def format_code_results(deduped) -> str:
|
|
|
38
87
|
centrality = file_meta.get("centrality")
|
|
39
88
|
community = file_meta.get("community_id")
|
|
40
89
|
header = (
|
|
41
|
-
f"**{p
|
|
90
|
+
f"**{_display_path(p, p.get('repo', ''))}** "
|
|
42
91
|
f"lines {p['start_line']}-{p['end_line']} "
|
|
43
92
|
f"({p.get('language', '')}) — score: {round(boosted_score, 3)}"
|
|
44
93
|
)
|
|
@@ -47,7 +96,8 @@ def format_code_results(deduped) -> str:
|
|
|
47
96
|
if community is not None:
|
|
48
97
|
header += f" · community: {community}"
|
|
49
98
|
url = p.get("github_url", "")
|
|
50
|
-
|
|
99
|
+
text = strip_embed_header(p["text"])
|
|
100
|
+
parts.append(f"{header}\n{url}\n\n{text}" if url else f"{header}\n\n{text}")
|
|
51
101
|
return "\n\n---\n\n".join(parts)
|
|
52
102
|
|
|
53
103
|
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Global cross-repo graph — scans root config files for inter-repo references."""
|
|
3
3
|
|
|
4
|
+
import logging
|
|
4
5
|
import json
|
|
5
6
|
import re
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
9
|
+
log = logging.getLogger("cortex.graph")
|
|
10
|
+
|
|
8
11
|
try:
|
|
9
12
|
import networkx as nx
|
|
10
13
|
_NX_AVAILABLE = True
|
|
@@ -84,4 +87,4 @@ def build_global_graph(repo_paths: dict, data_dir: Path) -> None:
|
|
|
84
87
|
}
|
|
85
88
|
out = data_dir / "graph_global.json"
|
|
86
89
|
out.write_text(json.dumps(data))
|
|
87
|
-
|
|
90
|
+
log.info(f" global graph: {len(repo_names)} repos, {len(data['edges'])} cross-repo edges -> {out.name}")
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Notes graph — Obsidian wikilink edges and Personalized PageRank augmentation."""
|
|
3
3
|
|
|
4
|
+
import logging
|
|
4
5
|
import json
|
|
5
6
|
import re
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
|
|
9
|
+
log = logging.getLogger("cortex.graph")
|
|
10
|
+
|
|
8
11
|
try:
|
|
9
12
|
import networkx as nx
|
|
10
13
|
_NX_AVAILABLE = True
|
|
11
14
|
except ImportError:
|
|
12
15
|
_NX_AVAILABLE = False
|
|
13
|
-
|
|
16
|
+
log.warning("Warning: networkx not installed - graph features disabled")
|
|
14
17
|
|
|
15
18
|
_WIKILINK_RE = re.compile(r'\[\[([^\[\]|#]+?)(?:\|[^\[\]]+)?\]\]')
|
|
16
19
|
|
|
@@ -52,7 +55,7 @@ def persist_notes_graph(G, data_dir: Path) -> None:
|
|
|
52
55
|
}
|
|
53
56
|
out = data_dir / "graph_notes.json"
|
|
54
57
|
out.write_text(json.dumps(data))
|
|
55
|
-
|
|
58
|
+
log.info(f" notes graph: {len(data['nodes'])} nodes, {len(data['edges'])} wikilink edges -> {out.name}")
|
|
56
59
|
|
|
57
60
|
|
|
58
61
|
def ppr_augment(matched_files: list, scores: list, graph_path: Path, threshold: float = 0.03, _return_reason: bool = False):
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Shared embedded-Qdrant client for the active workspace.
|
|
2
|
+
|
|
3
|
+
Embedded Qdrant holds an exclusive file lock per storage folder, so a process must use one
|
|
4
|
+
client per workspace. The MCP server searches while the watcher / reindex() thread writes;
|
|
5
|
+
every client call is serialized through one lock (local mode is not thread-safe), and whole
|
|
6
|
+
index runs are serialized through `index_lock`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import atexit
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
|
|
13
|
+
from qdrant_client import QdrantClient
|
|
14
|
+
|
|
15
|
+
from .config import data_dir, ensure_index_schema, get_active_workspace, migrate_legacy, qdrant_path
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("cortex.db")
|
|
18
|
+
|
|
19
|
+
_lock = threading.RLock()
|
|
20
|
+
index_lock = threading.Lock()
|
|
21
|
+
_client: QdrantClient | None = None
|
|
22
|
+
_client_ws: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Serialized:
|
|
26
|
+
"""Proxy that runs every client method under the shared lock."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, client: QdrantClient):
|
|
29
|
+
self._client = client
|
|
30
|
+
|
|
31
|
+
def __getattr__(self, name):
|
|
32
|
+
attr = getattr(self._client, name)
|
|
33
|
+
if not callable(attr):
|
|
34
|
+
return attr
|
|
35
|
+
|
|
36
|
+
def call(*args, **kwargs):
|
|
37
|
+
with _lock:
|
|
38
|
+
return attr(*args, **kwargs)
|
|
39
|
+
return call
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def client() -> _Serialized:
|
|
43
|
+
"""Client for the active workspace; reopened when the workspace changes."""
|
|
44
|
+
global _client, _client_ws
|
|
45
|
+
with _lock:
|
|
46
|
+
ws = get_active_workspace()
|
|
47
|
+
if _client is None or ws != _client_ws:
|
|
48
|
+
reset()
|
|
49
|
+
migrate_legacy()
|
|
50
|
+
ensure_index_schema(ws)
|
|
51
|
+
data_dir(ws).mkdir(parents=True, exist_ok=True)
|
|
52
|
+
try:
|
|
53
|
+
_client = QdrantClient(path=qdrant_path(ws))
|
|
54
|
+
except Exception as e:
|
|
55
|
+
if "already" in str(e).lower() or "lock" in str(e).lower():
|
|
56
|
+
raise SystemExit(
|
|
57
|
+
"Error: Qdrant database is locked by another process (cortex serve is running).\n"
|
|
58
|
+
"Stop cortex serve first, or use the reindex() MCP tool from Claude Code instead."
|
|
59
|
+
) from e
|
|
60
|
+
raise
|
|
61
|
+
_client_ws = ws
|
|
62
|
+
log.info("opened index for workspace '%s'", ws)
|
|
63
|
+
return _Serialized(_client)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def reset() -> None:
|
|
67
|
+
"""Close the current client (workspace switch, shutdown)."""
|
|
68
|
+
global _client, _client_ws
|
|
69
|
+
with _lock:
|
|
70
|
+
if _client is not None:
|
|
71
|
+
try:
|
|
72
|
+
_client.close()
|
|
73
|
+
except Exception:
|
|
74
|
+
pass
|
|
75
|
+
_client = None
|
|
76
|
+
_client_ws = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
atexit.register(reset)
|