absolutelyright 0.2.0a2__tar.gz

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,7 @@
1
+ .venv/
2
+ dist/
3
+ __pycache__/
4
+ *.egg-info/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ .env
@@ -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.
@@ -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,47 @@
1
+ # absolutelyright
2
+
3
+ analytics over your claude code session transcripts.
4
+
5
+ claude code keeps a jsonl transcript of every session under `~/.claude/projects/`.
6
+ absolutelyright separates what you actually typed from harness noise (tool results,
7
+ slash-command expansions, subagent sidechains) and tells you things like:
8
+
9
+ - your most-repeated literal prompts (everyone has a "make the PR description not suck")
10
+ - how often you open with `no` / `wait` / `stop` / `actually`
11
+ - where your prompts, interrupts, and output tokens go, per project
12
+ - which tools claude leans on, and what hours you really work
13
+
14
+ ## install
15
+
16
+ ```bash
17
+ uv tool install absolutelyright
18
+ ```
19
+
20
+ ## usage
21
+
22
+ ```bash
23
+ absolutelyright report # the whole picture, human-formatted
24
+ absolutelyright prompts --pretty # most-repeated literal prompts
25
+ absolutelyright hours --pretty # prompts by local hour of day
26
+ absolutelyright overview --days 7 # recent only
27
+ ```
28
+
29
+ machine-readable by default — lists are ndjson, single results are one json
30
+ object — so output pipes straight into jq or an agent:
31
+
32
+ ```bash
33
+ absolutelyright projects | jq -r '.project'
34
+ ```
35
+
36
+ note: claude code prunes local transcripts (30 days by default, see
37
+ `cleanupPeriodDays`), so absolutelyright sees a rolling window, not all time.
38
+
39
+ ## library
40
+
41
+ ```python
42
+ from absolutelyright import load_sessions, repeated_prompts
43
+
44
+ sessions = load_sessions(days=30)
45
+ for text, count in repeated_prompts(sessions, top=10):
46
+ print(count, text)
47
+ ```
@@ -0,0 +1,37 @@
1
+ # run tests
2
+ test:
3
+ uv run pytest tests/ -x
4
+
5
+ # format and lint
6
+ fmt:
7
+ uv run ruff format src/ tests/
8
+ uv run ruff check src/ tests/ --fix
9
+
10
+ # type check
11
+ check:
12
+ uv run ty check
13
+
14
+ # tag the next alpha, build, publish to pypi (token from .env / ~/.env)
15
+ release:
16
+ #!/usr/bin/env bash
17
+ set -euo pipefail
18
+ if [ -f .env ]; then source .env; elif [ -f ~/.env ]; then source ~/.env; fi
19
+ export UV_PUBLISH_TOKEN="${UV_PUBLISH_TOKEN:-${PYPI_API_TOKEN:?no pypi token in .env}}"
20
+ if [ -n "$(git status --porcelain)" ]; then
21
+ echo "working tree is dirty — commit first" >&2
22
+ exit 1
23
+ fi
24
+ last=$(git tag -l 'v*' --sort=-v:refname | head -1)
25
+ if [[ $last =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)a([0-9]+)$ ]]; then
26
+ next="v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}a$((BASH_REMATCH[4] + 1))"
27
+ elif [[ $last =~ ^v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
28
+ next="v${BASH_REMATCH[1]}.$((BASH_REMATCH[2] + 1)).0a1"
29
+ else
30
+ next="v0.2.0a1"
31
+ fi
32
+ just test
33
+ git tag "$next"
34
+ rm -rf dist && uv build
35
+ uv publish
36
+ echo
37
+ echo "published ${next#v} — try it: uvx absolutelyright@${next#v}"
@@ -0,0 +1,100 @@
1
+ [project]
2
+ name = "absolutelyright"
3
+ dynamic = ["version"]
4
+ description = "analytics over your claude code session transcripts"
5
+ readme = "README.md"
6
+ authors = [{ name = "zzstoatzz", email = "thrast36@gmail.com" }]
7
+ requires-python = ">=3.12"
8
+ license = "MIT"
9
+
10
+ keywords = ["claude", "claude-code", "transcripts", "analytics", "cli"]
11
+
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Programming Language :: Python :: 3.14",
20
+ "Topic :: Software Development :: Libraries :: Python Modules",
21
+ "Typing :: Typed",
22
+ ]
23
+
24
+ dependencies = [
25
+ "cyclopts>=4.17.0",
26
+ "textual>=8.2.7",
27
+ "textual-plotext>=1.0.1",
28
+ ]
29
+
30
+ [project.scripts]
31
+ absolutelyright = "absolutelyright.cli:main"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "pytest>=8.3.0",
36
+ "pytest-asyncio>=1.4.0",
37
+ "pytest-sugar",
38
+ "ruff>=0.12.0",
39
+ "ty>=0.0.1a25",
40
+ ]
41
+
42
+ [build-system]
43
+ requires = ["hatchling", "uv-dynamic-versioning>=0.7.0"]
44
+ build-backend = "hatchling.build"
45
+
46
+ [tool.hatch.build.targets.wheel]
47
+ packages = ["src/absolutelyright"]
48
+
49
+ [tool.hatch.version]
50
+ source = "uv-dynamic-versioning"
51
+
52
+ [tool.uv-dynamic-versioning]
53
+ vcs = "git"
54
+ style = "pep440"
55
+ bump = true
56
+ fallback-version = "0.0.0"
57
+
58
+ [tool.pytest.ini_options]
59
+ asyncio_mode = "auto"
60
+ asyncio_default_fixture_loop_scope = "function"
61
+ testpaths = ["tests"]
62
+ python_files = ["test_*.py", "*_test.py"]
63
+ python_classes = ["Test*"]
64
+ python_functions = ["test_*"]
65
+
66
+ [tool.ruff.lint]
67
+ fixable = ["ALL"]
68
+ ignore = [
69
+ "COM812",
70
+ "PLR0913", # Too many arguments
71
+ "SIM102", # Dont require combining if statements
72
+ "ANN401", # Any is allowed where it's the honest type (escape hatches)
73
+ ]
74
+ extend-select = [
75
+ "ANN", # flake8-annotations: require complete signatures
76
+ "B", # flake8-bugbear
77
+ "C4", # flake8-comprehensions
78
+ "I", # isort
79
+ "PIE", # flake8-pie
80
+ "RUF", # Ruff-specific
81
+ "SIM", # flake8-simplify
82
+ "UP", # pyupgrade
83
+ ]
84
+
85
+ [tool.ruff.lint.per-file-ignores]
86
+ "__init__.py" = ["F401", "I001"]
87
+ "tests/**/*.py" = ["S101"] # Allow assert in tests
88
+
89
+ [tool.ty.src]
90
+ include = ["src", "tests"]
91
+ exclude = [
92
+ "**/node_modules",
93
+ "**/__pycache__",
94
+ ".venv",
95
+ ".git",
96
+ "dist",
97
+ ]
98
+
99
+ [tool.ty.environment]
100
+ python-version = "3.12"
@@ -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]