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.
- absolutelyright/__init__.py +29 -0
- absolutelyright/_records.py +224 -0
- absolutelyright/_stats.py +163 -0
- absolutelyright/cli.py +185 -0
- absolutelyright/tui.py +396 -0
- absolutelyright-0.2.0a2.dist-info/METADATA +70 -0
- absolutelyright-0.2.0a2.dist-info/RECORD +10 -0
- absolutelyright-0.2.0a2.dist-info/WHEEL +4 -0
- absolutelyright-0.2.0a2.dist-info/entry_points.txt +2 -0
- absolutelyright-0.2.0a2.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|