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.
- co_agent_coder-0.1.0.dist-info/METADATA +396 -0
- co_agent_coder-0.1.0.dist-info/RECORD +51 -0
- co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
- co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
- co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
- co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
- coagent/__init__.py +84 -0
- coagent/__main__.py +6 -0
- coagent/agent/__init__.py +27 -0
- coagent/agent/budget.py +220 -0
- coagent/agent/loop.py +1102 -0
- coagent/agent/prompts.py +228 -0
- coagent/agent/state.py +181 -0
- coagent/cli.py +351 -0
- coagent/config.py +307 -0
- coagent/context/__init__.py +26 -0
- coagent/context/artifacts.py +431 -0
- coagent/context/repo_map.py +590 -0
- coagent/context/retrieval.py +375 -0
- coagent/context/tokens.py +93 -0
- coagent/evals/__init__.py +0 -0
- coagent/gitx/__init__.py +52 -0
- coagent/gitx/commit.py +91 -0
- coagent/gitx/durable.py +147 -0
- coagent/gitx/git.py +351 -0
- coagent/gitx/lock.py +157 -0
- coagent/gitx/log.py +85 -0
- coagent/gitx/resume.py +199 -0
- coagent/gitx/session.py +233 -0
- coagent/llm/__init__.py +83 -0
- coagent/llm/base.py +235 -0
- coagent/llm/grammar.py +306 -0
- coagent/llm/registry.py +147 -0
- coagent/llm/transformers_backend.py +326 -0
- coagent/py.typed +0 -0
- coagent/safety/__init__.py +24 -0
- coagent/safety/denylist.py +146 -0
- coagent/safety/policy.py +219 -0
- coagent/safety/redact.py +146 -0
- coagent/templates/config.toml +104 -0
- coagent/tools/__init__.py +155 -0
- coagent/tools/artifacts.py +307 -0
- coagent/tools/base.py +226 -0
- coagent/tools/fs.py +507 -0
- coagent/tools/notebooks.py +875 -0
- coagent/tools/pyt.py +240 -0
- coagent/tools/shell.py +558 -0
- coagent/ui/__init__.py +50 -0
- coagent/ui/gradio_app.py +353 -0
- coagent/ui/notebook.py +342 -0
- 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("./")
|