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
coagent/ui/render.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""Transcript rendering: the text the chat cell shows, built as data (§8.1).
|
|
2
|
+
|
|
3
|
+
Why this is separate from the widgets
|
|
4
|
+
-------------------------------------
|
|
5
|
+
`ipywidgets` cannot be exercised in a test, and the interesting decisions are not
|
|
6
|
+
widget decisions: which events become rows, how a tool call reads when collapsed,
|
|
7
|
+
how a diff is summarised, when the plan is worth re-rendering. All of that is a
|
|
8
|
+
pure function of the event stream, so it lives here and is tested directly. The
|
|
9
|
+
widget layer in `notebook.py` is then thin enough to read in one sitting.
|
|
10
|
+
|
|
11
|
+
The rendering targets §8.1's layout::
|
|
12
|
+
|
|
13
|
+
coAgent · Qwen2.5-Coder-7B · 12k ctx · session a1b2c3
|
|
14
|
+
────────────────────────────────────────────────────────
|
|
15
|
+
You: add a chunked CSV loader and test dtype preservation
|
|
16
|
+
|
|
17
|
+
▸ plan 4 steps, est. 6 min [approve] [edit]
|
|
18
|
+
▸ step 1 read src/loader.py, src/frame.py 1.2s
|
|
19
|
+
▸ step 2 edit src/loader.py +18 −3 0.4s
|
|
20
|
+
▸ step 3 pytest -q → 1 failed 2.1s
|
|
21
|
+
✓ committed a1b2c3d · artifacts none
|
|
22
|
+
|
|
23
|
+
Tool rows are *collapsed* `<details>` elements: one line when closed, args and
|
|
24
|
+
truncated output when opened. That is what keeps a 40-step run readable on a
|
|
25
|
+
phone-width Colab pane without hiding the evidence.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import html
|
|
31
|
+
import re
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"Row",
|
|
37
|
+
"Transcript",
|
|
38
|
+
"MODE_GLYPH",
|
|
39
|
+
"render_status_line",
|
|
40
|
+
"tool_summary",
|
|
41
|
+
"shorten_path",
|
|
42
|
+
"summarise_verify",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
#: One glyph per event kind, so a run scans vertically.
|
|
46
|
+
MODE_GLYPH: dict[str, str] = {
|
|
47
|
+
"plan": "▸",
|
|
48
|
+
"plan_rejected": "✗",
|
|
49
|
+
"tool": "▸",
|
|
50
|
+
"verify": "▸",
|
|
51
|
+
"commit": "✓",
|
|
52
|
+
"sync": "⇡",
|
|
53
|
+
"artifact": "◈",
|
|
54
|
+
"error": "✗",
|
|
55
|
+
"info": "·",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
#: An event kind's severity, for colouring and for "is this a stop?".
|
|
59
|
+
_SEVERITY = {
|
|
60
|
+
"commit": "good",
|
|
61
|
+
"sync": "good",
|
|
62
|
+
"artifact": "good",
|
|
63
|
+
"verify": "neutral",
|
|
64
|
+
"tool": "neutral",
|
|
65
|
+
"error": "bad",
|
|
66
|
+
"plan_rejected": "bad",
|
|
67
|
+
"denied": "bad",
|
|
68
|
+
"commit_failed": "bad",
|
|
69
|
+
"envelope_error": "warn",
|
|
70
|
+
"replan": "warn",
|
|
71
|
+
"truncated": "warn",
|
|
72
|
+
"repo_map_degraded": "warn",
|
|
73
|
+
"retrieval_degraded": "warn",
|
|
74
|
+
"artifact_manifest_degraded": "warn",
|
|
75
|
+
"final_checkpoint_failed": "warn",
|
|
76
|
+
"verify_failed_after_complete": "warn",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
#: Long output kept in a collapsed block. The full text is in the session log;
|
|
80
|
+
#: this is the evidence a human needs, not the whole stream (§5.1).
|
|
81
|
+
MAX_ROW_OUTPUT = 1600
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class Row:
|
|
86
|
+
"""One line of the transcript, plus the details hidden behind it."""
|
|
87
|
+
|
|
88
|
+
kind: str
|
|
89
|
+
summary: str
|
|
90
|
+
body: str = ""
|
|
91
|
+
severity: str = "neutral"
|
|
92
|
+
#: Seconds, when the event knows how long it took.
|
|
93
|
+
duration_s: float | None = None
|
|
94
|
+
#: Extra metadata for a renderer that wants to be smarter than text.
|
|
95
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def glyph(self) -> str:
|
|
99
|
+
return MODE_GLYPH.get(self.kind, "·")
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def collapsible(self) -> bool:
|
|
103
|
+
return bool(self.body.strip())
|
|
104
|
+
|
|
105
|
+
def headline(self) -> str:
|
|
106
|
+
"""The one line shown when the row is collapsed."""
|
|
107
|
+
parts = [f"{self.glyph} {self.summary}"]
|
|
108
|
+
if self.duration_s is not None:
|
|
109
|
+
parts.append(f"{self.duration_s:.1f}s")
|
|
110
|
+
return " ".join(parts)
|
|
111
|
+
|
|
112
|
+
def render_markdown(self) -> str:
|
|
113
|
+
line = self.headline()
|
|
114
|
+
if not self.collapsible:
|
|
115
|
+
# No nested emphasis: the summary already carries its own markers, and
|
|
116
|
+
# wrapping it again renders as literal `**` in a notebook.
|
|
117
|
+
return line if "**" in line else f"`{line}`"
|
|
118
|
+
closed = "<code>" + html.escape(line) + "</code>"
|
|
119
|
+
return f"<details><summary>{closed}</summary>\n\n```\n{self.body}\n```\n\n</details>"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Transcript:
|
|
123
|
+
"""The chat cell's whole visible state, as data.
|
|
124
|
+
|
|
125
|
+
Events arrive from `coAgent.on_event`; each one either appends a row or
|
|
126
|
+
updates the plan/status. `render()` is called far more often than events
|
|
127
|
+
arrive (a widget update per streamed chunk), so it must stay cheap and stable
|
|
128
|
+
— no timestamps, no counters that drift between identical renders.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(self, *, max_rows: int = 200) -> None:
|
|
132
|
+
self.rows: list[Row] = []
|
|
133
|
+
self.max_rows = max_rows
|
|
134
|
+
self.task = ""
|
|
135
|
+
self.plan_title = ""
|
|
136
|
+
self.plan_steps: list[str] = []
|
|
137
|
+
self.plan_decided = False
|
|
138
|
+
self.artifacts: list[str] = []
|
|
139
|
+
self.final = ""
|
|
140
|
+
self.status = ""
|
|
141
|
+
|
|
142
|
+
# ------------------------------------------------------------- events
|
|
143
|
+
|
|
144
|
+
def handle(self, kind: str, data: dict[str, Any]) -> None:
|
|
145
|
+
"""Fold one loop event into the transcript."""
|
|
146
|
+
if kind == "task_start":
|
|
147
|
+
self.task = str(data.get("task", ""))
|
|
148
|
+
self.status = "running"
|
|
149
|
+
return
|
|
150
|
+
if kind == "task_end":
|
|
151
|
+
self.status = "done" if data.get("done") else "stopped"
|
|
152
|
+
return
|
|
153
|
+
if kind == "plan":
|
|
154
|
+
self.plan_title = str(data.get("title", ""))
|
|
155
|
+
self.plan_steps = [str(s) for s in data.get("steps", [])]
|
|
156
|
+
steps = ", ".join(f"`{s}`" for s in self.plan_steps[:4])
|
|
157
|
+
self._add(
|
|
158
|
+
Row(
|
|
159
|
+
"plan",
|
|
160
|
+
f"**plan** {len(self.plan_steps)} steps — {self.plan_title}",
|
|
161
|
+
body="\n".join(f"[ ] {s}" for s in self.plan_steps) or steps,
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
return
|
|
165
|
+
if kind == "plan_rejected":
|
|
166
|
+
self.plan_decided = True
|
|
167
|
+
self._add(Row("plan_rejected", "plan rejected — nothing was written", severity="bad"))
|
|
168
|
+
return
|
|
169
|
+
if kind == "tool":
|
|
170
|
+
self._add(self._tool_row(data))
|
|
171
|
+
return
|
|
172
|
+
if kind == "verify":
|
|
173
|
+
ok = bool(data.get("ok"))
|
|
174
|
+
summary = summarise_verify(str(data.get("summary", "")))
|
|
175
|
+
self._add(
|
|
176
|
+
Row(
|
|
177
|
+
"verify",
|
|
178
|
+
f"**verify** {'passed' if ok else 'failed'} — {summary}",
|
|
179
|
+
body=str(data.get("summary", ""))[:MAX_ROW_OUTPUT],
|
|
180
|
+
severity="neutral" if ok else "bad",
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
return
|
|
184
|
+
if kind == "commit":
|
|
185
|
+
self._add(
|
|
186
|
+
Row(
|
|
187
|
+
"commit",
|
|
188
|
+
f"**committed** `{str(data.get('sha', ''))[:8]}` · step {data.get('step')}",
|
|
189
|
+
severity="good",
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
return
|
|
193
|
+
if kind == "sync":
|
|
194
|
+
ok = bool(data.get("ok"))
|
|
195
|
+
self._add(
|
|
196
|
+
Row(
|
|
197
|
+
"sync",
|
|
198
|
+
f"{'**checkpoint** ' if ok else '**checkpoint warning** '}"
|
|
199
|
+
f"{data.get('message', '')}",
|
|
200
|
+
severity="good" if ok else "warn",
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
return
|
|
204
|
+
if kind in {
|
|
205
|
+
"denied",
|
|
206
|
+
"commit_failed",
|
|
207
|
+
"plan_rejected",
|
|
208
|
+
"envelope_error",
|
|
209
|
+
"replan",
|
|
210
|
+
"truncated",
|
|
211
|
+
"repo_map_degraded",
|
|
212
|
+
"retrieval_degraded",
|
|
213
|
+
"artifact_manifest_degraded",
|
|
214
|
+
"final_checkpoint_failed",
|
|
215
|
+
"verify_failed_after_complete",
|
|
216
|
+
}:
|
|
217
|
+
self._add(self._diagnostic_row(kind, data))
|
|
218
|
+
return
|
|
219
|
+
if kind in {"survey", "resume"}:
|
|
220
|
+
notes = str(data.get("notes", "")).strip()
|
|
221
|
+
self._add(Row("info", f"**{kind}**", body=notes[:MAX_ROW_OUTPUT]))
|
|
222
|
+
return
|
|
223
|
+
# Unknown events are kept, not dropped: a new loop event should show up
|
|
224
|
+
# in the transcript the day it is added, not the day this file learns of it.
|
|
225
|
+
self._add(Row("info", f"**{kind}**", body=_format_data(data)))
|
|
226
|
+
|
|
227
|
+
def _tool_row(self, data: dict[str, Any]) -> Row:
|
|
228
|
+
tool = str(data.get("tool", "?"))
|
|
229
|
+
ok = bool(data.get("ok"))
|
|
230
|
+
body = str(data.get("content", ""))
|
|
231
|
+
summary = tool_summary(tool, body, ok=ok, path=str(data.get("path", "")))
|
|
232
|
+
return Row(
|
|
233
|
+
"tool" if ok else "error",
|
|
234
|
+
summary,
|
|
235
|
+
body=body[:MAX_ROW_OUTPUT],
|
|
236
|
+
severity="neutral" if ok else "bad",
|
|
237
|
+
meta={"tool": tool, "ok": ok},
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _diagnostic_row(self, kind: str, data: dict[str, Any]) -> Row:
|
|
241
|
+
label = kind.replace("_", " ")
|
|
242
|
+
detail = str(data.get("error") or data.get("corrective") or data.get("command") or "")
|
|
243
|
+
severity = _SEVERITY.get(kind, "warn")
|
|
244
|
+
summary = f"**{label}**" + (f" — {detail[:120]}" if detail else "")
|
|
245
|
+
return Row(kind, summary, body=_format_data(data), severity=severity)
|
|
246
|
+
|
|
247
|
+
def _add(self, row: Row) -> None:
|
|
248
|
+
self.rows.append(row)
|
|
249
|
+
if len(self.rows) > self.max_rows:
|
|
250
|
+
# Old rows are dropped from the *view*, never from the session log.
|
|
251
|
+
self.rows = self.rows[-self.max_rows :]
|
|
252
|
+
|
|
253
|
+
# ------------------------------------------------------------ artifacts
|
|
254
|
+
|
|
255
|
+
def note_artifacts(self, paths: list[str]) -> None:
|
|
256
|
+
for path in paths:
|
|
257
|
+
if path not in self.artifacts:
|
|
258
|
+
self.artifacts.append(path)
|
|
259
|
+
if paths:
|
|
260
|
+
self._add(
|
|
261
|
+
Row("artifact", "**artifacts** " + ", ".join(f"`{p}`" for p in paths), severity="good")
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# -------------------------------------------------------------- render
|
|
265
|
+
|
|
266
|
+
def render(self, *, status_line: str | None = None, max_rows: int | None = None) -> str:
|
|
267
|
+
"""The full markdown body for the chat cell."""
|
|
268
|
+
head = status_line if status_line is not None else self.status
|
|
269
|
+
blocks: list[str] = []
|
|
270
|
+
if head:
|
|
271
|
+
blocks.append(f"```\n{head}\n```")
|
|
272
|
+
if self.task:
|
|
273
|
+
blocks.append(f"**You:** {self.task}")
|
|
274
|
+
rows = self.rows[-max_rows:] if max_rows else self.rows
|
|
275
|
+
blocks.extend(row.render_markdown() for row in rows)
|
|
276
|
+
if self.final:
|
|
277
|
+
blocks.append(self.final)
|
|
278
|
+
return "\n\n".join(blocks) if blocks else "coAgent is idle."
|
|
279
|
+
|
|
280
|
+
def render_plan(self) -> str:
|
|
281
|
+
"""The plan block alone, for a widget that updates in place."""
|
|
282
|
+
if not self.plan_steps:
|
|
283
|
+
return ""
|
|
284
|
+
lines = [f"**{self.plan_title}**"] if self.plan_title else []
|
|
285
|
+
lines.extend(f"- [ ] {step}" for step in self.plan_steps)
|
|
286
|
+
return "\n".join(lines)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ------------------------------------------------------------------ helpers
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def render_status_line(
|
|
293
|
+
*,
|
|
294
|
+
model_id: str,
|
|
295
|
+
ctx_tokens: int,
|
|
296
|
+
session_id: str,
|
|
297
|
+
budget: str = "",
|
|
298
|
+
gpu: str = "",
|
|
299
|
+
) -> str:
|
|
300
|
+
"""FR-UX-3: model, context, GPU, session, and budget, always visible.
|
|
301
|
+
|
|
302
|
+
Deliberately one line: it sits at the top of every update, so a second line
|
|
303
|
+
would cost vertical space in a Colab pane on every repaint.
|
|
304
|
+
"""
|
|
305
|
+
parts = [f"coAgent · {model_id}", f"{ctx_tokens // 1000}k ctx"]
|
|
306
|
+
if gpu:
|
|
307
|
+
parts.append(gpu)
|
|
308
|
+
parts.append(f"session {session_id}")
|
|
309
|
+
if budget:
|
|
310
|
+
parts.append(budget)
|
|
311
|
+
return " · ".join(parts)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
_DIFF_STAT = re.compile(r"^\+([0-9]+) [−-]([0-9]+)$")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def tool_summary(tool: str, content: str, *, ok: bool, path: str = "") -> str:
|
|
318
|
+
"""One line describing a tool call, the way §8.1 shows it.
|
|
319
|
+
|
|
320
|
+
The point of collapsing is that the headline alone is informative: which file
|
|
321
|
+
was edited and by how much, which command ran and whether it passed.
|
|
322
|
+
"""
|
|
323
|
+
if not ok:
|
|
324
|
+
first = _first_meaningful_line(content)
|
|
325
|
+
return f"**{tool}** failed — {first}" if first else f"**{tool}** failed"
|
|
326
|
+
|
|
327
|
+
if tool in {"edit_file", "edit_notebook", "write_file"}:
|
|
328
|
+
added = content.count("\n+")
|
|
329
|
+
removed = content.count("\n-")
|
|
330
|
+
# `--- a/...` and `+++ b/...` are headers, not content.
|
|
331
|
+
added = max(0, added - content.count("\n+++"))
|
|
332
|
+
removed = max(0, removed - content.count("\n---"))
|
|
333
|
+
label = _short_target(content) or path or tool
|
|
334
|
+
return f"**{tool}** {label} +{added} −{removed}" if added or removed else f"**{tool}** {label}"
|
|
335
|
+
|
|
336
|
+
if tool in {"read_file", "read_notebook", "list_dir"}:
|
|
337
|
+
# The path is the useful fact; the line count tells you how much came back.
|
|
338
|
+
lines = sum(1 for line in content.splitlines() if line.strip())
|
|
339
|
+
if path:
|
|
340
|
+
return f"**{tool}** {shorten_path(path)} · {lines} line(s)"
|
|
341
|
+
return f"**{tool}** {lines} line(s)"
|
|
342
|
+
|
|
343
|
+
if tool in {"run_shell", "run_python", "exec_notebook"}:
|
|
344
|
+
status = _first_match(content, r"^\[(.*)\]$") or ""
|
|
345
|
+
verdict = "ok" if ok else "failed"
|
|
346
|
+
detail = shorten_path(status) if status else verdict
|
|
347
|
+
return f"**{tool}** {detail}" if detail else f"**{tool}** {verdict}"
|
|
348
|
+
|
|
349
|
+
if tool in {"save_artifact", "display_artifact"}:
|
|
350
|
+
line = _first_meaningful_line(content)
|
|
351
|
+
return f"**{tool}** {line}" if line else f"**{tool}**"
|
|
352
|
+
|
|
353
|
+
if tool in {"grep"}:
|
|
354
|
+
count = _first_match(content, r"^no matches")
|
|
355
|
+
return f"**{tool}** {count}" if count else f"**{tool}** {_count_hits(content)} match(es)"
|
|
356
|
+
|
|
357
|
+
first = _first_meaningful_line(content)
|
|
358
|
+
return f"**{tool}** {first[:80]}" if first else f"**{tool}**"
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def summarise_verify(summary: str) -> str:
|
|
362
|
+
"""`pytest -q → 12 passed`, or the first failure line.
|
|
363
|
+
|
|
364
|
+
A verify summary is a blob of `$ cmd` + output. The transcript wants the
|
|
365
|
+
verdict, not the blob; the blob is in the collapsed body.
|
|
366
|
+
"""
|
|
367
|
+
for line in summary.splitlines():
|
|
368
|
+
lowered = line.lower()
|
|
369
|
+
if any(word in lowered for word in ("failed", "error", "traceback")):
|
|
370
|
+
return line.strip()[:100]
|
|
371
|
+
for line in summary.splitlines():
|
|
372
|
+
if line.strip().startswith("$"):
|
|
373
|
+
return line.strip()[:100]
|
|
374
|
+
for line in reversed(summary.splitlines()):
|
|
375
|
+
if line.strip():
|
|
376
|
+
return line.strip()[:100]
|
|
377
|
+
return "no output"
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def shorten_path(text: str, limit: int = 60) -> str:
|
|
381
|
+
"""Middle-ellipsis a path so both the root and the filename survive."""
|
|
382
|
+
text = text.strip()
|
|
383
|
+
if len(text) <= limit:
|
|
384
|
+
return text
|
|
385
|
+
keep = max(10, (limit - 1) // 2)
|
|
386
|
+
return f"{text[:keep]}…{text[-keep:]}"
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _first_meaningful_line(text: str) -> str:
|
|
390
|
+
for line in text.splitlines():
|
|
391
|
+
stripped = line.strip()
|
|
392
|
+
if stripped and not stripped.startswith(("[", "$", "```")):
|
|
393
|
+
return stripped[:120]
|
|
394
|
+
return ""
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _first_match(text: str, pattern: str) -> str:
|
|
398
|
+
match = re.search(pattern, text, re.MULTILINE)
|
|
399
|
+
return match.group(0).strip() if match else ""
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _short_target(content: str) -> str:
|
|
403
|
+
match = re.search(r"^--- [ab]/(.+)$", content, re.MULTILINE)
|
|
404
|
+
return shorten_path(match.group(1)) if match else ""
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _count_hits(content: str) -> int:
|
|
408
|
+
if "matches=0" in content:
|
|
409
|
+
return 0
|
|
410
|
+
return sum(1 for line in content.splitlines() if re.match(r"^.+:\d+: ", line))
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _format_data(data: dict[str, Any]) -> str:
|
|
414
|
+
if not data:
|
|
415
|
+
return ""
|
|
416
|
+
lines = []
|
|
417
|
+
for key, value in data.items():
|
|
418
|
+
text = str(value)
|
|
419
|
+
if len(text) > 400:
|
|
420
|
+
text = text[:397] + "…"
|
|
421
|
+
lines.append(f"{key}: {text}")
|
|
422
|
+
return "\n".join(lines)
|