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.
- {cortex_local-2.1.16 → cortex_local-2.2.1}/PKG-INFO +3 -3
- {cortex_local-2.1.16 → cortex_local-2.2.1}/cortex/cli.py +45 -2
- {cortex_local-2.1.16 → cortex_local-2.2.1}/cortex/config.py +43 -0
- cortex_local-2.2.1/cortex/core/cache.py +36 -0
- {cortex_local-2.1.16 → cortex_local-2.2.1}/cortex/core/chunker.py +62 -20
- cortex_local-2.2.1/cortex/core/formatters.py +114 -0
- cortex_local-2.2.1/cortex/core/graph.py +11 -0
- {cortex_local-2.1.16 → cortex_local-2.2.1}/cortex/indexer.py +38 -17
- {cortex_local-2.1.16 → cortex_local-2.2.1}/cortex/mcp_server.py +72 -89
- cortex_local-2.2.1/cortex/watcher.py +60 -0
- {cortex_local-2.1.16 → cortex_local-2.2.1}/pyproject.toml +2 -3
- cortex_local-2.1.16/cortex/core/cache.py +0 -25
- cortex_local-2.1.16/cortex/core/graph.py +0 -6
- {cortex_local-2.1.16 → cortex_local-2.2.1}/README.md +0 -0
- {cortex_local-2.1.16 → cortex_local-2.2.1}/cortex/__init__.py +0 -0
- {cortex_local-2.1.16 → cortex_local-2.2.1}/cortex/core/__init__.py +0 -0
- {cortex_local-2.1.16 → cortex_local-2.2.1}/cortex/core/code_graph.py +0 -0
- {cortex_local-2.1.16 → cortex_local-2.2.1}/cortex/core/global_graph.py +0 -0
- {cortex_local-2.1.16 → cortex_local-2.2.1}/cortex/core/notes_graph.py +0 -0
- {cortex_local-2.1.16 → cortex_local-2.2.1}/cortex/embedder.py +0 -0
- {cortex_local-2.1.16 → cortex_local-2.2.1}/cortex/state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cortex-local
|
|
3
|
-
Version: 2.1
|
|
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
|
-
|
|
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,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
|
-
|
|
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}")
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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
|