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.
- absolutelyright-0.2.0a2/.gitignore +7 -0
- absolutelyright-0.2.0a2/LICENSE +21 -0
- absolutelyright-0.2.0a2/PKG-INFO +70 -0
- absolutelyright-0.2.0a2/README.md +47 -0
- absolutelyright-0.2.0a2/justfile +37 -0
- absolutelyright-0.2.0a2/pyproject.toml +100 -0
- absolutelyright-0.2.0a2/src/absolutelyright/__init__.py +29 -0
- absolutelyright-0.2.0a2/src/absolutelyright/_records.py +224 -0
- absolutelyright-0.2.0a2/src/absolutelyright/_stats.py +163 -0
- absolutelyright-0.2.0a2/src/absolutelyright/cli.py +185 -0
- absolutelyright-0.2.0a2/src/absolutelyright/tui.py +396 -0
- absolutelyright-0.2.0a2/tests/conftest.py +83 -0
- absolutelyright-0.2.0a2/tests/test_cli.py +40 -0
- absolutelyright-0.2.0a2/tests/test_records.py +61 -0
- absolutelyright-0.2.0a2/tests/test_stats.py +84 -0
- absolutelyright-0.2.0a2/tests/test_tui.py +48 -0
- absolutelyright-0.2.0a2/uv.lock +358 -0
|
@@ -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]
|