agent-recorder 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.
- agent_flight_recorder/__init__.py +0 -0
- agent_flight_recorder/adapters/__init__.py +0 -0
- agent_flight_recorder/adapters/claude.py +139 -0
- agent_flight_recorder/adapters/codex.py +139 -0
- agent_flight_recorder/analyzers/__init__.py +0 -0
- agent_flight_recorder/analyzers/skill_extractor.py +120 -0
- agent_flight_recorder/analyzers/stats.py +45 -0
- agent_flight_recorder/cli.py +117 -0
- agent_flight_recorder/db.py +163 -0
- agent_flight_recorder/ingester.py +42 -0
- agent_flight_recorder/models.py +65 -0
- agent_flight_recorder/redactor.py +20 -0
- agent_flight_recorder/render/__init__.py +0 -0
- agent_flight_recorder/render/terminal.py +88 -0
- agent_recorder-0.1.0.dist-info/METADATA +237 -0
- agent_recorder-0.1.0.dist-info/RECORD +18 -0
- agent_recorder-0.1.0.dist-info/WHEEL +4 -0
- agent_recorder-0.1.0.dist-info/entry_points.txt +2 -0
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import uuid
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Iterator
|
|
5
|
+
from ..models import ParsedSession, Run, ToolCall, ShellCommand, FileEvent, Error
|
|
6
|
+
from ..redactor import redact, redact_json
|
|
7
|
+
|
|
8
|
+
CLAUDE_DIR = Path.home() / ".claude" / "projects"
|
|
9
|
+
|
|
10
|
+
_FILE_TOOL_MAP = {
|
|
11
|
+
"Read": "read", "Write": "write", "Edit": "patch",
|
|
12
|
+
"MultiEdit": "patch", "Glob": "read", "Grep": "read", "Delete": "delete",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_session_file(path: Path) -> ParsedSession:
|
|
17
|
+
path = Path(path)
|
|
18
|
+
run_id = path.stem
|
|
19
|
+
lines = [json.loads(l) for l in path.read_text(encoding="utf-8").splitlines() if l.strip()]
|
|
20
|
+
|
|
21
|
+
project_path = path.parent.name
|
|
22
|
+
user_goal = final_summary = started_at = ended_at = ""
|
|
23
|
+
tokens_in = tokens_out = cache_read = cache_write = 0
|
|
24
|
+
tool_calls: list[ToolCall] = []
|
|
25
|
+
shell_commands: list[ShellCommand] = []
|
|
26
|
+
files: list[FileEvent] = []
|
|
27
|
+
errors: list[Error] = []
|
|
28
|
+
pending: dict[str, dict] = {} # tool_use_id -> tc_data
|
|
29
|
+
pending_bash: dict[str, ShellCommand] = {} # tool_use_id -> ShellCommand
|
|
30
|
+
|
|
31
|
+
for line in lines:
|
|
32
|
+
ts = line.get("timestamp", "")
|
|
33
|
+
if not started_at and ts:
|
|
34
|
+
started_at = ts
|
|
35
|
+
if ts:
|
|
36
|
+
ended_at = ts
|
|
37
|
+
|
|
38
|
+
msg_type = line.get("type", "")
|
|
39
|
+
message = line.get("message", {})
|
|
40
|
+
content = message.get("content", [])
|
|
41
|
+
if not isinstance(content, list):
|
|
42
|
+
content = []
|
|
43
|
+
|
|
44
|
+
if msg_type == "user":
|
|
45
|
+
for block in content:
|
|
46
|
+
if not isinstance(block, dict):
|
|
47
|
+
continue
|
|
48
|
+
if block.get("type") == "text" and not user_goal:
|
|
49
|
+
user_goal = redact(block.get("text", "")[:500])
|
|
50
|
+
elif block.get("type") == "tool_result":
|
|
51
|
+
tu_id = block.get("tool_use_id", "")
|
|
52
|
+
is_error = block.get("is_error", False)
|
|
53
|
+
raw_output = block.get("content", "")
|
|
54
|
+
if isinstance(raw_output, list):
|
|
55
|
+
raw_output = " ".join(b.get("text", "") for b in raw_output if isinstance(b, dict))
|
|
56
|
+
output = redact(str(raw_output)[:500])
|
|
57
|
+
|
|
58
|
+
if tu_id in pending:
|
|
59
|
+
tc_data = pending.pop(tu_id)
|
|
60
|
+
tc_data["output_summary"] = output
|
|
61
|
+
tc_data["status"] = "error" if is_error else "success"
|
|
62
|
+
tool_calls.append(ToolCall(**tc_data))
|
|
63
|
+
|
|
64
|
+
if tu_id in pending_bash:
|
|
65
|
+
sc = pending_bash.pop(tu_id)
|
|
66
|
+
sc.exit_code = 1 if is_error else 0
|
|
67
|
+
if is_error:
|
|
68
|
+
sc.stderr_excerpt = output
|
|
69
|
+
else:
|
|
70
|
+
sc.stdout_excerpt = output
|
|
71
|
+
shell_commands.append(sc)
|
|
72
|
+
|
|
73
|
+
if is_error:
|
|
74
|
+
errors.append(Error(
|
|
75
|
+
id=str(uuid.uuid4()), run_id=run_id, source="tool",
|
|
76
|
+
message=output[:300], raw_json=redact_json(block), timestamp=ts,
|
|
77
|
+
))
|
|
78
|
+
|
|
79
|
+
elif msg_type == "assistant":
|
|
80
|
+
usage = message.get("usage", {})
|
|
81
|
+
tokens_in += usage.get("input_tokens", 0)
|
|
82
|
+
tokens_out += usage.get("output_tokens", 0)
|
|
83
|
+
cache_read += usage.get("cache_read_input_tokens", 0)
|
|
84
|
+
cache_write += usage.get("cache_creation_input_tokens", 0)
|
|
85
|
+
|
|
86
|
+
for block in content:
|
|
87
|
+
if not isinstance(block, dict):
|
|
88
|
+
continue
|
|
89
|
+
if block.get("type") == "text":
|
|
90
|
+
txt = block.get("text", "").strip()
|
|
91
|
+
if txt:
|
|
92
|
+
final_summary = redact(txt[:500])
|
|
93
|
+
elif block.get("type") == "tool_use":
|
|
94
|
+
tu_id = block.get("id", str(uuid.uuid4()))
|
|
95
|
+
tool_name = block.get("name", "Unknown")
|
|
96
|
+
inp = block.get("input", {})
|
|
97
|
+
tc_data = {
|
|
98
|
+
"id": str(uuid.uuid4()), "run_id": run_id, "tool_name": tool_name,
|
|
99
|
+
"input_summary": redact(json.dumps(inp)[:300]),
|
|
100
|
+
"output_summary": "", "status": "success",
|
|
101
|
+
"duration_ms": None, "timestamp": ts, "raw_json": redact_json(block),
|
|
102
|
+
}
|
|
103
|
+
pending[tu_id] = tc_data
|
|
104
|
+
|
|
105
|
+
if tool_name == "Bash":
|
|
106
|
+
pending_bash[tu_id] = ShellCommand(
|
|
107
|
+
id=str(uuid.uuid4()), run_id=run_id,
|
|
108
|
+
command=redact(inp.get("command", "")[:500]), timestamp=ts,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
if tool_name in _FILE_TOOL_MAP:
|
|
112
|
+
fpath = inp.get("file_path") or inp.get("path") or inp.get("pattern") or ""
|
|
113
|
+
if fpath:
|
|
114
|
+
files.append(FileEvent(
|
|
115
|
+
id=str(uuid.uuid4()), run_id=run_id,
|
|
116
|
+
path=str(fpath), action=_FILE_TOOL_MAP[tool_name],
|
|
117
|
+
))
|
|
118
|
+
|
|
119
|
+
for tu_id, tc_data in pending.items():
|
|
120
|
+
tool_calls.append(ToolCall(**tc_data))
|
|
121
|
+
if tu_id in pending_bash:
|
|
122
|
+
shell_commands.append(pending_bash[tu_id])
|
|
123
|
+
|
|
124
|
+
return ParsedSession(
|
|
125
|
+
run=Run(id=run_id, source="claude", project_path=project_path,
|
|
126
|
+
started_at=started_at, ended_at=ended_at, user_goal=user_goal,
|
|
127
|
+
final_summary=final_summary, tokens_in=tokens_in, tokens_out=tokens_out,
|
|
128
|
+
cache_read=cache_read, cache_write=cache_write),
|
|
129
|
+
tool_calls=tool_calls, shell_commands=shell_commands, files=files, errors=errors,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def iter_sessions(claude_dir: Path = CLAUDE_DIR) -> Iterator[Path]:
|
|
134
|
+
claude_dir = Path(claude_dir)
|
|
135
|
+
if not claude_dir.exists():
|
|
136
|
+
return
|
|
137
|
+
for project_dir in claude_dir.iterdir():
|
|
138
|
+
if project_dir.is_dir():
|
|
139
|
+
yield from project_dir.glob("*.jsonl")
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import uuid
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Iterator
|
|
5
|
+
from ..models import ParsedSession, Run, ToolCall, ShellCommand, FileEvent, Error
|
|
6
|
+
from ..redactor import redact, redact_json
|
|
7
|
+
|
|
8
|
+
CODEX_DIR = Path.home() / ".codex" / "sessions"
|
|
9
|
+
|
|
10
|
+
_TOOL_NAME_MAP = {
|
|
11
|
+
"exec_command": "Bash", "apply_patch": "Edit", "read_file": "Read",
|
|
12
|
+
"write_file": "Write", "delete_file": "Delete", "list_directory": "Glob",
|
|
13
|
+
}
|
|
14
|
+
_FILE_TOOL_ACTIONS = {
|
|
15
|
+
"Read": "read", "Write": "write", "Edit": "patch", "Delete": "delete", "Glob": "read",
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def parse_session_file(path: Path) -> ParsedSession:
|
|
20
|
+
path = Path(path)
|
|
21
|
+
lines = [json.loads(l) for l in path.read_text(encoding="utf-8").splitlines() if l.strip()]
|
|
22
|
+
|
|
23
|
+
session_id = path.stem
|
|
24
|
+
project_path = user_goal = final_summary = started_at = ended_at = ""
|
|
25
|
+
tokens_in = tokens_out = 0
|
|
26
|
+
tool_calls: list[ToolCall] = []
|
|
27
|
+
shell_commands: list[ShellCommand] = []
|
|
28
|
+
files: list[FileEvent] = []
|
|
29
|
+
errors: list[Error] = []
|
|
30
|
+
pending: dict[str, dict] = {}
|
|
31
|
+
|
|
32
|
+
for line in lines:
|
|
33
|
+
ts = line.get("timestamp", "")
|
|
34
|
+
if not started_at and ts:
|
|
35
|
+
started_at = ts
|
|
36
|
+
if ts:
|
|
37
|
+
ended_at = ts
|
|
38
|
+
|
|
39
|
+
event_type = line.get("type", "")
|
|
40
|
+
|
|
41
|
+
if event_type == "session_meta":
|
|
42
|
+
session_id = line.get("session_id", session_id)
|
|
43
|
+
project_path = line.get("project_path", "")
|
|
44
|
+
|
|
45
|
+
elif event_type == "message":
|
|
46
|
+
role = line.get("role", "")
|
|
47
|
+
content = line.get("content", "")
|
|
48
|
+
if role == "user" and not user_goal:
|
|
49
|
+
user_goal = redact(str(content)[:500])
|
|
50
|
+
elif role == "assistant":
|
|
51
|
+
final_summary = redact(str(content)[:500])
|
|
52
|
+
|
|
53
|
+
elif event_type == "response_item:function_call":
|
|
54
|
+
call_id = line.get("call_id", str(uuid.uuid4()))
|
|
55
|
+
raw_name = line.get("name", "")
|
|
56
|
+
tool_name = _TOOL_NAME_MAP.get(raw_name, raw_name.capitalize() or "Unknown")
|
|
57
|
+
args = line.get("arguments", {})
|
|
58
|
+
if isinstance(args, str):
|
|
59
|
+
try:
|
|
60
|
+
args = json.loads(args)
|
|
61
|
+
except Exception:
|
|
62
|
+
args = {"raw": args}
|
|
63
|
+
inp_summary = redact(json.dumps(args)[:300])
|
|
64
|
+
pending[call_id] = {
|
|
65
|
+
"id": str(uuid.uuid4()), "run_id": session_id, "tool_name": tool_name,
|
|
66
|
+
"input_summary": inp_summary, "output_summary": "", "status": "success",
|
|
67
|
+
"duration_ms": None, "timestamp": ts, "raw_json": redact_json(line),
|
|
68
|
+
"_raw_name": raw_name, "_args": args,
|
|
69
|
+
}
|
|
70
|
+
if raw_name == "exec_command":
|
|
71
|
+
pending[call_id]["_shell"] = ShellCommand(
|
|
72
|
+
id=str(uuid.uuid4()), run_id=session_id,
|
|
73
|
+
command=redact(args.get("command", "")[:500]), timestamp=ts,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
elif event_type == "function_call_output":
|
|
77
|
+
call_id = line.get("call_id", "")
|
|
78
|
+
output = redact(str(line.get("output", ""))[:500])
|
|
79
|
+
exit_code = line.get("exit_code")
|
|
80
|
+
is_error = exit_code is not None and exit_code != 0
|
|
81
|
+
|
|
82
|
+
if call_id in pending:
|
|
83
|
+
data = pending.pop(call_id)
|
|
84
|
+
data["output_summary"] = output
|
|
85
|
+
data["status"] = "error" if is_error else "success"
|
|
86
|
+
tool_name = data["tool_name"]
|
|
87
|
+
args = data.pop("_args", {})
|
|
88
|
+
sc = data.pop("_shell", None)
|
|
89
|
+
data.pop("_raw_name", None)
|
|
90
|
+
|
|
91
|
+
tool_calls.append(ToolCall(**data))
|
|
92
|
+
|
|
93
|
+
if sc is not None:
|
|
94
|
+
sc.exit_code = exit_code
|
|
95
|
+
if is_error:
|
|
96
|
+
sc.stderr_excerpt = output
|
|
97
|
+
else:
|
|
98
|
+
sc.stdout_excerpt = output
|
|
99
|
+
shell_commands.append(sc)
|
|
100
|
+
|
|
101
|
+
if is_error:
|
|
102
|
+
errors.append(Error(
|
|
103
|
+
id=str(uuid.uuid4()), run_id=session_id, source="tool",
|
|
104
|
+
message=output[:300], raw_json=redact_json(line), timestamp=ts,
|
|
105
|
+
))
|
|
106
|
+
|
|
107
|
+
if tool_name in _FILE_TOOL_ACTIONS:
|
|
108
|
+
fpath = args.get("path") or args.get("file_path") or ""
|
|
109
|
+
if fpath:
|
|
110
|
+
files.append(FileEvent(
|
|
111
|
+
id=str(uuid.uuid4()), run_id=session_id,
|
|
112
|
+
path=str(fpath), action=_FILE_TOOL_ACTIONS[tool_name],
|
|
113
|
+
))
|
|
114
|
+
|
|
115
|
+
elif event_type == "token_count":
|
|
116
|
+
tokens_in += line.get("input_tokens", 0)
|
|
117
|
+
tokens_out += line.get("output_tokens", 0)
|
|
118
|
+
|
|
119
|
+
for data in pending.values():
|
|
120
|
+
sc = data.pop("_shell", None)
|
|
121
|
+
data.pop("_raw_name", None)
|
|
122
|
+
data.pop("_args", None)
|
|
123
|
+
tool_calls.append(ToolCall(**data))
|
|
124
|
+
if sc is not None:
|
|
125
|
+
shell_commands.append(sc)
|
|
126
|
+
|
|
127
|
+
return ParsedSession(
|
|
128
|
+
run=Run(id=session_id, source="codex", project_path=project_path,
|
|
129
|
+
started_at=started_at, ended_at=ended_at, user_goal=user_goal,
|
|
130
|
+
final_summary=final_summary, tokens_in=tokens_in, tokens_out=tokens_out),
|
|
131
|
+
tool_calls=tool_calls, shell_commands=shell_commands, files=files, errors=errors,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def iter_sessions(codex_dir: Path = CODEX_DIR) -> Iterator[Path]:
|
|
136
|
+
codex_dir = Path(codex_dir)
|
|
137
|
+
if not codex_dir.exists():
|
|
138
|
+
return
|
|
139
|
+
yield from codex_dir.rglob("*.jsonl")
|
|
File without changes
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from rapidfuzz import fuzz
|
|
4
|
+
|
|
5
|
+
_STOPWORDS = {"the", "a", "an", "to", "for", "and", "or", "in", "of", "with", "on", "is", "it", "fix", "debug"}
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _keywords(text: str) -> set[str]:
|
|
9
|
+
return {w.lower() for w in text.split() if w.lower() not in _STOPWORDS and len(w) > 2}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def cluster_runs(conn: sqlite3.Connection, min_runs: int = 3) -> list[dict]:
|
|
13
|
+
runs = conn.execute("SELECT id, user_goal, outcome FROM runs").fetchall()
|
|
14
|
+
assigned: set[str] = set()
|
|
15
|
+
clusters = []
|
|
16
|
+
|
|
17
|
+
for i, r1 in enumerate(runs):
|
|
18
|
+
if r1["id"] in assigned:
|
|
19
|
+
continue
|
|
20
|
+
cluster = [r1]
|
|
21
|
+
kw1 = _keywords(r1["user_goal"])
|
|
22
|
+
|
|
23
|
+
for j, r2 in enumerate(runs):
|
|
24
|
+
if i == j or r2["id"] in assigned:
|
|
25
|
+
continue
|
|
26
|
+
kw2 = _keywords(r2["user_goal"])
|
|
27
|
+
if kw1 and kw2:
|
|
28
|
+
overlap = len(kw1 & kw2) / max(len(kw1), len(kw2))
|
|
29
|
+
else:
|
|
30
|
+
overlap = 0.0
|
|
31
|
+
fuzzy = fuzz.partial_ratio(r1["user_goal"], r2["user_goal"])
|
|
32
|
+
if overlap >= 0.3 or fuzzy >= 70:
|
|
33
|
+
cluster.append(r2)
|
|
34
|
+
|
|
35
|
+
if len(cluster) < min_runs:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
for r in cluster:
|
|
39
|
+
assigned.add(r["id"])
|
|
40
|
+
|
|
41
|
+
run_ids = [r["id"] for r in cluster]
|
|
42
|
+
ph = ",".join("?" * len(run_ids))
|
|
43
|
+
top_tools = conn.execute(
|
|
44
|
+
f"SELECT tool_name, COUNT(*) as cnt FROM tool_calls WHERE run_id IN ({ph}) GROUP BY tool_name ORDER BY cnt DESC LIMIT 5",
|
|
45
|
+
run_ids,
|
|
46
|
+
).fetchall()
|
|
47
|
+
error_count = conn.execute(
|
|
48
|
+
f"SELECT COUNT(*) FROM errors WHERE run_id IN ({ph})", run_ids
|
|
49
|
+
).fetchone()[0]
|
|
50
|
+
|
|
51
|
+
kws = sorted(_keywords(cluster[0]["user_goal"]))[:3]
|
|
52
|
+
category = "-".join(kws) if kws else "general"
|
|
53
|
+
has_success = any(r["outcome"] == "shipped" for r in cluster)
|
|
54
|
+
|
|
55
|
+
clusters.append({
|
|
56
|
+
"category": category,
|
|
57
|
+
"run_count": len(cluster),
|
|
58
|
+
"run_ids": run_ids,
|
|
59
|
+
"has_success": has_success,
|
|
60
|
+
"top_tools": [(r["tool_name"], r["cnt"]) for r in top_tools],
|
|
61
|
+
"error_count": error_count,
|
|
62
|
+
"sample_goal": cluster[0]["user_goal"],
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
return clusters
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def generate_skill_md(category: str, cluster: dict, conn: sqlite3.Connection) -> str:
|
|
69
|
+
tools_str = ", ".join(f"{n} ({c}×)" for n, c in cluster["top_tools"])
|
|
70
|
+
shipped = [
|
|
71
|
+
rid for rid in cluster["run_ids"]
|
|
72
|
+
if conn.execute("SELECT outcome FROM runs WHERE id=?", (rid,)).fetchone()["outcome"] == "shipped"
|
|
73
|
+
]
|
|
74
|
+
pattern_lines = []
|
|
75
|
+
if shipped:
|
|
76
|
+
cmds = conn.execute(
|
|
77
|
+
"SELECT command FROM shell_commands WHERE run_id=? ORDER BY timestamp LIMIT 10",
|
|
78
|
+
(shipped[0],),
|
|
79
|
+
).fetchall()
|
|
80
|
+
pattern_lines = [f" {i + 1}. `{r['command']}`" for i, r in enumerate(cmds)]
|
|
81
|
+
pattern = "\n".join(pattern_lines) if pattern_lines else " (no shell commands recorded)"
|
|
82
|
+
return f"""# {category}
|
|
83
|
+
|
|
84
|
+
## Problem
|
|
85
|
+
{cluster['sample_goal']}
|
|
86
|
+
|
|
87
|
+
## Evidence
|
|
88
|
+
- Seen in {cluster['run_count']} sessions
|
|
89
|
+
- Top tools: {tools_str}
|
|
90
|
+
- Errors across sessions: {cluster['error_count']}
|
|
91
|
+
|
|
92
|
+
## Successful Pattern
|
|
93
|
+
{pattern}
|
|
94
|
+
|
|
95
|
+
## Notes
|
|
96
|
+
Generated by `afr extract-skills`. Review before using as an agent skill.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def run_extraction(conn: sqlite3.Connection, min_runs: int = 3) -> None:
|
|
101
|
+
from rich.console import Console
|
|
102
|
+
from rich.prompt import Confirm
|
|
103
|
+
|
|
104
|
+
console = Console()
|
|
105
|
+
clusters = [c for c in cluster_runs(conn, min_runs) if c["has_success"]]
|
|
106
|
+
|
|
107
|
+
if not clusters:
|
|
108
|
+
console.print(f"[yellow]No clusters with {min_runs}+ runs and a shipped outcome found.[/yellow]")
|
|
109
|
+
console.print("[dim]Tip: tag sessions with `afr tag <id> shipped` first.[/dim]")
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
for cluster in clusters:
|
|
113
|
+
console.print(f"\n[bold cyan]Candidate:[/bold cyan] {cluster['category']}")
|
|
114
|
+
console.print(f" {cluster['run_count']} sessions | tools: {', '.join(t for t, _ in cluster['top_tools'])} | errors: {cluster['error_count']}")
|
|
115
|
+
if Confirm.ask(" Generate SKILL.md?"):
|
|
116
|
+
content = generate_skill_md(cluster["category"], cluster, conn)
|
|
117
|
+
out = Path("generated_skills") / cluster["category"]
|
|
118
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
(out / "SKILL.md").write_text(content, encoding="utf-8")
|
|
120
|
+
console.print(f" [green]Written → {out / 'SKILL.md'}[/green]")
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def get_stats(conn: sqlite3.Connection, days: Optional[int] = None) -> dict:
|
|
6
|
+
where, params = "", []
|
|
7
|
+
if days:
|
|
8
|
+
where = "WHERE started_at >= datetime('now', ?)"
|
|
9
|
+
params = [f'-{days} days']
|
|
10
|
+
|
|
11
|
+
runs = conn.execute(f"SELECT * FROM runs {where}", params).fetchall()
|
|
12
|
+
if not runs:
|
|
13
|
+
return {}
|
|
14
|
+
|
|
15
|
+
outcomes: dict[str, int] = {}
|
|
16
|
+
for r in runs:
|
|
17
|
+
outcomes[r["outcome"]] = outcomes.get(r["outcome"], 0) + 1
|
|
18
|
+
|
|
19
|
+
run_ids = [r["id"] for r in runs]
|
|
20
|
+
ph = ",".join("?" * len(run_ids))
|
|
21
|
+
|
|
22
|
+
top_tools = conn.execute(
|
|
23
|
+
f"SELECT tool_name, COUNT(*) as cnt FROM tool_calls WHERE run_id IN ({ph}) GROUP BY tool_name ORDER BY cnt DESC LIMIT 10",
|
|
24
|
+
run_ids,
|
|
25
|
+
).fetchall()
|
|
26
|
+
|
|
27
|
+
error_count = conn.execute(
|
|
28
|
+
f"SELECT COUNT(*) FROM errors WHERE run_id IN ({ph})", run_ids
|
|
29
|
+
).fetchone()[0]
|
|
30
|
+
|
|
31
|
+
shell_failures = conn.execute(
|
|
32
|
+
f"SELECT COUNT(*) FROM shell_commands WHERE run_id IN ({ph}) AND exit_code IS NOT NULL AND exit_code != 0",
|
|
33
|
+
run_ids,
|
|
34
|
+
).fetchone()[0]
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
"total_runs": len(runs),
|
|
38
|
+
"outcomes": outcomes,
|
|
39
|
+
"total_cost_usd": sum(r["cost_usd"] for r in runs),
|
|
40
|
+
"total_tokens_in": sum(r["tokens_in"] for r in runs),
|
|
41
|
+
"total_tokens_out": sum(r["tokens_out"] for r in runs),
|
|
42
|
+
"top_tools": [(r["tool_name"], r["cnt"]) for r in top_tools],
|
|
43
|
+
"error_count": error_count,
|
|
44
|
+
"shell_failures": shell_failures,
|
|
45
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
if sys.platform == "win32":
|
|
3
|
+
import ctypes
|
|
4
|
+
ctypes.windll.kernel32.SetConsoleOutputCP(65001)
|
|
5
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
6
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
import typer
|
|
10
|
+
from .db import get_connection, init_db, DB_PATH, list_runs, get_run, get_run_events, search_runs, set_outcome
|
|
11
|
+
from .ingester import ingest_claude, ingest_codex
|
|
12
|
+
from .analyzers.stats import get_stats
|
|
13
|
+
from .analyzers.skill_extractor import run_extraction
|
|
14
|
+
from .render.terminal import console, print_run_list, print_run_detail, print_stats
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="afr", help="Agent Flight Recorder — local AI session observability", add_completion=False)
|
|
17
|
+
|
|
18
|
+
_VALID_OUTCOMES = {"shipped", "blocked", "abandoned", "exploratory"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@app.command()
|
|
22
|
+
def ingest(source: str = typer.Argument(..., help="claude or codex")):
|
|
23
|
+
"""Ingest sessions from Claude Code or Codex."""
|
|
24
|
+
if source == "claude":
|
|
25
|
+
new, skipped = ingest_claude()
|
|
26
|
+
elif source == "codex":
|
|
27
|
+
new, skipped = ingest_codex()
|
|
28
|
+
else:
|
|
29
|
+
console.print(f"[red]Unknown source '{source}'. Use 'claude' or 'codex'.[/red]")
|
|
30
|
+
raise typer.Exit(1)
|
|
31
|
+
console.print(f"[green]Done.[/green] {new} new, {skipped} skipped.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command("list")
|
|
35
|
+
def list_cmd(days: Optional[int] = typer.Option(None, "--days", "-d", help="Last N days")):
|
|
36
|
+
"""List all recorded runs."""
|
|
37
|
+
conn = get_connection()
|
|
38
|
+
init_db(conn)
|
|
39
|
+
runs = list_runs(conn, days)
|
|
40
|
+
conn.close()
|
|
41
|
+
if not runs:
|
|
42
|
+
console.print("[yellow]No runs. Try `afr ingest claude`.[/yellow]")
|
|
43
|
+
else:
|
|
44
|
+
print_run_list(runs)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command()
|
|
48
|
+
def show(run_id: str = typer.Argument(..., help="Run ID or prefix")):
|
|
49
|
+
"""Show full detail for a run."""
|
|
50
|
+
conn = get_connection()
|
|
51
|
+
init_db(conn)
|
|
52
|
+
row = conn.execute("SELECT * FROM runs WHERE id LIKE ?", (f"{run_id}%",)).fetchone()
|
|
53
|
+
if not row:
|
|
54
|
+
console.print(f"[red]Run not found: {run_id}[/red]")
|
|
55
|
+
conn.close()
|
|
56
|
+
raise typer.Exit(1)
|
|
57
|
+
events = get_run_events(conn, row["id"])
|
|
58
|
+
conn.close()
|
|
59
|
+
print_run_detail(row, events)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.command()
|
|
63
|
+
def search(
|
|
64
|
+
query: str = typer.Argument(..., help="Search query"),
|
|
65
|
+
days: Optional[int] = typer.Option(None, "--days", "-d"),
|
|
66
|
+
):
|
|
67
|
+
"""Full-text search across run goals and summaries."""
|
|
68
|
+
conn = get_connection()
|
|
69
|
+
init_db(conn)
|
|
70
|
+
runs = search_runs(conn, query, days)
|
|
71
|
+
conn.close()
|
|
72
|
+
if not runs:
|
|
73
|
+
console.print(f"[yellow]No results for: {query}[/yellow]")
|
|
74
|
+
else:
|
|
75
|
+
print_run_list(runs)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def stats(days: Optional[int] = typer.Option(None, "--days", "-d")):
|
|
80
|
+
"""Show session statistics."""
|
|
81
|
+
conn = get_connection()
|
|
82
|
+
init_db(conn)
|
|
83
|
+
s = get_stats(conn, days)
|
|
84
|
+
conn.close()
|
|
85
|
+
print_stats(s)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command()
|
|
89
|
+
def tag(
|
|
90
|
+
run_id: str = typer.Argument(...),
|
|
91
|
+
outcome: str = typer.Argument(..., help="shipped | blocked | abandoned | exploratory"),
|
|
92
|
+
):
|
|
93
|
+
"""Tag a run with an outcome."""
|
|
94
|
+
if outcome not in _VALID_OUTCOMES:
|
|
95
|
+
console.print(f"[red]Invalid outcome. Choose: {', '.join(sorted(_VALID_OUTCOMES))}[/red]")
|
|
96
|
+
raise typer.Exit(1)
|
|
97
|
+
conn = get_connection()
|
|
98
|
+
init_db(conn)
|
|
99
|
+
row = conn.execute("SELECT id FROM runs WHERE id LIKE ?", (f"{run_id}%",)).fetchone()
|
|
100
|
+
if not row:
|
|
101
|
+
console.print(f"[red]Run not found: {run_id}[/red]")
|
|
102
|
+
conn.close()
|
|
103
|
+
raise typer.Exit(1)
|
|
104
|
+
set_outcome(conn, row["id"], outcome)
|
|
105
|
+
conn.close()
|
|
106
|
+
console.print(f"[green]Tagged {row['id'][:8]} as {outcome}.[/green]")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@app.command("extract-skills")
|
|
110
|
+
def extract_skills(
|
|
111
|
+
min_runs: int = typer.Option(3, "--min-runs", help="Minimum sessions per cluster"),
|
|
112
|
+
):
|
|
113
|
+
"""Cluster sessions and propose SKILL.md candidates."""
|
|
114
|
+
conn = get_connection()
|
|
115
|
+
init_db(conn)
|
|
116
|
+
run_extraction(conn, min_runs)
|
|
117
|
+
conn.close()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from .models import ParsedSession
|
|
5
|
+
|
|
6
|
+
DB_PATH = Path.home() / ".afr" / "afr.db"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_connection(path: Path = DB_PATH) -> sqlite3.Connection:
|
|
10
|
+
path = Path(path)
|
|
11
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
conn = sqlite3.connect(str(path))
|
|
13
|
+
conn.row_factory = sqlite3.Row
|
|
14
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
15
|
+
conn.execute("PRAGMA foreign_keys=ON")
|
|
16
|
+
return conn
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def init_db(conn: sqlite3.Connection) -> None:
|
|
20
|
+
conn.executescript("""
|
|
21
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
source TEXT NOT NULL,
|
|
24
|
+
project_path TEXT DEFAULT '',
|
|
25
|
+
started_at TEXT DEFAULT '',
|
|
26
|
+
ended_at TEXT DEFAULT '',
|
|
27
|
+
user_goal TEXT DEFAULT '',
|
|
28
|
+
final_summary TEXT DEFAULT '',
|
|
29
|
+
outcome TEXT DEFAULT 'untagged',
|
|
30
|
+
cost_usd REAL DEFAULT 0.0,
|
|
31
|
+
tokens_in INTEGER DEFAULT 0,
|
|
32
|
+
tokens_out INTEGER DEFAULT 0,
|
|
33
|
+
cache_read INTEGER DEFAULT 0,
|
|
34
|
+
cache_write INTEGER DEFAULT 0
|
|
35
|
+
);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS tool_calls (
|
|
37
|
+
id TEXT,
|
|
38
|
+
run_id TEXT NOT NULL REFERENCES runs(id),
|
|
39
|
+
tool_name TEXT DEFAULT '',
|
|
40
|
+
input_summary TEXT DEFAULT '',
|
|
41
|
+
output_summary TEXT DEFAULT '',
|
|
42
|
+
status TEXT DEFAULT 'success',
|
|
43
|
+
duration_ms INTEGER,
|
|
44
|
+
timestamp TEXT DEFAULT '',
|
|
45
|
+
raw_json TEXT DEFAULT '',
|
|
46
|
+
PRIMARY KEY (run_id, id)
|
|
47
|
+
);
|
|
48
|
+
CREATE TABLE IF NOT EXISTS shell_commands (
|
|
49
|
+
id TEXT,
|
|
50
|
+
run_id TEXT NOT NULL REFERENCES runs(id),
|
|
51
|
+
command TEXT DEFAULT '',
|
|
52
|
+
exit_code INTEGER,
|
|
53
|
+
duration_ms INTEGER,
|
|
54
|
+
stdout_excerpt TEXT DEFAULT '',
|
|
55
|
+
stderr_excerpt TEXT DEFAULT '',
|
|
56
|
+
timestamp TEXT DEFAULT '',
|
|
57
|
+
PRIMARY KEY (run_id, id)
|
|
58
|
+
);
|
|
59
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
60
|
+
id TEXT,
|
|
61
|
+
run_id TEXT NOT NULL REFERENCES runs(id),
|
|
62
|
+
path TEXT DEFAULT '',
|
|
63
|
+
action TEXT DEFAULT '',
|
|
64
|
+
PRIMARY KEY (run_id, id)
|
|
65
|
+
);
|
|
66
|
+
CREATE TABLE IF NOT EXISTS errors (
|
|
67
|
+
id TEXT,
|
|
68
|
+
run_id TEXT NOT NULL REFERENCES runs(id),
|
|
69
|
+
source TEXT DEFAULT '',
|
|
70
|
+
message TEXT DEFAULT '',
|
|
71
|
+
raw_json TEXT DEFAULT '',
|
|
72
|
+
timestamp TEXT DEFAULT '',
|
|
73
|
+
PRIMARY KEY (run_id, id)
|
|
74
|
+
);
|
|
75
|
+
CREATE TABLE IF NOT EXISTS lessons (
|
|
76
|
+
id TEXT PRIMARY KEY,
|
|
77
|
+
run_id TEXT NOT NULL REFERENCES runs(id),
|
|
78
|
+
lesson TEXT DEFAULT '',
|
|
79
|
+
category TEXT DEFAULT '',
|
|
80
|
+
confidence REAL DEFAULT 0.0,
|
|
81
|
+
approved INTEGER DEFAULT 0
|
|
82
|
+
);
|
|
83
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS runs_fts USING fts5(
|
|
84
|
+
id UNINDEXED,
|
|
85
|
+
user_goal,
|
|
86
|
+
final_summary,
|
|
87
|
+
content='runs',
|
|
88
|
+
content_rowid='rowid'
|
|
89
|
+
);
|
|
90
|
+
CREATE TRIGGER IF NOT EXISTS runs_ai AFTER INSERT ON runs BEGIN
|
|
91
|
+
INSERT INTO runs_fts(rowid, id, user_goal, final_summary)
|
|
92
|
+
VALUES (new.rowid, new.id, new.user_goal, new.final_summary);
|
|
93
|
+
END;
|
|
94
|
+
""")
|
|
95
|
+
conn.commit()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def upsert_session(conn: sqlite3.Connection, session: ParsedSession) -> bool:
|
|
99
|
+
existing = conn.execute("SELECT id FROM runs WHERE id = ?", (session.run.id,)).fetchone()
|
|
100
|
+
if existing:
|
|
101
|
+
return False
|
|
102
|
+
r = session.run
|
|
103
|
+
conn.execute(
|
|
104
|
+
"INSERT INTO runs VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
105
|
+
(r.id, r.source, r.project_path, r.started_at, r.ended_at, r.user_goal,
|
|
106
|
+
r.final_summary, r.outcome, r.cost_usd, r.tokens_in, r.tokens_out, r.cache_read, r.cache_write)
|
|
107
|
+
)
|
|
108
|
+
for tc in session.tool_calls:
|
|
109
|
+
conn.execute(
|
|
110
|
+
"INSERT INTO tool_calls VALUES (?,?,?,?,?,?,?,?,?)",
|
|
111
|
+
(tc.id, tc.run_id, tc.tool_name, tc.input_summary, tc.output_summary,
|
|
112
|
+
tc.status, tc.duration_ms, tc.timestamp, tc.raw_json)
|
|
113
|
+
)
|
|
114
|
+
for sc in session.shell_commands:
|
|
115
|
+
conn.execute(
|
|
116
|
+
"INSERT INTO shell_commands VALUES (?,?,?,?,?,?,?,?)",
|
|
117
|
+
(sc.id, sc.run_id, sc.command, sc.exit_code, sc.duration_ms,
|
|
118
|
+
sc.stdout_excerpt, sc.stderr_excerpt, sc.timestamp)
|
|
119
|
+
)
|
|
120
|
+
for fe in session.files:
|
|
121
|
+
conn.execute("INSERT INTO files VALUES (?,?,?,?)", (fe.id, fe.run_id, fe.path, fe.action))
|
|
122
|
+
for err in session.errors:
|
|
123
|
+
conn.execute(
|
|
124
|
+
"INSERT INTO errors VALUES (?,?,?,?,?,?)",
|
|
125
|
+
(err.id, err.run_id, err.source, err.message, err.raw_json, err.timestamp)
|
|
126
|
+
)
|
|
127
|
+
conn.commit()
|
|
128
|
+
return True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def list_runs(conn: sqlite3.Connection, days: Optional[int] = None) -> list:
|
|
132
|
+
sql, params = "SELECT * FROM runs", []
|
|
133
|
+
if days:
|
|
134
|
+
sql += " WHERE started_at >= datetime('now', ?)"
|
|
135
|
+
params = [f'-{days} days']
|
|
136
|
+
return conn.execute(sql + " ORDER BY started_at DESC", params).fetchall()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def search_runs(conn: sqlite3.Connection, query: str, days: Optional[int] = None) -> list:
|
|
140
|
+
sql = "SELECT runs.* FROM runs_fts JOIN runs ON runs_fts.id = runs.id WHERE runs_fts MATCH ?"
|
|
141
|
+
params: list = [query]
|
|
142
|
+
if days:
|
|
143
|
+
sql += " AND runs.started_at >= datetime('now', ?)"
|
|
144
|
+
params.append(f'-{days} days')
|
|
145
|
+
return conn.execute(sql, params).fetchall()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def get_run(conn: sqlite3.Connection, run_id: str):
|
|
149
|
+
return conn.execute("SELECT * FROM runs WHERE id LIKE ?", (f"{run_id}%",)).fetchone()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def get_run_events(conn: sqlite3.Connection, run_id: str) -> dict:
|
|
153
|
+
return {
|
|
154
|
+
"tool_calls": conn.execute("SELECT * FROM tool_calls WHERE run_id=? ORDER BY timestamp", (run_id,)).fetchall(),
|
|
155
|
+
"shell_commands": conn.execute("SELECT * FROM shell_commands WHERE run_id=? ORDER BY timestamp", (run_id,)).fetchall(),
|
|
156
|
+
"files": conn.execute("SELECT * FROM files WHERE run_id=?", (run_id,)).fetchall(),
|
|
157
|
+
"errors": conn.execute("SELECT * FROM errors WHERE run_id=? ORDER BY timestamp", (run_id,)).fetchall(),
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def set_outcome(conn: sqlite3.Connection, run_id: str, outcome: str) -> None:
|
|
162
|
+
conn.execute("UPDATE runs SET outcome=? WHERE id=?", (outcome, run_id))
|
|
163
|
+
conn.commit()
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Literal
|
|
3
|
+
import sqlite3
|
|
4
|
+
from .db import get_connection, init_db, upsert_session, DB_PATH
|
|
5
|
+
from .adapters import claude, codex
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def ingest_from_paths(
|
|
9
|
+
paths: list[Path],
|
|
10
|
+
source: Literal["claude", "codex"],
|
|
11
|
+
conn: sqlite3.Connection,
|
|
12
|
+
) -> tuple[int, int]:
|
|
13
|
+
new, skipped = 0, 0
|
|
14
|
+
parser = claude.parse_session_file if source == "claude" else codex.parse_session_file
|
|
15
|
+
for p in paths:
|
|
16
|
+
try:
|
|
17
|
+
session = parser(Path(p))
|
|
18
|
+
if upsert_session(conn, session):
|
|
19
|
+
new += 1
|
|
20
|
+
else:
|
|
21
|
+
skipped += 1
|
|
22
|
+
except Exception:
|
|
23
|
+
skipped += 1
|
|
24
|
+
return new, skipped
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ingest_claude(db_path: Path = DB_PATH) -> tuple[int, int]:
|
|
28
|
+
conn = get_connection(db_path)
|
|
29
|
+
init_db(conn)
|
|
30
|
+
paths = list(claude.iter_sessions())
|
|
31
|
+
result = ingest_from_paths(paths, "claude", conn)
|
|
32
|
+
conn.close()
|
|
33
|
+
return result
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ingest_codex(db_path: Path = DB_PATH) -> tuple[int, int]:
|
|
37
|
+
conn = get_connection(db_path)
|
|
38
|
+
init_db(conn)
|
|
39
|
+
paths = list(codex.iter_sessions())
|
|
40
|
+
result = ingest_from_paths(paths, "codex", conn)
|
|
41
|
+
conn.close()
|
|
42
|
+
return result
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Literal, Optional
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class ToolCall(BaseModel):
|
|
6
|
+
id: str
|
|
7
|
+
run_id: str
|
|
8
|
+
tool_name: str = "Unknown"
|
|
9
|
+
input_summary: str = ""
|
|
10
|
+
output_summary: str = ""
|
|
11
|
+
status: Literal["success", "error"] = "success"
|
|
12
|
+
duration_ms: Optional[int] = None
|
|
13
|
+
timestamp: str = ""
|
|
14
|
+
raw_json: str = ""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ShellCommand(BaseModel):
|
|
18
|
+
id: str
|
|
19
|
+
run_id: str
|
|
20
|
+
command: str = ""
|
|
21
|
+
exit_code: Optional[int] = None
|
|
22
|
+
duration_ms: Optional[int] = None
|
|
23
|
+
stdout_excerpt: str = ""
|
|
24
|
+
stderr_excerpt: str = ""
|
|
25
|
+
timestamp: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class FileEvent(BaseModel):
|
|
29
|
+
id: str
|
|
30
|
+
run_id: str
|
|
31
|
+
path: str = ""
|
|
32
|
+
action: Literal["read", "write", "patch", "delete"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Error(BaseModel):
|
|
36
|
+
id: str
|
|
37
|
+
run_id: str
|
|
38
|
+
source: Literal["tool", "shell", "agent"]
|
|
39
|
+
message: str = ""
|
|
40
|
+
raw_json: str = ""
|
|
41
|
+
timestamp: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Run(BaseModel):
|
|
45
|
+
id: str
|
|
46
|
+
source: Literal["claude", "codex"]
|
|
47
|
+
project_path: str = ""
|
|
48
|
+
started_at: str = ""
|
|
49
|
+
ended_at: str = ""
|
|
50
|
+
user_goal: str = ""
|
|
51
|
+
final_summary: str = ""
|
|
52
|
+
outcome: str = "untagged"
|
|
53
|
+
cost_usd: float = 0.0
|
|
54
|
+
tokens_in: int = 0
|
|
55
|
+
tokens_out: int = 0
|
|
56
|
+
cache_read: int = 0
|
|
57
|
+
cache_write: int = 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ParsedSession(BaseModel):
|
|
61
|
+
run: Run
|
|
62
|
+
tool_calls: list[ToolCall] = []
|
|
63
|
+
shell_commands: list[ShellCommand] = []
|
|
64
|
+
files: list[FileEvent] = []
|
|
65
|
+
errors: list[Error] = []
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
_PATTERNS = [
|
|
5
|
+
re.compile(r'(?i)(api[_-]?key|secret|password|token)\s*[:=]\s*\S+'),
|
|
6
|
+
re.compile(r'sk-[a-zA-Z0-9-]{12,}'),
|
|
7
|
+
re.compile(r'ghp_[a-zA-Z0-9]{20,}'),
|
|
8
|
+
re.compile(r'(?i)bearer\s+[a-zA-Z0-9\-._~+/]+=*'),
|
|
9
|
+
re.compile(r'-----BEGIN [A-Z ]+ KEY-----[\s\S]*?-----END [A-Z ]+ KEY-----', re.DOTALL),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def redact(text: str) -> str:
|
|
14
|
+
for pattern in _PATTERNS:
|
|
15
|
+
text = pattern.sub('[REDACTED]', text)
|
|
16
|
+
return text
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def redact_json(obj: dict) -> str:
|
|
20
|
+
return redact(json.dumps(obj))
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from rich.panel import Panel
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
_OUTCOME_COLORS = {
|
|
9
|
+
"shipped": "green", "blocked": "red",
|
|
10
|
+
"abandoned": "yellow", "exploratory": "blue", "untagged": "dim",
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _fmt_tokens(n: int) -> str:
|
|
15
|
+
if n >= 1_000_000:
|
|
16
|
+
return f"{n/1_000_000:.1f}M"
|
|
17
|
+
if n >= 1_000:
|
|
18
|
+
return f"{n/1_000:.1f}K"
|
|
19
|
+
return str(n)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def print_run_list(runs: list) -> None:
|
|
23
|
+
table = Table(show_header=True, header_style="bold magenta")
|
|
24
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=8)
|
|
25
|
+
table.add_column("Source", no_wrap=True, min_width=6)
|
|
26
|
+
table.add_column("Goal", min_width=30)
|
|
27
|
+
table.add_column("Outcome", no_wrap=True, min_width=11)
|
|
28
|
+
table.add_column("In", justify="right", no_wrap=True, min_width=6)
|
|
29
|
+
table.add_column("Out", justify="right", no_wrap=True, min_width=7)
|
|
30
|
+
table.add_column("Date", no_wrap=True, min_width=13)
|
|
31
|
+
for r in runs:
|
|
32
|
+
color = _OUTCOME_COLORS.get(r["outcome"], "dim")
|
|
33
|
+
goal = r["user_goal"] or ""
|
|
34
|
+
goal_cell = goal[:40] + ".." if len(goal) > 42 else goal
|
|
35
|
+
table.add_row(
|
|
36
|
+
r["id"][:8],
|
|
37
|
+
r["source"],
|
|
38
|
+
goal_cell,
|
|
39
|
+
f"[{color}]{r['outcome']}[/{color}]",
|
|
40
|
+
_fmt_tokens(r["tokens_in"]),
|
|
41
|
+
_fmt_tokens(r["tokens_out"]),
|
|
42
|
+
r["started_at"][:16] if r["started_at"] else "",
|
|
43
|
+
)
|
|
44
|
+
console.print(table)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def print_run_detail(run, events: dict) -> None:
|
|
48
|
+
console.print(Panel(
|
|
49
|
+
f"[bold]{run['user_goal']}[/bold]\n"
|
|
50
|
+
f"[dim]{run['source']} | {run['started_at'][:16]} → {run['ended_at'][:16]} | {run['outcome']}[/dim]"
|
|
51
|
+
))
|
|
52
|
+
if events["tool_calls"]:
|
|
53
|
+
console.print("\n[bold]Tool Calls[/bold]")
|
|
54
|
+
for tc in events["tool_calls"]:
|
|
55
|
+
icon = "[green]✓[/green]" if tc["status"] == "success" else "[red]✗[/red]"
|
|
56
|
+
console.print(f" {icon} [cyan]{tc['tool_name']}[/cyan] {tc['input_summary'][:60]}")
|
|
57
|
+
if events["shell_commands"]:
|
|
58
|
+
console.print("\n[bold]Shell Commands[/bold]")
|
|
59
|
+
for sc in events["shell_commands"]:
|
|
60
|
+
ec = sc["exit_code"]
|
|
61
|
+
code_fmt = f"[green]{ec}[/green]" if ec == 0 else f"[red]{ec}[/red]"
|
|
62
|
+
console.print(f" [{code_fmt}] [yellow]{sc['command'][:70]}[/yellow]")
|
|
63
|
+
if events["errors"]:
|
|
64
|
+
console.print("\n[bold red]Errors[/bold red]")
|
|
65
|
+
for err in events["errors"]:
|
|
66
|
+
console.print(f" [red]•[/red] {err['message'][:80]}")
|
|
67
|
+
console.print(
|
|
68
|
+
f"\n[dim]Tokens: {run['tokens_in']} in / {run['tokens_out']} out"
|
|
69
|
+
f" | Cache read: {run['cache_read']} | Cost: ${run['cost_usd']:.4f}[/dim]"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def print_stats(stats: dict) -> None:
|
|
74
|
+
if not stats:
|
|
75
|
+
console.print("[yellow]No runs found. Run `afr ingest claude` first.[/yellow]")
|
|
76
|
+
return
|
|
77
|
+
console.print(Panel("[bold]Agent Flight Recorder — Stats[/bold]"))
|
|
78
|
+
console.print(f"Total runs: [bold]{stats['total_runs']}[/bold]")
|
|
79
|
+
console.print("\n[bold]Outcomes[/bold]")
|
|
80
|
+
for outcome, count in stats["outcomes"].items():
|
|
81
|
+
color = _OUTCOME_COLORS.get(outcome, "dim")
|
|
82
|
+
console.print(f" [{color}]{outcome}[/{color}]: {count}")
|
|
83
|
+
console.print(f"\n[bold]Tokens[/bold] {stats['total_tokens_in']:,} in / {stats['total_tokens_out']:,} out")
|
|
84
|
+
console.print(f"[bold]Errors[/bold] {stats['error_count']} tool | {stats['shell_failures']} shell")
|
|
85
|
+
if stats["top_tools"]:
|
|
86
|
+
console.print("\n[bold]Top Tools[/bold]")
|
|
87
|
+
for tool, count in stats["top_tools"]:
|
|
88
|
+
console.print(f" {tool}: {count}")
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agent-recorder
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local-first CLI that records every Claude Code and Codex session into a searchable SQLite database
|
|
5
|
+
Project-URL: Homepage, https://github.com/AravindKurapati/agent-flight-recorder
|
|
6
|
+
Project-URL: Repository, https://github.com/AravindKurapati/agent-flight-recorder
|
|
7
|
+
Project-URL: Issues, https://github.com/AravindKurapati/agent-flight-recorder/issues
|
|
8
|
+
Author-email: Aravind Kurapati <arvind.kurapati@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: agents,ai,claude,cli,codex,devtools,llm,observability,session-recording
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: pydantic>=2
|
|
23
|
+
Requires-Dist: rapidfuzz>=3
|
|
24
|
+
Requires-Dist: typer[all]>=0.12
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# agent-flight-recorder
|
|
28
|
+
|
|
29
|
+
> **Every agent session you run disappears the moment it ends. This one doesn't.**
|
|
30
|
+
|
|
31
|
+
A local-first CLI that records every Claude Code and Codex session — prompts, tool calls, shell commands, file changes, errors, and token costs — into a searchable SQLite database. Find what you worked on, see what failed, and extract reusable workflow patterns.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## The problem this solves
|
|
36
|
+
|
|
37
|
+
AI coding sessions are opaque and ephemeral. When a session ends, all you have is changed files and a vague memory of what the agent tried. There's no way to answer:
|
|
38
|
+
|
|
39
|
+
- What tools did the agent call, and in what order?
|
|
40
|
+
- Which shell commands failed, and what was the error?
|
|
41
|
+
- How much did that session actually cost in tokens?
|
|
42
|
+
- Why does this same class of problem keep taking 3 sessions to fix?
|
|
43
|
+
|
|
44
|
+
`afr` records all of that and keeps it queryable — locally, permanently, without sending anything to a server.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
git clone https://github.com/AravindKurapati/agent-flight-recorder
|
|
52
|
+
cd agent-flight-recorder
|
|
53
|
+
pip install -e .
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Requirements:** Python 3.11+ — no API keys, no accounts, everything stays on your machine.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Usage
|
|
61
|
+
|
|
62
|
+
### Ingest your sessions
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
afr ingest claude # reads ~/.claude/projects/**/*.jsonl
|
|
66
|
+
afr ingest codex # reads ~/.codex/sessions/**/*.jsonl
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Or wire a Claude Code hook to ingest automatically after every session (add to `~/.claude/settings.json`):
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"hooks": {
|
|
74
|
+
"Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": "afr ingest claude" }] }]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Browse recent runs
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
afr list --days 7
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```
|
|
86
|
+
ID Source Goal Outcome Tokens↑ Date
|
|
87
|
+
50c3f2a1 claude Fix the Modal deployment error untagged 1,842 2026-05-08
|
|
88
|
+
903b12cd claude Add React component for dashboard untagged 311 2026-05-08
|
|
89
|
+
ad7e9f21 claude Debug the authentication middleware untagged 250 2026-05-07
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Inspect a session
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
afr show 50c3f2a1
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
100
|
+
│ Fix the Modal deployment error │
|
|
101
|
+
│ claude | 2026-05-08 10:00 → 10:14 | untagged │
|
|
102
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
103
|
+
|
|
104
|
+
Tool Calls
|
|
105
|
+
✓ Read {"file_path": "finsight.py"}
|
|
106
|
+
✗ Bash {"command": "modal run finsight.py"}
|
|
107
|
+
✓ Edit {"file_path": "finsight.py"}
|
|
108
|
+
✓ Bash {"command": "modal deploy finsight.py"}
|
|
109
|
+
|
|
110
|
+
Shell Commands
|
|
111
|
+
[1] modal run finsight.py
|
|
112
|
+
[0] modal deploy finsight.py
|
|
113
|
+
|
|
114
|
+
Errors
|
|
115
|
+
• Error: missing secret huggingface-secret
|
|
116
|
+
|
|
117
|
+
Tokens: 1,842 in / 4,201 out | Cache read: 920 | Cost: $0.0000
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Search across all sessions
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
afr search "authentication"
|
|
124
|
+
afr search "modal" --days 30
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### See patterns across sessions
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
afr stats --days 30
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
Total runs: 74
|
|
135
|
+
|
|
136
|
+
Outcomes
|
|
137
|
+
shipped: 12
|
|
138
|
+
blocked: 8
|
|
139
|
+
untagged: 54
|
|
140
|
+
|
|
141
|
+
Tokens 126,450 in / 4,091,200 out
|
|
142
|
+
Errors 187 tool | 62 shell
|
|
143
|
+
|
|
144
|
+
Top Tools
|
|
145
|
+
Bash: 891
|
|
146
|
+
Read: 412
|
|
147
|
+
Edit: 308
|
|
148
|
+
Write: 201
|
|
149
|
+
Glob: 94
|
|
150
|
+
mcp__exa__web_search_exa: 87
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Tag a run with its outcome
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
afr tag 50c3f2a1 shipped
|
|
157
|
+
afr tag 903b12cd blocked
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Valid outcomes: `shipped`, `blocked`, `abandoned`, `exploratory`
|
|
161
|
+
|
|
162
|
+
### Extract reusable workflow skills
|
|
163
|
+
|
|
164
|
+
After a few sessions solving the same class of problem, run:
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
afr extract-skills --min-runs 3
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
Candidate: deployment-modal-debug
|
|
172
|
+
4 sessions | tools: Bash, Read | errors: 6
|
|
173
|
+
Generate SKILL.md? [y/n]: y
|
|
174
|
+
Written → generated_skills/deployment-modal-debug/SKILL.md
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
It clusters sessions by keyword similarity, finds ones that ended in `shipped`, and drafts a `SKILL.md` from the successful tool sequence. You approve before anything is written.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## All commands
|
|
182
|
+
|
|
183
|
+
| Command | What it does |
|
|
184
|
+
|---------|-------------|
|
|
185
|
+
| `afr ingest claude` | Parse `~/.claude/` sessions into the database |
|
|
186
|
+
| `afr ingest codex` | Parse `~/.codex/` sessions into the database |
|
|
187
|
+
| `afr list [--days N]` | Table of recent runs with outcome and token count |
|
|
188
|
+
| `afr show <id>` | Full detail: tool calls, shell commands, errors, cost |
|
|
189
|
+
| `afr search <query>` | Full-text search across run goals and summaries |
|
|
190
|
+
| `afr stats [--days N]` | Outcome distribution, top tools, error counts |
|
|
191
|
+
| `afr tag <id> <outcome>` | Label a run: shipped / blocked / abandoned / exploratory |
|
|
192
|
+
| `afr extract-skills` | Cluster sessions, propose SKILL.md candidates |
|
|
193
|
+
|
|
194
|
+
`<id>` accepts the first 8 characters from `afr list` output.
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## What gets recorded
|
|
199
|
+
|
|
200
|
+
For each session:
|
|
201
|
+
|
|
202
|
+
- **Goal** — first user message
|
|
203
|
+
- **Tool calls** — every tool fired, input summary, success or error
|
|
204
|
+
- **Shell commands** — command, exit code, stdout/stderr excerpt
|
|
205
|
+
- **File events** — every read, write, patch, or delete
|
|
206
|
+
- **Errors** — failed tool calls and non-zero shell exits
|
|
207
|
+
- **Token counts** — input, output, cache read, cache write
|
|
208
|
+
- **Outcome** — you tag this: `shipped`, `blocked`, `abandoned`, `exploratory`
|
|
209
|
+
|
|
210
|
+
Secrets are redacted before anything is written to the database (API keys, bearer tokens, private keys, `.env` contents).
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## How data is stored
|
|
215
|
+
|
|
216
|
+
Everything lives at `~/.afr/afr.db` — a single SQLite file on your machine. No data leaves your machine. No telemetry. No accounts.
|
|
217
|
+
|
|
218
|
+
You can query it directly with any SQLite client:
|
|
219
|
+
|
|
220
|
+
```bash
|
|
221
|
+
sqlite3 ~/.afr/afr.db "SELECT user_goal, outcome, tokens_in FROM runs ORDER BY started_at DESC LIMIT 10"
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## Supported agents
|
|
227
|
+
|
|
228
|
+
| Agent | Source | Adapter |
|
|
229
|
+
|-------|--------|---------|
|
|
230
|
+
| Claude Code | `~/.claude/projects/**/*.jsonl` | Full — tool calls, tokens, errors |
|
|
231
|
+
| Codex (OpenAI) | `~/.codex/sessions/**/*.jsonl` | Full — tool name mapping, tokens, errors |
|
|
232
|
+
|
|
233
|
+
---
|
|
234
|
+
|
|
235
|
+
## License
|
|
236
|
+
|
|
237
|
+
MIT
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
agent_flight_recorder/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
agent_flight_recorder/cli.py,sha256=RxSqFz5D29paQI1wD4NAb8lrzNtbGj95-ThizD7UqQ8,3950
|
|
3
|
+
agent_flight_recorder/db.py,sha256=Um5YB-fmNyogTmmj4UffvrDRkQk8saAYOZZ6NNo7Vjo,6410
|
|
4
|
+
agent_flight_recorder/ingester.py,sha256=AS-F7FCzY_G9xTWOBo61BCw261_TGL6ZvrLMeL3kijc,1222
|
|
5
|
+
agent_flight_recorder/models.py,sha256=MNEjG5m__3n6TBsnEh1Yu0ojyc3XeQ5ZyiRLEvaVsWU,1498
|
|
6
|
+
agent_flight_recorder/redactor.py,sha256=meF0emcbayNKzgVLlkJ15mwS_TitGkroShXkVJfYqL0,558
|
|
7
|
+
agent_flight_recorder/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
agent_flight_recorder/adapters/claude.py,sha256=ht6zt8-tKv-JqZom5M2wP0dJZCmp5VM3attT9Qx3MaI,6157
|
|
9
|
+
agent_flight_recorder/adapters/codex.py,sha256=QEganAMGtjOqZtgBZgSyfHy8J5kBg9USTEPefUw8fuc,5601
|
|
10
|
+
agent_flight_recorder/analyzers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
11
|
+
agent_flight_recorder/analyzers/skill_extractor.py,sha256=5jHY9zkMEIFhVyEToJ6MLKlzZvwQdKKSuV1SSqknQfs,4533
|
|
12
|
+
agent_flight_recorder/analyzers/stats.py,sha256=LG8Q8Hsj-cMV8lMoflnEYvr_3lavlrqdISo2OfZx6XE,1533
|
|
13
|
+
agent_flight_recorder/render/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
|
+
agent_flight_recorder/render/terminal.py,sha256=r6xA4QsSQ6FJ2gaX2_grJWtgtQInoY3a8e5YTa3X008,3704
|
|
15
|
+
agent_recorder-0.1.0.dist-info/METADATA,sha256=pttLZbJzbFb7GTHlEOgMx_dqotOgaQoRwjxcd5OKmQw,7247
|
|
16
|
+
agent_recorder-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
17
|
+
agent_recorder-0.1.0.dist-info/entry_points.txt,sha256=m9k2aNb_6qpYetSwInlkpjWUgHGBpHWibVU9JdP9xDs,54
|
|
18
|
+
agent_recorder-0.1.0.dist-info/RECORD,,
|