cortex-local 2.1.15__tar.gz → 2.2.0__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.1.15 → cortex_local-2.2.0}/PKG-INFO +3 -3
- {cortex_local-2.1.15 → cortex_local-2.2.0}/cortex/cli.py +45 -2
- {cortex_local-2.1.15 → cortex_local-2.2.0}/cortex/config.py +43 -0
- cortex_local-2.2.0/cortex/core/cache.py +36 -0
- {cortex_local-2.1.15 → cortex_local-2.2.0}/cortex/core/chunker.py +62 -20
- cortex_local-2.2.0/cortex/core/graph.py +11 -0
- {cortex_local-2.1.15 → cortex_local-2.2.0}/cortex/indexer.py +38 -17
- {cortex_local-2.1.15 → cortex_local-2.2.0}/cortex/mcp_server.py +67 -34
- cortex_local-2.2.0/cortex/watcher.py +60 -0
- {cortex_local-2.1.15 → cortex_local-2.2.0}/pyproject.toml +2 -3
- cortex_local-2.1.15/cortex/core/cache.py +0 -25
- cortex_local-2.1.15/cortex/core/graph.py +0 -6
- {cortex_local-2.1.15 → cortex_local-2.2.0}/README.md +0 -0
- {cortex_local-2.1.15 → cortex_local-2.2.0}/cortex/__init__.py +0 -0
- {cortex_local-2.1.15 → cortex_local-2.2.0}/cortex/core/__init__.py +0 -0
- {cortex_local-2.1.15 → cortex_local-2.2.0}/cortex/core/code_graph.py +0 -0
- {cortex_local-2.1.15 → cortex_local-2.2.0}/cortex/core/global_graph.py +0 -0
- {cortex_local-2.1.15 → cortex_local-2.2.0}/cortex/core/notes_graph.py +0 -0
- {cortex_local-2.1.15 → cortex_local-2.2.0}/cortex/embedder.py +0 -0
- {cortex_local-2.1.15 → cortex_local-2.2.0}/cortex/state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortex-local
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
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
|
-
|
|
12
|
-
Requires-Dist:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"{
|
|
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,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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
|
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
|
-
|
|
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}")
|
|
@@ -6,14 +6,15 @@ import time
|
|
|
6
6
|
from datetime import datetime, timezone
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
|
|
9
|
+
|
|
9
10
|
from mcp.server.fastmcp import FastMCP
|
|
10
11
|
from qdrant_client import QdrantClient
|
|
11
12
|
from qdrant_client.models import Prefetch, Fusion, SparseVector
|
|
12
13
|
|
|
13
14
|
from .config import (
|
|
14
|
-
qdrant_path, data_dir, VERSION, VECTOR_SIZE,
|
|
15
|
+
qdrant_path, data_dir, stats_file, VERSION, VECTOR_SIZE,
|
|
15
16
|
WORKSPACES_DIR, get_active_workspace, set_active_workspace,
|
|
16
|
-
get_workspace_dir, PPR_THRESHOLD,
|
|
17
|
+
get_workspace_dir, PPR_THRESHOLD, get_config,
|
|
17
18
|
)
|
|
18
19
|
from .embedder import embed, sparse_embed
|
|
19
20
|
from .state import _stats, get_graph_meta, save_stats, invalidate_graph_cache
|
|
@@ -86,20 +87,7 @@ def _ppr_block(matched_files: list, matched_scores: list) -> str | None:
|
|
|
86
87
|
|
|
87
88
|
# ── Tools ────────────────────────────────────────────────────────────────────
|
|
88
89
|
|
|
89
|
-
|
|
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,7 +105,7 @@ 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
110
|
parts = []
|
|
123
111
|
matched_files = []
|
|
@@ -129,25 +117,15 @@ def search_notes(query: str, limit: int = 5) -> str:
|
|
|
129
117
|
parts.append(f"**{p['file']} > {p['heading']}** (score: {round(r.score, 3)}){tag_str}\n\n{p['text']}")
|
|
130
118
|
matched_files.append(p.get("file", ""))
|
|
131
119
|
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
|
-
])
|
|
135
120
|
ppr = _ppr_block(matched_files, matched_scores)
|
|
136
121
|
if ppr:
|
|
137
122
|
parts.append(ppr)
|
|
138
123
|
out = "\n\n---\n\n".join(parts)
|
|
139
124
|
_stats["context_tokens_notes"] += len(out) // 4
|
|
140
|
-
return out
|
|
125
|
+
return out, matched_files, matched_scores
|
|
141
126
|
|
|
142
127
|
|
|
143
|
-
|
|
144
|
-
def search_code(query: str, limit: int = 5) -> str:
|
|
145
|
-
"""Search source code across indexed directories semantically.
|
|
146
|
-
|
|
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
|
|
128
|
+
def _search_code(query: str, limit: int = 5):
|
|
151
129
|
client = _qdrant()
|
|
152
130
|
vector = embed(query)
|
|
153
131
|
fetch_limit = min(limit * 3, 50)
|
|
@@ -166,7 +144,7 @@ def search_code(query: str, limit: int = 5) -> str:
|
|
|
166
144
|
except Exception:
|
|
167
145
|
results = client.query_points("code", query=vector, limit=fetch_limit, with_payload=True).points
|
|
168
146
|
if not results:
|
|
169
|
-
return "No results found."
|
|
147
|
+
return "No results found.", []
|
|
170
148
|
scored = []
|
|
171
149
|
for r in results:
|
|
172
150
|
p = r.payload
|
|
@@ -188,10 +166,10 @@ def search_code(query: str, limit: int = 5) -> str:
|
|
|
188
166
|
if len(deduped) >= limit:
|
|
189
167
|
break
|
|
190
168
|
_stats["total_results_code"] += len(deduped)
|
|
191
|
-
|
|
169
|
+
log_entries = [
|
|
192
170
|
{"repo": r.payload.get("repo", ""), "file": r.payload.get("file", ""), "score": round(bs, 3)}
|
|
193
171
|
for bs, r, _ in deduped
|
|
194
|
-
]
|
|
172
|
+
]
|
|
195
173
|
parts = []
|
|
196
174
|
for boosted_score, r, file_meta in deduped:
|
|
197
175
|
p = r.payload
|
|
@@ -210,9 +188,58 @@ def search_code(query: str, limit: int = 5) -> str:
|
|
|
210
188
|
parts.append(f"{header}\n{url}\n\n{p['text']}" if url else f"{header}\n\n{p['text']}")
|
|
211
189
|
out = "\n\n---\n\n".join(parts)
|
|
212
190
|
_stats["context_tokens_code"] += len(out) // 4
|
|
191
|
+
return out, log_entries
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@mcp.tool()
|
|
195
|
+
def search_notes(query: str, limit: int = 5) -> str:
|
|
196
|
+
"""Search the user's personal knowledge base semantically.
|
|
197
|
+
|
|
198
|
+
Call this proactively whenever the user asks about:
|
|
199
|
+
- Their projects, plans, ideas, or roadmap items
|
|
200
|
+
- Personal context, goals, or decisions they may have documented
|
|
201
|
+
- How something in their setup works
|
|
202
|
+
- Anything that sounds like it could be in personal notes
|
|
203
|
+
|
|
204
|
+
Returns matching note sections with file path, heading, score, and tags.
|
|
205
|
+
PPR over wikilinks surfaces related notes beyond direct vector matches.
|
|
206
|
+
"""
|
|
207
|
+
_stats["search_notes_calls"] += 1
|
|
208
|
+
out, matched_files, matched_scores = _search_notes(query, limit)
|
|
209
|
+
_log_search("search_notes", query, [
|
|
210
|
+
{"file": f, "score": round(s, 3)} for f, s in zip(matched_files, matched_scores)
|
|
211
|
+
])
|
|
213
212
|
return out
|
|
214
213
|
|
|
215
214
|
|
|
215
|
+
@mcp.tool()
|
|
216
|
+
def search_code(query: str, limit: int = 5) -> str:
|
|
217
|
+
"""Search source code across indexed directories semantically.
|
|
218
|
+
|
|
219
|
+
Results are re-ranked by centrality (highly-imported files score higher).
|
|
220
|
+
Returns code chunks with file path, line numbers, language, score, centrality, and community.
|
|
221
|
+
"""
|
|
222
|
+
_stats["search_code_calls"] += 1
|
|
223
|
+
out, log_entries = _search_code(query, limit)
|
|
224
|
+
_log_search("search_code", query, log_entries)
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@mcp.tool()
|
|
229
|
+
def search_all(query: str, limit: int = 5) -> str:
|
|
230
|
+
"""Search both notes and code simultaneously.
|
|
231
|
+
|
|
232
|
+
Use when unsure whether the answer is in notes or code,
|
|
233
|
+
or when you want a combined view across both collections.
|
|
234
|
+
"""
|
|
235
|
+
_stats["search_notes_calls"] += 1
|
|
236
|
+
_stats["search_code_calls"] += 1
|
|
237
|
+
notes_out, matched_files, matched_scores = _search_notes(query, limit)
|
|
238
|
+
code_out, log_entries = _search_code(query, limit)
|
|
239
|
+
_log_search("search_all", query, log_entries)
|
|
240
|
+
return f"## Notes\n\n{notes_out}\n\n## Code\n\n{code_out}"
|
|
241
|
+
|
|
242
|
+
|
|
216
243
|
@mcp.tool()
|
|
217
244
|
def reindex(path: str = "") -> str:
|
|
218
245
|
"""Re-index a path into the local Qdrant database.
|
|
@@ -253,10 +280,10 @@ def get_stats(all: bool = False) -> str:
|
|
|
253
280
|
all=False (default): current session stats.
|
|
254
281
|
all=True: all persisted versions side-by-side.
|
|
255
282
|
"""
|
|
256
|
-
from .config import STATS_FILE
|
|
257
283
|
if all:
|
|
258
284
|
try:
|
|
259
|
-
|
|
285
|
+
sf = stats_file()
|
|
286
|
+
versions = json.loads(sf.read_text()).get("versions", {}) if sf.exists() else {}
|
|
260
287
|
except Exception:
|
|
261
288
|
versions = {}
|
|
262
289
|
if not versions:
|
|
@@ -438,4 +465,10 @@ def list_workspaces() -> str:
|
|
|
438
465
|
|
|
439
466
|
def run() -> None:
|
|
440
467
|
"""Entry point for stdio MCP server."""
|
|
468
|
+
cfg = get_config()
|
|
469
|
+
if cfg["watch"]["enabled"]:
|
|
470
|
+
last = cfg["index"].get("last_path", "")
|
|
471
|
+
if last and Path(last).exists():
|
|
472
|
+
from .watcher import CortexWatcher
|
|
473
|
+
CortexWatcher(Path(last), cfg["watch"]["debounce_seconds"]).start()
|
|
441
474
|
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|