cortex-local 2.1.16__tar.gz → 2.2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortex-local
3
- Version: 2.1.16
3
+ Version: 2.2.1
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
@@ -8,8 +8,8 @@ Requires-Dist: fastembed>=0.4.0
8
8
  Requires-Dist: mcp[cli]>=1.0.0
9
9
  Requires-Dist: networkx>=3.0
10
10
  Requires-Dist: qdrant-client>=1.17.0
11
- Provides-Extra: legacy
12
- Requires-Dist: numpy==1.26.4; extra == 'legacy'
11
+ Requires-Dist: tqdm>=4.0
12
+ Requires-Dist: watchdog>=3.0
13
13
  Description-Content-Type: text/markdown
14
14
 
15
15
  # cortex-local
@@ -1,14 +1,17 @@
1
1
  """cortex CLI — index <path> and serve (stdio MCP)."""
2
2
 
3
+ import json
3
4
  import shutil
4
5
  import subprocess
5
6
  import sys
7
+ from collections import Counter
6
8
  from pathlib import Path
7
9
 
8
10
  import click
9
11
 
10
12
  from .config import (
11
13
  WORKSPACES_DIR, get_active_workspace, set_active_workspace, get_workspace_dir,
14
+ data_dir, save_last_indexed_path,
12
15
  )
13
16
 
14
17
 
@@ -19,10 +22,12 @@ def cli() -> None:
19
22
 
20
23
  @cli.command()
21
24
  @click.argument("path", type=click.Path(exists=True, file_okay=False, resolve_path=True))
22
- def index(path: str) -> None:
25
+ @click.option("--progress/--no-progress", default=True, help="Show tqdm progress bar.")
26
+ def index(path: str, progress: bool) -> None:
23
27
  """Index PATH (notes and/or code) into ~/.cortex."""
24
28
  from .indexer import index_path
25
- index_path(Path(path))
29
+ index_path(Path(path), show_progress=progress)
30
+ save_last_indexed_path(path)
26
31
 
27
32
 
28
33
  @cli.command()
@@ -117,6 +122,44 @@ def workspace_current() -> None:
117
122
  click.echo(get_active_workspace())
118
123
 
119
124
 
125
+ @cli.command("search-log")
126
+ @click.option("--top", default=10, show_default=True, help="Number of top queries to show.")
127
+ def search_log_cmd(top: int) -> None:
128
+ """Show search query analytics from the search log."""
129
+ log_file = data_dir() / "search_log.jsonl"
130
+ if not log_file.exists():
131
+ click.echo("No search log found. Run some searches first.")
132
+ return
133
+ queries = []
134
+ zero_result_queries = []
135
+ tool_counts: Counter = Counter()
136
+ with open(log_file) as f:
137
+ for line in f:
138
+ try:
139
+ entry = json.loads(line)
140
+ except Exception:
141
+ continue
142
+ q = entry.get("query", "")
143
+ queries.append(q)
144
+ tool_counts[entry.get("tool", "unknown")] += 1
145
+ if not entry.get("top"):
146
+ zero_result_queries.append(q)
147
+ if not queries:
148
+ click.echo("Search log is empty.")
149
+ return
150
+ click.echo(f"Total searches: {len(queries)}")
151
+ for tool, count in sorted(tool_counts.items()):
152
+ click.echo(f" {tool}: {count}")
153
+ click.echo(f"\nTop {top} queries:")
154
+ for q, n in Counter(queries).most_common(top):
155
+ click.echo(f" {n:3d}x {q}")
156
+ if zero_result_queries:
157
+ unique_zero = sorted(set(zero_result_queries))
158
+ click.echo(f"\nZero-result queries ({len(unique_zero)} unique):")
159
+ for q in unique_zero[:20]:
160
+ click.echo(f" {q}")
161
+
162
+
120
163
  @cli.command("install")
121
164
  def install_cmd() -> None:
122
165
  """Download embedding models and register cortex MCP in Claude Code."""
@@ -2,11 +2,19 @@
2
2
 
3
3
  import os
4
4
  import shutil
5
+ import tomllib
5
6
  from pathlib import Path
6
7
 
7
8
  CORTEX_DIR = Path.home() / ".cortex"
8
9
  WORKSPACES_DIR = CORTEX_DIR / "workspaces"
9
10
  ACTIVE_WORKSPACE_FILE = CORTEX_DIR / "active_workspace"
11
+ CONFIG_FILE = CORTEX_DIR / "config.toml"
12
+
13
+ _CONFIG_DEFAULTS: dict = {
14
+ "watch": {"enabled": True, "debounce_seconds": 2.0},
15
+ "index": {"last_path": "", "exclude_patterns": []},
16
+ }
17
+ _config_cache: dict | None = None
10
18
 
11
19
  EMBED_MODEL = "nomic-ai/nomic-embed-text-v1.5"
12
20
  VECTOR_SIZE = 768
@@ -50,6 +58,41 @@ def stats_file(ws: str | None = None) -> Path:
50
58
  return get_workspace_dir(ws) / "stats.json"
51
59
 
52
60
 
61
+ def get_config() -> dict:
62
+ global _config_cache
63
+ if _config_cache is not None:
64
+ return _config_cache
65
+ cfg: dict = {k: dict(v) for k, v in _CONFIG_DEFAULTS.items()}
66
+ if CONFIG_FILE.exists():
67
+ try:
68
+ loaded = tomllib.loads(CONFIG_FILE.read_text())
69
+ for section, vals in loaded.items():
70
+ if section in cfg and isinstance(vals, dict):
71
+ cfg[section].update(vals)
72
+ except Exception:
73
+ pass
74
+ _config_cache = cfg
75
+ return cfg
76
+
77
+
78
+ def save_last_indexed_path(path: str) -> None:
79
+ global _config_cache
80
+ CORTEX_DIR.mkdir(parents=True, exist_ok=True)
81
+ try:
82
+ if CONFIG_FILE.exists():
83
+ text = CONFIG_FILE.read_text()
84
+ import re
85
+ text = re.sub(r'^last_path\s*=.*$', f'last_path = "{path}"', text, flags=re.MULTILINE)
86
+ if 'last_path' not in text:
87
+ text += f'\n[index]\nlast_path = "{path}"\n'
88
+ else:
89
+ text = f'[watch]\nenabled = true\ndebounce_seconds = 2.0\n\n[index]\nlast_path = "{path}"\nexclude_patterns = []\n'
90
+ CONFIG_FILE.write_text(text)
91
+ except Exception:
92
+ pass
93
+ _config_cache = None # invalidate cache
94
+
95
+
53
96
  def migrate_legacy() -> None:
54
97
  """Move old flat ~/.cortex/ layout into ~/.cortex/workspaces/default/ on first run."""
55
98
  old_qdrant = CORTEX_DIR / "qdrant"
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env python3
2
+ """Mtime-based embedding cache — skip re-embedding unchanged files on full reindex."""
3
+
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def _default_cache_file() -> Path:
10
+ repos_config = os.environ.get("REPOS_CONFIG", "/app/data/repos.json")
11
+ return Path(repos_config).parent / "embed_cache.json"
12
+
13
+
14
+ def load_cache(section: str, cache_file: Path | None = None) -> dict:
15
+ """Load {rel_path: {"mtime": float, "hash": str}} for given section ('notes' or 'code')."""
16
+ if cache_file is None:
17
+ cache_file = _default_cache_file()
18
+ try:
19
+ if cache_file.exists():
20
+ raw = json.loads(cache_file.read_text()).get(section, {})
21
+ return {k: v if isinstance(v, dict) else {"mtime": v, "hash": ""} for k, v in raw.items()}
22
+ except Exception:
23
+ pass
24
+ return {}
25
+
26
+
27
+ def save_cache(section: str, data: dict, cache_file: Path | None = None) -> None:
28
+ """Persist updated cache section without touching other sections."""
29
+ if cache_file is None:
30
+ cache_file = _default_cache_file()
31
+ try:
32
+ existing = json.loads(cache_file.read_text()) if cache_file.exists() else {}
33
+ existing[section] = data
34
+ cache_file.write_text(json.dumps(existing))
35
+ except Exception as e:
36
+ print(f"warn: cache save failed: {e}", flush=True)
@@ -1,8 +1,12 @@
1
1
  #!/usr/bin/env python3
2
2
  """Source code chunking — tree-sitter semantic parsing with sliding-window fallback."""
3
3
 
4
+ import fnmatch
5
+ import os
4
6
  from pathlib import Path
5
7
 
8
+ REPOS_DIR = Path(os.environ.get("REPOS_DIR", "/tmp/repos"))
9
+
6
10
  CODE_EXTS = {".js", ".ts", ".tsx", ".jsx", ".svelte", ".py", ".java", ".go", ".rs", ".css", ".html", ".kt", ".kts", ".gd", ".yml", ".yaml"}
7
11
  SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".svelte-kit", "__pycache__", ".gradle", "target"}
8
12
  LANG_MAP = {
@@ -13,11 +17,16 @@ LANG_MAP = {
13
17
  ".yml": "yaml", ".yaml": "yaml",
14
18
  }
15
19
 
20
+ def _is_excluded(rel: str, patterns: list) -> bool:
21
+ return any(fnmatch.fnmatch(rel, p) for p in patterns)
22
+
23
+
16
24
  CHUNK_LINES = 30
17
25
  OVERLAP_LINES = 5
18
26
  MAX_CHUNK_LINES = 80
19
27
 
20
28
  _FRAMEWORK_FILENAMES = {
29
+ # SvelteKit route/hook conventions
21
30
  "hooks.server": "sveltekit server hooks handle",
22
31
  "hooks.client": "sveltekit client hooks",
23
32
  "+page": "sveltekit page component",
@@ -26,16 +35,19 @@ _FRAMEWORK_FILENAMES = {
26
35
  "+page.server": "sveltekit page server load action",
27
36
  "+layout.server": "sveltekit layout server load",
28
37
  "+error": "sveltekit error page",
38
+ # Framework config files (unambiguous)
29
39
  "svelte.config": "sveltekit",
30
40
  "next.config": "nextjs",
31
41
  "nuxt.config": "nuxt",
32
42
  "astro.config": "astro",
33
43
  "remix.config": "remix",
44
+ # Next.js pages router conventions
34
45
  "_app": "nextjs",
35
46
  "_document": "nextjs",
36
47
  }
37
48
 
38
49
  _FRAMEWORK_IMPORTS = [
50
+ # JS/TS frameworks
39
51
  ("@sveltejs/kit", "sveltekit"),
40
52
  ("from 'react'", "react"),
41
53
  ('from "react"', "react"),
@@ -53,6 +65,7 @@ _FRAMEWORK_IMPORTS = [
53
65
  ("@remix-run/", "remix"),
54
66
  ("from 'astro'", "astro"),
55
67
  ('from "astro"', "astro"),
68
+ # Python frameworks
56
69
  ("from fastapi", "fastapi"),
57
70
  ("import fastapi", "fastapi"),
58
71
  ("from django", "django"),
@@ -62,6 +75,7 @@ _FRAMEWORK_IMPORTS = [
62
75
  ("from starlette", "starlette"),
63
76
  ("from pydantic", "pydantic"),
64
77
  ("from sqlalchemy", "sqlalchemy"),
78
+ # Python ML/data
65
79
  ("import torch", "pytorch"),
66
80
  ("from torch", "pytorch"),
67
81
  ("import tensorflow", "tensorflow"),
@@ -72,31 +86,19 @@ _FRAMEWORK_IMPORTS = [
72
86
  ("from transformers", "huggingface"),
73
87
  ("from qdrant_client", "qdrant"),
74
88
  ("from fastembed", "fastembed"),
89
+ # Kotlin/Java
75
90
  ("import io.ktor", "ktor"),
76
91
  ("import org.springframework", "spring"),
77
92
  ("import androidx", "android"),
93
+ # Go
78
94
  ("gin-gonic/gin", "gin"),
79
95
  ("gofiber/fiber", "fiber"),
96
+ # Rust
80
97
  ("use actix", "actix"),
81
98
  ("use axum", "axum"),
82
99
  ("use tokio", "tokio"),
83
100
  ]
84
101
 
85
-
86
- def _extra_tokens(rel: str, lines: list) -> str:
87
- stem = Path(rel).stem
88
- parts = []
89
- if stem in _FRAMEWORK_FILENAMES:
90
- parts.append(_FRAMEWORK_FILENAMES[stem])
91
- header = "\n".join(lines[:50])
92
- seen = set(parts)
93
- for pattern, framework in _FRAMEWORK_IMPORTS:
94
- if pattern in header and framework not in seen:
95
- parts.append(framework)
96
- seen.add(framework)
97
- return " ".join(parts)
98
-
99
-
100
102
  try:
101
103
  from tree_sitter import Language, Parser as _TSParser
102
104
  import tree_sitter_python as _tspy
@@ -129,6 +131,29 @@ except Exception:
129
131
  _TS_SEMANTIC = {}
130
132
 
131
133
 
134
+ def _path_tokens(repo_name: str, rel: str) -> str:
135
+ parts = [repo_name.replace("-", " ").replace("_", " ")]
136
+ for p in Path(rel).parts[:-1]:
137
+ parts.append(p.replace("-", " ").replace("_", " "))
138
+ stem = Path(rel).stem.replace("-", " ").replace("_", " ").replace(".", " ")
139
+ parts.append(stem)
140
+ return " ".join(parts)
141
+
142
+
143
+ def _extra_tokens(rel: str, lines: list) -> str:
144
+ stem = Path(rel).stem
145
+ parts = []
146
+ if stem in _FRAMEWORK_FILENAMES:
147
+ parts.append(_FRAMEWORK_FILENAMES[stem])
148
+ header = "\n".join(lines[:50])
149
+ seen = set(parts)
150
+ for pattern, framework in _FRAMEWORK_IMPORTS:
151
+ if pattern in header and framework not in seen:
152
+ parts.append(framework)
153
+ seen.add(framework)
154
+ return " ".join(parts)
155
+
156
+
132
157
  def _sliding_window(lines: list, start: int, end: int, repo_name: str, rel: str, language: str, github_url_base: str = "", extra_tokens: str = "") -> list:
133
158
  step = CHUNK_LINES - OVERLAP_LINES
134
159
  chunks = []
@@ -138,7 +163,8 @@ def _sliding_window(lines: list, start: int, end: int, repo_name: str, rel: str,
138
163
  body = "\n".join(lines[i:j]).strip()
139
164
  if body:
140
165
  url = f"{github_url_base}/{rel}#L{i+1}-L{j}" if github_url_base else ""
141
- header = f"# {repo_name}/{rel} (lines {i+1}-{j})"
166
+ tokens = _path_tokens(repo_name, rel)
167
+ header = f"# {repo_name}/{rel} (lines {i+1}-{j})\n# {tokens}"
142
168
  if extra_tokens:
143
169
  header += f"\n# {extra_tokens}"
144
170
  chunks.append({
@@ -195,6 +221,24 @@ def chunk_file(path: Path, repo_name: str, base_dir: Path | None = None, github_
195
221
  language = LANG_MAP.get(ext, ext.lstrip("."))
196
222
  extra = _extra_tokens(rel, lines)
197
223
 
224
+ def _header(rel: str, s: int, e: int, tokens: str) -> str:
225
+ h = f"# {repo_name}/{rel} (lines {s}-{e})\n# {tokens}"
226
+ return h + f"\n# {extra}" if extra else h
227
+
228
+ # Small files: keep as single chunk to avoid diluting path/filename signal
229
+ if len(lines) <= MAX_CHUNK_LINES:
230
+ body = "\n".join(lines).strip()
231
+ if body:
232
+ url = f"{github_url_base}/{rel}#L1-L{len(lines)}" if github_url_base else ""
233
+ tokens = _path_tokens(repo_name, rel)
234
+ return [{
235
+ "repo": repo_name, "file": rel, "language": language,
236
+ "start_line": 1, "end_line": len(lines),
237
+ "text": f"{_header(rel, 1, len(lines), tokens)}\n\n{body}",
238
+ "github_url": url,
239
+ }]
240
+ return []
241
+
198
242
  if _TS_AVAILABLE and ext in _TS_LANGUAGES:
199
243
  try:
200
244
  parser = _TSParser(_TS_LANGUAGES[ext])
@@ -212,13 +256,11 @@ def chunk_file(path: Path, repo_name: str, base_dir: Path | None = None, github_
212
256
  body = "\n".join(lines[s:e]).strip()
213
257
  if body:
214
258
  url = f"{github_url_base}/{rel}#L{s+1}-L{e}" if github_url_base else ""
215
- header = f"# {repo_name}/{rel} (lines {s+1}-{e})"
216
- if extra:
217
- header += f"\n# {extra}"
259
+ tokens = _path_tokens(repo_name, rel)
218
260
  chunks.append({
219
261
  "repo": repo_name, "file": rel, "language": language,
220
262
  "start_line": s + 1, "end_line": e,
221
- "text": f"{header}\n\n{body}",
263
+ "text": f"{_header(rel, s+1, e, tokens)}\n\n{body}",
222
264
  "github_url": url,
223
265
  })
224
266
  return chunks
@@ -0,0 +1,114 @@
1
+ """Shared result formatters for MCP search tools — used by server and local modes."""
2
+
3
+ from datetime import datetime, timezone
4
+
5
+
6
+ def format_notes_parts(results) -> tuple:
7
+ """Build parts list from note search results.
8
+
9
+ Returns (parts, matched_files, matched_scores). Caller is responsible for:
10
+ - computing ppr_block from matched_files/matched_scores and appending to parts
11
+ - joining parts with '\\n\\n---\\n\\n'
12
+ - updating stats['context_tokens_notes']
13
+ """
14
+ parts = []
15
+ matched_files = []
16
+ matched_scores = []
17
+ for r in results:
18
+ p = r.payload
19
+ tags = p.get("tags", [])
20
+ tag_str = f" `{'`, `'.join(tags)}`" if tags else ""
21
+ parts.append(
22
+ f"**{p['file']} > {p['heading']}** (score: {round(r.score, 3)}){tag_str}\n\n{p['text']}"
23
+ )
24
+ matched_files.append(p.get("file", ""))
25
+ matched_scores.append(r.score)
26
+ return parts, matched_files, matched_scores
27
+
28
+
29
+ def format_code_results(deduped) -> str:
30
+ """Build formatted string from scored/deduped code search results.
31
+
32
+ Input: list of (boosted_score, qdrant_point, file_meta) tuples.
33
+ Caller is responsible for updating stats['context_tokens_code'].
34
+ """
35
+ parts = []
36
+ for boosted_score, r, file_meta in deduped:
37
+ p = r.payload
38
+ centrality = file_meta.get("centrality")
39
+ community = file_meta.get("community_id")
40
+ header = (
41
+ f"**{p['repo']}/{p['file']}** "
42
+ f"lines {p['start_line']}-{p['end_line']} "
43
+ f"({p.get('language', '')}) — score: {round(boosted_score, 3)}"
44
+ )
45
+ if centrality is not None:
46
+ header += f" · centrality: {centrality}"
47
+ if community is not None:
48
+ header += f" · community: {community}"
49
+ 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']}")
51
+ return "\n\n---\n\n".join(parts)
52
+
53
+
54
+ def fmt_stats(v: str, stats: dict, current: bool = False) -> str:
55
+ """Format stats block for one version. Safe for both server and local stats dicts."""
56
+ total_searches = stats.get("search_code_calls", 0) + stats.get("search_notes_calls", 0)
57
+ ppr_rate = (
58
+ f"{stats['ppr_fires'] / stats['search_notes_calls'] * 100:.1f}%"
59
+ if stats.get("search_notes_calls", 0) > 0 else "—"
60
+ )
61
+ avg_ppr = round(stats["ppr_results_added"] / stats["ppr_fires"], 1) if stats.get("ppr_fires", 0) > 0 else "—"
62
+ total_cache = stats.get("graph_cache_hits", 0) + stats.get("graph_cache_misses", 0)
63
+ cache_rate = f"{stats['graph_cache_hits'] / total_cache * 100:.1f}%" if total_cache > 0 else "—"
64
+ avg_lift = round(stats["centrality_lift_total"] / stats["centrality_lift_count"], 4) if stats.get("centrality_lift_count", 0) > 0 else 0
65
+ total_results = stats.get("total_results_code", 0) + stats.get("total_results_notes", 0)
66
+ avg_results = round(total_results / total_searches, 1) if total_searches > 0 else "—"
67
+ notes_pct = f"{stats['search_notes_calls'] / total_searches * 100:.0f}%" if total_searches > 0 else "—"
68
+
69
+ label = f"{v} (current)" if current else v
70
+ lines = [label]
71
+
72
+ started = stats.get("started_at", "")
73
+ if current and started:
74
+ try:
75
+ delta = datetime.now(timezone.utc) - datetime.fromisoformat(started)
76
+ h, rem = divmod(int(delta.total_seconds()), 3600)
77
+ m = rem // 60
78
+ lines.append(f" uptime: {h}h {m}m")
79
+ except Exception:
80
+ pass
81
+ elif started:
82
+ last = stats.get("last_saved", "")
83
+ period = started[:10]
84
+ if last:
85
+ period += f" -> {last[:10]}"
86
+ lines.append(f" period: {period}")
87
+
88
+ lines += [
89
+ f" searches: code={stats.get('search_code_calls', 0)} notes={stats.get('search_notes_calls', 0)} total={total_searches} (notes {notes_pct})",
90
+ f" results: code={stats.get('total_results_code', 0)} notes={stats.get('total_results_notes', 0)} avg/search={avg_results}",
91
+ f" centrality lift avg: {avg_lift} (across {stats.get('centrality_lift_count', 0)} results)",
92
+ f" PPR: {stats.get('ppr_fires', 0)} fires ({ppr_rate} of note searches) · +{stats.get('ppr_results_added', 0)} results · avg {avg_ppr}/fire",
93
+ ]
94
+ _ppr_diag = {k: stats.get(k, 0) for k in ("ppr_nx_missing", "ppr_graph_missing", "ppr_no_matches", "ppr_below_threshold", "ppr_exception")}
95
+ if any(_ppr_diag.values()):
96
+ lines.append(
97
+ f" PPR misses: nx={_ppr_diag['ppr_nx_missing']} graph={_ppr_diag['ppr_graph_missing']}"
98
+ f" no_match={_ppr_diag['ppr_no_matches']} below_threshold={_ppr_diag['ppr_below_threshold']}"
99
+ f" exception={_ppr_diag['ppr_exception']}"
100
+ )
101
+ ctx_notes = stats.get("context_tokens_notes", 0)
102
+ ctx_code = stats.get("context_tokens_code", 0)
103
+ ctx_total = ctx_notes + ctx_code
104
+
105
+ def _k(n):
106
+ return f"{n // 1000}k" if n >= 1000 else str(n)
107
+
108
+ lines += [
109
+ f" context injected: notes={_k(ctx_notes)} code={_k(ctx_code)} total={_k(ctx_total)} tokens (~chars/4 est.)",
110
+ f" graph cache: {cache_rate} ({stats.get('graph_cache_hits', 0)}/{total_cache})",
111
+ f" embed cache skipped: notes={stats.get('embed_cache_notes', 0)} code={stats.get('embed_cache_code', 0)}",
112
+ f" reindexes: {stats.get('reindex_count', 0)}",
113
+ ]
114
+ return "\n".join(lines)
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env python3
2
+ """Graph module facade — re-exports all graph building and PPR utilities."""
3
+
4
+ try:
5
+ from .code_graph import build_code_graph, compute_code_metadata, persist_code_graph # noqa: F401
6
+ from .notes_graph import build_notes_graph, persist_notes_graph, ppr_augment # noqa: F401
7
+ from .global_graph import build_global_graph # noqa: F401
8
+ except ImportError:
9
+ from code_graph import build_code_graph, compute_code_metadata, persist_code_graph # noqa: F401
10
+ from notes_graph import build_notes_graph, persist_notes_graph, ppr_augment # noqa: F401
11
+ from global_graph import build_global_graph # noqa: F401
@@ -4,6 +4,8 @@ import hashlib
4
4
  import re
5
5
  from pathlib import Path
6
6
 
7
+ from tqdm import tqdm
8
+
7
9
  from qdrant_client import QdrantClient
8
10
  from qdrant_client.models import (
9
11
  Distance, FieldCondition, Filter, FilterSelector,
@@ -12,7 +14,7 @@ from qdrant_client.models import (
12
14
 
13
15
  from .config import qdrant_path, data_dir, cache_file, VECTOR_SIZE, migrate_legacy
14
16
  from .embedder import embed, sparse_embed
15
- from .core.chunker import chunk_file, CODE_EXTS, SKIP_DIRS
17
+ from .core.chunker import chunk_file, CODE_EXTS, SKIP_DIRS, _is_excluded
16
18
  from .core.cache import load_cache, save_cache
17
19
  from .core import graph as _graph
18
20
 
@@ -110,8 +112,11 @@ def _chunk_id(file: str, heading: str) -> int:
110
112
  return int(hashlib.md5(f"{file}:{heading}".encode()).hexdigest()[:8], 16)
111
113
 
112
114
 
113
- def index_notes(notes_dir: Path) -> int:
115
+ def index_notes(notes_dir: Path, show_progress: bool = False) -> int:
114
116
  """Index all .md files in notes_dir into the 'notes' collection. Returns chunk count."""
117
+ from .config import get_config
118
+ exclude_patterns = get_config()["index"]["exclude_patterns"]
119
+
115
120
  client = _qdrant()
116
121
  _ensure_collection(client, "notes")
117
122
  use_sparse = _sparse_available()
@@ -126,11 +131,19 @@ def index_notes(notes_dir: Path) -> int:
126
131
  total = 0
127
132
  cached = 0
128
133
 
129
- for path in md_files:
134
+ for path in tqdm(md_files, desc="notes", disable=not show_progress):
130
135
  rel = str(path.relative_to(notes_dir))
136
+ if _is_excluded(rel, exclude_patterns):
137
+ continue
131
138
  mtime = path.stat().st_mtime
132
- if cache.get(rel) == mtime:
133
- new_cache[rel] = mtime
139
+ entry = cache.get(rel, {})
140
+ if entry.get("mtime") == mtime:
141
+ new_cache[rel] = entry
142
+ cached += 1
143
+ continue
144
+ content_hash = hashlib.sha1(path.read_bytes()).hexdigest()[:16]
145
+ if entry.get("hash") == content_hash:
146
+ new_cache[rel] = {"mtime": mtime, "hash": content_hash}
134
147
  cached += 1
135
148
  continue
136
149
  chunks = _chunk_markdown(path, notes_dir)
@@ -139,8 +152,9 @@ def index_notes(notes_dir: Path) -> int:
139
152
  if points:
140
153
  client.upsert("notes", points)
141
154
  total += len(points)
142
- print(f" {rel}: {len(points)} chunk(s)", flush=True)
143
- new_cache[rel] = mtime
155
+ if not show_progress:
156
+ print(f" {rel}: {len(points)} chunk(s)", flush=True)
157
+ new_cache[rel] = {"mtime": mtime, "hash": content_hash}
144
158
 
145
159
  save_cache("notes", new_cache, cache_file())
146
160
  print(f"Done. {total} chunks indexed, {cached}/{len(md_files)} files cached.", flush=True)
@@ -160,8 +174,11 @@ def _code_chunk_id(repo: str, file: str, start: int) -> int:
160
174
  return int(hashlib.md5(f"{repo}:{file}:{start}".encode()).hexdigest()[:8], 16)
161
175
 
162
176
 
163
- def index_code(code_dir: Path) -> int:
177
+ def index_code(code_dir: Path, show_progress: bool = False) -> int:
164
178
  """Index all code files in code_dir into the 'code' collection. Returns chunk count."""
179
+ from .config import get_config
180
+ exclude_patterns = get_config()["index"]["exclude_patterns"]
181
+
165
182
  repo_name = code_dir.name
166
183
  client = _qdrant()
167
184
  _ensure_collection(client, "code")
@@ -171,6 +188,7 @@ def index_code(code_dir: Path) -> int:
171
188
  p for p in code_dir.rglob("*")
172
189
  if p.is_file() and p.suffix in CODE_EXTS
173
190
  and not any(s in p.parts for s in SKIP_DIRS)
191
+ and not _is_excluded(str(p.relative_to(code_dir)), exclude_patterns)
174
192
  ]
175
193
 
176
194
  cache = load_cache("code", cache_file())
@@ -181,13 +199,16 @@ def index_code(code_dir: Path) -> int:
181
199
  cached = 0
182
200
  file_point_ids: dict = {}
183
201
 
184
- for i, path in enumerate(code_files):
185
- if i % 10 == 0 and i > 0:
186
- print(f" {repo_name}: {i}/{len(code_files)} files, {total} chunks...", flush=True)
187
-
202
+ for path in tqdm(code_files, desc=repo_name, disable=not show_progress):
188
203
  cache_key = f"{repo_name}/{str(path.relative_to(code_dir))}"
189
204
  mtime = path.stat().st_mtime
190
- if cache.get(cache_key) == mtime:
205
+ entry = cache.get(cache_key, {})
206
+ if entry.get("mtime") == mtime:
207
+ cached += 1
208
+ continue
209
+ content_hash = hashlib.sha1(path.read_bytes()).hexdigest()[:16]
210
+ if entry.get("hash") == content_hash:
211
+ new_cache[cache_key] = {"mtime": mtime, "hash": content_hash}
191
212
  cached += 1
192
213
  continue
193
214
 
@@ -199,7 +220,7 @@ def index_code(code_dir: Path) -> int:
199
220
  if points:
200
221
  client.upsert("code", points)
201
222
  total += len(points)
202
- new_cache[cache_key] = mtime
223
+ new_cache[cache_key] = {"mtime": mtime, "hash": content_hash}
203
224
 
204
225
  save_cache("code", new_cache, cache_file())
205
226
  print(f" {repo_name}: {total} chunks from {len(code_files) - cached} files ({cached} cached)", flush=True)
@@ -233,7 +254,7 @@ def index_code(code_dir: Path) -> int:
233
254
  return total
234
255
 
235
256
 
236
- def index_path(path: Path) -> None:
257
+ def index_path(path: Path, show_progress: bool = False) -> None:
237
258
  """Index a path — .md files as notes, code files as code."""
238
259
  path = path.resolve()
239
260
  data_dir().mkdir(parents=True, exist_ok=True)
@@ -245,11 +266,11 @@ def index_path(path: Path) -> None:
245
266
 
246
267
  if md_files:
247
268
  print(f"\nIndexing notes from {path} ({len(md_files)} .md files)...")
248
- index_notes(path)
269
+ index_notes(path, show_progress=show_progress)
249
270
 
250
271
  if code_files:
251
272
  print(f"\nIndexing code from {path} ({len(code_files)} files)...")
252
- index_code(path)
273
+ index_code(path, show_progress=show_progress)
253
274
 
254
275
  if not md_files and not code_files:
255
276
  print(f"No indexable files found in {path}")
@@ -3,21 +3,22 @@
3
3
  import json
4
4
  import threading
5
5
  import time
6
- from datetime import datetime, timezone
7
6
  from pathlib import Path
8
7
 
8
+
9
9
  from mcp.server.fastmcp import FastMCP
10
10
  from qdrant_client import QdrantClient
11
11
  from qdrant_client.models import Prefetch, Fusion, SparseVector
12
12
 
13
13
  from .config import (
14
- qdrant_path, data_dir, VERSION, VECTOR_SIZE,
14
+ qdrant_path, data_dir, stats_file, VERSION, VECTOR_SIZE,
15
15
  WORKSPACES_DIR, get_active_workspace, set_active_workspace,
16
- get_workspace_dir, PPR_THRESHOLD,
16
+ get_workspace_dir, PPR_THRESHOLD, get_config,
17
17
  )
18
18
  from .embedder import embed, sparse_embed
19
19
  from .state import _stats, get_graph_meta, save_stats, invalidate_graph_cache
20
20
  from .indexer import index_path
21
+ from .core.formatters import format_notes_parts, format_code_results, fmt_stats
21
22
 
22
23
  mcp = FastMCP("cortex")
23
24
 
@@ -86,20 +87,7 @@ def _ppr_block(matched_files: list, matched_scores: list) -> str | None:
86
87
 
87
88
  # ── Tools ────────────────────────────────────────────────────────────────────
88
89
 
89
- @mcp.tool()
90
- def search_notes(query: str, limit: int = 5) -> str:
91
- """Search the user's personal knowledge base semantically.
92
-
93
- Call this proactively whenever the user asks about:
94
- - Their projects, plans, ideas, or roadmap items
95
- - Personal context, goals, or decisions they may have documented
96
- - How something in their setup works
97
- - Anything that sounds like it could be in personal notes
98
-
99
- Returns matching note sections with file path, heading, score, and tags.
100
- PPR over wikilinks surfaces related notes beyond direct vector matches.
101
- """
102
- _stats["search_notes_calls"] += 1
90
+ def _search_notes(query: str, limit: int = 5):
103
91
  client = _qdrant()
104
92
  vector = embed(query)
105
93
  try:
@@ -117,37 +105,18 @@ def search_notes(query: str, limit: int = 5) -> str:
117
105
  except Exception:
118
106
  results = client.query_points("notes", query=vector, limit=limit, with_payload=True).points
119
107
  if not results:
120
- return "No results found."
108
+ return "No results found.", [], []
121
109
  _stats["total_results_notes"] += len(results)
122
- parts = []
123
- matched_files = []
124
- matched_scores = []
125
- for r in results:
126
- p = r.payload
127
- tags = p.get("tags", [])
128
- tag_str = f" `{'`, `'.join(tags)}`" if tags else ""
129
- parts.append(f"**{p['file']} > {p['heading']}** (score: {round(r.score, 3)}){tag_str}\n\n{p['text']}")
130
- matched_files.append(p.get("file", ""))
131
- matched_scores.append(r.score)
132
- _log_search("search_notes", query, [
133
- {"file": f, "score": round(s, 3)} for f, s in zip(matched_files, matched_scores)
134
- ])
110
+ parts, matched_files, matched_scores = format_notes_parts(results)
135
111
  ppr = _ppr_block(matched_files, matched_scores)
136
112
  if ppr:
137
113
  parts.append(ppr)
138
114
  out = "\n\n---\n\n".join(parts)
139
115
  _stats["context_tokens_notes"] += len(out) // 4
140
- return out
141
-
116
+ return out, matched_files, matched_scores
142
117
 
143
- @mcp.tool()
144
- def search_code(query: str, limit: int = 5) -> str:
145
- """Search source code across indexed directories semantically.
146
118
 
147
- Results are re-ranked by centrality (highly-imported files score higher).
148
- Returns code chunks with file path, line numbers, language, score, centrality, and community.
149
- """
150
- _stats["search_code_calls"] += 1
119
+ def _search_code(query: str, limit: int = 5):
151
120
  client = _qdrant()
152
121
  vector = embed(query)
153
122
  fetch_limit = min(limit * 3, 50)
@@ -166,7 +135,7 @@ def search_code(query: str, limit: int = 5) -> str:
166
135
  except Exception:
167
136
  results = client.query_points("code", query=vector, limit=fetch_limit, with_payload=True).points
168
137
  if not results:
169
- return "No results found."
138
+ return "No results found.", []
170
139
  scored = []
171
140
  for r in results:
172
141
  p = r.payload
@@ -188,31 +157,64 @@ def search_code(query: str, limit: int = 5) -> str:
188
157
  if len(deduped) >= limit:
189
158
  break
190
159
  _stats["total_results_code"] += len(deduped)
191
- _log_search("search_code", query, [
160
+ log_entries = [
192
161
  {"repo": r.payload.get("repo", ""), "file": r.payload.get("file", ""), "score": round(bs, 3)}
193
162
  for bs, r, _ in deduped
194
- ])
195
- parts = []
196
- for boosted_score, r, file_meta in deduped:
197
- p = r.payload
198
- centrality = file_meta.get("centrality")
199
- community = file_meta.get("community_id")
200
- header = (
201
- f"**{p['repo']}/{p['file']}** "
202
- f"lines {p['start_line']}-{p['end_line']} "
203
- f"({p.get('language', '')}) — score: {round(boosted_score, 3)}"
204
- )
205
- if centrality is not None:
206
- header += f" · centrality: {centrality}"
207
- if community is not None:
208
- header += f" · community: {community}"
209
- url = p.get("github_url", "")
210
- parts.append(f"{header}\n{url}\n\n{p['text']}" if url else f"{header}\n\n{p['text']}")
211
- out = "\n\n---\n\n".join(parts)
163
+ ]
164
+ out = format_code_results(deduped)
212
165
  _stats["context_tokens_code"] += len(out) // 4
166
+ return out, log_entries
167
+
168
+
169
+ @mcp.tool()
170
+ def search_notes(query: str, limit: int = 5) -> str:
171
+ """Search the user's personal knowledge base semantically.
172
+
173
+ Call this proactively whenever the user asks about:
174
+ - Their projects, plans, ideas, or roadmap items
175
+ - Personal context, goals, or decisions they may have documented
176
+ - How something in their setup works
177
+ - Anything that sounds like it could be in personal notes
178
+
179
+ Returns matching note sections with file path, heading, score, and tags.
180
+ PPR over wikilinks surfaces related notes beyond direct vector matches.
181
+ """
182
+ _stats["search_notes_calls"] += 1
183
+ out, matched_files, matched_scores = _search_notes(query, limit)
184
+ _log_search("search_notes", query, [
185
+ {"file": f, "score": round(s, 3)} for f, s in zip(matched_files, matched_scores)
186
+ ])
187
+ return out
188
+
189
+
190
+ @mcp.tool()
191
+ def search_code(query: str, limit: int = 5) -> str:
192
+ """Search source code across indexed directories semantically.
193
+
194
+ Results are re-ranked by centrality (highly-imported files score higher).
195
+ Returns code chunks with file path, line numbers, language, score, centrality, and community.
196
+ """
197
+ _stats["search_code_calls"] += 1
198
+ out, log_entries = _search_code(query, limit)
199
+ _log_search("search_code", query, log_entries)
213
200
  return out
214
201
 
215
202
 
203
+ @mcp.tool()
204
+ def search_all(query: str, limit: int = 5) -> str:
205
+ """Search both notes and code simultaneously.
206
+
207
+ Use when unsure whether the answer is in notes or code,
208
+ or when you want a combined view across both collections.
209
+ """
210
+ _stats["search_notes_calls"] += 1
211
+ _stats["search_code_calls"] += 1
212
+ notes_out, matched_files, matched_scores = _search_notes(query, limit)
213
+ code_out, log_entries = _search_code(query, limit)
214
+ _log_search("search_all", query, log_entries)
215
+ return f"## Notes\n\n{notes_out}\n\n## Code\n\n{code_out}"
216
+
217
+
216
218
  @mcp.tool()
217
219
  def reindex(path: str = "") -> str:
218
220
  """Re-index a path into the local Qdrant database.
@@ -253,44 +255,19 @@ def get_stats(all: bool = False) -> str:
253
255
  all=False (default): current session stats.
254
256
  all=True: all persisted versions side-by-side.
255
257
  """
256
- from .config import STATS_FILE
257
258
  if all:
258
259
  try:
259
- versions = json.loads(STATS_FILE.read_text()).get("versions", {}) if STATS_FILE.exists() else {}
260
+ sf = stats_file()
261
+ versions = json.loads(sf.read_text()).get("versions", {}) if sf.exists() else {}
260
262
  except Exception:
261
263
  versions = {}
262
264
  if not versions:
263
265
  return "No persisted stats found."
264
266
  blocks = []
265
267
  for v in sorted(versions):
266
- s = versions[v]
267
- blocks.append(_fmt_stats(v, s, current=(v == VERSION)))
268
+ blocks.append(fmt_stats(v, versions[v], current=(v == VERSION)))
268
269
  return "\n\n".join(blocks)
269
- return _fmt_stats(VERSION, _stats, current=True)
270
-
271
-
272
- def _fmt_stats(v: str, s: dict, current: bool = False) -> str:
273
- total = s.get("search_code_calls", 0) + s.get("search_notes_calls", 0)
274
- ppr_rate = f"{s['ppr_fires'] / s['search_notes_calls'] * 100:.1f}%" if s.get("search_notes_calls") else "—"
275
- lift_avg = round(s["centrality_lift_total"] / s["centrality_lift_count"], 4) if s.get("centrality_lift_count") else 0
276
- label = f"{v} (current)" if current else v
277
- lines = [label]
278
- started = s.get("started_at", "")
279
- if current and started:
280
- try:
281
- delta = datetime.now(timezone.utc) - datetime.fromisoformat(started)
282
- h, rem = divmod(int(delta.total_seconds()), 3600)
283
- m = rem // 60
284
- lines.append(f" uptime: {h}h {m}m")
285
- except Exception:
286
- pass
287
- lines += [
288
- f" searches: code={s.get('search_code_calls', 0)} notes={s.get('search_notes_calls', 0)} total={total}",
289
- f" centrality lift avg: {lift_avg} (across {s.get('centrality_lift_count', 0)} results)",
290
- f" PPR: {s.get('ppr_fires', 0)} fires ({ppr_rate} of note searches) · +{s.get('ppr_results_added', 0)} results",
291
- f" reindexes: {s.get('reindex_count', 0)}",
292
- ]
293
- return "\n".join(lines)
270
+ return fmt_stats(VERSION, _stats, current=True)
294
271
 
295
272
 
296
273
  @mcp.tool()
@@ -438,4 +415,10 @@ def list_workspaces() -> str:
438
415
 
439
416
  def run() -> None:
440
417
  """Entry point for stdio MCP server."""
418
+ cfg = get_config()
419
+ if cfg["watch"]["enabled"]:
420
+ last = cfg["index"].get("last_path", "")
421
+ if last and Path(last).exists():
422
+ from .watcher import CortexWatcher
423
+ CortexWatcher(Path(last), cfg["watch"]["debounce_seconds"]).start()
441
424
  mcp.run(transport="stdio")
@@ -0,0 +1,60 @@
1
+ """Debounced filesystem watcher for automatic reindex on file changes."""
2
+
3
+ import threading
4
+ from pathlib import Path
5
+
6
+ try:
7
+ from watchdog.observers import Observer
8
+ from watchdog.events import FileSystemEventHandler as _Handler
9
+
10
+ class CortexWatcher:
11
+ def __init__(self, path: Path, debounce: float = 2.0):
12
+ self._path = path
13
+ self._debounce = debounce
14
+ self._timer: threading.Timer | None = None
15
+ self._lock = threading.Lock()
16
+ self._observer = Observer()
17
+
18
+ def _schedule(self):
19
+ with self._lock:
20
+ if self._timer:
21
+ self._timer.cancel()
22
+ self._timer = threading.Timer(self._debounce, self._fire)
23
+ self._timer.daemon = True
24
+ self._timer.start()
25
+
26
+ def _fire(self):
27
+ with self._lock:
28
+ self._timer = None
29
+ try:
30
+ from .indexer import index_path
31
+ index_path(self._path)
32
+ except Exception as e:
33
+ print(f"[watcher] reindex failed: {e}", flush=True)
34
+
35
+ def start(self):
36
+ handler = type("_H", (_Handler,), {
37
+ "on_any_event": lambda s, e: (
38
+ None if e.is_directory or not str(e.src_path).endswith(".md")
39
+ else self._schedule()
40
+ ),
41
+ })()
42
+ self._observer.schedule(handler, str(self._path), recursive=True)
43
+ self._observer.daemon = True
44
+ self._observer.start()
45
+ print(f"[cortex] watching {self._path} (debounce {self._debounce}s)", flush=True)
46
+
47
+ def stop(self):
48
+ self._observer.stop()
49
+ self._observer.join()
50
+
51
+ except ImportError:
52
+ class CortexWatcher: # type: ignore[no-redef]
53
+ def __init__(self, *args, **kwargs):
54
+ pass
55
+
56
+ def start(self):
57
+ print("[cortex] watchdog not installed — auto-reindex disabled", flush=True)
58
+
59
+ def stop(self):
60
+ pass
@@ -14,11 +14,10 @@ dependencies = [
14
14
  "fastembed>=0.4.0",
15
15
  "click>=8.0.0",
16
16
  "networkx>=3.0",
17
+ "watchdog>=3.0",
18
+ "tqdm>=4.0",
17
19
  ]
18
20
 
19
- [project.optional-dependencies]
20
- legacy = ["numpy==1.26.4"]
21
-
22
21
  [project.scripts]
23
22
  cortex = "cortex.cli:cli"
24
23
 
@@ -1,25 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Mtime-based embedding cache — skip re-embedding unchanged files on full reindex."""
3
-
4
- import json
5
- from pathlib import Path
6
-
7
-
8
- def load_cache(section: str, cache_file: Path) -> dict:
9
- """Load {rel_path: mtime} for given section ('notes' or 'code')."""
10
- try:
11
- if cache_file.exists():
12
- return json.loads(cache_file.read_text()).get(section, {})
13
- except Exception:
14
- pass
15
- return {}
16
-
17
-
18
- def save_cache(section: str, data: dict, cache_file: Path) -> None:
19
- """Persist updated cache section without touching other sections."""
20
- try:
21
- existing = json.loads(cache_file.read_text()) if cache_file.exists() else {}
22
- existing[section] = data
23
- cache_file.write_text(json.dumps(existing))
24
- except Exception as e:
25
- print(f"warn: cache save failed: {e}", flush=True)
@@ -1,6 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Graph module facade — re-exports all graph building and PPR utilities."""
3
-
4
- from .code_graph import build_code_graph, compute_code_metadata, persist_code_graph # noqa: F401
5
- from .notes_graph import build_notes_graph, persist_notes_graph, ppr_augment # noqa: F401
6
- from .global_graph import build_global_graph # noqa: F401
File without changes