cortex-local 2.4.5__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.
Files changed (30) hide show
  1. {cortex_local-2.4.5 → cortex_local-2.5.2}/PKG-INFO +7 -1
  2. cortex_local-2.5.2/cortex/__init__.py +10 -0
  3. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/cli.py +48 -8
  4. cortex_local-2.5.2/cortex/config.py +184 -0
  5. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/core/cache.py +4 -1
  6. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/core/chunker.py +1 -1
  7. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/core/code_graph.py +53 -4
  8. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/core/formatters.py +53 -3
  9. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/core/global_graph.py +4 -1
  10. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/core/notes_graph.py +5 -2
  11. cortex_local-2.5.2/cortex/db.py +79 -0
  12. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/indexer.py +152 -85
  13. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/mcp_server.py +138 -52
  14. cortex_local-2.5.2/cortex/watcher.py +108 -0
  15. cortex_local-2.5.2/hatch_build.py +16 -0
  16. {cortex_local-2.4.5 → cortex_local-2.5.2}/pyproject.toml +15 -0
  17. cortex_local-2.5.2/tests/test_stdio_init.py +69 -0
  18. cortex_local-2.4.5/cortex/config.py +0 -110
  19. cortex_local-2.4.5/cortex/core/__init__.py +0 -0
  20. cortex_local-2.4.5/cortex/watcher.py +0 -60
  21. {cortex_local-2.4.5 → cortex_local-2.5.2}/.gitignore +0 -0
  22. {cortex_local-2.4.5 → cortex_local-2.5.2}/README.md +0 -0
  23. {cortex_local-2.4.5/cortex → cortex_local-2.5.2/cortex/core}/__init__.py +0 -0
  24. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/core/graph.py +0 -0
  25. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/core/roslyn_analyzer.py +0 -0
  26. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/core/roslyn_tool/CortexAnalyzer.csproj +0 -0
  27. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/core/roslyn_tool/Program.cs +0 -0
  28. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/embedder.py +0 -0
  29. {cortex_local-2.4.5 → cortex_local-2.5.2}/cortex/state.py +0 -0
  30. {cortex_local-2.4.5 → 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.4.5
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]
@@ -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=True, help="Show tqdm progress bar.")
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
- from .mcp_server import run
38
- run()
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}) — restart Claude Code to activate.")
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 — run: cortex pull-models'}")
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
- detail = r.stdout.strip().splitlines()[0] if r.stdout.strip() else "registered"
242
- mcp_status = detail
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 — run: cortex install"
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
- print(f"warn: cache save failed: {e}", flush=True)
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 = str(path.relative_to(base))
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
- print("Warning: networkx not installed — graph features disabled")
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
- def _extract_call_targets(path: Path, ext: str, all_files: set, rel: str) -> list:
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
- print(
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['file']} > {p['heading']}** (score: {round(r.score, 3)}){tag_str}\n\n{p['text']}"
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['repo']}/{p['file']}** "
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
- parts.append(f"{header}\n{url}\n\n{p['text']}" if url else f"{header}\n\n{p['text']}")
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
- print(f" global graph: {len(repo_names)} repos, {len(data['edges'])} cross-repo edges -> {out.name}")
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
- print("Warning: networkx not installed — graph features disabled")
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
- print(f" notes graph: {len(data['nodes'])} nodes, {len(data['edges'])} wikilink edges -> {out.name}")
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)