cortex-local 2.2.2__tar.gz → 2.3.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.
Files changed (22) hide show
  1. {cortex_local-2.2.2 → cortex_local-2.3.1}/PKG-INFO +1 -1
  2. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/cli.py +2 -2
  3. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/core/chunker.py +88 -5
  4. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/core/code_graph.py +40 -0
  5. cortex_local-2.3.1/cortex/core/roslyn_analyzer.py +35 -0
  6. cortex_local-2.3.1/cortex/core/roslyn_tool/CortexAnalyzer.csproj +13 -0
  7. cortex_local-2.3.1/cortex/core/roslyn_tool/Program.cs +118 -0
  8. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/indexer.py +42 -14
  9. {cortex_local-2.2.2 → cortex_local-2.3.1}/README.md +0 -0
  10. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/__init__.py +0 -0
  11. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/config.py +0 -0
  12. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/core/__init__.py +0 -0
  13. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/core/cache.py +0 -0
  14. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/core/formatters.py +0 -0
  15. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/core/global_graph.py +0 -0
  16. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/core/graph.py +0 -0
  17. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/core/notes_graph.py +0 -0
  18. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/embedder.py +0 -0
  19. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/mcp_server.py +0 -0
  20. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/state.py +0 -0
  21. {cortex_local-2.2.2 → cortex_local-2.3.1}/cortex/watcher.py +0 -0
  22. {cortex_local-2.2.2 → cortex_local-2.3.1}/pyproject.toml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cortex-local
3
- Version: 2.2.2
3
+ Version: 2.3.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
@@ -48,9 +48,9 @@ def pull_models() -> None:
48
48
  def stats() -> None:
49
49
  """Show cortex efficiency stats."""
50
50
  from .state import _stats
51
- from .mcp_server import _fmt_stats
51
+ from .core.formatters import fmt_stats
52
52
  from .config import VERSION
53
- click.echo(_fmt_stats(VERSION, _stats, current=True))
53
+ click.echo(fmt_stats(VERSION, _stats, current=True))
54
54
 
55
55
 
56
56
  @cli.group()
@@ -3,11 +3,36 @@
3
3
 
4
4
  import fnmatch
5
5
  import os
6
+ import re as _re
6
7
  from pathlib import Path
7
8
 
9
+ _RAZOR_BLOCK_RE = _re.compile(r'@(?:code|functions)\s*\{', _re.IGNORECASE)
10
+
11
+
12
+ def _extract_razor_cs_blocks(source: str) -> list:
13
+ """Extract @code{} and @functions{} C# blocks from Razor markup using brace counting."""
14
+ blocks = []
15
+ for match in _RAZOR_BLOCK_RE.finditer(source):
16
+ open_pos = source.index('{', match.start())
17
+ depth = 0
18
+ j = open_pos
19
+ while j < len(source):
20
+ if source[j] == '{':
21
+ depth += 1
22
+ elif source[j] == '}':
23
+ depth -= 1
24
+ if depth == 0:
25
+ start_line = source[:open_pos].count('\n') + 2 # line after opening {
26
+ end_line = source[:j].count('\n') + 1 # line of closing }
27
+ if end_line > start_line:
28
+ blocks.append({"start_line": start_line, "end_line": end_line})
29
+ break
30
+ j += 1
31
+ return blocks
32
+
8
33
  REPOS_DIR = Path(os.environ.get("REPOS_DIR", "/tmp/repos"))
9
34
 
10
- CODE_EXTS = {".js", ".ts", ".tsx", ".jsx", ".svelte", ".py", ".java", ".go", ".rs", ".css", ".html", ".kt", ".kts", ".gd", ".yml", ".yaml", ".cs"}
35
+ CODE_EXTS = {".js", ".ts", ".tsx", ".jsx", ".svelte", ".py", ".java", ".go", ".rs", ".css", ".html", ".kt", ".kts", ".gd", ".yml", ".yaml", ".cs", ".razor", ".cshtml"}
11
36
  SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".svelte-kit", "__pycache__", ".gradle", "target", "bin", "obj"}
12
37
  LANG_MAP = {
13
38
  ".js": "javascript", ".ts": "typescript", ".tsx": "typescript",
@@ -15,6 +40,7 @@ LANG_MAP = {
15
40
  ".java": "java", ".go": "go", ".rs": "rust", ".css": "css", ".html": "html",
16
41
  ".kt": "kotlin", ".kts": "kotlin", ".gd": "gdscript",
17
42
  ".yml": "yaml", ".yaml": "yaml", ".cs": "csharp",
43
+ ".razor": "razor", ".cshtml": "cshtml",
18
44
  }
19
45
 
20
46
  def _is_excluded(rel: str, patterns: list) -> bool:
@@ -216,15 +242,17 @@ def _collect_semantic_nodes(node, target_types: set, depth: int = 0, max_depth:
216
242
  return result
217
243
 
218
244
 
219
- def chunk_file(path: Path, repo_name: str, base_dir: Path | None = None, github_url_base: str = "") -> list:
245
+ def chunk_file(path: Path, repo_name: str, base_dir: Path | None = None, github_url_base: str = "", roslyn_data: dict | None = None) -> list:
220
246
  """Chunk a source file into indexable segments.
221
247
 
222
248
  base_dir: directory to compute relative paths from (defaults to path.parent).
223
249
  github_url_base: e.g. "https://github.com/owner/repo/blob/master"
250
+ roslyn_data: per-file Roslyn analysis dict with "symbols" and "typeRefs" keys.
224
251
  """
225
252
  try:
226
- source = path.read_bytes()
227
- lines = source.decode("utf-8", errors="replace").splitlines()
253
+ raw = path.read_bytes()
254
+ source_text = raw.decode("utf-8", errors="replace")
255
+ lines = source_text.splitlines()
228
256
  except Exception:
229
257
  return []
230
258
  if not lines:
@@ -243,6 +271,35 @@ def chunk_file(path: Path, repo_name: str, base_dir: Path | None = None, github_
243
271
  h = f"# {repo_name}/{rel} (lines {s}-{e})\n# {tokens}"
244
272
  return h + f"\n# {extra}" if extra else h
245
273
 
274
+ # Razor/Blazor: extract @code blocks as csharp + whole file as razor context
275
+ if ext in (".razor", ".cshtml"):
276
+ chunks = []
277
+ blocks = _extract_razor_cs_blocks(source_text)
278
+ for block in blocks:
279
+ s, e = block["start_line"], block["end_line"]
280
+ body = "\n".join(lines[s - 1:e]).strip()
281
+ if body:
282
+ url = f"{github_url_base}/{rel}#L{s}-L{e}" if github_url_base else ""
283
+ tokens = _path_tokens(repo_name, rel)
284
+ chunks.append({
285
+ "repo": repo_name, "file": rel, "language": "csharp",
286
+ "start_line": s, "end_line": e,
287
+ "text": f"{_header(rel, s, e, tokens)}\n\n{body}",
288
+ "github_url": url,
289
+ })
290
+ # Always include a full-file razor chunk for markup context
291
+ body = "\n".join(lines).strip()
292
+ if body:
293
+ url = f"{github_url_base}/{rel}#L1-L{len(lines)}" if github_url_base else ""
294
+ tokens = _path_tokens(repo_name, rel)
295
+ chunks.append({
296
+ "repo": repo_name, "file": rel, "language": language,
297
+ "start_line": 1, "end_line": len(lines),
298
+ "text": f"{_header(rel, 1, len(lines), tokens)}\n\n{body}",
299
+ "github_url": url,
300
+ })
301
+ return chunks
302
+
246
303
  # Small files: keep as single chunk to avoid diluting path/filename signal
247
304
  if len(lines) <= MAX_CHUNK_LINES:
248
305
  body = "\n".join(lines).strip()
@@ -257,10 +314,36 @@ def chunk_file(path: Path, repo_name: str, base_dir: Path | None = None, github_
257
314
  }]
258
315
  return []
259
316
 
317
+ # Roslyn semantic chunking for .cs (preferred over tree-sitter when available)
318
+ if roslyn_data and ext == ".cs":
319
+ top_level_kinds = {"class", "interface", "struct", "enum", "record"}
320
+ all_syms = roslyn_data.get("symbols", [])
321
+ syms = [s for s in all_syms if s.get("kind") in top_level_kinds] or all_syms
322
+ if syms:
323
+ chunks = []
324
+ for sym in syms:
325
+ s = sym["startLine"] - 1 # 0-indexed
326
+ e = sym["endLine"]
327
+ if e - s > MAX_CHUNK_LINES:
328
+ chunks.extend(_sliding_window(lines, s, e, repo_name, rel, language, github_url_base, extra))
329
+ else:
330
+ body = "\n".join(lines[s:e]).strip()
331
+ if body:
332
+ url = f"{github_url_base}/{rel}#L{s+1}-L{e}" if github_url_base else ""
333
+ tokens = _path_tokens(repo_name, rel)
334
+ chunks.append({
335
+ "repo": repo_name, "file": rel, "language": language,
336
+ "start_line": s + 1, "end_line": e,
337
+ "text": f"{_header(rel, s+1, e, tokens)}\n\n{body}",
338
+ "github_url": url,
339
+ })
340
+ if chunks:
341
+ return chunks
342
+
260
343
  if _TS_AVAILABLE and ext in _TS_LANGUAGES:
261
344
  try:
262
345
  parser = _TSParser(_TS_LANGUAGES[ext])
263
- tree = parser.parse(source)
346
+ tree = parser.parse(raw)
264
347
  nodes = _collect_semantic_nodes(tree.root_node, _TS_SEMANTIC[ext])
265
348
  nodes = [n for n in nodes if n.type != "lexical_declaration" or _has_function_value(n)]
266
349
  if nodes:
@@ -17,6 +17,7 @@ _IMPORT_RE: dict = {
17
17
  "js": re.compile(r'''(?:import|from|require)\s*\(?['"](\.[^'"]+|\$lib/[^'"]+)['"]'''),
18
18
  "ts": re.compile(r'''(?:import|from|require)\s*\(?['"](\.[^'"]+|\$lib/[^'"]+)['"]'''),
19
19
  "kt": re.compile(r'^import\s+([\w.]+)', re.MULTILINE),
20
+ "cs": re.compile(r'^using\s+(?!static\b)(?:\w+\s*=\s*)?(\w+(?:\.\w+)+)\s*;', re.MULTILINE),
20
21
  }
21
22
 
22
23
  _NAMED_IMPORT_RE = re.compile(
@@ -31,6 +32,7 @@ _TOP_LEVEL_DEF_RE: dict = {
31
32
  "py": re.compile(r'^(?:class|def)\s+(\w+)', re.MULTILINE),
32
33
  "ts": re.compile(r'(?:export\s+)?(?:class|function|const|interface|type)\s+(\w+)'),
33
34
  "kt": re.compile(r'(?:class|fun|object)\s+(\w+)'),
35
+ "cs": re.compile(r'(?m)(?:class|interface|struct|enum|record)\s+(\w+)'),
34
36
  }
35
37
 
36
38
  _INHERIT_RE: dict = {
@@ -42,12 +44,17 @@ _INHERIT_RE: dict = {
42
44
  r'(?:class|interface|object)\s+\w+[^:{]*:\s*([\w,\s()]+?)(?:\{|$)',
43
45
  re.MULTILINE,
44
46
  ),
47
+ "cs": re.compile(
48
+ r'(?:class|struct|record)\s+\w+(?:<[^>]*>)?\s*:\s*([\w,\s<>.?[\]]+?)(?=\s*(?:where|\{))',
49
+ re.MULTILINE,
50
+ ),
45
51
  }
46
52
 
47
53
  _LANG_KEY: dict = {
48
54
  ".py": "py", ".js": "js", ".jsx": "js",
49
55
  ".ts": "ts", ".tsx": "ts", ".svelte": "js",
50
56
  ".kt": "kt", ".kts": "kt",
57
+ ".cs": "cs", ".razor": "cs", ".cshtml": "cs",
51
58
  }
52
59
 
53
60
  _EDGE_PRIORITY: dict = {"import": 0, "call": 1, "inherits": 2}
@@ -62,6 +69,12 @@ _PY_SKIP_BASES = frozenset({
62
69
  "list", "dict", "set", "tuple", "Enum", "IntEnum",
63
70
  })
64
71
  _TS_SKIP_BASES = frozenset({"Error", "EventEmitter", "Component", "React"})
72
+ _CS_SKIP_BASES = frozenset({
73
+ "object", "Object", "Exception", "SystemException", "ApplicationException",
74
+ "Attribute", "Controller", "ControllerBase", "ApiController", "PageModel",
75
+ "BackgroundService", "IHostedService", "IDisposable", "ValueType",
76
+ "IEnumerable", "IList", "ICollection", "IQueryable", "IComparable",
77
+ })
65
78
 
66
79
 
67
80
  def _extract_imports(path: Path, ext: str) -> list:
@@ -217,6 +230,14 @@ def _extract_inheritance_targets(
217
230
  t = symbol_to_file.get(base)
218
231
  if t and t != rel:
219
232
  targets.add(t)
233
+ elif key == "cs":
234
+ for m in _INHERIT_RE["cs"].finditer(src):
235
+ for base in m.group(1).split(","):
236
+ base = base.strip().split("<")[0].strip()
237
+ if base and base not in _CS_SKIP_BASES:
238
+ t = symbol_to_file.get(base)
239
+ if t and t != rel:
240
+ targets.add(t)
220
241
 
221
242
  return list(targets)
222
243
 
@@ -230,6 +251,25 @@ def _add_edge(G, src: str, dst: str, edge_type: str) -> None:
230
251
  G.add_edge(src, dst, type=edge_type)
231
252
 
232
253
 
254
+ def inject_roslyn_edges(G, roslyn_data: dict, all_files: set) -> None:
255
+ """Add cross-file type reference edges from Roslyn analysis output."""
256
+ if not _NX_AVAILABLE or G is None or not roslyn_data:
257
+ return
258
+ symbol_to_file: dict = {}
259
+ for rel, data in roslyn_data.items():
260
+ for sym in data.get("symbols", []):
261
+ name = sym.get("name", "")
262
+ if name and name not in symbol_to_file:
263
+ symbol_to_file[name] = rel
264
+ for rel, data in roslyn_data.items():
265
+ if rel not in all_files or not G.has_node(rel):
266
+ continue
267
+ for type_ref in data.get("typeRefs", []):
268
+ target = symbol_to_file.get(type_ref)
269
+ if target and target != rel and target in all_files and G.has_node(target):
270
+ _add_edge(G, rel, target, "import")
271
+
272
+
233
273
  def build_code_graph(repo_path: Path, all_files: set):
234
274
  if not _NX_AVAILABLE:
235
275
  return None
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env python3
2
+ """Python wrapper for the Roslyn C# syntax analyzer subprocess."""
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ ROSLYN_BINARY = Path(os.environ.get(
10
+ "ROSLYN_ANALYZER",
11
+ str(Path(__file__).parent / "roslyn_tool" / "CortexAnalyzer")
12
+ ))
13
+
14
+
15
+ def is_available() -> bool:
16
+ return ROSLYN_BINARY.exists() and os.access(ROSLYN_BINARY, os.X_OK)
17
+
18
+
19
+ def analyze_dir(dir_path: Path) -> dict:
20
+ """Run Roslyn analyzer on a directory. Returns per-file symbol + type_ref data.
21
+
22
+ Keys are relative paths (forward slashes). Returns {} on any failure.
23
+ """
24
+ if not is_available():
25
+ return {}
26
+ try:
27
+ result = subprocess.run(
28
+ [str(ROSLYN_BINARY), str(dir_path)],
29
+ capture_output=True, text=True, timeout=60,
30
+ )
31
+ if result.returncode != 0:
32
+ return {}
33
+ return json.loads(result.stdout)
34
+ except Exception:
35
+ return {}
@@ -0,0 +1,13 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+ <PropertyGroup>
3
+ <OutputType>Exe</OutputType>
4
+ <TargetFramework>net8.0</TargetFramework>
5
+ <Nullable>enable</Nullable>
6
+ <ImplicitUsings>enable</ImplicitUsings>
7
+ <AssemblyName>CortexAnalyzer</AssemblyName>
8
+ <RootNamespace>CortexAnalyzer</RootNamespace>
9
+ </PropertyGroup>
10
+ <ItemGroup>
11
+ <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
12
+ </ItemGroup>
13
+ </Project>
@@ -0,0 +1,118 @@
1
+ using Microsoft.CodeAnalysis.CSharp;
2
+ using Microsoft.CodeAnalysis.CSharp.Syntax;
3
+ using System.Text.Json;
4
+
5
+ var dir = args.Length > 0 ? args[0] : Directory.GetCurrentDirectory();
6
+ var jOpts = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
7
+ var results = new Dictionary<string, FileResult>();
8
+
9
+ foreach (var file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories))
10
+ {
11
+ var rel = Path.GetRelativePath(dir, file).Replace('\\', '/');
12
+ try
13
+ {
14
+ var source = File.ReadAllText(file);
15
+ var tree = CSharpSyntaxTree.ParseText(source);
16
+ var root = tree.GetRoot();
17
+
18
+ var symbols = new List<SymbolInfo>();
19
+ var typeRefs = new HashSet<string>();
20
+
21
+ foreach (var node in root.DescendantNodes())
22
+ {
23
+ var span = tree.GetLineSpan(node.Span);
24
+ int s = span.StartLinePosition.Line + 1;
25
+ int e = span.EndLinePosition.Line + 1;
26
+
27
+ switch (node)
28
+ {
29
+ case ClassDeclarationSyntax c:
30
+ symbols.Add(new(c.Identifier.Text, "class", s, e));
31
+ AddBaseTypes(typeRefs, c.BaseList);
32
+ break;
33
+ case InterfaceDeclarationSyntax i:
34
+ symbols.Add(new(i.Identifier.Text, "interface", s, e));
35
+ AddBaseTypes(typeRefs, i.BaseList);
36
+ break;
37
+ case RecordDeclarationSyntax r:
38
+ symbols.Add(new(r.Identifier.Text, "record", s, e));
39
+ AddBaseTypes(typeRefs, r.BaseList);
40
+ break;
41
+ case StructDeclarationSyntax st:
42
+ symbols.Add(new(st.Identifier.Text, "struct", s, e));
43
+ AddBaseTypes(typeRefs, st.BaseList);
44
+ break;
45
+ case EnumDeclarationSyntax en:
46
+ symbols.Add(new(en.Identifier.Text, "enum", s, e));
47
+ break;
48
+ case MethodDeclarationSyntax m:
49
+ symbols.Add(new(m.Identifier.Text, "method", s, e));
50
+ AddTypeName(typeRefs, m.ReturnType);
51
+ foreach (var p in m.ParameterList.Parameters)
52
+ if (p.Type is not null) AddTypeName(typeRefs, p.Type);
53
+ break;
54
+ case ConstructorDeclarationSyntax ctor:
55
+ symbols.Add(new(ctor.Identifier.Text, "constructor", s, e));
56
+ foreach (var p in ctor.ParameterList.Parameters)
57
+ if (p.Type is not null) AddTypeName(typeRefs, p.Type);
58
+ break;
59
+ case PropertyDeclarationSyntax prop:
60
+ symbols.Add(new(prop.Identifier.Text, "property", s, e));
61
+ AddTypeName(typeRefs, prop.Type);
62
+ break;
63
+ case FieldDeclarationSyntax f:
64
+ AddTypeName(typeRefs, f.Declaration.Type);
65
+ break;
66
+ case ObjectCreationExpressionSyntax obj:
67
+ AddTypeName(typeRefs, obj.Type);
68
+ break;
69
+ }
70
+ }
71
+
72
+ results[rel] = new FileResult(symbols, typeRefs.ToList());
73
+ }
74
+ catch { /* skip unparseable files */ }
75
+ }
76
+
77
+ Console.WriteLine(JsonSerializer.Serialize(results, jOpts));
78
+
79
+ void AddBaseTypes(HashSet<string> refs, BaseListSyntax? baseList)
80
+ {
81
+ if (baseList is null) return;
82
+ foreach (var bt in baseList.Types)
83
+ AddTypeName(refs, bt.Type);
84
+ }
85
+
86
+ void AddTypeName(HashSet<string> refs, TypeSyntax type)
87
+ {
88
+ switch (type)
89
+ {
90
+ case IdentifierNameSyntax id when !IsPrimitive(id.Identifier.Text):
91
+ refs.Add(id.Identifier.Text);
92
+ break;
93
+ case GenericNameSyntax gen:
94
+ if (!IsPrimitive(gen.Identifier.Text)) refs.Add(gen.Identifier.Text);
95
+ foreach (var arg in gen.TypeArgumentList.Arguments) AddTypeName(refs, arg);
96
+ break;
97
+ case QualifiedNameSyntax q:
98
+ AddTypeName(refs, q.Right);
99
+ break;
100
+ case NullableTypeSyntax n:
101
+ AddTypeName(refs, n.ElementType);
102
+ break;
103
+ case ArrayTypeSyntax a:
104
+ AddTypeName(refs, a.ElementType);
105
+ break;
106
+ }
107
+ }
108
+
109
+ bool IsPrimitive(string n) => n is
110
+ "void" or "string" or "int" or "long" or "double" or "float" or "bool"
111
+ or "byte" or "char" or "object" or "decimal" or "short" or "uint" or "ulong"
112
+ or "ushort" or "sbyte" or "nint" or "nuint" or "var" or "dynamic"
113
+ or "String" or "Int32" or "Int64" or "Boolean" or "Double" or "Object" or "Void"
114
+ or "Byte" or "Char" or "Decimal" or "Single" or "DateTime" or "DateTimeOffset"
115
+ or "Guid" or "TimeSpan" or "Uri";
116
+
117
+ record SymbolInfo(string Name, string Kind, int StartLine, int EndLine);
118
+ record FileResult(List<SymbolInfo> Symbols, List<string> TypeRefs);
@@ -1,6 +1,7 @@
1
1
  """Index notes and code from a local path into embedded Qdrant."""
2
2
 
3
3
  import hashlib
4
+ import os
4
5
  import re
5
6
  from pathlib import Path
6
7
 
@@ -17,13 +18,36 @@ from .embedder import embed, sparse_embed
17
18
  from .core.chunker import chunk_file, CODE_EXTS, SKIP_DIRS, _is_excluded
18
19
  from .core.cache import load_cache, save_cache
19
20
  from .core import graph as _graph
21
+ from .core import roslyn_analyzer as _roslyn
22
+
23
+
24
+ def _walk_code_files(root: Path, exclude_patterns: list) -> list[Path]:
25
+ """Walk root pruning SKIP_DIRS at directory level — avoids descending into bin/obj/node_modules."""
26
+ found = []
27
+ for dirpath, dirs, files in os.walk(root):
28
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
29
+ for fname in files:
30
+ p = Path(dirpath) / fname
31
+ if p.suffix in CODE_EXTS:
32
+ rel = str(p.relative_to(root)).replace("\\", "/")
33
+ if not _is_excluded(rel, exclude_patterns):
34
+ found.append(p)
35
+ return found
20
36
 
21
37
 
22
38
  def _qdrant() -> QdrantClient:
23
39
  migrate_legacy()
24
40
  d = data_dir()
25
41
  d.mkdir(parents=True, exist_ok=True)
26
- return QdrantClient(path=qdrant_path())
42
+ try:
43
+ return QdrantClient(path=qdrant_path())
44
+ except Exception as e:
45
+ if "already" in str(e).lower() or "lock" in str(e).lower():
46
+ raise SystemExit(
47
+ "Error: Qdrant database is locked by another process (cortex serve is running).\n"
48
+ "Stop cortex serve first, or use the reindex() MCP tool from Claude Code instead."
49
+ ) from e
50
+ raise
27
51
 
28
52
 
29
53
  def _ensure_collection(client: QdrantClient, name: str) -> bool:
@@ -184,12 +208,7 @@ def index_code(code_dir: Path, show_progress: bool = False) -> int:
184
208
  _ensure_collection(client, "code")
185
209
  use_sparse = _sparse_available()
186
210
 
187
- code_files = [
188
- p for p in code_dir.rglob("*")
189
- if p.is_file() and p.suffix in CODE_EXTS
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)
192
- ]
211
+ code_files = _walk_code_files(code_dir, exclude_patterns)
193
212
 
194
213
  cache = load_cache("code", cache_file())
195
214
  if client.get_collection("code").points_count == 0:
@@ -199,8 +218,13 @@ def index_code(code_dir: Path, show_progress: bool = False) -> int:
199
218
  cached = 0
200
219
  file_point_ids: dict = {}
201
220
 
221
+ roslyn_data: dict = {}
222
+ if _roslyn.is_available() and any(p.suffix == ".cs" for p in code_files):
223
+ roslyn_data = _roslyn.analyze_dir(code_dir)
224
+
202
225
  for path in tqdm(code_files, desc=repo_name, disable=not show_progress):
203
- cache_key = f"{repo_name}/{str(path.relative_to(code_dir))}"
226
+ rel_path = str(path.relative_to(code_dir)).replace("\\", "/")
227
+ cache_key = f"{repo_name}/{rel_path}"
204
228
  mtime = path.stat().st_mtime
205
229
  entry = cache.get(cache_key, {})
206
230
  if entry.get("mtime") == mtime:
@@ -213,7 +237,7 @@ def index_code(code_dir: Path, show_progress: bool = False) -> int:
213
237
  continue
214
238
 
215
239
  points = []
216
- for chunk in chunk_file(path, repo_name, base_dir=code_dir):
240
+ for chunk in chunk_file(path, repo_name, base_dir=code_dir, roslyn_data=roslyn_data.get(rel_path)):
217
241
  pid = _code_chunk_id(repo_name, chunk["file"], chunk["start_line"])
218
242
  points.append(_make_point(chunk["text"], pid, chunk, use_sparse))
219
243
  file_point_ids.setdefault(chunk["file"], []).append(pid)
@@ -226,10 +250,13 @@ def index_code(code_dir: Path, show_progress: bool = False) -> int:
226
250
  print(f" {repo_name}: {total} chunks from {len(code_files) - cached} files ({cached} cached)", flush=True)
227
251
 
228
252
  all_files = {
229
- str(p.relative_to(code_dir)) for p in code_files
253
+ str(p.relative_to(code_dir)).replace("\\", "/") for p in code_files
230
254
  }
231
255
  try:
232
256
  G = _graph.build_code_graph(code_dir, all_files)
257
+ if roslyn_data:
258
+ from .core.code_graph import inject_roslyn_edges
259
+ inject_roslyn_edges(G, roslyn_data, all_files)
233
260
  if G is not None:
234
261
  metadata = _graph.compute_code_metadata(G)
235
262
  _graph.persist_code_graph(G, metadata, repo_name, data_dir())
@@ -259,10 +286,11 @@ def index_path(path: Path, show_progress: bool = False) -> None:
259
286
  path = path.resolve()
260
287
  data_dir().mkdir(parents=True, exist_ok=True)
261
288
 
262
- md_files = list(path.rglob("*.md"))
263
- code_files = [p for p in path.rglob("*")
264
- if p.is_file() and p.suffix in CODE_EXTS
265
- and not any(s in p.parts for s in SKIP_DIRS)]
289
+ md_files = []
290
+ for dirpath, dirs, files in os.walk(path):
291
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
292
+ md_files.extend(Path(dirpath) / f for f in files if f.endswith(".md"))
293
+ code_files = _walk_code_files(path, [])
266
294
 
267
295
  if md_files:
268
296
  print(f"\nIndexing notes from {path} ({len(md_files)} .md files)...")
File without changes