cortex-local 2.2.0__tar.gz → 2.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortex-local
3
- Version: 2.2.0
3
+ Version: 2.2.2
4
4
  Summary: Local semantic search MCP server — notes + code, embedded Qdrant, fastembed
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: click>=8.0.0
@@ -7,14 +7,14 @@ from pathlib import Path
7
7
 
8
8
  REPOS_DIR = Path(os.environ.get("REPOS_DIR", "/tmp/repos"))
9
9
 
10
- CODE_EXTS = {".js", ".ts", ".tsx", ".jsx", ".svelte", ".py", ".java", ".go", ".rs", ".css", ".html", ".kt", ".kts", ".gd", ".yml", ".yaml"}
11
- SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".svelte-kit", "__pycache__", ".gradle", "target"}
10
+ CODE_EXTS = {".js", ".ts", ".tsx", ".jsx", ".svelte", ".py", ".java", ".go", ".rs", ".css", ".html", ".kt", ".kts", ".gd", ".yml", ".yaml", ".cs"}
11
+ SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".svelte-kit", "__pycache__", ".gradle", "target", "bin", "obj"}
12
12
  LANG_MAP = {
13
13
  ".js": "javascript", ".ts": "typescript", ".tsx": "typescript",
14
14
  ".jsx": "javascript", ".svelte": "svelte", ".py": "python",
15
15
  ".java": "java", ".go": "go", ".rs": "rust", ".css": "css", ".html": "html",
16
16
  ".kt": "kotlin", ".kts": "kotlin", ".gd": "gdscript",
17
- ".yml": "yaml", ".yaml": "yaml",
17
+ ".yml": "yaml", ".yaml": "yaml", ".cs": "csharp",
18
18
  }
19
19
 
20
20
  def _is_excluded(rel: str, patterns: list) -> bool:
@@ -97,6 +97,15 @@ _FRAMEWORK_IMPORTS = [
97
97
  ("use actix", "actix"),
98
98
  ("use axum", "axum"),
99
99
  ("use tokio", "tokio"),
100
+ # C#
101
+ ("using Microsoft.AspNetCore", "aspnetcore"),
102
+ ("using Microsoft.Extensions", "aspnetcore"),
103
+ ("using Microsoft.EntityFrameworkCore", "entityframework"),
104
+ ("using Newtonsoft.Json", "newtonsoft"),
105
+ ("using System.Text.Json", "system-text-json"),
106
+ ("using Xunit", "xunit"),
107
+ ("using NUnit.Framework", "nunit"),
108
+ ("using Microsoft.Azure", "azure-sdk"),
100
109
  ]
101
110
 
102
111
  try:
@@ -124,6 +133,15 @@ try:
124
133
  ".kt": {"function_declaration", "class_declaration", "object_declaration"},
125
134
  ".kts": {"function_declaration", "class_declaration", "object_declaration"},
126
135
  }
136
+ try:
137
+ import tree_sitter_c_sharp as _tscs
138
+ _TS_LANGUAGES[".cs"] = Language(_tscs.language())
139
+ _TS_SEMANTIC[".cs"] = {
140
+ "method_declaration", "class_declaration", "interface_declaration",
141
+ "constructor_declaration", "struct_declaration", "enum_declaration",
142
+ }
143
+ except Exception:
144
+ pass
127
145
  _TS_AVAILABLE = True
128
146
  except Exception:
129
147
  _TS_AVAILABLE = False
@@ -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)
@@ -3,7 +3,6 @@
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
 
9
8
 
@@ -19,6 +18,7 @@ from .config import (
19
18
  from .embedder import embed, sparse_embed
20
19
  from .state import _stats, get_graph_meta, save_stats, invalidate_graph_cache
21
20
  from .indexer import index_path
21
+ from .core.formatters import format_notes_parts, format_code_results, fmt_stats
22
22
 
23
23
  mcp = FastMCP("cortex")
24
24
 
@@ -107,16 +107,7 @@ def _search_notes(query: str, limit: int = 5):
107
107
  if not results:
108
108
  return "No results found.", [], []
109
109
  _stats["total_results_notes"] += len(results)
110
- parts = []
111
- matched_files = []
112
- matched_scores = []
113
- for r in results:
114
- p = r.payload
115
- tags = p.get("tags", [])
116
- tag_str = f" `{'`, `'.join(tags)}`" if tags else ""
117
- parts.append(f"**{p['file']} > {p['heading']}** (score: {round(r.score, 3)}){tag_str}\n\n{p['text']}")
118
- matched_files.append(p.get("file", ""))
119
- matched_scores.append(r.score)
110
+ parts, matched_files, matched_scores = format_notes_parts(results)
120
111
  ppr = _ppr_block(matched_files, matched_scores)
121
112
  if ppr:
122
113
  parts.append(ppr)
@@ -170,23 +161,7 @@ def _search_code(query: str, limit: int = 5):
170
161
  {"repo": r.payload.get("repo", ""), "file": r.payload.get("file", ""), "score": round(bs, 3)}
171
162
  for bs, r, _ in deduped
172
163
  ]
173
- parts = []
174
- for boosted_score, r, file_meta in deduped:
175
- p = r.payload
176
- centrality = file_meta.get("centrality")
177
- community = file_meta.get("community_id")
178
- header = (
179
- f"**{p['repo']}/{p['file']}** "
180
- f"lines {p['start_line']}-{p['end_line']} "
181
- f"({p.get('language', '')}) — score: {round(boosted_score, 3)}"
182
- )
183
- if centrality is not None:
184
- header += f" · centrality: {centrality}"
185
- if community is not None:
186
- header += f" · community: {community}"
187
- url = p.get("github_url", "")
188
- parts.append(f"{header}\n{url}\n\n{p['text']}" if url else f"{header}\n\n{p['text']}")
189
- out = "\n\n---\n\n".join(parts)
164
+ out = format_code_results(deduped)
190
165
  _stats["context_tokens_code"] += len(out) // 4
191
166
  return out, log_entries
192
167
 
@@ -290,34 +265,9 @@ def get_stats(all: bool = False) -> str:
290
265
  return "No persisted stats found."
291
266
  blocks = []
292
267
  for v in sorted(versions):
293
- s = versions[v]
294
- blocks.append(_fmt_stats(v, s, current=(v == VERSION)))
268
+ blocks.append(fmt_stats(v, versions[v], current=(v == VERSION)))
295
269
  return "\n\n".join(blocks)
296
- return _fmt_stats(VERSION, _stats, current=True)
297
-
298
-
299
- def _fmt_stats(v: str, s: dict, current: bool = False) -> str:
300
- total = s.get("search_code_calls", 0) + s.get("search_notes_calls", 0)
301
- ppr_rate = f"{s['ppr_fires'] / s['search_notes_calls'] * 100:.1f}%" if s.get("search_notes_calls") else "—"
302
- lift_avg = round(s["centrality_lift_total"] / s["centrality_lift_count"], 4) if s.get("centrality_lift_count") else 0
303
- label = f"{v} (current)" if current else v
304
- lines = [label]
305
- started = s.get("started_at", "")
306
- if current and started:
307
- try:
308
- delta = datetime.now(timezone.utc) - datetime.fromisoformat(started)
309
- h, rem = divmod(int(delta.total_seconds()), 3600)
310
- m = rem // 60
311
- lines.append(f" uptime: {h}h {m}m")
312
- except Exception:
313
- pass
314
- lines += [
315
- f" searches: code={s.get('search_code_calls', 0)} notes={s.get('search_notes_calls', 0)} total={total}",
316
- f" centrality lift avg: {lift_avg} (across {s.get('centrality_lift_count', 0)} results)",
317
- f" PPR: {s.get('ppr_fires', 0)} fires ({ppr_rate} of note searches) · +{s.get('ppr_results_added', 0)} results",
318
- f" reindexes: {s.get('reindex_count', 0)}",
319
- ]
320
- return "\n".join(lines)
270
+ return fmt_stats(VERSION, _stats, current=True)
321
271
 
322
272
 
323
273
  @mcp.tool()
File without changes
File without changes