mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 22:27:01 +01:00
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (mypy, mypy) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (pylint, pylint) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (rest, ) (push) Waiting to run
Pre-commit checks / pre-commit (${{ matrix.group }}) (shellcheck, shellcheck) (push) Waiting to run
Python tests / test (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / lib-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JThjboXTeZ3yJPBzxMx5wS
213 lines
8.2 KiB
Python
213 lines
8.2 KiB
Python
"""Read Claude Code transcripts into :class:`Session` objects.
|
|
|
|
Transcripts are JSONL under ``~/.claude/projects/<slug>/<session-id>.jsonl``,
|
|
one JSON object per line, appended live. Three properties of that format drive
|
|
the design here:
|
|
|
|
1. **Lines can be malformed.** A transcript being written while it is read can
|
|
end in a partial line, and older sessions contain records this package does
|
|
not model. Every line is therefore parsed defensively and skipped on error;
|
|
losing one line is always better than aborting a weekly report.
|
|
2. **A tool call and its result are in different messages.** ``tool_use`` blocks
|
|
carry the name and input, ``tool_result`` blocks carry the payload and only a
|
|
``tool_use_id`` back-reference. Attributing result size to the right tool
|
|
requires holding an id map for the length of the file.
|
|
3. **Order matters.** The image-cost model needs to know how many turns followed
|
|
each image, so :func:`iter_events` preserves the interleaving rather than
|
|
returning separate lists.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
from python_pkg.token_audit import discover, records, turns
|
|
from python_pkg.token_audit.model import IMAGE_SUFFIXES, Session, ToolCall, Turn
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
# Divisor turning rendered characters into an approximate token count. Four is
|
|
# the usual English-text rule of thumb and is only ever used for ranking tools,
|
|
# never for the reconciled totals.
|
|
CHARS_PER_TOKEN = 4
|
|
|
|
# Median billed size of one screenshot, measured from real transcripts by
|
|
# isolating turns whose only new content was a single image (n=484, median
|
|
# 2,566, p90 2,692). Anthropic bills images by pixel area, so a constant is a
|
|
# far better estimate than the base64 payload length, which is ~20x too high.
|
|
# Halving a screenshot's dimensions cuts this roughly fourfold.
|
|
IMAGE_TOKENS = 2566
|
|
|
|
# Commands that land work: a plain commit or the scripted wrap-up.
|
|
COMMIT = re.compile(r"\bgit(\s+-C\s+\S+)?\s+commit\b|finish_auto\.sh")
|
|
|
|
|
|
def _load_line(line: str) -> dict[str, Any] | None:
|
|
"""Parse one JSONL line, returning ``None`` if it is not a usable record."""
|
|
try:
|
|
record = json.loads(line)
|
|
except ValueError, UnicodeDecodeError:
|
|
return None
|
|
return record if isinstance(record, dict) else None
|
|
|
|
|
|
def _blocks(record: dict[str, Any]) -> list[dict[str, Any]]:
|
|
"""Return the content blocks of a record's message, if it has any."""
|
|
message = record.get("message")
|
|
if not isinstance(message, dict):
|
|
return []
|
|
content = message.get("content")
|
|
if not isinstance(content, list):
|
|
return []
|
|
return [block for block in content if isinstance(block, dict)]
|
|
|
|
|
|
def _result_tokens(block: dict[str, Any], *, is_image: bool = False) -> int:
|
|
"""Estimate how many tokens a tool result occupied in context.
|
|
|
|
Text is charged by length, but an **image is not**. An image arrives as a
|
|
base64 blob whose character count has nothing to do with its billed size —
|
|
Anthropic charges by pixel area. Measuring images by payload length
|
|
overstates them by roughly 20x (a 240KB PNG looks like ~82k tokens against
|
|
a real cost near 2.5k), which is enough to make screenshots look like the
|
|
dominant cost driver when they are not. Validated against real transcripts
|
|
by isolating turns whose only new content was one image: the context grew
|
|
~1.6-3k tokens, never the tens of thousands the payload length implied.
|
|
"""
|
|
content = block.get("content")
|
|
if content is None:
|
|
return 0
|
|
if is_image:
|
|
return IMAGE_TOKENS
|
|
return len(json.dumps(content)) // CHARS_PER_TOKEN
|
|
|
|
|
|
def iter_events(
|
|
path: Path,
|
|
window: records.Window | None = None,
|
|
) -> Iterator[tuple[str, object]]:
|
|
"""Yield ``("turn", Turn)`` and ``("tool", ToolCall)`` in transcript order.
|
|
|
|
Emitting a single ordered stream — rather than two lists — is what lets
|
|
:mod:`imagecost` count how many turns each image survived for. Turns are
|
|
one per API message (see :mod:`records`), and only records inside
|
|
``window`` are emitted.
|
|
"""
|
|
pending: dict[str, dict[str, Any]] = {}
|
|
merger = records.TurnMerger()
|
|
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
for line in handle:
|
|
record = _load_line(line)
|
|
if record is None or not records.in_window(record, window):
|
|
continue
|
|
pairs: list[tuple[str, object]] = []
|
|
for block in _blocks(record):
|
|
pairs += _block_events(block, pending, merger)
|
|
yield from pairs
|
|
found = turns.usage_of(record)
|
|
if found is not None:
|
|
done = merger.push(turns.build_turn(record, *found))
|
|
if done is not None:
|
|
yield "turn", done
|
|
held = merger.flush()
|
|
if held is not None:
|
|
yield "turn", held
|
|
|
|
|
|
def _block_events(
|
|
block: dict[str, Any],
|
|
pending: dict[str, dict[str, Any]],
|
|
merger: records.TurnMerger,
|
|
) -> list[tuple[str, object]]:
|
|
"""Events one content block emits: remember a ``tool_use``, pair a result.
|
|
|
|
A ``tool_result`` ends the assistant message before it, so the held turn is
|
|
released first to keep the stream in transcript order.
|
|
"""
|
|
kind = block.get("type")
|
|
if kind == "tool_use":
|
|
call_id = block.get("id")
|
|
if isinstance(call_id, str):
|
|
pending[call_id] = block
|
|
return []
|
|
if kind != "tool_result":
|
|
return []
|
|
out: list[tuple[str, object]] = []
|
|
held = merger.flush()
|
|
if held is not None:
|
|
out.append(("turn", held))
|
|
call = _pair_result(block, pending)
|
|
if call is not None:
|
|
out.append(("tool", call))
|
|
return out
|
|
|
|
|
|
def _pair_result(
|
|
block: dict[str, Any],
|
|
pending: dict[str, dict[str, Any]],
|
|
) -> ToolCall | None:
|
|
"""Join a ``tool_result`` back to the ``tool_use`` that produced it."""
|
|
use = pending.pop(block.get("tool_use_id", ""), None)
|
|
if use is None:
|
|
return None
|
|
tool_input = use.get("input")
|
|
tool_input = tool_input if isinstance(tool_input, dict) else {}
|
|
path = tool_input.get("file_path")
|
|
skill = tool_input.get("skill")
|
|
command = tool_input.get("command")
|
|
call_path = path if isinstance(path, str) else None
|
|
return ToolCall(
|
|
name=str(use.get("name") or "unknown"),
|
|
result_tokens=_result_tokens(block, is_image=_looks_like_image(call_path)),
|
|
path=call_path,
|
|
skill=skill if isinstance(skill, str) else None,
|
|
commit=isinstance(command, str) and COMMIT.search(command) is not None,
|
|
)
|
|
|
|
|
|
def _looks_like_image(path: str | None) -> bool:
|
|
"""Whether a tool-result path refers to an image, by extension."""
|
|
if path is None:
|
|
return False
|
|
dot = path.rfind(".")
|
|
return dot != -1 and path[dot:].lower() in IMAGE_SUFFIXES
|
|
|
|
|
|
def load_session(path: Path, window: records.Window | None = None) -> Session:
|
|
"""Read one transcript file into a :class:`Session`.
|
|
|
|
A subagent transcript (``<session>/subagents/agent-*.jsonl``) is tagged
|
|
with its parent session id and the ``agentType``/``description`` the
|
|
spawning ``Agent`` call recorded in the sibling ``.meta.json``. With no
|
|
description anywhere, the first prompt line stands in, then the agent id.
|
|
"""
|
|
session = Session(
|
|
session_id=path.stem, path=str(path), cwd=discover.first_cwd(path)
|
|
)
|
|
if path.parent.name == "subagents":
|
|
meta = discover.agent_meta(path)
|
|
session.parent_id = path.parent.parent.name
|
|
session.agent_type = str(meta.get("agentType") or "unknown")
|
|
session.description = str(
|
|
meta.get("description")
|
|
or discover.first_prompt(path)
|
|
or f"(no label) {path.stem}"
|
|
)
|
|
for kind, event in iter_events(path, window):
|
|
if kind == "turn" and isinstance(event, Turn):
|
|
session.turns.append(event)
|
|
elif isinstance(event, ToolCall):
|
|
session.tools.append(event)
|
|
return session
|
|
|
|
|
|
# Public names for the record primitives, shared with token_dashboard's walker
|
|
# (which needs prompt boundaries and timestamps that iter_events drops).
|
|
load_line = _load_line
|
|
content_blocks = _blocks
|
|
pair_result = _pair_result
|