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.
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ afr = agent_flight_recorder.cli:app