co-agent-coder 0.1.0__py3-none-any.whl

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 (51) hide show
  1. co_agent_coder-0.1.0.dist-info/METADATA +396 -0
  2. co_agent_coder-0.1.0.dist-info/RECORD +51 -0
  3. co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
  4. co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
  5. co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
  6. co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
  7. coagent/__init__.py +84 -0
  8. coagent/__main__.py +6 -0
  9. coagent/agent/__init__.py +27 -0
  10. coagent/agent/budget.py +220 -0
  11. coagent/agent/loop.py +1102 -0
  12. coagent/agent/prompts.py +228 -0
  13. coagent/agent/state.py +181 -0
  14. coagent/cli.py +351 -0
  15. coagent/config.py +307 -0
  16. coagent/context/__init__.py +26 -0
  17. coagent/context/artifacts.py +431 -0
  18. coagent/context/repo_map.py +590 -0
  19. coagent/context/retrieval.py +375 -0
  20. coagent/context/tokens.py +93 -0
  21. coagent/evals/__init__.py +0 -0
  22. coagent/gitx/__init__.py +52 -0
  23. coagent/gitx/commit.py +91 -0
  24. coagent/gitx/durable.py +147 -0
  25. coagent/gitx/git.py +351 -0
  26. coagent/gitx/lock.py +157 -0
  27. coagent/gitx/log.py +85 -0
  28. coagent/gitx/resume.py +199 -0
  29. coagent/gitx/session.py +233 -0
  30. coagent/llm/__init__.py +83 -0
  31. coagent/llm/base.py +235 -0
  32. coagent/llm/grammar.py +306 -0
  33. coagent/llm/registry.py +147 -0
  34. coagent/llm/transformers_backend.py +326 -0
  35. coagent/py.typed +0 -0
  36. coagent/safety/__init__.py +24 -0
  37. coagent/safety/denylist.py +146 -0
  38. coagent/safety/policy.py +219 -0
  39. coagent/safety/redact.py +146 -0
  40. coagent/templates/config.toml +104 -0
  41. coagent/tools/__init__.py +155 -0
  42. coagent/tools/artifacts.py +307 -0
  43. coagent/tools/base.py +226 -0
  44. coagent/tools/fs.py +507 -0
  45. coagent/tools/notebooks.py +875 -0
  46. coagent/tools/pyt.py +240 -0
  47. coagent/tools/shell.py +558 -0
  48. coagent/ui/__init__.py +50 -0
  49. coagent/ui/gradio_app.py +353 -0
  50. coagent/ui/notebook.py +342 -0
  51. coagent/ui/render.py +422 -0
@@ -0,0 +1,590 @@
1
+ """Repo map: signatures only, ranked, packed to a token budget (DESIGN §5.2).
2
+
3
+ Why this exists
4
+ ---------------
5
+ On a 12k window, what you show the model beats what you tell it. A naive
6
+ "walk every .py and list every def" map spends its budget on files that have
7
+ nothing to do with the task and blows up on large repos, so the model greps
8
+ anyway — which is the failure this module is supposed to prevent. So the map
9
+ is built in three deliberate stages:
10
+
11
+ 1. **Parse** every source file into signatures (no bodies). tree-sitter when a
12
+ grammar is available, a Python ``ast``/regex fallback otherwise. A missing
13
+ grammar degrades the map; it never ends the session (§5.2).
14
+ 2. **Rank** with PageRank-lite:
15
+ ``0.5·reference_count + 0.3·recently_edited + 0.2·mentioned_in_task``.
16
+ Files the task names always survive the budget.
17
+ 3. **Pack** greedily into the repo map's token slot from §5.1 (default 2000),
18
+ dropping whole files rather than mid-signature lines.
19
+
20
+ Everything here is deterministic: no LLM, no network, no clock reads beyond the
21
+ mtime signal the caller passes in. That is what makes it testable and what lets
22
+ the loop trust the same map across a resume.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import ast
28
+ import os
29
+ import re
30
+ from dataclasses import dataclass, field
31
+ from pathlib import Path
32
+
33
+ from .tokens import fit
34
+
35
+ __all__ = [
36
+ "DEFAULT_MAX_TOKENS",
37
+ "FileMap",
38
+ "RepoMap",
39
+ "RepoMapConfig",
40
+ "Signature",
41
+ ]
42
+
43
+ #: §5.1 gives the repo map a 2000-token slot out of a 12k window.
44
+ DEFAULT_MAX_TOKENS = 2000
45
+
46
+ #: Source extensions we can extract Python signatures from. Other languages get
47
+ #: a shallow tree-sitter pass when their grammar is installed.
48
+ _PY_SUFFIXES = {".py", ".pyi"}
49
+
50
+ #: Directories that are never part of "the repo" as a model should see it.
51
+ _SKIP_DIRS = {
52
+ ".git",
53
+ ".hg",
54
+ ".svn",
55
+ ".coagent",
56
+ ".venv",
57
+ "venv",
58
+ "env",
59
+ "__pycache__",
60
+ ".mypy_cache",
61
+ ".pytest_cache",
62
+ ".ruff_cache",
63
+ ".tox",
64
+ "node_modules",
65
+ "dist",
66
+ "build",
67
+ ".eggs",
68
+ "site-packages",
69
+ }
70
+
71
+ #: Generated files are noise: they are huge, and their signatures tell the model
72
+ #: nothing it could edit.
73
+ _SKIP_SUFFIXES = {".pyc", ".pyo", ".so", ".dylib", ".pyd"}
74
+
75
+ #: Cap on files considered at all. A repo with 20k files is not one a 7B model
76
+ #: can navigate; the ranking below is only meaningful if parsing stays cheap.
77
+ MAX_FILES = 800
78
+
79
+ #: Cap on signatures emitted per file. A 400-line generated class body would
80
+ #: otherwise eat the whole map; 40 is enough to convey a module's shape.
81
+ MAX_SIGNATURES_PER_FILE = 40
82
+
83
+ #: Identifier tokens used for both task-mention matching and reference counts.
84
+ #: The 3-character minimum is deliberate: one- and two-character names (`x`,
85
+ #: `_`, `fn`) would match almost every file and make the ranking meaningless.
86
+ _IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]{2,}")
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Signature:
91
+ """One line of the map: a def, a class, or a class member.
92
+
93
+ ``start_line``/``end_line`` are 1-based and inclusive, and bound the *body*
94
+ as well as the signature. They exist so BM25 retrieval (DESIGN §5.3) can cut
95
+ function-sized chunks from the same parse that built the map — a second
96
+ parse for chunking would double the only real cost in this module.
97
+ """
98
+
99
+ kind: str # "class" | "def" | "member" | "const"
100
+ text: str # rendered signature, no body, no trailing colon
101
+ start_line: int = 0
102
+ end_line: int = 0
103
+
104
+ def render(self) -> str:
105
+ indent = {"def": " ", "member": " ", "const": " "}.get(self.kind, "")
106
+ return f"{indent}{self.text}"
107
+
108
+ @property
109
+ def name(self) -> str | None:
110
+ """The defined symbol, or None for shapes we cannot name (e.g. `X = ...`)."""
111
+ return _symbol_name(self.text)
112
+
113
+
114
+ @dataclass
115
+ class FileMap:
116
+ """Parsed shape of one file plus the signals used to rank it."""
117
+
118
+ rel: str
119
+ signatures: list[Signature] = field(default_factory=list)
120
+ #: Symbols the file defines and references elsewhere — drives reference_count.
121
+ references: int = 0
122
+ #: mtime, used only for the "recently edited" signal.
123
+ mtime: float = 0.0
124
+ #: True when the task text names this file or one of its symbols.
125
+ mentioned: bool = False
126
+ #: True when the session has read or written this file recently.
127
+ recently_edited: bool = False
128
+ #: Set when parsing fell back, so the map can note it honestly.
129
+ degraded: bool = False
130
+
131
+ def render(self) -> str:
132
+ lines = [self.rel]
133
+ for sig in self.signatures:
134
+ lines.append(sig.render())
135
+ if not self.signatures:
136
+ lines.append(" (no signatures)")
137
+ return "\n".join(lines)
138
+
139
+
140
+ @dataclass
141
+ class RepoMapConfig:
142
+ """Knobs for map construction; defaults match DESIGN §5.1/§5.2."""
143
+
144
+ max_tokens: int = DEFAULT_MAX_TOKENS
145
+ max_files: int = MAX_FILES
146
+ use_tree_sitter: bool = True
147
+
148
+
149
+ class _Parsers:
150
+ """Lazily built signature extractors.
151
+
152
+ Import-time failure is the normal case on a slim Colab image, so every
153
+ optional dependency is resolved on first use and cached as either a parser
154
+ or ``None``. Nothing here raises into the loop.
155
+ """
156
+
157
+ def __init__(self, *, use_tree_sitter: bool = True) -> None:
158
+ self._use_ts = use_tree_sitter
159
+ self._ts: dict[str, object] = {}
160
+ self._ts_tried = False
161
+ self.degraded_languages: set[str] = set()
162
+
163
+ # ------------------------------------------------------------------ python
164
+
165
+ def python_signatures(self, text: str, *, rel: str) -> tuple[list[Signature], bool]:
166
+ """Return (signatures, degraded).
167
+
168
+ ``ast`` is tried first for Python: it is stdlib, exact, and already gives
169
+ us qualified names and decorators. tree-sitter is only better for
170
+ *partially broken* files, so it is the recovery path, then regex.
171
+ """
172
+ try:
173
+ return self._ast_signatures(text), False
174
+ except (SyntaxError, ValueError, RecursionError):
175
+ pass
176
+
177
+ if self._use_ts:
178
+ sigs = self._tree_sitter_signatures(text, rel=rel)
179
+ if sigs is not None:
180
+ return sigs, False
181
+
182
+ self.degraded_languages.add("python")
183
+ return self._regex_signatures(text), True
184
+
185
+ @staticmethod
186
+ def _ast_signatures(text: str) -> list[Signature]:
187
+ tree = ast.parse(text)
188
+ out: list[Signature] = []
189
+
190
+ def visit_body(body: list[ast.stmt], *, in_class: bool, depth: int) -> None:
191
+ for node in body:
192
+ if len(out) >= MAX_SIGNATURES_PER_FILE:
193
+ return
194
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
195
+ kind = "member" if in_class else "def"
196
+ out.append(
197
+ Signature(kind, _render_function(node), *_span(node))
198
+ )
199
+ elif isinstance(node, ast.ClassDef):
200
+ out.append(Signature("class", _render_class(node), *_span(node)))
201
+ # One level of members only: nested classes collapse.
202
+ if depth < 1:
203
+ visit_body(node.body, in_class=True, depth=depth + 1)
204
+ elif isinstance(node, ast.Assign) and not in_class and depth == 0:
205
+ # Module-level constants are part of a module's shape, but
206
+ # functions are not defined by them — emit only simple names
207
+ # so `a = b = f()` does not leak a call expression.
208
+ names = [t.id for t in node.targets if isinstance(t, ast.Name)]
209
+ if names:
210
+ out.append(
211
+ Signature("const", f"{' = '.join(names)} = ...", *_span(node))
212
+ )
213
+
214
+ visit_body(tree.body, in_class=False, depth=0)
215
+ return out
216
+
217
+ # ------------------------------------------------------------- tree-sitter
218
+
219
+ def _ensure_ts(self, rel: str) -> object | None:
220
+ """Build a tree-sitter parser for ``rel``'s language, or None."""
221
+ if not self._use_ts:
222
+ return None
223
+ suffix = Path(rel).suffix.lower()
224
+ if suffix in self._ts:
225
+ return self._ts[suffix]
226
+ try:
227
+ from tree_sitter import Language, Parser # noqa: PLC0415 - optional dep
228
+
229
+ if suffix in _PY_SUFFIXES:
230
+ import tree_sitter_python as mod # noqa: PLC0415
231
+
232
+ lang = Language(mod.language())
233
+ else:
234
+ self._ts[suffix] = None
235
+ return None
236
+ parser = Parser(lang)
237
+ except Exception: # noqa: BLE001 - any import/ABI failure degrades
238
+ self._ts[suffix] = None
239
+ return None
240
+ self._ts[suffix] = parser
241
+ return parser
242
+
243
+ def _tree_sitter_signatures(self, text: str, *, rel: str) -> list[Signature] | None:
244
+ parser = self._ensure_ts(rel)
245
+ if parser is None:
246
+ return None
247
+ try:
248
+ tree = parser.parse(text.encode("utf-8", errors="replace")) # type: ignore[attr-defined]
249
+ except Exception: # noqa: BLE001
250
+ return None
251
+
252
+ # Read signatures straight from the source bytes the node spans: the
253
+ # text between the `def`/`class` line start and the body's opening
254
+ # colon is the signature, and it survives syntax errors above/below.
255
+ raw = text.encode("utf-8", errors="replace")
256
+ out: list[Signature] = []
257
+
258
+ def walk(node: object, *, depth: int) -> None:
259
+ if len(out) >= MAX_SIGNATURES_PER_FILE:
260
+ return
261
+ ntype = getattr(node, "type", "")
262
+ if ntype in {"function_definition", "class_definition"}:
263
+ head = _node_header(raw, node)
264
+ if head:
265
+ kind = "class" if ntype == "class_definition" else ("member" if depth else "def")
266
+ # tree-sitter rows are 0-based; the chunker wants 1-based.
267
+ out.append(Signature(kind, head, _row(node, "start_point"), _row(node, "end_point")))
268
+ if ntype == "class_definition" and depth < 1:
269
+ for child in getattr(node, "named_children", []):
270
+ walk(child, depth=depth + 1)
271
+ return
272
+ return
273
+ for child in getattr(node, "named_children", []):
274
+ walk(child, depth=depth)
275
+
276
+ walk(tree.root_node, depth=0) # type: ignore[attr-defined]
277
+ return out or None
278
+
279
+ # ------------------------------------------------------------------- regex
280
+
281
+ @staticmethod
282
+ def _regex_signatures(text: str) -> list[Signature]:
283
+ """Last-resort extraction for files that will not parse.
284
+
285
+ Deliberately dumb and line-anchored: it must never raise, and a map with
286
+ slightly wrong signatures is still far better than no map.
287
+ """
288
+ out: list[Signature] = []
289
+ class_depth = 0
290
+ lines = text.splitlines()
291
+ # (signature index, indent) so a def's end can be resolved on the next
292
+ # line that dedents to its level.
293
+ open_defs: list[tuple[int, int]] = []
294
+
295
+ def close_defs(upto_line: int, indent_le: int) -> None:
296
+ """End every open def whose body has now dedented by ``upto_line``."""
297
+ for idx, body_indent in list(open_defs):
298
+ if indent_le <= body_indent - 1:
299
+ sig = out[idx]
300
+ if not sig.end_line:
301
+ out[idx] = Signature(sig.kind, sig.text, sig.start_line, upto_line - 1)
302
+ open_defs.remove((idx, body_indent))
303
+
304
+ for lineno, line in enumerate(lines, start=1):
305
+ if len(out) >= MAX_SIGNATURES_PER_FILE:
306
+ break
307
+ stripped = line.strip()
308
+ if not stripped or stripped.startswith("#"):
309
+ continue
310
+ indent = len(line) - len(line.lstrip())
311
+ close_defs(lineno, indent)
312
+ m_class = re.match(r"class\s+(\w+)\s*(\([^)]*\))?", stripped)
313
+ if m_class:
314
+ cls_indent = indent
315
+ out.append(Signature("class", f"class {m_class.group(1)}", lineno, 0))
316
+ class_depth = cls_indent
317
+ continue
318
+ m_def = re.match(
319
+ r"(async\s+)?def\s+(\w+)\s*\(([^)]*)",
320
+ stripped,
321
+ )
322
+ if m_def:
323
+ prefix = "async def" if m_def.group(1) else "def"
324
+ args = " ".join(m_def.group(3).split())
325
+ kind = "member" if indent > class_depth else "def"
326
+ out.append(
327
+ Signature(kind, f"{prefix} {m_def.group(2)}({args})".rstrip("(, "), lineno, 0)
328
+ )
329
+ open_defs.append((len(out) - 1, indent))
330
+ continue
331
+ m_assign = re.match(r"([A-Z][A-Z0-9_]{2,})\s*=", stripped)
332
+ if m_assign and indent == 0:
333
+ out.append(Signature("const", f"{m_assign.group(1)} = ...", lineno, lineno))
334
+
335
+ # Anything still open runs to the end of the file.
336
+ for idx, _ in open_defs:
337
+ sig = out[idx]
338
+ out[idx] = Signature(sig.kind, sig.text, sig.start_line, len(lines))
339
+ return out
340
+
341
+
342
+ def _row(node: object, attr: str) -> int:
343
+ """Read a tree-sitter point's 0-based row as a 1-based line number.
344
+
345
+ tree-sitter nodes are untyped from Python's point of view, so the attribute
346
+ access lives here rather than being scattered through the walk.
347
+ """
348
+ try:
349
+ return int(getattr(node, attr)[0]) + 1 # noqa: B009 - dynamic by design
350
+ except Exception: # noqa: BLE001
351
+ return 0
352
+
353
+
354
+ def _span(node: ast.stmt) -> tuple[int, int]:
355
+ """1-based inclusive line span of an AST node (Python 3.8+ has end_lineno)."""
356
+ return (
357
+ int(getattr(node, "lineno", 0) or 0),
358
+ int(getattr(node, "end_lineno", 0) or getattr(node, "lineno", 0) or 0),
359
+ )
360
+
361
+
362
+ def _node_header(raw: bytes, node: object) -> str:
363
+ """Slice the signature text of a tree-sitter node, up to its body colon."""
364
+ try:
365
+ start = int(node.start_byte) # type: ignore[attr-defined]
366
+ end = int(node.end_byte) # type: ignore[attr-defined]
367
+ except Exception: # noqa: BLE001
368
+ return ""
369
+ chunk = raw[start:end].decode("utf-8", errors="replace")
370
+ # A signature ends at the first colon that is not inside the parameter list.
371
+ depth = 0
372
+ for i, ch in enumerate(chunk):
373
+ if ch in "([{":
374
+ depth += 1
375
+ elif ch in ")]}":
376
+ depth -= 1
377
+ elif ch == ":" and depth == 0:
378
+ chunk = chunk[:i]
379
+ break
380
+ else:
381
+ chunk = chunk.split("\n", 1)[0]
382
+ return " ".join(chunk.split())
383
+
384
+
385
+ def _render_function(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
386
+ prefix = "async def" if isinstance(node, ast.AsyncFunctionDef) else "def"
387
+ args = " ".join(ast.unparse(node.args).split())
388
+ ret = f" -> {ast.unparse(node.returns)}" if node.returns is not None else ""
389
+ return f"{prefix} {node.name}({args}){ret}".rstrip("(, ")
390
+
391
+
392
+ def _render_class(node: ast.ClassDef) -> str:
393
+ bases = ", ".join(ast.unparse(b) for b in node.bases)
394
+ return f"class {node.name}({bases})".rstrip("(") if bases else f"class {node.name}"
395
+
396
+
397
+ class RepoMap:
398
+ """Builds the ranked, budget-packed signature map for one repository.
399
+
400
+ Usage from the loop::
401
+
402
+ repo_map = RepoMap(repo_path, task=state.task, recently_edited=...)
403
+ text = repo_map.render()
404
+
405
+ The object is cheap to rebuild each turn; parsing is the only real cost and
406
+ it is bounded by :data:`MAX_FILES`.
407
+ """
408
+
409
+ def __init__(
410
+ self,
411
+ root: Path | str,
412
+ *,
413
+ task: str = "",
414
+ recently_edited: set[str] | None = None,
415
+ config: RepoMapConfig | None = None,
416
+ ) -> None:
417
+ self.root = Path(root)
418
+ self.task = task
419
+ self.recently_edited = {_norm(p) for p in (recently_edited or set())}
420
+ self.config = config or RepoMapConfig()
421
+ self._parsers = _Parsers(use_tree_sitter=self.config.use_tree_sitter)
422
+ self._files: list[FileMap] = []
423
+ self._token_sets: dict[str, set[str]] = {}
424
+ self._built = False
425
+
426
+ # ------------------------------------------------------------------ public
427
+
428
+ def render(self) -> str:
429
+ """Return the map text, packed to ``config.max_tokens``."""
430
+ files = self._ranked()
431
+ if not files:
432
+ return "(empty repository)"
433
+
434
+ blocks = [f.render() for f in files]
435
+ text = fit(blocks, self.config.max_tokens)
436
+ if self._parsers.degraded_languages:
437
+ langs = ", ".join(sorted(self._parsers.degraded_languages))
438
+ text += f"\n(map degraded: no parser for {langs}; signatures are approximate)"
439
+ return text
440
+
441
+ def files(self) -> list[FileMap]:
442
+ """Ranked file maps, highest score first. Exposed for tests and evals."""
443
+ return self._ranked()
444
+
445
+ # ------------------------------------------------------------------ build
446
+
447
+ def _ranked(self) -> list[FileMap]:
448
+ if not self._built:
449
+ self._files = self._scan()
450
+ self._built = True
451
+ return sorted(self._files, key=_score, reverse=True)
452
+
453
+ def _scan(self) -> list[FileMap]:
454
+ paths = self._candidate_paths()
455
+ if not paths:
456
+ return []
457
+
458
+ # Pass 1: parse each file. File text is cached for this scan so pass 2
459
+ # does not re-read from disk.
460
+ parsed: list[FileMap] = []
461
+ for path, rel in paths:
462
+ fm = self._parse_file(path, rel)
463
+ if fm is None:
464
+ continue
465
+ parsed.append(fm)
466
+
467
+ # Pass 2: reference counts. A file is "important" when the symbols it
468
+ # defines are used elsewhere — that is the PageRank-lite signal, and it
469
+ # is why a shared util outranks a leaf script.
470
+ for fm in parsed:
471
+ fm.references = self._count_references(fm)
472
+
473
+ return parsed
474
+
475
+ def _candidate_paths(self) -> list[tuple[Path, str]]:
476
+ """Walk the repo, skipping vendored/generated trees, capped at max_files."""
477
+ out: list[tuple[Path, str]] = []
478
+ root = self.root
479
+ for dirpath, dirnames, filenames in os.walk(root):
480
+ # Prune in place so os.walk never descends into them.
481
+ dirnames[:] = sorted(d for d in dirnames if d not in _SKIP_DIRS and not d.startswith("."))
482
+ for name in sorted(filenames):
483
+ suffix = Path(name).suffix.lower()
484
+ if suffix in _SKIP_SUFFIXES:
485
+ continue
486
+ if suffix not in _PY_SUFFIXES:
487
+ continue
488
+ path = Path(dirpath) / name
489
+ try:
490
+ rel = path.relative_to(root).as_posix()
491
+ except ValueError:
492
+ continue
493
+ out.append((path, rel))
494
+ if len(out) >= self.config.max_files:
495
+ return out
496
+ return out
497
+
498
+ def _parse_file(self, path: Path, rel: str) -> FileMap | None:
499
+ try:
500
+ text = path.read_text(encoding="utf-8", errors="replace")
501
+ except OSError:
502
+ return None
503
+ # A file with no newline in 200k chars is minified/generated; its
504
+ # "signatures" would be one unreadable line.
505
+ if len(text) > 200_000 and text.count("\n") < 50:
506
+ return None
507
+
508
+ sigs, degraded = self._parsers.python_signatures(text, rel=rel)
509
+ # Identifier set per file: cheap membership test for reference counts.
510
+ self._token_sets[rel] = set(_IDENT_RE.findall(text))
511
+ try:
512
+ mtime = path.stat().st_mtime
513
+ except OSError:
514
+ mtime = 0.0
515
+
516
+ return FileMap(
517
+ rel=rel,
518
+ signatures=sigs,
519
+ mtime=mtime,
520
+ mentioned=self._is_mentioned(rel, sigs),
521
+ recently_edited=rel in self.recently_edited,
522
+ degraded=degraded,
523
+ )
524
+
525
+ def _is_mentioned(self, rel: str, sigs: list[Signature]) -> bool:
526
+ """True when the task names the file, its stem, or a symbol it defines."""
527
+ if not self.task:
528
+ return False
529
+ task = self.task.lower()
530
+ if rel.lower() in task:
531
+ return True
532
+ stem = Path(rel).stem.lower()
533
+ if len(stem) >= 3 and re.search(rf"\b{re.escape(stem)}\b", task):
534
+ return True
535
+ for sig in sigs:
536
+ name = _symbol_name(sig.text)
537
+ if name and len(name) >= 3 and re.search(rf"\b{re.escape(name.lower())}\b", task):
538
+ return True
539
+ return False
540
+
541
+ def _count_references(self, fm: FileMap) -> int:
542
+ """Count how many *other* files mention the symbols ``fm`` defines.
543
+
544
+ This is the inbound edge of the PageRank-lite signal: a module everyone
545
+ imports matters more than a leaf script, so it earns the top of the map.
546
+ Counting the wrong direction (mentions *made* by this file) would rank
547
+ buggy callers above the shared code they depend on.
548
+
549
+ Matching is done on pre-tokenized identifier sets rather than per-symbol
550
+ regexes: the naive version ran ~26k regex searches on a 7k-line repo and
551
+ cost 4 seconds, which on a Colab VM is time the model spends idle.
552
+ """
553
+ own = {_symbol_name(s.text) for s in fm.signatures}
554
+ own.discard(None)
555
+ if not own:
556
+ return 0
557
+ return sum(1 for other, tokens in self._token_sets.items() if other != fm.rel and own & tokens)
558
+
559
+
560
+ def _score(fm: FileMap) -> float:
561
+ """PageRank-lite score (DESIGN §5.2).
562
+
563
+ ``0.5·reference_count + 0.3·recently_edited + 0.2·mentioned_in_task``
564
+
565
+ The reference count is normalised by a soft cap so one hub file cannot
566
+ dominate the whole map, and "mentioned" is a hard boost because a file the
567
+ task names must survive the budget.
568
+ """
569
+ refs = min(fm.references, 20) / 20.0
570
+ recent = 1.0 if fm.recently_edited else 0.0
571
+ mentioned = 1.0 if fm.mentioned else 0.0
572
+ return 0.5 * refs + 0.3 * recent + 0.2 * mentioned
573
+
574
+
575
+ def _symbol_name(sig_text: str) -> str | None:
576
+ """Extract the defined name from a rendered signature line."""
577
+ m = re.match(r"(?:async\s+)?def\s+(\w+)", sig_text)
578
+ if m:
579
+ return m.group(1)
580
+ m = re.match(r"class\s+(\w+)", sig_text)
581
+ if m:
582
+ return m.group(1)
583
+ m = re.match(r"([A-Z][A-Z0-9_]{2,})\s*=", sig_text)
584
+ if m:
585
+ return m.group(1)
586
+ return None
587
+
588
+
589
+ def _norm(path: str) -> str:
590
+ return path.replace("\\", "/").lstrip("./")