bug2context 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.
- bug2context/__init__.py +1 -0
- bug2context/android.py +179 -0
- bug2context/cli.py +90 -0
- bug2context/config.py +93 -0
- bug2context/mcp_server.py +119 -0
- bug2context/menu.py +160 -0
- bug2context/pipeline/__init__.py +0 -0
- bug2context/pipeline/assemble.py +216 -0
- bug2context/pipeline/audio.py +94 -0
- bug2context/pipeline/frames.py +185 -0
- bug2context/pipeline/logs.py +187 -0
- bug2context/pipeline/ocr.py +207 -0
- bug2context-0.1.0.dist-info/METADATA +232 -0
- bug2context-0.1.0.dist-info/RECORD +17 -0
- bug2context-0.1.0.dist-info/WHEEL +4 -0
- bug2context-0.1.0.dist-info/entry_points.txt +3 -0
- bug2context-0.1.0.dist-info/licenses/LICENSE +21 -0
bug2context/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
bug2context/android.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Android capture: screen recording and logcat, started together.
|
|
2
|
+
|
|
3
|
+
Recording both from one command is what makes the timelines line up — the
|
|
4
|
+
alignment anchor is simply the moment we start, recorded on the host clock.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
DEVICE_VIDEO = "/sdcard/bug2context.mp4"
|
|
17
|
+
SCREENRECORD_LIMIT = 180
|
|
18
|
+
"""screenrecord stops itself at 3 minutes; asking for more silently truncates."""
|
|
19
|
+
|
|
20
|
+
SETTLE_SECONDS = 3.0
|
|
21
|
+
"""Grace for screenrecord to close the container before the file is pulled.
|
|
22
|
+
|
|
23
|
+
Measured: pulling immediately yields a file ffprobe cannot read at all.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AdbError(RuntimeError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Capture:
|
|
33
|
+
"""What a recording session produced."""
|
|
34
|
+
|
|
35
|
+
video: Path
|
|
36
|
+
logcat: Path
|
|
37
|
+
started_at: datetime
|
|
38
|
+
pid: int | None = None
|
|
39
|
+
"""PID of the app under test, when a package was named."""
|
|
40
|
+
stopped_early: bool = False
|
|
41
|
+
"""Whether the operator cut the recording short."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _adb(*args: str, serial: str | None = None) -> list[str]:
|
|
45
|
+
return ["adb", *(["-s", serial] if serial else []), *args]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def require_device(serial: str | None = None) -> str:
|
|
49
|
+
"""Fail early with an actionable message rather than mid-recording."""
|
|
50
|
+
if shutil.which("adb") is None:
|
|
51
|
+
raise AdbError("adb not found. Install Android platform-tools.")
|
|
52
|
+
|
|
53
|
+
proc = subprocess.run(_adb("devices"), capture_output=True, text=True)
|
|
54
|
+
devices = [
|
|
55
|
+
line.split("\t")[0]
|
|
56
|
+
for line in proc.stdout.splitlines()[1:]
|
|
57
|
+
if line.strip().endswith("device")
|
|
58
|
+
]
|
|
59
|
+
if not devices:
|
|
60
|
+
raise AdbError("No Android device connected. Check USB debugging is on.")
|
|
61
|
+
if serial and serial not in devices:
|
|
62
|
+
raise AdbError(f"Device {serial} not connected. Found: {', '.join(devices)}")
|
|
63
|
+
if not serial and len(devices) > 1:
|
|
64
|
+
raise AdbError(f"Several devices connected, pass --serial: {', '.join(devices)}")
|
|
65
|
+
return serial or devices[0]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def resolve_pid(package: str, serial: str | None = None) -> int | None:
|
|
69
|
+
"""PID of a running package, or None if it is not running.
|
|
70
|
+
|
|
71
|
+
Not running is not an error: the user may be about to launch it. The logs
|
|
72
|
+
simply stay unscoped in that case.
|
|
73
|
+
"""
|
|
74
|
+
proc = subprocess.run(
|
|
75
|
+
_adb("shell", "pidof", "-s", package, serial=serial),
|
|
76
|
+
capture_output=True, text=True,
|
|
77
|
+
)
|
|
78
|
+
pid = proc.stdout.strip()
|
|
79
|
+
return int(pid) if pid.isdigit() else None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _stop_recording(serial: str | None) -> None:
|
|
83
|
+
"""Ask screenrecord on the device to finish and close its file.
|
|
84
|
+
|
|
85
|
+
It has to be signalled on the device: terminating the local adb was
|
|
86
|
+
measured to leave an unreadable file, while SIGINT there finalises it.
|
|
87
|
+
pkill also matches processes it may not signal and says so on stderr —
|
|
88
|
+
that complaint is not about screenrecord, so it is ignored.
|
|
89
|
+
"""
|
|
90
|
+
subprocess.run(
|
|
91
|
+
_adb("shell", "pkill", "-INT", "screenrecord", serial=serial),
|
|
92
|
+
capture_output=True,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _wait_or_kill(proc: subprocess.Popen, timeout: float = 20.0) -> None:
|
|
97
|
+
try:
|
|
98
|
+
proc.wait(timeout=timeout)
|
|
99
|
+
except subprocess.TimeoutExpired:
|
|
100
|
+
proc.kill()
|
|
101
|
+
proc.wait(timeout=5)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def record(
|
|
105
|
+
out_dir: Path,
|
|
106
|
+
seconds: int = 60,
|
|
107
|
+
serial: str | None = None,
|
|
108
|
+
bit_rate: str = "4M",
|
|
109
|
+
size: str | None = None,
|
|
110
|
+
package: str | None = None,
|
|
111
|
+
) -> Capture:
|
|
112
|
+
"""Record screen and logcat together, then pull both off the device.
|
|
113
|
+
|
|
114
|
+
Blocks until the time runs out or Ctrl-C cuts it short; either way the file
|
|
115
|
+
is finalised on the device before it is pulled. The returned started_at is
|
|
116
|
+
what puts the log entries on the video clock later.
|
|
117
|
+
"""
|
|
118
|
+
serial = require_device(serial)
|
|
119
|
+
if seconds > SCREENRECORD_LIMIT:
|
|
120
|
+
raise AdbError(
|
|
121
|
+
f"screenrecord caps at {SCREENRECORD_LIMIT}s; asked for {seconds}s."
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
video = out_dir / "screen.mp4"
|
|
126
|
+
logcat = out_dir / "logcat.txt"
|
|
127
|
+
|
|
128
|
+
# Capture unfiltered and scope afterwards: if the app crashes and restarts,
|
|
129
|
+
# a --pid capture would stop at the death of the old process.
|
|
130
|
+
pid = resolve_pid(package, serial) if package else None
|
|
131
|
+
|
|
132
|
+
subprocess.run(_adb("logcat", "-c", serial=serial), capture_output=True)
|
|
133
|
+
|
|
134
|
+
record_cmd = _adb(
|
|
135
|
+
"shell", "screenrecord", "--bit-rate", bit_rate,
|
|
136
|
+
"--time-limit", str(seconds),
|
|
137
|
+
*(["--size", size] if size else []),
|
|
138
|
+
DEVICE_VIDEO,
|
|
139
|
+
serial=serial,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Both start now; this timestamp is the alignment anchor for the logs.
|
|
143
|
+
started_at = datetime.now()
|
|
144
|
+
stopped_early = False
|
|
145
|
+
with logcat.open("w") as log_file:
|
|
146
|
+
log_proc = subprocess.Popen(
|
|
147
|
+
_adb("logcat", "-v", "threadtime", serial=serial),
|
|
148
|
+
stdout=log_file, stderr=subprocess.DEVNULL,
|
|
149
|
+
)
|
|
150
|
+
# Its own session, so Ctrl-C reaches this process and not adb: killing
|
|
151
|
+
# adb mid-recording leaves an unfinalised file that ffprobe rejects.
|
|
152
|
+
rec_proc = subprocess.Popen(
|
|
153
|
+
record_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
154
|
+
start_new_session=True,
|
|
155
|
+
)
|
|
156
|
+
try:
|
|
157
|
+
rec_proc.wait(timeout=seconds + 30)
|
|
158
|
+
except KeyboardInterrupt:
|
|
159
|
+
stopped_early = True
|
|
160
|
+
_stop_recording(serial)
|
|
161
|
+
_wait_or_kill(rec_proc)
|
|
162
|
+
except subprocess.TimeoutExpired:
|
|
163
|
+
_stop_recording(serial)
|
|
164
|
+
_wait_or_kill(rec_proc)
|
|
165
|
+
finally:
|
|
166
|
+
log_proc.terminate()
|
|
167
|
+
log_proc.wait(timeout=10)
|
|
168
|
+
|
|
169
|
+
# screenrecord finishes writing the container after it stops.
|
|
170
|
+
time.sleep(SETTLE_SECONDS)
|
|
171
|
+
pull = subprocess.run(
|
|
172
|
+
_adb("pull", DEVICE_VIDEO, str(video), serial=serial),
|
|
173
|
+
capture_output=True, text=True,
|
|
174
|
+
)
|
|
175
|
+
if pull.returncode != 0 or not video.is_file():
|
|
176
|
+
raise AdbError(f"Could not pull the recording off the device:\n{pull.stderr}")
|
|
177
|
+
subprocess.run(_adb("shell", "rm", DEVICE_VIDEO, serial=serial), capture_output=True)
|
|
178
|
+
|
|
179
|
+
return Capture(video, logcat, started_at, pid, stopped_early)
|
bug2context/cli.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from .config import Config
|
|
6
|
+
|
|
7
|
+
app = typer.Typer(help="Turn bug screen recordings into context for AI agents.")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@app.callback(invoke_without_command=True)
|
|
11
|
+
def main(ctx: typer.Context) -> None:
|
|
12
|
+
"""Run with no arguments for a guided menu; the subcommands take flags."""
|
|
13
|
+
if ctx.invoked_subcommand is None:
|
|
14
|
+
from .menu import run
|
|
15
|
+
|
|
16
|
+
run()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@app.command()
|
|
20
|
+
def process(
|
|
21
|
+
video: Path = typer.Argument(..., exists=True, dir_okay=False),
|
|
22
|
+
out: Path = typer.Option(Path("bundle"), "--out", "-o"),
|
|
23
|
+
scene_threshold: float = Config.scene_threshold,
|
|
24
|
+
max_duration: float = Config.max_duration,
|
|
25
|
+
interval_seconds: float = Config.interval_seconds,
|
|
26
|
+
max_frames: int | None = Config.max_frames,
|
|
27
|
+
phash_distance: int = Config.phash_distance,
|
|
28
|
+
ocr: bool = Config.ocr,
|
|
29
|
+
ocr_upscale: int = Config.ocr_upscale,
|
|
30
|
+
ocr_max_lines: int = Config.ocr_max_lines,
|
|
31
|
+
transcribe: bool = Config.transcribe,
|
|
32
|
+
whisper_model: str = Config.whisper_model,
|
|
33
|
+
language: str | None = Config.language,
|
|
34
|
+
logcat: Path | None = typer.Option(None, exists=True, dir_okay=False),
|
|
35
|
+
log_levels: str = Config.log_levels,
|
|
36
|
+
log_tag: str | None = Config.log_tag,
|
|
37
|
+
log_offset: float = Config.log_offset,
|
|
38
|
+
video_started_at: str | None = Config.video_started_at,
|
|
39
|
+
log_pid: int | None = Config.log_pid,
|
|
40
|
+
log_max_lines: int = Config.log_max_lines,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Process VIDEO into a context bundle."""
|
|
43
|
+
from .pipeline.assemble import build_bundle
|
|
44
|
+
|
|
45
|
+
cfg = Config(
|
|
46
|
+
scene_threshold, max_duration, interval_seconds, max_frames, phash_distance,
|
|
47
|
+
ocr, ocr_upscale, ocr_max_lines,
|
|
48
|
+
transcribe, whisper_model, language,
|
|
49
|
+
str(logcat) if logcat else None,
|
|
50
|
+
log_levels, log_tag, log_offset, video_started_at,
|
|
51
|
+
log_pid, log_max_lines,
|
|
52
|
+
)
|
|
53
|
+
typer.echo(build_bundle(video, out, cfg))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@app.command()
|
|
57
|
+
def record(
|
|
58
|
+
out: Path = typer.Option(Path("bundle"), "--out", "-o"),
|
|
59
|
+
seconds: int = typer.Option(60, "--seconds", "-t", help="Recording length."),
|
|
60
|
+
serial: str | None = typer.Option(None, help="Device serial, if several are attached."),
|
|
61
|
+
size: str | None = typer.Option(None, help="e.g. 720x1280 — smaller records faster."),
|
|
62
|
+
package: str | None = typer.Option(None, help="App package, to scope logs to it."),
|
|
63
|
+
log_levels: str = Config.log_levels,
|
|
64
|
+
log_tag: str | None = Config.log_tag,
|
|
65
|
+
ocr: bool = Config.ocr,
|
|
66
|
+
max_frames: int | None = Config.max_frames,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Record an Android device's screen and logcat, then build the bundle.
|
|
69
|
+
|
|
70
|
+
Reproduce the bug while it records; both streams share one start time, so
|
|
71
|
+
the stack trace lands next to the frame that shows the crash.
|
|
72
|
+
"""
|
|
73
|
+
from .android import AdbError, record as capture
|
|
74
|
+
from .pipeline.assemble import build_bundle
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
session = capture(out, seconds=seconds, serial=serial, size=size, package=package)
|
|
78
|
+
except AdbError as error:
|
|
79
|
+
raise typer.BadParameter(str(error)) from error
|
|
80
|
+
|
|
81
|
+
cfg = Config(
|
|
82
|
+
max_frames=max_frames,
|
|
83
|
+
ocr=ocr,
|
|
84
|
+
logcat_path=str(session.logcat),
|
|
85
|
+
log_levels=log_levels,
|
|
86
|
+
log_tag=log_tag,
|
|
87
|
+
video_started_at=session.started_at.isoformat(),
|
|
88
|
+
log_pid=session.pid,
|
|
89
|
+
)
|
|
90
|
+
typer.echo(build_bundle(session.video, out, cfg))
|
bug2context/config.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class Config:
|
|
6
|
+
"""Pipeline options. Defaults come from PLAN section 3."""
|
|
7
|
+
|
|
8
|
+
scene_threshold: float = 0.08
|
|
9
|
+
|
|
10
|
+
max_duration: float = 600.0
|
|
11
|
+
"""Refuse recordings longer than this, in seconds. 0 disables the guard.
|
|
12
|
+
|
|
13
|
+
Nothing here scales badly with length except time, and it scales linearly:
|
|
14
|
+
decoding alone ran at roughly a third of real time on a 60 fps capture, so
|
|
15
|
+
a 44 minute recording means a quarter of an hour before the first frame is
|
|
16
|
+
read. Trimming to the interesting minute is almost always what was meant.
|
|
17
|
+
"""
|
|
18
|
+
interval_seconds: float = 1.0
|
|
19
|
+
max_frames: int | None = None
|
|
20
|
+
"""Hard cap on published frames; None derives one from the video's length.
|
|
21
|
+
|
|
22
|
+
A fixed 20 was wrong in both directions. Deduplication already collapses
|
|
23
|
+
ordinary recordings well below it — measured 7 states in a 44 s capture and
|
|
24
|
+
12 in an 11 s one — so the cap never bound there. On an animated app it bit
|
|
25
|
+
hard: one 52 s recording held over 70 distinct states, and 20 threw away
|
|
26
|
+
two thirds of them. Across ten real analyses the cap was raised by hand
|
|
27
|
+
seven times.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
phash_distance: int = 4
|
|
31
|
+
"""Frames closer than this are treated as duplicates.
|
|
32
|
+
|
|
33
|
+
Calibration knob. Measured on encoded video: identical screens sit at 0, a
|
|
34
|
+
small text-only change lands around 6 before compression and can drop to 4.
|
|
35
|
+
Text is the payload of a bug report, so the default errs toward keeping —
|
|
36
|
+
the max_frames cap absorbs the extra. Raise it for videos of constantly
|
|
37
|
+
animating UI that produce noisy near-duplicates.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
ocr: bool = True
|
|
41
|
+
"""Read on-screen text. Silently skipped when no OCR backend is installed."""
|
|
42
|
+
|
|
43
|
+
ocr_upscale: int = 2
|
|
44
|
+
"""Enlarge frames before OCR.
|
|
45
|
+
|
|
46
|
+
Calibration knob. Recompressed video (WhatsApp is the worst offender)
|
|
47
|
+
smears small text. Measured on a 1080x1920 frame, a stack trace read as
|
|
48
|
+
"Null PointerSxception Ride Requestkt14" at 1x and correctly at 3x. 2x is
|
|
49
|
+
the default because it recovers most of that at a fraction of the cost;
|
|
50
|
+
raise it for badly recompressed sources.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
ocr_max_lines: int = 12
|
|
54
|
+
"""Cap on text lines reported per frame, so chrome cannot bury the signal."""
|
|
55
|
+
|
|
56
|
+
transcribe: bool = False
|
|
57
|
+
"""Transcribe spoken narration.
|
|
58
|
+
|
|
59
|
+
Off by default: it downloads a model on first use and is the slowest stage
|
|
60
|
+
by far, while most bug recordings have no voice-over at all.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
whisper_model: str = "small"
|
|
64
|
+
"""faster-whisper size. 'tiny'/'base' are faster, 'medium' more accurate."""
|
|
65
|
+
|
|
66
|
+
language: str | None = None
|
|
67
|
+
"""Force a language code (e.g. "es") instead of letting whisper detect it."""
|
|
68
|
+
|
|
69
|
+
logcat_path: str | None = None
|
|
70
|
+
"""Device log file to merge (`adb logcat -v threadtime > log.txt`)."""
|
|
71
|
+
|
|
72
|
+
log_levels: str = "WEF"
|
|
73
|
+
"""Minimum logcat levels to keep. A crash drowns in a busy app's debug chatter."""
|
|
74
|
+
|
|
75
|
+
log_tag: str | None = None
|
|
76
|
+
"""Only keep entries whose tag contains this substring."""
|
|
77
|
+
|
|
78
|
+
log_offset: float = 0.0
|
|
79
|
+
"""Seconds to shift logs by. Manual escape when device and host clocks disagree."""
|
|
80
|
+
|
|
81
|
+
video_started_at: str | None = None
|
|
82
|
+
"""ISO time the recording began, used to align logs. `record` fills this in."""
|
|
83
|
+
|
|
84
|
+
log_pid: int | None = None
|
|
85
|
+
"""Only keep logs from this process. `record --package` resolves it for you.
|
|
86
|
+
|
|
87
|
+
Level filtering alone is not enough on a real device: a 2 min capture from
|
|
88
|
+
a phone held 7,634 W/E/F lines and none came from the app under test.
|
|
89
|
+
Crash tags (AndroidRuntime, DEBUG) are kept regardless of process.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
log_max_lines: int = 80
|
|
93
|
+
"""Cap on log entries in the report, most severe first. meta.json keeps all."""
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""MCP server: the primary interface, per PLAN principle 2.
|
|
2
|
+
|
|
3
|
+
Exposes the pipeline as tools so an agent can go from "here is a video of the
|
|
4
|
+
bug" to a chronology without the user running anything by hand.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from mcp.server.mcpserver import Image, MCPServer
|
|
14
|
+
|
|
15
|
+
from .config import Config
|
|
16
|
+
|
|
17
|
+
DEFAULT_WORKDIR = Path("~/.bug2context").expanduser()
|
|
18
|
+
|
|
19
|
+
mcp = MCPServer(
|
|
20
|
+
name="bug2context",
|
|
21
|
+
instructions=(
|
|
22
|
+
"Turns screen recordings of bugs into a chronological, readable report. "
|
|
23
|
+
"Call analyze_bug_video first; it returns the report text plus the bundle "
|
|
24
|
+
"path. Use get_frame to actually look at a moment the report describes."
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _bundle_dir(video: Path, workdir: Path) -> Path:
|
|
30
|
+
"""A directory that does not exist yet.
|
|
31
|
+
|
|
32
|
+
The timestamp only resolves to seconds, so two analyses in the same second
|
|
33
|
+
would land on the same path and the second would overwrite the first.
|
|
34
|
+
"""
|
|
35
|
+
base = workdir / f"{video.stem}-{datetime.now():%Y%m%d-%H%M%S}"
|
|
36
|
+
candidate, n = base, 2
|
|
37
|
+
while candidate.exists():
|
|
38
|
+
candidate = base.with_name(f"{base.name}-{n}")
|
|
39
|
+
n += 1
|
|
40
|
+
return candidate
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@mcp.tool()
|
|
44
|
+
def analyze_bug_video(
|
|
45
|
+
video_path: str,
|
|
46
|
+
max_frames: int | None = None,
|
|
47
|
+
ocr: bool = Config.ocr,
|
|
48
|
+
transcribe_audio: bool = Config.transcribe,
|
|
49
|
+
workdir: str = str(DEFAULT_WORKDIR),
|
|
50
|
+
) -> str:
|
|
51
|
+
"""Process a bug screen recording into a chronological markdown report.
|
|
52
|
+
|
|
53
|
+
Returns the report itself, so you can reason about it directly. Frames are
|
|
54
|
+
written next to it and referenced by relative path; use get_frame to view one.
|
|
55
|
+
|
|
56
|
+
Leave max_frames unset unless the report looks too coarse or too long: it
|
|
57
|
+
is derived from the recording's length.
|
|
58
|
+
|
|
59
|
+
Set transcribe_audio when the recording has someone narrating the bug; it
|
|
60
|
+
is slow and downloads a model on first use, so it stays off otherwise.
|
|
61
|
+
"""
|
|
62
|
+
from .pipeline.assemble import build_bundle
|
|
63
|
+
|
|
64
|
+
video = Path(video_path).expanduser()
|
|
65
|
+
if not video.is_file():
|
|
66
|
+
return f"No video at {video}. Give me the full path to the recording."
|
|
67
|
+
|
|
68
|
+
out_dir = _bundle_dir(video, Path(workdir).expanduser())
|
|
69
|
+
cfg = Config(max_frames=max_frames, ocr=ocr, transcribe=transcribe_audio)
|
|
70
|
+
report_path = build_bundle(video, out_dir, cfg)
|
|
71
|
+
|
|
72
|
+
return f"Bundle: {out_dir}\n\n{report_path.read_text()}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@mcp.tool()
|
|
76
|
+
def get_frame(bundle_path: str, frame_index: int) -> Image:
|
|
77
|
+
"""Return one frame from a bundle so you can look at it.
|
|
78
|
+
|
|
79
|
+
frame_index is the 1-based number shown in the report (frame_003 -> 3).
|
|
80
|
+
"""
|
|
81
|
+
frames_dir = Path(bundle_path).expanduser() / "frames"
|
|
82
|
+
matches = sorted(frames_dir.glob(f"frame_{frame_index:03d}_*.png"))
|
|
83
|
+
if not matches:
|
|
84
|
+
available = len(list(frames_dir.glob("frame_*.png")))
|
|
85
|
+
raise ValueError(
|
|
86
|
+
f"No frame {frame_index} in {bundle_path} (bundle has {available})."
|
|
87
|
+
)
|
|
88
|
+
return Image(path=matches[0])
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@mcp.tool()
|
|
92
|
+
def list_bundles(workdir: str = str(DEFAULT_WORKDIR)) -> list[dict]:
|
|
93
|
+
"""List previously processed bundles, newest first."""
|
|
94
|
+
root = Path(workdir).expanduser()
|
|
95
|
+
if not root.is_dir():
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
bundles = []
|
|
99
|
+
for meta_path in root.glob("*/meta.json"):
|
|
100
|
+
try:
|
|
101
|
+
meta = json.loads(meta_path.read_text())
|
|
102
|
+
except (OSError, json.JSONDecodeError):
|
|
103
|
+
continue # a half-written bundle should not break the listing
|
|
104
|
+
bundles.append({
|
|
105
|
+
"bundle_path": str(meta_path.parent),
|
|
106
|
+
"video": meta.get("video"),
|
|
107
|
+
"generated_at": meta.get("generated_at"),
|
|
108
|
+
"frames": len(meta.get("frames", [])),
|
|
109
|
+
})
|
|
110
|
+
return sorted(bundles, key=lambda b: b["generated_at"] or "", reverse=True)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def main() -> None:
|
|
114
|
+
"""Entry point for `bug2context-mcp` and `python -m bug2context.mcp_server`."""
|
|
115
|
+
mcp.run(transport="stdio")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
main()
|
bug2context/menu.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Guided flow for people who do not want to remember flags.
|
|
2
|
+
|
|
3
|
+
Everything here is a thin shell over the same commands: the menu asks for what
|
|
4
|
+
the flags would have carried, then calls the identical code path. Nothing is
|
|
5
|
+
reachable only through the menu.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import date
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
from rich.prompt import Confirm, IntPrompt, Prompt
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
|
|
17
|
+
from .config import Config
|
|
18
|
+
|
|
19
|
+
DEFAULT_WORKDIR = Path("~/.bug2context").expanduser()
|
|
20
|
+
console = Console()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _slugify(text: str) -> str:
|
|
24
|
+
kept = [c.lower() if c.isalnum() else "-" for c in text.strip()]
|
|
25
|
+
return "-".join(filter(None, "".join(kept).split("-"))) or "bug"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _bundle_path(label: str) -> Path:
|
|
29
|
+
"""Bundles land where the MCP server looks, so Claude can find them."""
|
|
30
|
+
base = DEFAULT_WORKDIR / f"{_slugify(label)}-{date.today():%Y-%m-%d}"
|
|
31
|
+
candidate, n = base, 2
|
|
32
|
+
while candidate.exists():
|
|
33
|
+
candidate = base.with_name(f"{base.name}-{n}")
|
|
34
|
+
n += 1
|
|
35
|
+
return candidate
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _report(bundle: Path) -> None:
|
|
39
|
+
console.print(f"\n[green]Listo[/green] → [bold]{bundle}[/bold]")
|
|
40
|
+
console.print("Pídeselo a Claude Code así:")
|
|
41
|
+
console.print(f' [dim]analiza el bundle {bundle.name}[/dim]\n')
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _record_android() -> None:
|
|
45
|
+
from .android import AdbError, record, resolve_pid
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
from .android import require_device
|
|
49
|
+
|
|
50
|
+
serial = require_device()
|
|
51
|
+
except AdbError as error:
|
|
52
|
+
console.print(f"[red]{error}[/red]")
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
console.print(f"Dispositivo: [bold]{serial}[/bold]")
|
|
56
|
+
label = Prompt.ask("¿Qué bug vas a reproducir?", default="bug")
|
|
57
|
+
seconds = IntPrompt.ask(
|
|
58
|
+
"Segundos de grabación (máx. 180)", default=120, choices=None
|
|
59
|
+
)
|
|
60
|
+
if seconds > 180:
|
|
61
|
+
console.print("[yellow]screenrecord corta a 180 s; uso 180.[/yellow]")
|
|
62
|
+
seconds = 180
|
|
63
|
+
|
|
64
|
+
package = None
|
|
65
|
+
if Confirm.ask("¿Acotar los logs a una app?", default=False):
|
|
66
|
+
package = Prompt.ask("Paquete (ej. com.rumborides.driver)")
|
|
67
|
+
if resolve_pid(package, serial) is None:
|
|
68
|
+
console.print(
|
|
69
|
+
f"[yellow]{package} no está corriendo; los logs quedarán sin acotar.[/yellow]"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
bundle = _bundle_path(label)
|
|
73
|
+
console.print(
|
|
74
|
+
f"\n[bold]Grabando {seconds} s.[/bold] Reproduce el bug ahora — "
|
|
75
|
+
"si la pantalla no cambia, no habrá nada que contar."
|
|
76
|
+
)
|
|
77
|
+
console.print("[dim]Ctrl-C corta antes de tiempo sin dañar el video.[/dim]\n")
|
|
78
|
+
try:
|
|
79
|
+
session = record(bundle, seconds=seconds, serial=serial, package=package)
|
|
80
|
+
except AdbError as error:
|
|
81
|
+
console.print(f"[red]{error}[/red]")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
if session.stopped_early:
|
|
85
|
+
console.print("[dim]Grabación cortada por ti.[/dim]")
|
|
86
|
+
|
|
87
|
+
from .pipeline.assemble import build_bundle
|
|
88
|
+
|
|
89
|
+
with console.status("Procesando…"):
|
|
90
|
+
build_bundle(session.video, bundle, Config(
|
|
91
|
+
logcat_path=str(session.logcat),
|
|
92
|
+
log_pid=session.pid,
|
|
93
|
+
video_started_at=session.started_at.isoformat(),
|
|
94
|
+
))
|
|
95
|
+
_report(bundle)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _process_video() -> None:
|
|
99
|
+
from .pipeline.assemble import build_bundle
|
|
100
|
+
from .pipeline.frames import FFmpegError
|
|
101
|
+
|
|
102
|
+
raw = Prompt.ask("Ruta del video")
|
|
103
|
+
video = Path(raw.strip().strip("'\"")).expanduser()
|
|
104
|
+
if not video.is_file():
|
|
105
|
+
console.print(f"[red]No hay ningún archivo en {video}[/red]")
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
cfg = Config()
|
|
109
|
+
if Confirm.ask("¿El video viene recomprimido (WhatsApp)?", default=False):
|
|
110
|
+
cfg = Config(ocr_upscale=3)
|
|
111
|
+
|
|
112
|
+
bundle = _bundle_path(Prompt.ask("Nombre para el bundle", default=video.stem))
|
|
113
|
+
with console.status("Procesando…"):
|
|
114
|
+
try:
|
|
115
|
+
build_bundle(video, bundle, cfg)
|
|
116
|
+
except FFmpegError as error:
|
|
117
|
+
console.print(f"[yellow]{error}[/yellow]")
|
|
118
|
+
return
|
|
119
|
+
_report(bundle)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _list_bundles() -> None:
|
|
123
|
+
from .mcp_server import list_bundles
|
|
124
|
+
|
|
125
|
+
bundles = list_bundles(str(DEFAULT_WORKDIR))
|
|
126
|
+
if not bundles:
|
|
127
|
+
console.print(f"[dim]Todavía no hay bundles en {DEFAULT_WORKDIR}[/dim]")
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
table = Table(box=None, header_style="bold")
|
|
131
|
+
table.add_column("Bundle")
|
|
132
|
+
table.add_column("Frames", justify="right")
|
|
133
|
+
table.add_column("Creado")
|
|
134
|
+
for entry in bundles:
|
|
135
|
+
table.add_row(
|
|
136
|
+
Path(entry["bundle_path"]).name,
|
|
137
|
+
str(entry["frames"]),
|
|
138
|
+
(entry["generated_at"] or "")[:16].replace("T", " "),
|
|
139
|
+
)
|
|
140
|
+
console.print(table)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
ACTIONS = {
|
|
144
|
+
"1": ("Grabar un Android (pantalla + logs)", _record_android),
|
|
145
|
+
"2": ("Procesar un video que ya tienes", _process_video),
|
|
146
|
+
"3": ("Ver bundles anteriores", _list_bundles),
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def run() -> None:
|
|
151
|
+
"""Show the menu once and run the chosen action."""
|
|
152
|
+
console.print("\n[bold]bug2context[/bold] — contexto de bugs para agentes\n")
|
|
153
|
+
for key, (label, _) in ACTIONS.items():
|
|
154
|
+
console.print(f" [bold]{key}[/bold] {label}")
|
|
155
|
+
console.print(" [bold]q[/bold] Salir\n")
|
|
156
|
+
|
|
157
|
+
choice = Prompt.ask("Elige", choices=[*ACTIONS, "q"], default="1")
|
|
158
|
+
if choice == "q":
|
|
159
|
+
return
|
|
160
|
+
ACTIONS[choice][1]()
|
|
File without changes
|