absolutelyright 0.2.0a2__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.
@@ -0,0 +1,29 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("absolutelyright")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
7
+
8
+ from absolutelyright._records import (
9
+ Prompt,
10
+ Session,
11
+ load_sessions,
12
+ parse_session,
13
+ session_files,
14
+ )
15
+ from absolutelyright._stats import (
16
+ by_project,
17
+ claudeisms,
18
+ corrections,
19
+ daily_counts,
20
+ hour_histogram,
21
+ model_mix,
22
+ overview,
23
+ repeated_prompts,
24
+ session_rows,
25
+ shipped,
26
+ slash_counts,
27
+ tool_counts,
28
+ vibe,
29
+ )
@@ -0,0 +1,224 @@
1
+ """parsing for claude code session transcripts.
2
+
3
+ each session is a jsonl file under ~/.claude/projects/<encoded-cwd>/<uuid>.jsonl.
4
+ user-role records are a grab bag: typed prompts, tool results, slash-command
5
+ expansions, bash passthroughs, and interrupt markers all share `type: "user"`.
6
+ this module separates what a human actually typed from harness noise — and
7
+ mines the rest of the record stream (titles, pr links, models, usage, tics)
8
+ in the same single pass.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import re
16
+ from collections import Counter
17
+ from dataclasses import dataclass, field
18
+ from datetime import UTC, datetime, timedelta
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+
23
+ def default_claude_dir() -> Path:
24
+ """where claude code keeps its data: $CLAUDE_CONFIG_DIR, else ~/.claude."""
25
+ return Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
26
+
27
+
28
+ DEFAULT_CLAUDE_DIR = default_claude_dir()
29
+
30
+ # substrings that mark a user-role record as harness traffic, not a typed prompt
31
+ NOISE_MARKERS = (
32
+ "<command-name>",
33
+ "<command-message>",
34
+ "<bash-input>",
35
+ "<bash-stdout>",
36
+ "<local-command-stdout>",
37
+ "<task-notification>",
38
+ "<system-reminder>",
39
+ "[Request interrupted",
40
+ "Caveat: the messages below",
41
+ )
42
+
43
+ # things claude says
44
+ ISM_PATTERNS = {
45
+ "you're absolutely right": re.compile(r"you'?re absolutely right", re.I),
46
+ "let me …": re.compile(r"^let me ", re.I | re.M),
47
+ "now i see": re.compile(r"\bnow i see\b", re.I),
48
+ "the issue is": re.compile(r"\bthe (?:real )?issue is\b", re.I),
49
+ "should now work": re.compile(r"\bshould (?:now )?work\b", re.I),
50
+ "great question": re.compile(r"\bgreat question\b", re.I),
51
+ "perfect!": re.compile(r"\bperfect!", re.I),
52
+ }
53
+
54
+ # things you say
55
+ VIBE_PATTERNS = {
56
+ "please": re.compile(r"\bplease\b|\bpls\b", re.I),
57
+ "thanks": re.compile(r"\bthanks?\b|\bty\b", re.I),
58
+ "dude": re.compile(r"\bdude\b", re.I),
59
+ "bro": re.compile(r"\bbro\b", re.I),
60
+ "lol": re.compile(r"\blo+l\b|\blmao\b", re.I),
61
+ "profanity": re.compile(r"\b(?:fuck\w*|shit\w*|damn|wtf)\b", re.I),
62
+ }
63
+
64
+ IMAGE_RE = re.compile(r"\[Image #\d+\]")
65
+ SLASH_RE = re.compile(r"<command-name>(\S+?)</command-name>")
66
+
67
+ # gaps longer than this don't count toward active time (you walked away)
68
+ IDLE_CAP_SECONDS = 300
69
+
70
+
71
+ @dataclass(slots=True)
72
+ class Prompt:
73
+ text: str
74
+ project: str
75
+ session_id: str
76
+ timestamp: datetime | None
77
+
78
+
79
+ @dataclass(slots=True)
80
+ class Session:
81
+ path: Path
82
+ session_id: str
83
+ project: str
84
+ title: str = ""
85
+ prompts: list[Prompt] = field(default_factory=list)
86
+ interrupts: int = 0
87
+ tool_calls: Counter[str] = field(default_factory=Counter)
88
+ output_tokens: int = 0
89
+ input_tokens: int = 0
90
+ cache_read_tokens: int = 0
91
+ models: Counter[str] = field(default_factory=Counter)
92
+ isms: Counter[str] = field(default_factory=Counter)
93
+ vibe: Counter[str] = field(default_factory=Counter)
94
+ slash_commands: Counter[str] = field(default_factory=Counter)
95
+ pr_urls: set[str] = field(default_factory=set)
96
+ images: int = 0
97
+ first_ts: datetime | None = None
98
+ last_ts: datetime | None = None
99
+ active_seconds: float = 0.0
100
+
101
+
102
+ def _parse_timestamp(raw: str | None) -> datetime | None:
103
+ if not raw:
104
+ return None
105
+ try:
106
+ return datetime.fromisoformat(raw.replace("Z", "+00:00"))
107
+ except ValueError:
108
+ return None
109
+
110
+
111
+ def _flatten_text(content: Any) -> str | None:
112
+ """collapse message content to text, or None if there is none (e.g. pure tool_result)."""
113
+ if isinstance(content, str):
114
+ return content
115
+ if isinstance(content, list):
116
+ texts = [
117
+ block.get("text", "")
118
+ for block in content
119
+ if isinstance(block, dict) and block.get("type") == "text"
120
+ ]
121
+ if texts:
122
+ return "\n".join(texts)
123
+ return None
124
+
125
+
126
+ def _shorten_home(cwd: str) -> str:
127
+ home = str(Path.home())
128
+ if cwd == home:
129
+ return "~"
130
+ return cwd.removeprefix(home + "/")
131
+
132
+
133
+ def session_files(claude_dir: Path) -> list[Path]:
134
+ """top-level session transcripts. subdirectories hold subagent sidechains."""
135
+ return sorted(claude_dir.glob("projects/*/*.jsonl"))
136
+
137
+
138
+ def parse_session(path: Path, since: datetime | None = None) -> Session:
139
+ session = Session(path=path, session_id=path.stem, project="?")
140
+
141
+ with path.open() as f:
142
+ for line in f:
143
+ try:
144
+ record = json.loads(line)
145
+ except json.JSONDecodeError:
146
+ continue
147
+ if record.get("isSidechain"):
148
+ continue
149
+
150
+ kind = record.get("type")
151
+ if kind == "ai-title":
152
+ session.title = record.get("aiTitle") or session.title
153
+ continue
154
+ if kind == "pr-link":
155
+ if url := record.get("prUrl"):
156
+ session.pr_urls.add(url)
157
+ continue
158
+
159
+ timestamp = _parse_timestamp(record.get("timestamp"))
160
+ if since and timestamp and timestamp < since:
161
+ continue
162
+ if timestamp:
163
+ if session.first_ts is None:
164
+ session.first_ts = timestamp
165
+ elif session.last_ts is not None:
166
+ gap = (timestamp - session.last_ts).total_seconds()
167
+ if 0 < gap <= IDLE_CAP_SECONDS:
168
+ session.active_seconds += gap
169
+ session.last_ts = timestamp
170
+ if session.project == "?" and record.get("cwd"):
171
+ session.project = _shorten_home(record["cwd"])
172
+
173
+ message = record.get("message") or {}
174
+ if kind == "assistant":
175
+ if model := message.get("model"):
176
+ if not model.startswith("<"): # `<synthetic>` placeholder rows
177
+ session.models[model] += 1
178
+ for block in message.get("content") or []:
179
+ if not isinstance(block, dict):
180
+ continue
181
+ if block.get("type") == "tool_use":
182
+ session.tool_calls[block.get("name", "?")] += 1
183
+ elif block.get("type") == "text":
184
+ for name, pattern in ISM_PATTERNS.items():
185
+ session.isms[name] += len(pattern.findall(block["text"]))
186
+ usage = message.get("usage") or {}
187
+ session.output_tokens += usage.get("output_tokens", 0)
188
+ session.input_tokens += usage.get("input_tokens", 0)
189
+ session.cache_read_tokens += usage.get("cache_read_input_tokens", 0)
190
+ elif kind == "user":
191
+ text = _flatten_text(message.get("content"))
192
+ if text is None:
193
+ continue
194
+ if "[Request interrupted" in text:
195
+ session.interrupts += 1
196
+ continue
197
+ if match := SLASH_RE.search(text):
198
+ session.slash_commands[match.group(1)] += 1
199
+ continue
200
+ if record.get("isMeta") or any(m in text for m in NOISE_MARKERS):
201
+ continue
202
+ text = text.strip()
203
+ if text:
204
+ session.images += len(IMAGE_RE.findall(text))
205
+ for name, pattern in VIBE_PATTERNS.items():
206
+ session.vibe[name] += len(pattern.findall(text))
207
+ session.prompts.append(
208
+ Prompt(
209
+ text=text,
210
+ project=session.project,
211
+ session_id=record.get("sessionId", session.session_id),
212
+ timestamp=timestamp,
213
+ )
214
+ )
215
+
216
+ return session
217
+
218
+
219
+ def load_sessions(
220
+ claude_dir: Path = DEFAULT_CLAUDE_DIR, days: int | None = None
221
+ ) -> list[Session]:
222
+ since = datetime.now(UTC) - timedelta(days=days) if days is not None else None
223
+ sessions = [parse_session(path, since=since) for path in session_files(claude_dir)]
224
+ return [s for s in sessions if s.prompts or s.tool_calls or s.interrupts]
@@ -0,0 +1,163 @@
1
+ """aggregations over parsed sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections import Counter
7
+ from datetime import UTC, date, datetime, timedelta
8
+ from typing import Any
9
+
10
+ from absolutelyright._records import Prompt, Session
11
+
12
+ CORRECTION_RE = re.compile(
13
+ r"^(no|nope|wait|stop|actually|wrong|not that|undo|revert|that's not)\b", re.I
14
+ )
15
+
16
+ MAX_REPEAT_LEN = 200 # longer prompts are never literal repeats worth surfacing
17
+
18
+
19
+ def all_prompts(sessions: list[Session]) -> list[Prompt]:
20
+ return [p for s in sessions for p in s.prompts]
21
+
22
+
23
+ def overview(sessions: list[Session]) -> dict[str, Any]:
24
+ prompts = all_prompts(sessions)
25
+ timestamps = sorted(p.timestamp for p in prompts if p.timestamp)
26
+ return {
27
+ "sessions": len(sessions),
28
+ "prompts": len(prompts),
29
+ "interrupts": sum(s.interrupts for s in sessions),
30
+ "toolCalls": sum(sum(s.tool_calls.values()) for s in sessions),
31
+ "outputTokens": sum(s.output_tokens for s in sessions),
32
+ "cacheReadTokens": sum(s.cache_read_tokens for s in sessions),
33
+ "prsShipped": len({url for s in sessions for url in s.pr_urls}),
34
+ "projects": len({s.project for s in sessions}),
35
+ "first": timestamps[0].isoformat() if timestamps else None,
36
+ "last": timestamps[-1].isoformat() if timestamps else None,
37
+ }
38
+
39
+
40
+ def repeated_prompts(
41
+ sessions: list[Session], top: int = 15, min_count: int = 2
42
+ ) -> list[tuple[str, int]]:
43
+ counts = Counter(
44
+ p.text.lower() for p in all_prompts(sessions) if len(p.text) <= MAX_REPEAT_LEN
45
+ )
46
+ return [(text, n) for text, n in counts.most_common(top) if n >= min_count]
47
+
48
+
49
+ def corrections(sessions: list[Session]) -> list[Prompt]:
50
+ return [p for p in all_prompts(sessions) if CORRECTION_RE.match(p.text)]
51
+
52
+
53
+ def by_project(sessions: list[Session]) -> list[dict[str, Any]]:
54
+ rows: dict[str, dict[str, Any]] = {}
55
+ for s in sessions:
56
+ row = rows.setdefault(
57
+ s.project,
58
+ {
59
+ "project": s.project,
60
+ "sessions": 0,
61
+ "prompts": 0,
62
+ "interrupts": 0,
63
+ "outputTokens": 0,
64
+ },
65
+ )
66
+ row["sessions"] += 1
67
+ row["prompts"] += len(s.prompts)
68
+ row["interrupts"] += s.interrupts
69
+ row["outputTokens"] += s.output_tokens
70
+ return sorted(rows.values(), key=lambda r: r["prompts"], reverse=True)
71
+
72
+
73
+ def tool_counts(sessions: list[Session]) -> list[tuple[str, int]]:
74
+ totals: Counter[str] = Counter()
75
+ for s in sessions:
76
+ totals.update(s.tool_calls)
77
+ return totals.most_common()
78
+
79
+
80
+ def hour_histogram(sessions: list[Session]) -> dict[int, int]:
81
+ """prompts by local hour of day."""
82
+ hours: Counter[int] = Counter()
83
+ for p in all_prompts(sessions):
84
+ if p.timestamp:
85
+ hours[p.timestamp.astimezone().hour] += 1
86
+ return dict(sorted(hours.items()))
87
+
88
+
89
+ def claudeisms(sessions: list[Session]) -> list[tuple[str, int]]:
90
+ """things claude says, counted across all assistant turns."""
91
+ totals: Counter[str] = Counter()
92
+ for s in sessions:
93
+ totals.update(s.isms)
94
+ return totals.most_common()
95
+
96
+
97
+ def vibe(sessions: list[Session]) -> list[tuple[str, int]]:
98
+ """things you say."""
99
+ totals: Counter[str] = Counter()
100
+ for s in sessions:
101
+ totals.update(s.vibe)
102
+ totals["images pasted"] = sum(s.images for s in sessions)
103
+ return totals.most_common()
104
+
105
+
106
+ def model_mix(sessions: list[Session]) -> list[tuple[str, int]]:
107
+ totals: Counter[str] = Counter()
108
+ for s in sessions:
109
+ totals.update(s.models)
110
+ return totals.most_common()
111
+
112
+
113
+ def slash_counts(sessions: list[Session]) -> list[tuple[str, int]]:
114
+ totals: Counter[str] = Counter()
115
+ for s in sessions:
116
+ totals.update(s.slash_commands)
117
+ return totals.most_common()
118
+
119
+
120
+ def shipped(sessions: list[Session]) -> list[dict[str, str]]:
121
+ """unique PRs opened, most recent project first."""
122
+ epoch = datetime.min.replace(tzinfo=UTC)
123
+ seen: dict[str, str] = {}
124
+ for s in sorted(
125
+ sessions, key=lambda s: s.last_ts or s.first_ts or epoch, reverse=True
126
+ ):
127
+ for url in sorted(s.pr_urls):
128
+ seen.setdefault(url, s.project)
129
+ return [{"url": url, "project": project} for url, project in seen.items()]
130
+
131
+
132
+ def session_rows(sessions: list[Session]) -> list[dict[str, Any]]:
133
+ """one row per session for the sessions browser, most recent first."""
134
+ rows = []
135
+ for s in sessions:
136
+ if s.first_ts is None:
137
+ continue
138
+ rows.append(
139
+ {
140
+ "when": s.first_ts.astimezone().strftime("%m-%d %H:%M"),
141
+ "title": s.title or "(untitled)",
142
+ "project": s.project,
143
+ "activeMinutes": round(s.active_seconds / 60),
144
+ "prompts": len(s.prompts),
145
+ "interrupts": s.interrupts,
146
+ "outputTokens": s.output_tokens,
147
+ "_sort": s.first_ts,
148
+ }
149
+ )
150
+ rows.sort(key=lambda r: r["_sort"], reverse=True)
151
+ for row in rows:
152
+ del row["_sort"]
153
+ return rows
154
+
155
+
156
+ def daily_counts(sessions: list[Session], days: int = 30) -> list[int]:
157
+ """prompts per day for the trailing window, oldest first (sparkline food)."""
158
+ today = date.today()
159
+ counts: Counter[date] = Counter()
160
+ for p in all_prompts(sessions):
161
+ if p.timestamp:
162
+ counts[p.timestamp.astimezone().date()] += 1
163
+ return [counts.get(today - timedelta(days=i), 0) for i in range(days - 1, -1, -1)]
absolutelyright/cli.py ADDED
@@ -0,0 +1,185 @@
1
+ """command-line interface for absolutelyright.
2
+
3
+ machine-readable by default: lists are ndjson (one json object per line) and
4
+ single results are one json object, so output pipes straight into jq or an
5
+ agent. pass --pretty for human-formatted output.
6
+ """
7
+
8
+ import json
9
+ from functools import lru_cache
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import cyclopts
14
+
15
+ import absolutelyright
16
+ from absolutelyright import _stats
17
+ from absolutelyright._records import DEFAULT_CLAUDE_DIR, Session, load_sessions
18
+
19
+ app = cyclopts.App(
20
+ name="absolutelyright",
21
+ help="analytics over your claude code session transcripts",
22
+ version=absolutelyright.__version__,
23
+ )
24
+
25
+
26
+ def _emit(data: dict[str, Any]) -> None:
27
+ print(json.dumps(data))
28
+
29
+
30
+ @app.default
31
+ def tui(*, days: int | None = None, claude_dir: Path = DEFAULT_CLAUDE_DIR) -> None:
32
+ """interactive dashboard (the default — just run `absolutelyright`)."""
33
+ if not (claude_dir / "projects").is_dir():
34
+ raise SystemExit(f"no transcripts found under {claude_dir}/projects")
35
+ from absolutelyright.tui import run
36
+
37
+ run(claude_dir=claude_dir, days=days)
38
+
39
+
40
+ @lru_cache # `report` runs every command in-process; parse transcripts once
41
+ def _load(claude_dir: Path, days: int | None) -> list[Session]:
42
+ if not (claude_dir / "projects").is_dir():
43
+ raise SystemExit(f"no transcripts found under {claude_dir}/projects")
44
+ return load_sessions(claude_dir, days=days)
45
+
46
+
47
+ @app.command
48
+ def overview(
49
+ *,
50
+ days: int | None = None,
51
+ claude_dir: Path = DEFAULT_CLAUDE_DIR,
52
+ pretty: bool = False,
53
+ ) -> None:
54
+ """totals: sessions, prompts, interrupts, tokens, date range."""
55
+ stats = _stats.overview(_load(claude_dir, days))
56
+ if not pretty:
57
+ _emit(stats)
58
+ return
59
+ span = ""
60
+ if stats["first"]:
61
+ span = f" {stats['first'][:10]} → {stats['last'][:10]}"
62
+ print(f"{stats['sessions']} sessions across {stats['projects']} projects{span}")
63
+ print(f" prompts: {stats['prompts']}")
64
+ print(f" interrupts: {stats['interrupts']}")
65
+ print(f" tool calls: {stats['toolCalls']}")
66
+ print(f" output tokens: {stats['outputTokens']:,}")
67
+
68
+
69
+ @app.command
70
+ def prompts(
71
+ top: int = 15,
72
+ *,
73
+ days: int | None = None,
74
+ claude_dir: Path = DEFAULT_CLAUDE_DIR,
75
+ pretty: bool = False,
76
+ ) -> None:
77
+ """your most-repeated literal prompts."""
78
+ for text, count in _stats.repeated_prompts(_load(claude_dir, days), top=top):
79
+ if pretty:
80
+ print(f"{count:5d} {text}")
81
+ else:
82
+ _emit({"count": count, "text": text})
83
+
84
+
85
+ @app.command
86
+ def corrections(
87
+ *,
88
+ days: int | None = None,
89
+ claude_dir: Path = DEFAULT_CLAUDE_DIR,
90
+ pretty: bool = False,
91
+ ) -> None:
92
+ """prompts that open with no / wait / stop / actually."""
93
+ sessions = _load(claude_dir, days)
94
+ found = _stats.corrections(sessions)
95
+ total = len(_stats.all_prompts(sessions))
96
+ if pretty:
97
+ rate = 100 * len(found) / total if total else 0
98
+ print(f"{len(found)} of {total} prompts ({rate:.1f}%)\n")
99
+ for p in found:
100
+ first_line = p.text.splitlines()[0]
101
+ print(f" [{p.project}] {first_line[:100]}")
102
+ return
103
+ for p in found:
104
+ _emit(
105
+ {
106
+ "text": p.text,
107
+ "project": p.project,
108
+ "timestamp": p.timestamp.isoformat() if p.timestamp else None,
109
+ }
110
+ )
111
+
112
+
113
+ @app.command
114
+ def projects(
115
+ *,
116
+ days: int | None = None,
117
+ claude_dir: Path = DEFAULT_CLAUDE_DIR,
118
+ pretty: bool = False,
119
+ ) -> None:
120
+ """per-project sessions, prompts, interrupts, and output tokens."""
121
+ rows = _stats.by_project(_load(claude_dir, days))
122
+ if not pretty:
123
+ for row in rows:
124
+ _emit(row)
125
+ return
126
+ width = max((len(r["project"]) for r in rows), default=7)
127
+ print(f"{'project':<{width}} sessions prompts interrupts output tokens")
128
+ for r in rows:
129
+ print(
130
+ f"{r['project']:<{width}} {r['sessions']:8d} {r['prompts']:7d}"
131
+ f" {r['interrupts']:10d} {r['outputTokens']:13,}"
132
+ )
133
+
134
+
135
+ @app.command
136
+ def tools(
137
+ *,
138
+ days: int | None = None,
139
+ claude_dir: Path = DEFAULT_CLAUDE_DIR,
140
+ pretty: bool = False,
141
+ ) -> None:
142
+ """tool call counts across all sessions."""
143
+ for name, count in _stats.tool_counts(_load(claude_dir, days)):
144
+ if pretty:
145
+ print(f"{count:7d} {name}")
146
+ else:
147
+ _emit({"tool": name, "count": count})
148
+
149
+
150
+ @app.command
151
+ def hours(
152
+ *,
153
+ days: int | None = None,
154
+ claude_dir: Path = DEFAULT_CLAUDE_DIR,
155
+ pretty: bool = False,
156
+ ) -> None:
157
+ """prompts by local hour of day."""
158
+ histogram = _stats.hour_histogram(_load(claude_dir, days))
159
+ if not pretty:
160
+ _emit({f"{h:02d}": n for h, n in histogram.items()})
161
+ return
162
+ peak = max(histogram.values(), default=1)
163
+ for hour, count in histogram.items():
164
+ bar = "█" * max(1, round(40 * count / peak))
165
+ print(f"{hour:02d}:00 {count:5d} {bar}")
166
+
167
+
168
+ @app.command
169
+ def report(
170
+ *,
171
+ days: int | None = None,
172
+ claude_dir: Path = DEFAULT_CLAUDE_DIR,
173
+ ) -> None:
174
+ """the whole picture, human-formatted."""
175
+ for section in (overview, prompts, corrections, projects, tools, hours):
176
+ print(f"\n── {section.__name__} {'─' * (40 - len(section.__name__))}")
177
+ section(days=days, claude_dir=claude_dir, pretty=True)
178
+
179
+
180
+ def main() -> None:
181
+ app()
182
+
183
+
184
+ if __name__ == "__main__":
185
+ main()
absolutelyright/tui.py ADDED
@@ -0,0 +1,396 @@
1
+ """textual dashboard for absolutelyright.
2
+
3
+ one screen, no scrolling walls of text: a sidebar of sections and a detail
4
+ pane. number keys narrow the time window without leaving the app.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any, ClassVar
11
+
12
+ from textual import work
13
+ from textual.app import App, ComposeResult
14
+ from textual.binding import Binding
15
+ from textual.containers import Horizontal, Vertical
16
+ from textual.widgets import (
17
+ ContentSwitcher,
18
+ DataTable,
19
+ Digits,
20
+ Footer,
21
+ Label,
22
+ ListItem,
23
+ ListView,
24
+ Sparkline,
25
+ Static,
26
+ )
27
+ from textual_plotext import PlotextPlot
28
+
29
+ from absolutelyright import _stats
30
+ from absolutelyright._records import DEFAULT_CLAUDE_DIR, Session, load_sessions
31
+
32
+ SECTIONS = (
33
+ "overview",
34
+ "sessions",
35
+ "prompts",
36
+ "corrections",
37
+ "vibe",
38
+ "projects",
39
+ "tools",
40
+ "hours",
41
+ )
42
+
43
+ TERRACOTTA = "#d97757"
44
+ TERRACOTTA_RGB = (217, 119, 87)
45
+
46
+
47
+ def _compact(n: int | float) -> str:
48
+ for threshold, suffix in ((1_000_000_000, "b"), (1_000_000, "m"), (1_000, "k")):
49
+ if n >= threshold:
50
+ return f"{n / threshold:.1f}{suffix}"
51
+ return str(round(n))
52
+
53
+
54
+ class AbsolutelyApp(App[None]):
55
+ """analytics over your claude code sessions, one screen at a time."""
56
+
57
+ TITLE = "absolutely right."
58
+
59
+ CSS = f"""
60
+ Screen {{
61
+ background: #16120e;
62
+ color: #e8dcc8;
63
+ }}
64
+
65
+ #hero {{
66
+ height: 4;
67
+ padding: 1 2 0 2;
68
+ background: #1f1913;
69
+ border-bottom: solid {TERRACOTTA};
70
+ }}
71
+
72
+ #title {{
73
+ text-style: bold;
74
+ color: {TERRACOTTA};
75
+ }}
76
+
77
+ #stats {{
78
+ color: #97896f;
79
+ }}
80
+
81
+ #main {{
82
+ height: 1fr;
83
+ }}
84
+
85
+ #sidebar {{
86
+ width: 20;
87
+ background: #1f1913;
88
+ border-right: solid #3a2e22;
89
+ }}
90
+
91
+ #sidebar ListView {{
92
+ height: 1fr;
93
+ padding: 1;
94
+ background: #1f1913;
95
+ }}
96
+
97
+ #sidebar ListItem {{
98
+ padding: 0 1;
99
+ background: #1f1913;
100
+ color: #97896f;
101
+ }}
102
+
103
+ #sidebar ListItem.--highlight {{
104
+ background: {TERRACOTTA};
105
+ color: #16120e;
106
+ text-style: bold;
107
+ }}
108
+
109
+ #detail {{
110
+ width: 1fr;
111
+ padding: 1 2;
112
+ }}
113
+
114
+ DataTable {{
115
+ height: 1fr;
116
+ background: #16120e;
117
+ }}
118
+
119
+ DataTable > .datatable--header {{
120
+ background: #16120e;
121
+ color: {TERRACOTTA};
122
+ text-style: bold;
123
+ }}
124
+
125
+ DataTable > .datatable--cursor {{
126
+ background: #3a2e22;
127
+ }}
128
+
129
+ .muted {{
130
+ color: #97896f;
131
+ padding: 0 0 1 0;
132
+ }}
133
+
134
+ #overview {{
135
+ padding: 1 2;
136
+ }}
137
+
138
+ #digits-row {{
139
+ height: 6;
140
+ }}
141
+
142
+ .stat {{
143
+ width: 1fr;
144
+ height: 6;
145
+ }}
146
+
147
+ .stat Digits {{
148
+ color: {TERRACOTTA};
149
+ width: auto;
150
+ }}
151
+
152
+ .stat Label {{
153
+ color: #97896f;
154
+ }}
155
+
156
+ #spark-caption {{
157
+ padding: 1 0 0 0;
158
+ color: #97896f;
159
+ }}
160
+
161
+ Sparkline {{
162
+ height: 3;
163
+ margin: 0 0 1 0;
164
+ }}
165
+
166
+ Sparkline > .sparkline--max-color {{
167
+ color: {TERRACOTTA};
168
+ }}
169
+
170
+ Sparkline > .sparkline--min-color {{
171
+ color: #3a2e22;
172
+ }}
173
+
174
+ #vibe Horizontal {{
175
+ height: 1fr;
176
+ }}
177
+
178
+ .vibe-col {{
179
+ width: 1fr;
180
+ padding: 0 2 0 0;
181
+ }}
182
+
183
+ .vibe-col > Label {{
184
+ text-style: bold;
185
+ color: {TERRACOTTA};
186
+ padding: 0 0 1 0;
187
+ }}
188
+
189
+ PlotextPlot {{
190
+ height: 1fr;
191
+ }}
192
+ """
193
+
194
+ BINDINGS: ClassVar = [
195
+ Binding("q", "quit", "quit"),
196
+ Binding("7", "window(7)", "week"),
197
+ Binding("3", "window(30)", "month"),
198
+ Binding("a", "window(None)", "all"),
199
+ ]
200
+
201
+ def __init__(
202
+ self, claude_dir: Path = DEFAULT_CLAUDE_DIR, days: int | None = None
203
+ ) -> None:
204
+ super().__init__()
205
+ self.claude_dir = claude_dir
206
+ self.days = days
207
+ self.sessions: list[Session] = []
208
+
209
+ def compose(self) -> ComposeResult:
210
+ with Vertical(id="hero"):
211
+ yield Static("absolutely right.", id="title")
212
+ yield Static("loading transcripts…", id="stats")
213
+ with Horizontal(id="main"):
214
+ with Vertical(id="sidebar"):
215
+ yield ListView(
216
+ *[ListItem(Label(name), id=f"nav-{name}") for name in SECTIONS],
217
+ id="nav",
218
+ )
219
+ with ContentSwitcher(initial="overview", id="detail"):
220
+ with Vertical(id="overview"):
221
+ with Horizontal(id="digits-row"):
222
+ for stat in ("prompts", "interrupts", "prs", "hours-active"):
223
+ with Vertical(classes="stat", id=f"stat-{stat}"):
224
+ yield Digits("0", id=f"digits-{stat}")
225
+ yield Label(stat.replace("-", " "))
226
+ yield Static("", id="spark-caption")
227
+ yield Sparkline([0], summary_function=max, id="spark")
228
+ yield Static("", id="model-mix", classes="muted")
229
+ yield Static("", id="token-line", classes="muted")
230
+ yield DataTable(id="sessions", cursor_type="row", zebra_stripes=True)
231
+ yield DataTable(id="prompts", cursor_type="row", zebra_stripes=True)
232
+ with Vertical(id="corrections"):
233
+ yield Label("", id="corrections-rate", classes="muted")
234
+ yield DataTable(
235
+ id="corrections-table", cursor_type="row", zebra_stripes=True
236
+ )
237
+ with Vertical(id="vibe"), Horizontal():
238
+ with Vertical(classes="vibe-col"):
239
+ yield Label("you said")
240
+ yield DataTable(id="vibe-you", cursor_type="row")
241
+ with Vertical(classes="vibe-col"):
242
+ yield Label("claude said")
243
+ yield DataTable(id="vibe-claude", cursor_type="row")
244
+ yield DataTable(id="projects", cursor_type="row", zebra_stripes=True)
245
+ yield DataTable(id="tools", cursor_type="row", zebra_stripes=True)
246
+ yield PlotextPlot(id="hours")
247
+ yield Footer()
248
+
249
+ def on_mount(self) -> None:
250
+ self.query_one("#sessions", DataTable).add_columns(
251
+ "when", "title", "project", "active", "prompts", "stops"
252
+ )
253
+ self.query_one("#prompts", DataTable).add_columns("count", "prompt")
254
+ self.query_one("#corrections-table", DataTable).add_columns("project", "prompt")
255
+ self.query_one("#vibe-you", DataTable).add_columns("word", "count")
256
+ self.query_one("#vibe-claude", DataTable).add_columns("tic", "count")
257
+ self.query_one("#projects", DataTable).add_columns(
258
+ "project", "sessions", "prompts", "stops", "output tokens"
259
+ )
260
+ self.query_one("#tools", DataTable).add_columns("count", "tool")
261
+ self._load()
262
+
263
+ def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
264
+ if event.item is not None and event.item.id is not None:
265
+ self.query_one(
266
+ "#detail", ContentSwitcher
267
+ ).current = event.item.id.removeprefix("nav-")
268
+
269
+ def action_window(self, days: int | None) -> None:
270
+ self.days = days
271
+ self.query_one("#stats", Static).update("reloading…")
272
+ self._load()
273
+
274
+ @work(thread=True, exclusive=True)
275
+ def _load(self) -> None:
276
+ sessions = load_sessions(self.claude_dir, days=self.days)
277
+ self.call_from_thread(self._render, sessions)
278
+
279
+ def _render(self, sessions: list[Session]) -> None:
280
+ self.sessions = sessions
281
+ stats = _stats.overview(sessions)
282
+ isms = dict(_stats.claudeisms(sessions))
283
+
284
+ window = f"last {self.days}d" if self.days else "all local history"
285
+ span = (
286
+ f" {stats['first'][:10]} → {stats['last'][:10]}" if stats["first"] else ""
287
+ )
288
+ right = isms.get("you're absolutely right", 0)
289
+ self.query_one("#stats", Static).update(
290
+ f"{window}{span} · claude was absolutely right {right} times"
291
+ )
292
+
293
+ self._render_overview(sessions, stats)
294
+
295
+ table = self.query_one("#sessions", DataTable)
296
+ table.clear()
297
+ for row in _stats.session_rows(sessions):
298
+ table.add_row(
299
+ row["when"],
300
+ row["title"][:60],
301
+ row["project"],
302
+ f"{row['activeMinutes']}m",
303
+ str(row["prompts"]),
304
+ str(row["interrupts"]),
305
+ )
306
+
307
+ prompts = self.query_one("#prompts", DataTable)
308
+ prompts.clear()
309
+ for text, count in _stats.repeated_prompts(sessions, top=50, min_count=2):
310
+ prompts.add_row(str(count), text.replace("\n", " ")[:120])
311
+
312
+ corrections = _stats.corrections(sessions)
313
+ total = len(_stats.all_prompts(sessions))
314
+ rate = 100 * len(corrections) / total if total else 0
315
+ self.query_one("#corrections-rate", Label).update(
316
+ f"{len(corrections)} of {total} prompts ({rate:.1f}%) open with no / wait / stop / actually"
317
+ )
318
+ ctable = self.query_one("#corrections-table", DataTable)
319
+ ctable.clear()
320
+ for p in corrections:
321
+ ctable.add_row(p.project, p.text.splitlines()[0][:120])
322
+
323
+ you = self.query_one("#vibe-you", DataTable)
324
+ you.clear()
325
+ for word, count in _stats.vibe(sessions):
326
+ you.add_row(word, str(count))
327
+ claude = self.query_one("#vibe-claude", DataTable)
328
+ claude.clear()
329
+ for tic, count in _stats.claudeisms(sessions):
330
+ claude.add_row(tic, str(count))
331
+
332
+ projects = self.query_one("#projects", DataTable)
333
+ projects.clear()
334
+ for row in _stats.by_project(sessions):
335
+ projects.add_row(
336
+ row["project"],
337
+ str(row["sessions"]),
338
+ str(row["prompts"]),
339
+ str(row["interrupts"]),
340
+ f"{row['outputTokens']:,}",
341
+ )
342
+
343
+ tools = self.query_one("#tools", DataTable)
344
+ tools.clear()
345
+ for name, count in _stats.tool_counts(sessions):
346
+ tools.add_row(str(count), name)
347
+
348
+ self._render_hours(sessions)
349
+
350
+ def _render_overview(self, sessions: list[Session], stats: dict[str, Any]) -> None:
351
+ hours_active = sum(s.active_seconds for s in sessions) / 3600
352
+ self.query_one("#digits-prompts", Digits).update(_compact(stats["prompts"]))
353
+ self.query_one("#digits-interrupts", Digits).update(
354
+ _compact(stats["interrupts"])
355
+ )
356
+ self.query_one("#digits-prs", Digits).update(_compact(stats["prsShipped"]))
357
+ self.query_one("#digits-hours-active", Digits).update(f"{hours_active:.0f}")
358
+
359
+ days = min(self.days or 30, 30)
360
+ self.query_one("#spark-caption", Static).update(
361
+ f"prompts per day · last {days} days"
362
+ )
363
+ spark = self.query_one("#spark", Sparkline)
364
+ spark.data = _stats.daily_counts(sessions, days=days)
365
+
366
+ mix = _stats.model_mix(sessions)
367
+ total_turns = sum(n for _, n in mix) or 1
368
+ mix_text = " ".join(
369
+ f"{name.removeprefix('claude-')} {100 * n / total_turns:.0f}%"
370
+ for name, n in mix[:4]
371
+ )
372
+ self.query_one("#model-mix", Static).update(f"models {mix_text}")
373
+ self.query_one("#token-line", Static).update(
374
+ f"tokens {_compact(stats['outputTokens'])} out"
375
+ f" · {_compact(stats['cacheReadTokens'])} cache read"
376
+ f" · {stats['sessions']} sessions across {stats['projects']} projects"
377
+ )
378
+
379
+ def _render_hours(self, sessions: list[Session]) -> None:
380
+ histogram = _stats.hour_histogram(sessions)
381
+ plot = self.query_one("#hours", PlotextPlot)
382
+ plt = plot.plt
383
+ plt.clear_data()
384
+ hours = list(range(24))
385
+ plt.bar(
386
+ [f"{h:02d}" for h in hours],
387
+ [histogram.get(h, 0) for h in hours],
388
+ color=TERRACOTTA_RGB,
389
+ marker="hd",
390
+ )
391
+ plt.title("prompts by local hour")
392
+ plot.refresh()
393
+
394
+
395
+ def run(claude_dir: Path = DEFAULT_CLAUDE_DIR, days: int | None = None) -> None:
396
+ AbsolutelyApp(claude_dir=claude_dir, days=days).run()
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.4
2
+ Name: absolutelyright
3
+ Version: 0.2.0a2
4
+ Summary: analytics over your claude code session transcripts
5
+ Author-email: zzstoatzz <thrast36@gmail.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: analytics,claude,claude-code,cli,transcripts
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: cyclopts>=4.17.0
20
+ Requires-Dist: textual-plotext>=1.0.1
21
+ Requires-Dist: textual>=8.2.7
22
+ Description-Content-Type: text/markdown
23
+
24
+ # absolutelyright
25
+
26
+ analytics over your claude code session transcripts.
27
+
28
+ claude code keeps a jsonl transcript of every session under `~/.claude/projects/`.
29
+ absolutelyright separates what you actually typed from harness noise (tool results,
30
+ slash-command expansions, subagent sidechains) and tells you things like:
31
+
32
+ - your most-repeated literal prompts (everyone has a "make the PR description not suck")
33
+ - how often you open with `no` / `wait` / `stop` / `actually`
34
+ - where your prompts, interrupts, and output tokens go, per project
35
+ - which tools claude leans on, and what hours you really work
36
+
37
+ ## install
38
+
39
+ ```bash
40
+ uv tool install absolutelyright
41
+ ```
42
+
43
+ ## usage
44
+
45
+ ```bash
46
+ absolutelyright report # the whole picture, human-formatted
47
+ absolutelyright prompts --pretty # most-repeated literal prompts
48
+ absolutelyright hours --pretty # prompts by local hour of day
49
+ absolutelyright overview --days 7 # recent only
50
+ ```
51
+
52
+ machine-readable by default — lists are ndjson, single results are one json
53
+ object — so output pipes straight into jq or an agent:
54
+
55
+ ```bash
56
+ absolutelyright projects | jq -r '.project'
57
+ ```
58
+
59
+ note: claude code prunes local transcripts (30 days by default, see
60
+ `cleanupPeriodDays`), so absolutelyright sees a rolling window, not all time.
61
+
62
+ ## library
63
+
64
+ ```python
65
+ from absolutelyright import load_sessions, repeated_prompts
66
+
67
+ sessions = load_sessions(days=30)
68
+ for text, count in repeated_prompts(sessions, top=10):
69
+ print(count, text)
70
+ ```
@@ -0,0 +1,10 @@
1
+ absolutelyright/__init__.py,sha256=QpATqoVnZt4baJJ576XvAFsxQhX9yxYmHYDzURbbS0o,544
2
+ absolutelyright/_records.py,sha256=57z8mjYZyElePTPc7uSHwZ7kcy273hg6kXyOfB9JT6s,8130
3
+ absolutelyright/_stats.py,sha256=4_aFoZlXsd54-GHuKKFHBcqptiMZxw2OkPJGeIx7DJ8,5514
4
+ absolutelyright/cli.py,sha256=NCyMPDjA8abjniZFo0RxzTiBRLj73m7Yu52PaDgFcck,5454
5
+ absolutelyright/tui.py,sha256=eFH6ghbaajSNX76nztKHiIXJABNfTWsIAln8V6hh5W8,12243
6
+ absolutelyright-0.2.0a2.dist-info/METADATA,sha256=AU-4nQNJrhFCW8d1y22y8fzwArFs3bBSRBl66BWsncQ,2340
7
+ absolutelyright-0.2.0a2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ absolutelyright-0.2.0a2.dist-info/entry_points.txt,sha256=jA0gLcOeOWQig0whqQG8Iv7Q8Q9TWQsKMOXfkGK9ank,61
9
+ absolutelyright-0.2.0a2.dist-info/licenses/LICENSE,sha256=GCaBhyWUE9SeLzklZVA9BKgh424EOjDzofeP-9_OB-8,1066
10
+ absolutelyright-0.2.0a2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ absolutelyright = absolutelyright.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zzstoatzz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.