cortex-local 2.4.4__tar.gz → 2.5.2__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.4 → cortex_local-2.5.2}/PKG-INFO +7 -1
- cortex_local-2.5.2/cortex/__init__.py +10 -0
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/cli.py +74 -17
- cortex_local-2.5.2/cortex/config.py +184 -0
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/core/cache.py +4 -1
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/core/chunker.py +1 -1
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/core/code_graph.py +53 -4
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/core/formatters.py +53 -3
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/core/global_graph.py +4 -1
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/core/notes_graph.py +5 -2
- cortex_local-2.5.2/cortex/db.py +79 -0
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/embedder.py +3 -3
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/indexer.py +152 -85
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/mcp_server.py +138 -52
- cortex_local-2.5.2/cortex/watcher.py +108 -0
- cortex_local-2.5.2/hatch_build.py +16 -0
- {cortex_local-2.4.4 → cortex_local-2.5.2}/pyproject.toml +15 -0
- cortex_local-2.5.2/tests/test_stdio_init.py +69 -0
- cortex_local-2.4.4/cortex/config.py +0 -109
- cortex_local-2.4.4/cortex/core/__init__.py +0 -0
- cortex_local-2.4.4/cortex/watcher.py +0 -60
- {cortex_local-2.4.4 → cortex_local-2.5.2}/.gitignore +0 -0
- {cortex_local-2.4.4 → cortex_local-2.5.2}/README.md +0 -0
- {cortex_local-2.4.4/cortex → cortex_local-2.5.2/cortex/core}/__init__.py +0 -0
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/core/graph.py +0 -0
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/core/roslyn_analyzer.py +0 -0
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/core/roslyn_tool/CortexAnalyzer.csproj +0 -0
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/core/roslyn_tool/Program.cs +0 -0
- {cortex_local-2.4.4 → cortex_local-2.5.2}/cortex/state.py +0 -0
- {cortex_local-2.4.4 → cortex_local-2.5.2}/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.2
|
|
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]
|
|
@@ -11,7 +11,7 @@ import click
|
|
|
11
11
|
|
|
12
12
|
from .config import (
|
|
13
13
|
WORKSPACES_DIR, get_active_workspace, set_active_workspace, get_workspace_dir,
|
|
14
|
-
data_dir, save_last_indexed_path, qdrant_path, get_config, VERSION,
|
|
14
|
+
data_dir, save_last_indexed_path, qdrant_path, get_config, VERSION, MODELS_DIR,
|
|
15
15
|
)
|
|
16
16
|
|
|
17
17
|
|
|
@@ -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()
|
|
@@ -161,18 +189,25 @@ def search_log_cmd(top: int) -> None:
|
|
|
161
189
|
click.echo(f" {q}")
|
|
162
190
|
|
|
163
191
|
|
|
164
|
-
|
|
192
|
+
_SYSTEM_PREFIXES = [Path(p) for p in ("/usr", "/bin", "/etc", "/sbin", "/var", r"C:\Windows")]
|
|
165
193
|
_SCOPE_CHOICES = click.Choice(["user", "local", "project"])
|
|
166
194
|
|
|
167
195
|
|
|
196
|
+
def _is_system_dir(p: Path) -> bool:
|
|
197
|
+
r = p.resolve()
|
|
198
|
+
if r == Path("/"):
|
|
199
|
+
return True
|
|
200
|
+
return any(prefix.exists() and r.is_relative_to(prefix) for prefix in _SYSTEM_PREFIXES)
|
|
201
|
+
|
|
202
|
+
|
|
168
203
|
@cli.command("install")
|
|
169
204
|
@click.option("--scope", default="user", type=_SCOPE_CHOICES, show_default=True,
|
|
170
205
|
help="MCP registration scope.")
|
|
171
206
|
def install_cmd(scope: str) -> None:
|
|
172
207
|
"""Download embedding models and register cortex MCP in Claude Code."""
|
|
173
208
|
from .embedder import pull_models as _pull
|
|
174
|
-
cwd =
|
|
175
|
-
if scope != "user" and cwd
|
|
209
|
+
cwd = Path.cwd()
|
|
210
|
+
if scope != "user" and _is_system_dir(cwd):
|
|
176
211
|
click.echo(f"Warning: cwd looks like a system directory ({cwd}). Use --scope user to register globally.")
|
|
177
212
|
click.echo("Downloading embedding models...")
|
|
178
213
|
_pull()
|
|
@@ -192,7 +227,7 @@ def install_cmd(scope: str) -> None:
|
|
|
192
227
|
capture_output=True, text=True,
|
|
193
228
|
)
|
|
194
229
|
if result.returncode == 0:
|
|
195
|
-
click.echo(f"Done. cortex is ready (scope: {scope})
|
|
230
|
+
click.echo(f"Done. cortex is ready (scope: {scope}) - restart Claude Code to activate.")
|
|
196
231
|
else:
|
|
197
232
|
click.echo(f"MCP registration failed. Run manually:\n {mcp_cmd}")
|
|
198
233
|
click.echo(result.stderr.strip())
|
|
@@ -223,20 +258,22 @@ def doctor_cmd() -> None:
|
|
|
223
258
|
"""Show cortex status: version, model cache, MCP registration, workspace, index."""
|
|
224
259
|
click.echo(f"version {VERSION}")
|
|
225
260
|
|
|
226
|
-
|
|
227
|
-
cache_ok
|
|
228
|
-
click.echo(f"model cache {'ok' if cache_ok else 'missing — run: cortex pull-models'}")
|
|
261
|
+
cache_ok = MODELS_DIR.exists() and any(MODELS_DIR.iterdir())
|
|
262
|
+
click.echo(f"model cache {'ok' if cache_ok else 'missing - run: cortex pull-models'}")
|
|
229
263
|
|
|
230
264
|
claude = shutil.which("claude")
|
|
231
265
|
mcp_status = "claude not found in PATH"
|
|
232
266
|
if claude:
|
|
233
|
-
r = subprocess.run([claude, "mcp", "
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
267
|
+
r = subprocess.run([claude, "mcp", "get", "cortex"], capture_output=True, text=True)
|
|
268
|
+
if r.returncode == 0:
|
|
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"
|
|
238
275
|
else:
|
|
239
|
-
mcp_status = "not registered
|
|
276
|
+
mcp_status = "not registered - run: cortex install"
|
|
240
277
|
click.echo(f"MCP {mcp_status}")
|
|
241
278
|
|
|
242
279
|
ws = get_active_workspace()
|
|
@@ -245,3 +282,23 @@ def doctor_cmd() -> None:
|
|
|
245
282
|
|
|
246
283
|
last = get_config()["index"].get("last_path") or "(none)"
|
|
247
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>")
|
|
293
|
+
|
|
294
|
+
claude_json = Path.home() / ".claude.json"
|
|
295
|
+
if claude_json.exists():
|
|
296
|
+
try:
|
|
297
|
+
projects = json.loads(claude_json.read_text()).get("projects", {})
|
|
298
|
+
stale = [p for p, v in projects.items() if "cortex" in v.get("mcpServers", {})]
|
|
299
|
+
if stale:
|
|
300
|
+
click.echo("\nStale local registrations (run from each dir to remove):")
|
|
301
|
+
for p in stale:
|
|
302
|
+
click.echo(f" cd {p} && claude mcp remove cortex --scope local")
|
|
303
|
+
except Exception:
|
|
304
|
+
pass
|
|
@@ -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):
|