cortex-local 2.5.3__tar.gz → 2.7.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.
Files changed (28) hide show
  1. {cortex_local-2.5.3 → cortex_local-2.7.0}/.gitignore +1 -0
  2. {cortex_local-2.5.3 → cortex_local-2.7.0}/PKG-INFO +2 -1
  3. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/core/chunker.py +88 -2
  4. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/core/code_graph.py +18 -0
  5. cortex_local-2.7.0/cortex/core/godot.py +296 -0
  6. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/indexer.py +3 -3
  7. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/mcp_server.py +2 -1
  8. {cortex_local-2.5.3 → cortex_local-2.7.0}/pyproject.toml +1 -0
  9. {cortex_local-2.5.3 → cortex_local-2.7.0}/README.md +0 -0
  10. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/__init__.py +0 -0
  11. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/cli.py +0 -0
  12. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/config.py +0 -0
  13. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/core/__init__.py +0 -0
  14. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/core/cache.py +0 -0
  15. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/core/formatters.py +0 -0
  16. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/core/global_graph.py +0 -0
  17. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/core/graph.py +0 -0
  18. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/core/notes_graph.py +0 -0
  19. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/core/roslyn_analyzer.py +0 -0
  20. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/core/roslyn_tool/CortexAnalyzer.csproj +0 -0
  21. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/core/roslyn_tool/Program.cs +0 -0
  22. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/db.py +0 -0
  23. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/embedder.py +0 -0
  24. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/state.py +0 -0
  25. {cortex_local-2.5.3 → cortex_local-2.7.0}/cortex/watcher.py +0 -0
  26. {cortex_local-2.5.3 → cortex_local-2.7.0}/hatch_build.py +0 -0
  27. {cortex_local-2.5.3 → cortex_local-2.7.0}/tests/test_imports.py +0 -0
  28. {cortex_local-2.5.3 → cortex_local-2.7.0}/tests/test_stdio_init.py +0 -0
@@ -6,3 +6,4 @@ __pycache__/
6
6
  dist/
7
7
  build/
8
8
  .pytest_cache/
9
+ .claude/worktrees/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cortex-local
3
- Version: 2.5.3
3
+ Version: 2.7.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
@@ -10,6 +10,7 @@ Requires-Dist: networkx>=3.0
10
10
  Requires-Dist: qdrant-client>=1.17.0
11
11
  Requires-Dist: tqdm>=4.0
12
12
  Requires-Dist: tree-sitter-c-sharp
13
+ Requires-Dist: tree-sitter-gdscript
13
14
  Requires-Dist: tree-sitter-javascript
14
15
  Requires-Dist: tree-sitter-kotlin
15
16
  Requires-Dist: tree-sitter-python
@@ -6,6 +6,11 @@ import os
6
6
  import re as _re
7
7
  from pathlib import Path
8
8
 
9
+ try:
10
+ from . import godot as _godot
11
+ except ImportError: # server: core/ is on sys.path as top-level modules
12
+ import godot as _godot
13
+
9
14
  _RAZOR_BLOCK_RE = _re.compile(r'@(?:code|functions)\s*\{', _re.IGNORECASE)
10
15
 
11
16
 
@@ -32,8 +37,9 @@ def _extract_razor_cs_blocks(source: str) -> list:
32
37
 
33
38
  REPOS_DIR = Path(os.environ.get("REPOS_DIR", "/tmp/repos"))
34
39
 
35
- CODE_EXTS = {".js", ".ts", ".tsx", ".jsx", ".svelte", ".py", ".java", ".go", ".rs", ".css", ".html", ".kt", ".kts", ".gd", ".yml", ".yaml", ".cs", ".razor", ".cshtml"}
36
- SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".svelte-kit", "__pycache__", ".gradle", "target", "bin", "obj"}
40
+ CODE_EXTS = {".js", ".ts", ".tsx", ".jsx", ".svelte", ".py", ".java", ".go", ".rs", ".css", ".html", ".kt", ".kts", ".gd", ".yml", ".yaml", ".cs", ".razor", ".cshtml",
41
+ ".tscn", ".tres", ".godot", ".gdshader", ".dialogue"}
42
+ SKIP_DIRS = {"node_modules", ".git", "dist", "build", ".next", ".svelte-kit", "__pycache__", ".gradle", "target", "bin", "obj", ".godot"}
37
43
  LANG_MAP = {
38
44
  ".js": "javascript", ".ts": "typescript", ".tsx": "typescript",
39
45
  ".jsx": "javascript", ".svelte": "svelte", ".py": "python",
@@ -41,12 +47,45 @@ LANG_MAP = {
41
47
  ".kt": "kotlin", ".kts": "kotlin", ".gd": "gdscript",
42
48
  ".yml": "yaml", ".yaml": "yaml", ".cs": "csharp",
43
49
  ".razor": "razor", ".cshtml": "cshtml",
50
+ ".tscn": "godot-scene", ".tres": "godot-resource", ".godot": "godot-project",
51
+ ".gdshader": "gdshader", ".dialogue": "dialogue",
44
52
  }
45
53
 
46
54
  def _is_excluded(rel: str, patterns: list) -> bool:
47
55
  return any(fnmatch.fnmatch(rel, p) for p in patterns)
48
56
 
49
57
 
58
+ def repo_ignore_patterns(root: Path) -> list:
59
+ """Per-repo exclude globs for _is_excluded():
60
+
61
+ - `.cortexignore` at the repo root, gitignore-like: `addons/gut/` or `/vendor` (anchored),
62
+ `*.min.js`, `# comments`. Unanchored entries match at any depth. No `!` negation.
63
+ - every directory containing a `.gdignore` (Godot's "do not import" marker).
64
+ """
65
+ pats: list = []
66
+ ci = root / ".cortexignore"
67
+ if ci.is_file():
68
+ for line in ci.read_text(encoding="utf-8", errors="replace").splitlines():
69
+ line = line.strip()
70
+ if not line or line.startswith(("#", "!")):
71
+ continue
72
+ anchored = line.startswith("/")
73
+ line = line.strip("/")
74
+ if not line:
75
+ continue
76
+ base = [line, line + "/*"]
77
+ pats += base if anchored else base + ["*/" + b for b in base]
78
+ for dirpath, dirs, files in os.walk(root):
79
+ dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
80
+ if ".gdignore" in files:
81
+ rel = Path(dirpath).relative_to(root).as_posix()
82
+ if rel == ".":
83
+ return ["*"]
84
+ pats.append(rel + "/*")
85
+ dirs[:] = []
86
+ return pats
87
+
88
+
50
89
  CHUNK_LINES = 30
51
90
  OVERLAP_LINES = 5
52
91
  MAX_CHUNK_LINES = 80
@@ -266,6 +305,8 @@ def chunk_file(path: Path, repo_name: str, base_dir: Path | None = None, github_
266
305
  rel = path.name
267
306
  language = LANG_MAP.get(ext, ext.lstrip("."))
268
307
  extra = _extra_tokens(rel, lines)
308
+ if ext == ".gd":
309
+ extra = (extra + " " + _godot.gd_context(source_text)).strip()
269
310
 
270
311
  def _header(rel: str, s: int, e: int, tokens: str) -> str:
271
312
  h = f"# {repo_name}/{rel} (lines {s}-{e})\n# {tokens}"
@@ -300,6 +341,29 @@ def chunk_file(path: Path, repo_name: str, base_dir: Path | None = None, github_
300
341
  })
301
342
  return chunks
302
343
 
344
+ # Godot scenes/resources/project: index a readable summary, not the raw serialization
345
+ if ext in _godot.GODOT_SUMMARY_EXTS:
346
+ summary = _godot.summarize(path, source_text).splitlines()
347
+ if not summary:
348
+ return []
349
+ tokens = _path_tokens(repo_name, rel)
350
+ n_file = len(lines)
351
+ windows = [summary[i:i + MAX_CHUNK_LINES] for i in range(0, len(summary), MAX_CHUNK_LINES)]
352
+ k = len(windows)
353
+ out = []
354
+ for i, w in enumerate(windows):
355
+ # distinct pseudo line ranges per window (chunk ids are keyed on start_line)
356
+ s_line = 1 + (i * n_file) // k
357
+ e_line = max(s_line, ((i + 1) * n_file) // k)
358
+ url = f"{github_url_base}/{rel}#L{s_line}-L{e_line}" if github_url_base else ""
359
+ out.append({
360
+ "repo": repo_name, "file": rel, "language": language,
361
+ "start_line": s_line, "end_line": e_line,
362
+ "text": f"{_header(rel, s_line, e_line, tokens)}\n\n" + "\n".join(w),
363
+ "github_url": url,
364
+ })
365
+ return out
366
+
303
367
  # Small files: keep as single chunk to avoid diluting path/filename signal
304
368
  if len(lines) <= MAX_CHUNK_LINES:
305
369
  body = "\n".join(lines).strip()
@@ -340,6 +404,28 @@ def chunk_file(path: Path, repo_name: str, base_dir: Path | None = None, github_
340
404
  if chunks:
341
405
  return chunks
342
406
 
407
+ # GDScript: functions / inner classes / member blocks, annotations kept with their decl
408
+ if ext == ".gd":
409
+ spans = _godot.gd_spans(raw)
410
+ if spans:
411
+ chunks = []
412
+ tokens = _path_tokens(repo_name, rel)
413
+ for s0, e, _kind in spans:
414
+ if e - s0 > MAX_CHUNK_LINES:
415
+ chunks.extend(_sliding_window(lines, s0, e, repo_name, rel, language, github_url_base, extra))
416
+ continue
417
+ body = "\n".join(lines[s0:e]).strip()
418
+ if body:
419
+ url = f"{github_url_base}/{rel}#L{s0+1}-L{e}" if github_url_base else ""
420
+ chunks.append({
421
+ "repo": repo_name, "file": rel, "language": language,
422
+ "start_line": s0 + 1, "end_line": e,
423
+ "text": f"{_header(rel, s0+1, e, tokens)}\n\n{body}",
424
+ "github_url": url,
425
+ })
426
+ if chunks:
427
+ return chunks
428
+
343
429
  if _TS_AVAILABLE and ext in _TS_LANGUAGES:
344
430
  try:
345
431
  parser = _TSParser(_TS_LANGUAGES[ext])
@@ -8,6 +8,13 @@ from pathlib import Path
8
8
 
9
9
  log = logging.getLogger("cortex.graph")
10
10
 
11
+ try:
12
+ from . import godot as _godot
13
+ except ImportError: # server: core/ is on sys.path as top-level modules
14
+ import godot as _godot
15
+
16
+ _GODOT_EXTS = (".gd", ".tscn", ".tres", ".godot")
17
+
11
18
  try:
12
19
  import networkx as nx
13
20
  _NX_AVAILABLE = True
@@ -336,6 +343,17 @@ def build_code_graph(repo_path: Path, all_files: set):
336
343
  _add_edge(G, rel, target, "call")
337
344
  for target in _extract_inheritance_targets(path, ext, all_files, rel, symbol_to_file):
338
345
  _add_edge(G, rel, target, "inherits")
346
+ godot_files = [f for f in all_files if f.endswith(_GODOT_EXTS)]
347
+ if godot_files:
348
+ proj = _godot.GodotProject(repo_path, all_files)
349
+ for rel in godot_files:
350
+ try:
351
+ text = (repo_path / rel).read_text(encoding="utf-8", errors="replace")
352
+ except Exception:
353
+ continue
354
+ for target, kind in _godot.gd_edges(rel, text, proj):
355
+ if target in all_files:
356
+ _add_edge(G, rel, target, kind)
339
357
  return G
340
358
 
341
359
 
@@ -0,0 +1,296 @@
1
+ """Godot support: GDScript semantic chunks, scene/resource/project summaries, graph edges.
2
+
3
+ Used by chunker.chunk_file (chunks) and code_graph.build_code_graph (edges). Paths handed in
4
+ and out are repo-relative POSIX; `res://` paths are resolved against the directory that holds
5
+ project.godot (the Godot project root, not necessarily the repo root).
6
+ """
7
+
8
+ import re
9
+ from pathlib import Path
10
+
11
+ try:
12
+ from tree_sitter import Language, Parser
13
+ import tree_sitter_gdscript as _tsgd
14
+ _GD_LANG = Language(_tsgd.language())
15
+ except Exception: # grammar missing -> chunker falls back to the sliding window
16
+ _GD_LANG = None
17
+
18
+ GODOT_SUMMARY_EXTS = {".tscn", ".tres", ".godot"}
19
+
20
+ # ── res:// / uid:// resolution ───────────────────────────────────────────────
21
+
22
+ _SECTION_RE = re.compile(r'^\[(\w+)([^\]]*)\]\s*$', re.MULTILINE)
23
+ _ATTR_RE = re.compile(r'(\w+)=("([^"]*)"|[^\s\]]+)')
24
+
25
+
26
+ def _attrs(s: str) -> dict:
27
+ return {m.group(1): (m.group(3) if m.group(3) is not None else m.group(2)) for m in _ATTR_RE.finditer(s)}
28
+
29
+
30
+ class GodotProject:
31
+ """Repo-level Godot context: project roots, autoloads, uid map, class_name map."""
32
+
33
+ def __init__(self, repo_path: Path, all_files: set):
34
+ self.roots = sorted({str(Path(f).parent.as_posix()) for f in all_files if Path(f).name == "project.godot"},
35
+ key=len, reverse=True)
36
+ self.autoloads: dict = {} # Name -> repo-relative file
37
+ self.uids: dict = {} # uid://x -> repo-relative file
38
+ self.classes: dict = {} # class_name -> repo-relative file
39
+ for f in all_files:
40
+ p = repo_path / f
41
+ if Path(f).name == "project.godot":
42
+ for name, target in _parse_autoloads(_read(p)).items():
43
+ resolved = self.res(target, f)
44
+ if resolved:
45
+ self.autoloads[name] = resolved
46
+ elif f.endswith(".gd"):
47
+ m = re.search(r'^class_name\s+(\w+)', _read(p), re.MULTILINE)
48
+ if m:
49
+ self.classes.setdefault(m.group(1), f)
50
+ if f.endswith((".tscn", ".tres")):
51
+ head = _read(p, 400)
52
+ m = re.search(r'uid="(uid://[^"]+)"', head.split("\n", 1)[0])
53
+ if m:
54
+ self.uids[m.group(1)] = f
55
+ # `.uid` twin files (Godot 4.4+): script.gd.uid contains the script's uid
56
+ for uidfile in repo_path.rglob("*.uid"):
57
+ try:
58
+ rel = uidfile.relative_to(repo_path).as_posix()[:-4]
59
+ if rel in all_files:
60
+ self.uids[uidfile.read_text(errors="replace").strip()] = rel
61
+ except Exception:
62
+ continue
63
+
64
+ def project_root(self, rel: str) -> str:
65
+ for r in self.roots:
66
+ if r == "." or rel.startswith(r + "/"):
67
+ return r
68
+ return "."
69
+
70
+ def res(self, path: str, from_file: str) -> str | None:
71
+ """res://a/b.gd (or uid://...) -> repo-relative path, None if unknown."""
72
+ path = path.strip().lstrip("*")
73
+ if path.startswith("uid://"):
74
+ return self.uids.get(path)
75
+ if not path.startswith("res://"):
76
+ return None
77
+ root = self.project_root(from_file)
78
+ tail = path[len("res://"):]
79
+ return tail if root == "." else f"{root}/{tail}"
80
+
81
+
82
+ def _read(p: Path, limit: int | None = None) -> str:
83
+ try:
84
+ text = p.read_text(encoding="utf-8", errors="replace")
85
+ return text[:limit] if limit else text
86
+ except Exception:
87
+ return ""
88
+
89
+
90
+ def _parse_autoloads(text: str) -> dict:
91
+ out, in_section = {}, False
92
+ for line in text.splitlines():
93
+ s = line.strip()
94
+ if s.startswith("["):
95
+ in_section = s == "[autoload]"
96
+ continue
97
+ if in_section and "=" in s:
98
+ k, v = s.split("=", 1)
99
+ out[k.strip()] = v.strip().strip('"')
100
+ return out
101
+
102
+
103
+ # ── GDScript chunking ────────────────────────────────────────────────────────
104
+
105
+ _DECL_TYPES = {"signal_statement", "variable_statement", "const_statement", "enum_definition",
106
+ "export_variable_statement", "onready_variable_statement"}
107
+
108
+
109
+ def gd_context(source: str) -> str:
110
+ """'class_name X extends Y' for embedding headers ('' when the file declares neither)."""
111
+ cn = re.search(r'^class_name\s+(\w+)', source, re.MULTILINE)
112
+ ex = re.search(r'^extends\s+(\S+)', source, re.MULTILINE)
113
+ parts = []
114
+ if cn:
115
+ parts.append(f"class_name {cn.group(1)}")
116
+ if ex:
117
+ parts.append(f"extends {ex.group(1)}")
118
+ return " ".join(parts)
119
+
120
+
121
+ def gd_spans(raw: bytes) -> list | None:
122
+ """Top-level semantic spans [(start_line0, end_line_excl, kind)], or None without the grammar.
123
+
124
+ Functions and inner classes are their own spans, with preceding annotations (@rpc,
125
+ @warning_ignore ...) attached. Runs of signals/vars/consts/enums form one 'members' span.
126
+ """
127
+ if _GD_LANG is None:
128
+ return None
129
+ try:
130
+ tree = Parser(_GD_LANG).parse(raw)
131
+ except Exception:
132
+ return None
133
+ spans: list = []
134
+ pending_ann = None # start line of annotations waiting for their declaration
135
+ decl = None # [start, end] of the current members run
136
+ for node in tree.root_node.children:
137
+ t = node.type
138
+ s, e = node.start_point[0], node.end_point[0] + 1
139
+ if t == "annotation":
140
+ pending_ann = s if pending_ann is None else pending_ann
141
+ continue
142
+ start = pending_ann if pending_ann is not None else s
143
+ pending_ann = None
144
+ if t in ("function_definition", "class_definition", "constructor_definition"):
145
+ if decl:
146
+ spans.append((decl[0], decl[1], "members")); decl = None
147
+ spans.append((start, e, "class" if t == "class_definition" else "function"))
148
+ elif t in _DECL_TYPES:
149
+ decl = [decl[0], e] if decl else [start, e]
150
+ # class_name / extends / comments: covered by the header context
151
+ if decl:
152
+ spans.append((decl[0], decl[1], "members"))
153
+ return spans
154
+
155
+
156
+ # ── Scene / resource / project summaries (indexed instead of raw text) ──────
157
+
158
+ def _ext_resources(text: str) -> dict:
159
+ out = {}
160
+ for m in _SECTION_RE.finditer(text):
161
+ if m.group(1) == "ext_resource":
162
+ a = _attrs(m.group(2))
163
+ if "id" in a:
164
+ out[a["id"]] = a.get("path") or a.get("uid", "")
165
+ return out
166
+
167
+
168
+ def _sections(text: str):
169
+ """Yield (kind, attrs, body_lines) for each [section] in a .tscn/.tres file."""
170
+ matches = list(_SECTION_RE.finditer(text))
171
+ for i, m in enumerate(matches):
172
+ end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
173
+ body = text[m.end():end].strip("\n").splitlines()
174
+ yield m.group(1), _attrs(m.group(2)), body
175
+
176
+
177
+ _EXTRES_REF = re.compile(r'ExtResource\(\s*"?([^")\s]+)"?\s*\)')
178
+ _SKIP_VALUE = re.compile(r'^(Packed\w*Array|PoolByteArray|Image)\(')
179
+
180
+
181
+ def summarize_tscn(text: str) -> str:
182
+ ext = _ext_resources(text)
183
+ lines = ["Godot scene", "Nodes:"]
184
+ conns = []
185
+ for kind, a, body in _sections(text):
186
+ if kind == "node":
187
+ name, parent = a.get("name", "?"), a.get("parent")
188
+ path = name if parent is None else (name if parent == "." else f"{parent}/{name}")
189
+ desc = f" {path}"
190
+ if a.get("type"):
191
+ desc += f" ({a['type']})"
192
+ inst = _EXTRES_REF.search(a.get("instance", ""))
193
+ if inst:
194
+ desc += f" instance={ext.get(inst.group(1), inst.group(1))}"
195
+ for line in body:
196
+ if line.startswith("script ="):
197
+ ref = _EXTRES_REF.search(line)
198
+ if ref:
199
+ desc += f" script={ext.get(ref.group(1), ref.group(1))}"
200
+ lines.append(desc)
201
+ elif kind == "connection":
202
+ conns.append(f" {a.get('from', '?')}.{a.get('signal', '?')} -> {a.get('method', '?')} (on {a.get('to', '?')})")
203
+ if conns:
204
+ lines.append("Signal connections:")
205
+ lines += conns
206
+ if ext:
207
+ lines.append("Uses: " + ", ".join(sorted(set(v for v in ext.values() if v))))
208
+ return "\n".join(lines)
209
+
210
+
211
+ def summarize_tres(text: str) -> str:
212
+ ext = _ext_resources(text)
213
+ lines = []
214
+ for kind, a, body in _sections(text):
215
+ if kind == "gd_resource":
216
+ head = f"Godot resource type={a.get('type', '?')}"
217
+ if a.get("script_class"):
218
+ head += f" script_class={a['script_class']}"
219
+ lines.append(head)
220
+ elif kind in ("resource", "sub_resource"):
221
+ if kind == "sub_resource":
222
+ lines.append(f"Sub-resource {a.get('type', '')} {a.get('id', '')}".rstrip())
223
+ for line in body:
224
+ if "=" not in line:
225
+ continue
226
+ k, v = (x.strip() for x in line.split("=", 1))
227
+ if _SKIP_VALUE.match(v):
228
+ continue
229
+ v = _EXTRES_REF.sub(lambda m: ext.get(m.group(1), m.group(1)), v)
230
+ lines.append(f" {k} = {v[:200]}")
231
+ if ext:
232
+ lines.append("Uses: " + ", ".join(sorted(set(v for v in ext.values() if v))))
233
+ return "\n".join(lines)
234
+
235
+
236
+ def summarize_project(text: str) -> str:
237
+ lines, section, actions = ["Godot project.godot"], "", []
238
+ for raw in text.splitlines():
239
+ s = raw.strip()
240
+ if s.startswith("["):
241
+ section = s.strip("[]")
242
+ continue
243
+ if "=" not in s or s.startswith(";"):
244
+ continue
245
+ k, v = (x.strip() for x in s.split("=", 1))
246
+ if section == "application" and k in ("config/name", "run/main_scene", "config/features"):
247
+ lines.append(f"{k} = {v}")
248
+ elif section == "autoload":
249
+ lines.append(f"autoload {k} = {v.strip(chr(34)).lstrip('*')}")
250
+ elif section == "input" and not k.startswith(" "):
251
+ actions.append(k)
252
+ if actions:
253
+ lines.append("Input actions: " + ", ".join(actions))
254
+ return "\n".join(lines)
255
+
256
+
257
+ def summarize(path: Path, text: str) -> str:
258
+ if path.suffix == ".tscn":
259
+ return summarize_tscn(text)
260
+ if path.suffix == ".tres":
261
+ return summarize_tres(text)
262
+ return summarize_project(text)
263
+
264
+
265
+ # ── Graph edges ──────────────────────────────────────────────────────────────
266
+
267
+ _RES_STR = re.compile(r'"\*?((?:res|uid)://[^"]+)"')
268
+ _LOAD_RE = re.compile(r'\b(?:preload|load|ResourceLoader\.load)\s*\(\s*"((?:res|uid)://[^"]+)"')
269
+ _EXTENDS_RE = re.compile(r'^extends\s+("([^"]+)"|(\w+))', re.MULTILINE)
270
+ _USE_RE = re.compile(r'\b([A-Z]\w*)\s*(?:\.|\(|$)|(?::|\bas|\bis|->)\s*([A-Z]\w*)', re.MULTILINE)
271
+
272
+
273
+ def gd_edges(rel: str, text: str, proj: GodotProject) -> list:
274
+ """[(target, edge_type)] for a .gd / .tscn / .tres / project.godot file."""
275
+ edges: list = []
276
+ if rel.endswith(".gd"):
277
+ m = _EXTENDS_RE.search(text)
278
+ if m:
279
+ tgt = proj.res(m.group(2), rel) if m.group(2) else proj.classes.get(m.group(3))
280
+ if tgt:
281
+ edges.append((tgt, "inherits"))
282
+ for m in _LOAD_RE.finditer(text):
283
+ tgt = proj.res(m.group(1), rel)
284
+ if tgt:
285
+ edges.append((tgt, "import"))
286
+ used = {a or b for a, b in _USE_RE.findall(text)}
287
+ for name in used:
288
+ tgt = proj.classes.get(name) or proj.autoloads.get(name)
289
+ if tgt:
290
+ edges.append((tgt, "call"))
291
+ else: # scenes/resources/project: every res:// reference is a dependency
292
+ for m in _RES_STR.finditer(text):
293
+ tgt = proj.res(m.group(1), rel)
294
+ if tgt:
295
+ edges.append((tgt, "import"))
296
+ return [(t, k) for t, k in edges if t != rel]
@@ -18,7 +18,7 @@ from qdrant_client.models import (
18
18
  from . import db
19
19
  from .config import data_dir, cache_file, VECTOR_SIZE, get_config, read_index_meta, write_index_meta
20
20
  from .embedder import embed, sparse_embed
21
- from .core.chunker import chunk_file, CODE_EXTS, SKIP_DIRS, _is_excluded
21
+ from .core.chunker import chunk_file, CODE_EXTS, SKIP_DIRS, _is_excluded, repo_ignore_patterns
22
22
  from .core.cache import load_cache, save_cache
23
23
  from .core import graph as _graph
24
24
  from .core import roslyn_analyzer as _roslyn
@@ -171,7 +171,7 @@ def index_notes(notes_dir: Path, show_progress: bool = False) -> int:
171
171
  Changed files have their old points deleted first; files gone from disk (or newly
172
172
  excluded) are removed from the index.
173
173
  """
174
- exclude_patterns = get_config()["index"]["exclude_patterns"]
174
+ exclude_patterns = get_config()["index"]["exclude_patterns"] + repo_ignore_patterns(notes_dir)
175
175
  root = notes_dir.resolve().as_posix()
176
176
  prefix = root + "/"
177
177
 
@@ -235,7 +235,7 @@ def index_code(code_dir: Path, show_progress: bool = False) -> int:
235
235
 
236
236
  Same incremental contract as index_notes: changed files are replaced, deleted files removed.
237
237
  """
238
- exclude_patterns = get_config()["index"]["exclude_patterns"]
238
+ exclude_patterns = get_config()["index"]["exclude_patterns"] + repo_ignore_patterns(code_dir)
239
239
  repo_name = code_dir.name
240
240
  root = code_dir.resolve().as_posix()
241
241
  prefix = root + "/"
@@ -369,9 +369,10 @@ def get_neighbors(file: str, repo: str) -> str:
369
369
  meta = get_graph_meta(repo)
370
370
  if not meta:
371
371
  return f"No graph data for '{repo}'. Run reindex() first."
372
+ file = file.removeprefix("res://") # Godot paths: res:// is relative to the project root
372
373
  file_meta = meta.get(file)
373
374
  if file_meta is None:
374
- matches = [f for f in meta if file in f]
375
+ matches = sorted((f for f in meta if f.endswith("/" + file) or file in f), key=len)
375
376
  if not matches:
376
377
  return f"File '{file}' not found in graph for '{repo}'."
377
378
  file = matches[0]
@@ -22,6 +22,7 @@ dependencies = [
22
22
  "tree-sitter-typescript",
23
23
  "tree-sitter-kotlin",
24
24
  "tree-sitter-c-sharp",
25
+ "tree-sitter-gdscript",
25
26
  ]
26
27
 
27
28
  [project.scripts]
File without changes
File without changes
File without changes