From 79b2bc9479f5eb1b44e9d5e62dd6ebba2e78f8ea Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Sun, 9 Aug 2026 10:13:02 -0400 Subject: [PATCH 1/2] feat(examples): claude-loaded, a standalone session context inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds examples/claude-loaded/ — one bash script that lists the files loaded into a Claude Code session's context (the CLAUDE.md chain, the auto-memory index, and every file a Read pulled in) with a per-file token estimate, plus a --budget mode that checks only the re-sent-every-turn set against a ceiling and needs no session or transcript, so it can run from a SessionStart hook. It does not import claude_batch_runner. It sits in examples/ as a peer of health-check/ and workflows/ because a campaign inherits whatever CLAUDE.md and auto-memory bloat a session already carries and pays for it once per unit. The new README documents three things the script reports badly, all verified against a live run rather than assumed: every "read" row carries the same whole-session size instead of a per-file one, token counts are a 3.6-chars-per-token estimate, and the "cost per turn" its --help advertises is never printed. Script is byte-identical to its source; only docs are authored here. --- CHANGELOG.md | 10 + README.md | 6 + examples/README.md | 24 +- examples/claude-loaded/README.md | 173 +++++++++ examples/claude-loaded/claude-loaded | 540 +++++++++++++++++++++++++++ 5 files changed, 751 insertions(+), 2 deletions(-) create mode 100644 examples/claude-loaded/README.md create mode 100755 examples/claude-loaded/claude-loaded diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b3ec5..bc58092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- **`examples/claude-loaded/`** — a standalone bash context inspector. It lists + the files loaded into a Claude Code session (the `CLAUDE.md` chain, the + auto-memory index, and every file a `Read` pulled in) with a per-file token + estimate, and its `--budget` mode checks just the re-sent-every-turn set + against a ceiling with no session or transcript needed, so it can run from a + `SessionStart` hook. It does not import `claude_batch_runner`. Its README + documents the three things it reports badly: `read` rows all carry the same + (whole-session) size rather than a per-file one, token counts are a + 3.6-chars-each estimate, and the "cost per turn" its `--help` advertises is + never printed. - **`cwd` and `permission_mode` on `call_agent`** — two optional keyword-only arguments. `cwd` runs the `claude` subprocess in that directory, validated to exist first so an unusable path raises `AgentError` before a process starts diff --git a/README.md b/README.md index 057f121..9422f4e 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,12 @@ as before. strategy agent for a verdict, and writes a report. It is the fullest example of the split this library is built around: code owns the loop, models own the judgement. +- [`examples/claude-loaded/`](examples/claude-loaded/) — a standalone bash + script that lists the files loaded into a Claude Code session's context with a + token estimate each, and whose `--budget` mode exits non-zero when the + re-sent-every-turn set (`CLAUDE.md` chain plus auto-memory index) is too big. + It does not use this library; it is here because a campaign pays for that + bloat once per unit. - [`examples/workflows/`](examples/workflows/) — two JavaScript workflow programs showing the same tiered patterns. **They do not run standalone** — they expect an agent-workflow host that supplies `agent()`, `parallel()`, diff --git a/examples/README.md b/examples/README.md index f4d91e1..923272e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ # Examples -Four things live here, in rough order of how useful they are when you're +Five things live here, in rough order of how useful they are when you're starting out. ## 1. A runnable campaign @@ -66,7 +66,27 @@ What's worth stealing from it: `--report-dir` controls where reports go (default `./health-reports`). -## 4. Two workflow programs, for reading +## 4. A standalone context inspector + +`claude-loaded/` — one bash script that prints which files are loaded into a +Claude Code session's context, largest first, with a token estimate each. + +```bash +examples/claude-loaded/claude-loaded # the current session +examples/claude-loaded/claude-loaded --budget # only the always-loaded set +``` + +It is the odd one out here: it does not import `claude_batch_runner` and has +nothing to do with campaigns. It is included because a batch of agent calls +inherits whatever `CLAUDE.md` and auto-memory bloat the session already carries, +and this is how you find that before paying for it on every unit. + +`--budget` is the automatable part — it needs no session, exits non-zero when +the every-turn set is over a ceiling, and is meant for a `SessionStart` hook. +See [`claude-loaded/README.md`](claude-loaded/README.md), which is also honest +about what the script measures badly. + +## 5. Two workflow programs, for reading `workflows/tiered-wave.js` and `workflows/tiered-audit-loop.js`. diff --git a/examples/claude-loaded/README.md b/examples/claude-loaded/README.md new file mode 100644 index 0000000..c0f6016 --- /dev/null +++ b/examples/claude-loaded/README.md @@ -0,0 +1,173 @@ +# claude-loaded + +Show which files are loaded into a Claude Code session's context, with a token +estimate for each one. + +A long session gets expensive quietly. Every `CLAUDE.md` in the directory chain +and the project's auto-memory index are re-sent on *every* turn, and each file a +tool Read stays in the window for the rest of the session. This prints that set, +sorted largest first, so the thing bloating your context is a row you can see +rather than a number you can't explain. + +It is one bash script with no Python in it. It does not import +`claude_batch_runner` — it is here because it belongs to the same toolbox, not +because it uses the library. + +```bash +examples/claude-loaded/claude-loaded +``` + +``` +Session: 4f9c2ab1 (/srv/project) + +Files loaded into context: + + TOKENS TYPE PATH + ------ ---- ---- + 4.5K static ~/.claude/CLAUDE.md + 3.0K static /srv/project/CLAUDE.md + 1.6K static ~/.claude/projects/-srv-project/memory/MEMORY.md + +Breakdown (sums to total): + Files listed above (CLAUDE.md + memory): 9.1K + Files Read by tools this session: 12.4K + Everything else (system + tools + conv): 31.0K + -------- + Total context: 52.5K / 200.0K (26.3%) +``` + +`static` rows are re-sent every turn. `read` rows entered the window once, when +a tool read them. "Everything else" is the remainder after subtracting the +listed files from the total the API actually reported — system prompt, tool +schemas, and the conversation itself. + +## How it finds your session + +With no arguments it walks up the process tree from its own parent, up to eight +levels, looking for a PID with a session file in `~/.claude/sessions/`. That +resolves to a session id, which resolves to a transcript under +`~/.claude/projects/`. So it works when you run it *from inside* a Claude Code +session, whose shell is a descendant of the `claude` process. + +From outside one, name the session yourself: + +```bash +claude-loaded --all # what's running +claude-loaded --session 4f9c2ab1 # by session-id prefix +claude-loaded --transcript path.jsonl # by transcript path +``` + +If it can't resolve a session it prints the running-session list and exits `2`. + +## Modes + +| Flag | What it does | +|---|---| +| *(none)* | The breakdown above, for the current session. | +| `--all` | List running Claude Code sessions: PID, cwd, session id. | +| `--session ` | Use this session id (prefix match). | +| `--transcript ` | Use this transcript file directly. | +| `--prune` | Report auto-memory files that look abandoned. | +| `--budget [CHARS]` | Check the always-loaded set against a size ceiling. | +| `--cwd ` | Which directory `--budget` measures. Default `$PWD`. | +| `--no-color` | No ANSI escapes. | +| `--json` | Machine-readable output; implies `--no-color`. | +| `--help` | Usage. | + +### `--budget` — the one you can automate + +`--budget` measures only the set that is loaded on every single turn: the global +`~/.claude/CLAUDE.md`, every `CLAUDE.md` from the given directory up toward `/`, +and that project's auto-memory `MEMORY.md` index. Under the ceiling it prints +nothing and exits `0`; over it, it lists the contributors largest-first and +exits `1`. + +```bash +claude-loaded --budget # default ceiling: 38000 chars +claude-loaded --budget 20000 --cwd /path/to/project +``` + +``` +[static-budget] injected bundle is 41210 chars (budget 38000) for /path/to/project +[static-budget] contributors: + 16110 ~/.claude/CLAUDE.md + 14300 /path/to/project/CLAUDE.md + 10800 ~/.claude/projects/-path-to-project/memory/MEMORY.md +``` + +This mode reads no transcript and needs no session, which is what makes it +usable from a `SessionStart` hook — it warns you while consolidating is still +cheap, before the harness's own oversized-`CLAUDE.md` warning fires. The default +of 38000 sits just under that. + +Note that it resolves the memory index by the **git toplevel** of the directory, +not the raw path, because that is how the harness keys auto-memory to a project. +A session in `repo/sub/dir` is injected `repo`'s index. + +### `--prune` — abandoned auto-memory + +Lists the topic files in the session project's memory directory with their age +and whether the filename has turned up in any recent transcript, then names the +ones that are both old and unreferenced. + +```bash +claude-loaded --prune +STALE_DAYS=90 RECENT_DAYS=14 claude-loaded --prune +``` + +A file is a candidate when it has been unmodified for `STALE_DAYS` **and** its +name appears in no transcript modified within `RECENT_DAYS`. It only ever +prints; it deletes nothing, and it tells you the two commands to run yourself. +The "recently referenced" test is a filename grep across transcripts, so a +distinctive filename is judged well and a generic one (`notes.md`) is not. + +## Environment + +| Variable | Default | Effect | +|---|---|---| +| `CLAUDE_HOME` | `$HOME/.claude` | Where sessions, projects, and the global `CLAUDE.md` live. | +| `CLAUDE_CTX_MAX` | derived from the model | Context-window size used for the percentage. | +| `STALE_DAYS` | `60` | `--prune`: unmodified-for-N-days threshold. | +| `RECENT_DAYS` | `30` | `--prune`: unseen-in-transcripts-for-N-days threshold. | +| `PRUNE_HINT_MIN` | `10` | Topic-memory count above which the default view suggests `--prune`. | +| `COST_RATE_USD_M` | `1.50` | Read, but currently unused — see below. | + +Without `CLAUDE_CTX_MAX` the window is inferred from the `model` in +`~/.claude/settings.json`: a `[1m]` alias gets 1,000,000, anything else 200,000. +An unrecognized model therefore reads as 200K, so set the variable explicitly if +that is wrong for yours. + +## Exit codes + +| Code | Meaning | +|---|---| +| `0` | Ran. Under budget, in `--budget` mode. | +| `1` | Over budget, or an unknown argument. | +| `2` | Could not resolve a session. | + +## Requirements + +`bash`, `jq`, `awk`, and Linux. Session auto-resolution reads `/proc`, and the +file-age logic uses GNU `stat -c`, so neither works as-is on macOS or BSD. +Nothing here spawns `claude`, spends anything, or writes any file. + +## What it does not tell you accurately + +- **Token counts for files are estimated at 3.6 characters per token**, not + produced by a tokenizer. Treat them as relative sizes for spotting the big + rows, not as billing figures. The one exact number on the screen is "Total + context", which comes from the last API response's own usage. +- **The per-file number on `read` rows is not per-file.** The counting step + never narrows the transcript to the file it is pricing, so every `read` row + reports the same value — the total size of *all* tool output in the session — + and their sum is correspondingly inflated. Which files were read is right; + how big each one was is not. Static rows are measured on disk and are + unaffected, as are `--budget` and `--prune`. +- **Only the `Read` tool counts.** Output from Grep, Glob, Bash, and everything + else lands in "Everything else" rather than getting a row. +- **No cost figure is printed, despite `--help`.** The usage text advertises a + "cost per turn" and a `COST_RATE_USD_M` rate to compute it with. The formatter + exists in the script but nothing calls it, so setting the variable changes no + output. Read the help line as a plan, not a feature. +- **`--all` prints `startedAt` as stored.** When the harness writes it as a + number rather than a timestamp, that column shows the raw number. diff --git a/examples/claude-loaded/claude-loaded b/examples/claude-loaded/claude-loaded new file mode 100755 index 0000000..0b8742d --- /dev/null +++ b/examples/claude-loaded/claude-loaded @@ -0,0 +1,540 @@ +#!/usr/bin/env bash +# claude-loaded — inspect what's loaded into the current Claude Code session's context. +# +# Lists every file injected into the active session (CLAUDE.md files, auto-memory files, +# files Read by tools during the session), with per-file token estimates, color-coded +# size, and steady-state cost per turn. Useful for spotting context bloat. +# +# Usage: +# claude-loaded # current session (resolved via PPID) +# claude-loaded --all # list running Claude Code sessions +# claude-loaded --session # specific session by id prefix +# claude-loaded --transcript

# specific transcript path +# claude-loaded --prune # flag stale auto-memory files for cleanup +# claude-loaded --budget [CHARS] # warn when the always-loaded static set (chain +# # CLAUDE.md + auto-memory index) exceeds CHARS +# # (default 38000); silent + exit 0 when under, +# # prints offenders + exit 1 when over +# claude-loaded --cwd

# cwd for --budget (default: $PWD); no session needed +# claude-loaded --no-color # plain output +# claude-loaded --json # machine-readable +# claude-loaded --help +# +# Environment: +# CLAUDE_HOME Override ~/.claude (default: $HOME/.claude) +# CLAUDE_CTX_MAX Force the context-window size (default: derived from the model) +# STALE_DAYS Prune threshold: file unmodified for N days (default 60) +# RECENT_DAYS Prune threshold: filename unseen in transcripts for N days (default 30) +# COST_RATE_USD_M Per-1M-token cost for cache reads (default 1.50, Opus-class) + +set -uo pipefail +trap '' PIPE + +CLAUDE_HOME=${CLAUDE_HOME:-$HOME/.claude} +PROJECTS_DIR="$CLAUDE_HOME/projects" +SESSIONS_DIR="$CLAUDE_HOME/sessions" + +GREEN_MAX=2000 +YELLOW_MAX=10000 +RATE=${COST_RATE_USD_M:-1.50} + +USE_COLOR=1 +OUTPUT_JSON=0 +FORCE_TRANSCRIPT="" +FORCE_SESSION="" + +die() { echo "claude-loaded: $*" >&2; exit 1; } + +c() { + [ "$USE_COLOR" = 0 ] && { printf "%s" "$2"; return; } + case "$1" in + green) printf "\033[32m%s\033[0m" "$2" ;; + yellow) printf "\033[33m%s\033[0m" "$2" ;; + red) printf "\033[31m%s\033[0m" "$2" ;; + dim) printf "\033[2m%s\033[0m" "$2" ;; + bold) printf "\033[1m%s\033[0m" "$2" ;; + cyan) printf "\033[36m%s\033[0m" "$2" ;; + *) printf "%s" "$2" ;; + esac +} + +tok_color() { + local n=$1 + if [ "$n" -lt "$GREEN_MAX" ]; then echo green + elif [ "$n" -lt "$YELLOW_MAX" ]; then echo yellow + else echo red + fi +} + +fmt_tok() { + local n=$1 + if [ "$n" -ge 1000000 ]; then awk "BEGIN{printf \"%.1fM\", $n/1000000}" + elif [ "$n" -ge 1000 ]; then awk "BEGIN{printf \"%.1fK\", $n/1000}" + else printf "%d" "$n" + fi +} + +fmt_cost() { awk "BEGIN{printf \"\$%.4f\", $1 * $RATE / 1000000}"; } + +# Claude tokenizer ≈ 3.6 chars/token for English+code +chars_to_tokens() { echo $(( ${1:-0} * 10 / 36 )); } + +# Encode a path to its Claude projects-dir name (replace / with -) +# Claude Code project-dir encoding: replace / and _ with - +encode_cwd() { echo "$1" | sed 's|[/_]|-|g'; } + +# Resolve the auto-memory project dir for a cwd. +# +# WHY the git toplevel and not the raw cwd: the harness keys a session's +# auto-memory project to the cwd's git toplevel, so a session in /sub/dir +# is injected 's MEMORY.md. Slugging the raw cwd pointed at a per-directory +# project index that is never injected — usually a stale one, or none at all — +# so the static-budget check silently measured the wrong file and understated the +# real bundle. Not-in-a-repo cwds (e.g. $HOME) keep the raw-cwd slug. +memory_project_dir() { + local cwd=$1 root + root=$(git -C "$cwd" rev-parse --show-toplevel 2>/dev/null) + [ -n "$root" ] || root=$cwd + echo "$PROJECTS_DIR/$(encode_cwd "$root")/memory" +} + +# Find the transcript for a sessionId by globbing all project dirs +find_transcript_by_sid() { + local sid=$1 + [ -z "$sid" ] && return 1 + for f in "$PROJECTS_DIR"/*/"$sid".jsonl; do + [ -f "$f" ] && { echo "$f"; return 0; } + done + # Prefix match + for f in "$PROJECTS_DIR"/*/"$sid"*.jsonl; do + [ -f "$f" ] && { echo "$f"; return 0; } + done + return 1 +} + +# Walk up the process tree, find the Claude PID, map to session, locate transcript +resolve_transcript_from_ppid() { + local pid=$PPID + for _ in 1 2 3 4 5 6 7 8; do + [ "$pid" -le 1 ] && break + local sf="$SESSIONS_DIR/$pid.json" + if [ -f "$sf" ]; then + local sid t + sid=$(jq -r '.sessionId // empty' "$sf" 2>/dev/null) + t=$(find_transcript_by_sid "$sid") + [ -n "$t" ] && { echo "$t"; return 0; } + fi + [ -r "/proc/$pid/stat" ] || break + pid=$(awk '{print $4}' "/proc/$pid/stat" 2>/dev/null || echo 1) + done + return 1 +} + +list_sessions() { + printf "%-8s %-10s %-50s %s\n" "PID" "STARTED" "CWD" "SESSION-ID" + [ -d "$SESSIONS_DIR" ] || { echo "(no sessions dir at $SESSIONS_DIR)"; return; } + for sf in "$SESSIONS_DIR"/*.json; do + [ -f "$sf" ] || continue + local pid cwd started sid + pid=$(jq -r '.pid // empty' "$sf") + [ -d "/proc/$pid" ] || continue + cwd=$(jq -r '.cwd // empty' "$sf") + started=$(jq -r '.startedAt // empty' "$sf" | cut -dT -f2 | cut -d. -f1) + sid=$(jq -r '.sessionId // empty' "$sf") + printf "%-8s %-10s %-50s %s\n" "$pid" "$started" "${cwd:0:50}" "${sid:0:8}" + done +} + +# --- prune subcommand -------------------------------------------------------- + +prune_memory() { + local session_cwd=${1:-$HOME} + local mem_dir + mem_dir=$(memory_project_dir "$session_cwd") + local mem_index="$mem_dir/MEMORY.md" + + if [ ! -f "$mem_index" ]; then + echo "No MEMORY.md found at $mem_index" + echo "(Auto-memory is per-project. Either no memory exists for this cwd, or" + echo " auto-memory is not enabled. Nothing to prune.)" + return 0 + fi + + local now=$(date +%s) + local stale_days=${STALE_DAYS:-60} + local recent_days=${RECENT_DAYS:-30} + local cutoff_recent=$((now - recent_days * 86400)) + + echo + c bold "Memory prune analysis" + echo + c dim " Memory dir: $mem_dir" + echo + c dim " Stale if not modified in ${stale_days}d AND filename not seen in any transcript in ${recent_days}d" + echo; echo + + declare -A RECENT_HITS + while IFS= read -r t; do + [ -f "$t" ] || continue + local mtime + mtime=$(stat -c %Y "$t" 2>/dev/null || echo 0) + [ "$mtime" -lt "$cutoff_recent" ] && continue + while IFS= read -r name; do + RECENT_HITS[$name]=1 + done < <(grep -oE '[a-zA-Z0-9_-]+\.md' "$t" 2>/dev/null | sort -u) + done < <(find "$PROJECTS_DIR" -maxdepth 2 -name '*.jsonl' -type f 2>/dev/null) + + printf " %-7s %-10s %-10s %s\n" "TOKENS" "MODIFIED" "RECENT?" "FILE" + printf " %-7s %-10s %-10s %s\n" "------" "--------" "-------" "----" + + CANDIDATES=() + ROWS_OUT=() + # find, not overlapping globs: "*.md" + "archive/*.md" + "**/*.md" (no `shopt -s + # globstar` here, so bash treats "**" as a single "*") each matched the SAME + # archive/ files, so every archived candidate was counted — and listed — twice. + while IFS= read -r f; do + [ -f "$f" ] || continue + [ "$(basename "$f")" = "MEMORY.md" ] && continue + local mtime age_days name chars tok hit_label color disp + mtime=$(stat -c %Y "$f" 2>/dev/null || echo 0) + age_days=$(( (now - mtime) / 86400 )) + name=$(basename "$f") + chars=$(wc -c <"$f" 2>/dev/null | tr -d ' ') + tok=$(chars_to_tokens "$chars") + if [ -n "${RECENT_HITS[$name]:-}" ]; then + hit_label="yes"; color=green + else + hit_label="no"; color=red + fi + if [ "$age_days" -ge "$stale_days" ] && [ -z "${RECENT_HITS[$name]:-}" ]; then + CANDIDATES+=("$f") + fi + disp=${f/#$HOME/\~} + ROWS_OUT+=("$(printf '%08d|%s|%s|%s|%s' "$age_days" "$tok" "$color" "$hit_label" "$disp")") + done < <(find "$mem_dir" -type f -name '*.md' 2>/dev/null | sort -u) + + if [ "${#ROWS_OUT[@]}" -eq 0 ]; then + echo " (no detail memory files found)" + return 0 + fi + + IFS=$'\n' SORTED_ROWS=($(printf "%s\n" "${ROWS_OUT[@]}" | sort -r -u)) + unset IFS + for row in "${SORTED_ROWS[@]}"; do + IFS='|' read -r age tok color hit disp <<<"$row" + age=$((10#$age)) + printf " " + c "$(tok_color $tok)" "$(printf '%-7s' "$(fmt_tok $tok)")" + printf " %-10s " "${age}d ago" + c "$color" "$(printf '%-10s' "$hit")" + printf " %s\n" "$disp" + done + + echo + if [ "${#CANDIDATES[@]}" -eq 0 ]; then + c green " No prune candidates — all memory files are either fresh or recently referenced." + echo + else + c bold "Prune candidates (${#CANDIDATES[@]}):" + echo + for f in "${CANDIDATES[@]}"; do + echo " ${f/#$HOME/~}" + done + echo + c dim " Review each manually. To remove a file:" + echo + echo " rm # delete the memory file" + echo " \$EDITOR $mem_index # remove its index line" + echo + c dim " Tune thresholds: STALE_DAYS=90 RECENT_DAYS=14 claude-loaded --prune" + echo + fi +} + +# --- budget subcommand ------------------------------------------------------- +# Mirrors the harness's "CLAUDE.md over 40k-char limit" counter: the injected +# bundle is every CLAUDE.md from / down to the cwd, the global CLAUDE.md, and +# the cwd's auto-memory MEMORY.md index. Topic memory files are loaded on +# demand (when Read) and do NOT count. Needs no transcript — usable from a +# SessionStart hook. Verified against a live session's claudeMd system-reminder +# contents; if the harness changes what it injects, update this list. + +budget_check() { + local cwd=$1 budget=$2 + local -a files=() + [ -f "$CLAUDE_HOME/CLAUDE.md" ] && files+=("$CLAUDE_HOME/CLAUDE.md") + local dir=$cwd + while [ "$dir" != "/" ] && [ -n "$dir" ]; do + [ -f "$dir/CLAUDE.md" ] && files+=("$dir/CLAUDE.md") + dir=$(dirname "$dir") + done + local mem_dir + mem_dir=$(memory_project_dir "$cwd") + [ -f "$mem_dir/MEMORY.md" ] && files+=("$mem_dir/MEMORY.md") + local total=0 chars rows=() + local f + for f in "${files[@]:-}"; do + [ -z "$f" ] && continue + chars=$(wc -c <"$f" 2>/dev/null | tr -d ' ') + total=$((total + chars)) + rows+=("$chars|$f") + done + [ "$total" -le "$budget" ] && return 0 + echo "[static-budget] injected bundle is ${total} chars (budget ${budget}) for $cwd" + echo "[static-budget] contributors:" + printf "%s\n" "${rows[@]}" | sort -t'|' -k1,1 -rn | while IFS='|' read -r chars f; do + printf " %6d %s\n" "$chars" "${f/#$HOME/\~}" + done + echo "[static-budget] consolidate CLAUDE.md / the MEMORY.md index before the harness 40k warning fires. Run /loaded for the full breakdown." + return 1 +} + +# --- arg parsing ------------------------------------------------------------- + +while [ $# -gt 0 ]; do + case "$1" in + --all) list_sessions; exit 0 ;; + --prune) PRUNE=1 ;; + --budget) BUDGET_MODE=1 + case "${2:-}" in [0-9]*) BUDGET_CHARS=$2; shift ;; esac ;; + --cwd) FORCE_CWD=${2:-}; shift ;; + --help|-h) sed -n '2,28p' "$0" | sed 's/^# \?//'; exit 0 ;; + --no-color) USE_COLOR=0 ;; + --json) OUTPUT_JSON=1; USE_COLOR=0 ;; + --transcript) FORCE_TRANSCRIPT=${2:-}; shift ;; + --session) FORCE_SESSION=${2:-}; shift ;; + *) die "unknown arg: $1 (try --help)" ;; + esac + shift +done + +# Budget mode needs no transcript — resolve cwd and exit on the check's status. +if [ "${BUDGET_MODE:-0}" = 1 ]; then + budget_check "${FORCE_CWD:-$PWD}" "${BUDGET_CHARS:-38000}" + exit $? +fi + +# Resolve transcript +if [ -n "$FORCE_TRANSCRIPT" ]; then + TRANSCRIPT=$FORCE_TRANSCRIPT +elif [ -n "$FORCE_SESSION" ]; then + TRANSCRIPT=$(find_transcript_by_sid "$FORCE_SESSION") +else + TRANSCRIPT=$(resolve_transcript_from_ppid) +fi + +if [ -z "${TRANSCRIPT:-}" ] || [ ! -f "$TRANSCRIPT" ]; then + echo "claude-loaded: couldn't resolve session." >&2 + echo "" >&2 + echo "If you're running this from inside a Claude Code session, your shell" >&2 + echo "should be a descendant of the 'claude' process. From outside, pass" >&2 + echo "--session or --transcript ." >&2 + echo "" >&2 + list_sessions >&2 + exit 2 +fi + +# Discover the session's cwd (for project CLAUDE.md walk and per-project memory) +SESSION_CWD=$(jq -r 'select(.cwd) | .cwd' "$TRANSCRIPT" 2>/dev/null | tail -1) +SESSION_CWD=${SESSION_CWD:-$HOME} + +if [ "${PRUNE:-0}" = 1 ]; then + prune_memory "$SESSION_CWD" + exit 0 +fi + +# Accurate total context tokens from last API call +TOTAL=$(jq -r 'select(.message.usage) | .message.usage | (.input_tokens // 0) + (.cache_creation_input_tokens // 0) + (.cache_read_input_tokens // 0)' "$TRANSCRIPT" 2>/dev/null | tail -1) +TOTAL=${TOTAL:-0} + +# Context window is model-dependent, NOT a fixed constant: a "[1m]" alias (e.g. +# "opus[1m]", "claude-fable-5[1m]") gets the 1M-token extended window; every other +# model tops out at the standard 200K. Reading a 200K session's TOTAL against a +# hardcoded 1M denominator understated its %-of-window by 5x. CLAUDE_CTX_MAX +# forces an explicit value (e.g. for a model this heuristic doesn't recognize). +if [ -n "${CLAUDE_CTX_MAX:-}" ]; then + CTX_MAX=$CLAUDE_CTX_MAX +else + MODEL=$(jq -r '.model // empty' "$CLAUDE_HOME/settings.json" 2>/dev/null) + case "$MODEL" in + *\[1m\]*) CTX_MAX=1000000 ;; + *) CTX_MAX=200000 ;; + esac +fi + +# Build static-load list +declare -a STATIC_FILES +add_if_exists() { [ -f "$1" ] && STATIC_FILES+=("$1"); } +add_if_exists "$CLAUDE_HOME/CLAUDE.md" +dir=$SESSION_CWD +while [ "$dir" != "/" ] && [ -n "$dir" ]; do + [ -f "$dir/CLAUDE.md" ] && STATIC_FILES+=("$dir/CLAUDE.md") + dir=$(dirname "$dir") +done +MEM_DIR=$(memory_project_dir "$SESSION_CWD") +MEM_INDEX="$MEM_DIR/MEMORY.md" +add_if_exists "$MEM_INDEX" +# Topic memory files are NOT statically injected — the harness loads only the +# MEMORY.md index; topic files enter context on demand when Read. Summarize +# them separately instead of miscounting them as static. +MEM_TOPIC_COUNT=0; MEM_TOPIC_CHARS=0 +for _mf in "$MEM_DIR"/*.md; do + [ -f "$_mf" ] || continue + [ "$(basename "$_mf")" = "MEMORY.md" ] && continue + MEM_TOPIC_COUNT=$((MEM_TOPIC_COUNT + 1)) + MEM_TOPIC_CHARS=$((MEM_TOPIC_CHARS + $(wc -c <"$_mf" 2>/dev/null | tr -d ' '))) +done + +# Files Read during the session +declare -A READ_FILES +while IFS= read -r path; do + [ -z "$path" ] && continue + case "$path" in /*) ;; *) continue ;; esac + chars=$(jq -r --arg p "$path" ' + select(.message.content) | + .message.content[]? | + select(.type=="tool_result") | + .content // "" | + if type=="array" then map(.text // "") | join("") else tostring end + ' "$TRANSCRIPT" 2>/dev/null | awk 'BEGIN{n=0} {n+=length($0)} END{print n}') + prev=${READ_FILES[$path]:-0} + [ "${chars:-0}" -gt "$prev" ] && READ_FILES[$path]=${chars:-0} +done < <(jq -r ' + select(.message.content) | + .message.content[]? | + select(.type=="tool_use" and .name=="Read") | + .input.file_path // empty +' "$TRANSCRIPT" 2>/dev/null | sort -u) + +declare -a ROWS +for f in "${STATIC_FILES[@]:-}"; do + [ -z "$f" ] && continue + chars=$(wc -c <"$f" 2>/dev/null | tr -d ' ') + tok=$(chars_to_tokens "$chars") + ROWS+=("$tok|static|$f") +done +for f in "${!READ_FILES[@]}"; do + tok=$(chars_to_tokens "${READ_FILES[$f]}") + ROWS+=("$tok|read|$f") +done + +if [ "${#ROWS[@]}" -eq 0 ]; then + SORTED=() +else + IFS=$'\n' SORTED=($(printf "%s\n" "${ROWS[@]}" | sort -t'|' -k1,1 -rn)) + unset IFS +fi + +if [ "$OUTPUT_JSON" = 1 ]; then + printf '{"transcript":"%s","cwd":"%s","total_tokens":%s,"ctx_max":%s,"files":[' "$TRANSCRIPT" "$SESSION_CWD" "$TOTAL" "$CTX_MAX" + first=1 + for row in "${SORTED[@]:-}"; do + [ -z "$row" ] && continue + tok=${row%%|*}; rest=${row#*|}; cat=${rest%%|*}; path=${rest#*|} + [ "$first" = 1 ] || printf "," + printf '{"tokens":%s,"category":"%s","path":"%s"}' "$tok" "$cat" "$path" + first=0 + done + printf ']}\n' + exit 0 +fi + +echo +c bold "Session: "; echo "$(basename "$TRANSCRIPT" .jsonl | cut -c1-8) ($SESSION_CWD)" +echo +c bold "Files loaded into context:" +echo +printf " %-7s %-7s %s\n" "TOKENS" "TYPE" "PATH" +printf " %-7s %-7s %s\n" "------" "----" "----" + +static_sum=0; read_sum=0 +for row in "${SORTED[@]:-}"; do + [ -z "$row" ] && continue + tok=${row%%|*}; rest=${row#*|}; cat=${rest%%|*}; path=${rest#*|} + color=$(tok_color "$tok") + tok_str=$(fmt_tok "$tok") + disp=${path/#$HOME/\~} + printf " " + c "$color" "$(printf '%-7s' "$tok_str")" + printf " " + if [ "$cat" = "static" ]; then + c dim "$(printf '%-7s' "static")" + static_sum=$((static_sum + tok)) + else + c cyan "$(printf '%-7s' "read")" + read_sum=$((read_sum + tok)) + fi + printf " %s\n" "$disp" +done + +if [ "${MEM_TOPIC_COUNT:-0}" -gt 0 ]; then + c dim " (+ ${MEM_TOPIC_COUNT} topic memory files, ${MEM_TOPIC_CHARS} chars on disk — loaded on demand when Read, not in static context)" + echo +fi + +# The prune hint used to live in the skill's SKILL.md as "if the inspection shows +# >=10 auto-memory files, suggest --prune" — a fixed threshold test that needed no +# model to evaluate. Emitting it here makes the advice unmissable and identical every +# run, and lets the skill be a pure wrapper. +PRUNE_HINT_MIN=${PRUNE_HINT_MIN:-10} +if [ "${MEM_TOPIC_COUNT:-0}" -ge "$PRUNE_HINT_MIN" ]; then + c yellow " ${MEM_TOPIC_COUNT} auto-memory files — run 'claude-loaded --prune' to flag stale ones" + c dim " (unmodified >=${STALE_DAYS:-60}d and unreferenced in any transcript for >=${RECENT_DAYS:-30}d)" + echo + echo +fi + +echo +sum_listed=$((static_sum + read_sum)) + +if [ "$TOTAL" -gt 0 ]; then + overhead=$((TOTAL - sum_listed)) + [ "$overhead" -lt 0 ] && overhead=0 + # Color thresholds for the breakdown sums. + # Static is loaded EVERY turn forever — most expensive long-run; tightest thresholds. + # Read is per-session, less compounding; medium thresholds. + static_color=green + [ "$static_sum" -ge 5000 ] && static_color=yellow + [ "$static_sum" -ge 15000 ] && static_color=red + read_color=green + [ "$read_sum" -ge 10000 ] && read_color=yellow + [ "$read_sum" -ge 50000 ] && read_color=red + # Total: percentage of context window, with an absolute floor (anything ≥100K is yellow regardless) + pct=$(awk "BEGIN{printf \"%.1f\", $TOTAL*100/$CTX_MAX}") + pct_color=green + { [ "$TOTAL" -ge 100000 ] || awk "BEGIN{exit !($TOTAL*100/$CTX_MAX >= 25)}"; } && pct_color=yellow + { [ "$TOTAL" -ge 300000 ] || awk "BEGIN{exit !($TOTAL*100/$CTX_MAX >= 50)}"; } && pct_color=red + + c bold "Breakdown (sums to total):" + echo + printf " %-42s " "Files listed above (CLAUDE.md + memory):" + c "$static_color" "$(printf '%8s' "$(fmt_tok $static_sum)")"; echo + printf " %-42s " "Files Read by tools this session:" + c "$read_color" "$(printf '%8s' "$(fmt_tok $read_sum)")"; echo + printf " %-42s %8s\n" "Everything else (system + tools + conv):" "$(fmt_tok $overhead)" + printf " %-42s %8s\n" "" "--------" + printf " %-42s " "Total context:" + c "$pct_color" "$(printf '%8s' "$(fmt_tok $TOTAL)")" + printf " / %s (%s%%)\n" "$(fmt_tok $CTX_MAX)" "$pct" + echo + c dim " Sum thresholds — Static: "; c green "<5K"; c dim " "; c yellow "<15K"; c dim " "; c red "≥15K" + echo + c dim " Read: "; c green "<10K"; c dim " "; c yellow "<50K"; c dim " "; c red "≥50K" + echo + c dim " Total: "; c green "<25%"; c dim " "; c yellow "<50% or ≥100K"; c dim " "; c red "≥50% or ≥300K" + echo + c dim " \"Everything else\" = system prompt + tool schemas + conversation messages." + echo +else + c bold "Files identified above:" + echo + printf " %-42s %8s\n" "Static (CLAUDE.md + memory):" "$(fmt_tok $static_sum)" + printf " %-42s %8s\n" "Read by tools this session:" "$(fmt_tok $read_sum)" + echo + c yellow " Total context unknown — no assistant turn recorded yet in this session." + echo + c dim " Re-run /loaded after the next response to see the full breakdown (system prompt + tool schemas + conversation are only measured when the API returns usage data)." + echo +fi +c dim " Color per-file: "; c green "green"; c dim " <2K "; c yellow "yellow"; c dim " <10K "; c red "red"; c dim " ≥10K" +echo From 997b94f6d37edaaacd6bd13cb340c4f73014261d Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Sun, 9 Aug 2026 10:24:46 -0400 Subject: [PATCH 2/2] fix(claude-loaded): size read rows per file, print the advertised cost, format STARTED The per-file "read" accounting passed --arg p "$path" into a jq filter that never referenced $p, so it summed every tool_result in the transcript once per path. Every read row printed the same number -- the whole session's tool output -- and their sum overshot the real total, clamping "Everything else" to 0. A tool_result carries only its tool_use_id, so the path has to come from the matching tool_use in the preceding assistant message: pass 1 maps id -> path for Read calls, pass 2 maps id -> result size, awk joins them and keeps the largest result per path. It also drops the per-path re-scan of the whole transcript, so a 4.3MB transcript now reports in 0.4s. fmt_cost was defined and never called, so COST_RATE_USD_M changed no output while --help advertised "steady-state cost per turn". Wired it up rather than deleting it: the whole window is re-sent as a cache read every turn, so TOTAL at the cache-read rate is a well-defined per-turn floor, and the env var is already documented. The rate is now validated, because it is interpolated into an awk program and a non-numeric value is injected awk code. --all printed startedAt raw: the harness writes epoch milliseconds and cut -dT is a no-op on a number. Both shapes are handled now. README and CHANGELOG drop the three known-issue entries and keep the honest residue: the estimate is 3.6 chars/token, a read row measures the tool result rather than the file on disk, and the cost line is an estimate at one rate. --- CHANGELOG.md | 15 +++--- examples/claude-loaded/README.md | 35 +++++++------ examples/claude-loaded/claude-loaded | 74 +++++++++++++++++++++------- 3 files changed, 83 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc58092..e6b83f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,13 +22,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - **`examples/claude-loaded/`** — a standalone bash context inspector. It lists the files loaded into a Claude Code session (the `CLAUDE.md` chain, the auto-memory index, and every file a `Read` pulled in) with a per-file token - estimate, and its `--budget` mode checks just the re-sent-every-turn set - against a ceiling with no session or transcript needed, so it can run from a - `SessionStart` hook. It does not import `claude_batch_runner`. Its README - documents the three things it reports badly: `read` rows all carry the same - (whole-session) size rather than a per-file one, token counts are a - 3.6-chars-each estimate, and the "cost per turn" its `--help` advertises is - never printed. + estimate and a steady-state cost per turn, and its `--budget` mode checks just + the re-sent-every-turn set against a ceiling with no session or transcript + needed, so it can run from a `SessionStart` hook. It does not import + `claude_batch_runner`. A `read` row is sized by joining each `tool_result` + back to the `tool_use` that requested it, so the number is that file's own; + the README is explicit that it measures the tool result rather than the file + on disk, that token counts are a 3.6-chars-each estimate rather than a + tokenizer's, and that the cost line is an estimate at a rate you supply. - **`cwd` and `permission_mode` on `call_agent`** — two optional keyword-only arguments. `cwd` runs the `claude` subprocess in that directory, validated to exist first so an unusable path raises `AgentError` before a process starts diff --git a/examples/claude-loaded/README.md b/examples/claude-loaded/README.md index c0f6016..92c8c19 100644 --- a/examples/claude-loaded/README.md +++ b/examples/claude-loaded/README.md @@ -34,12 +34,15 @@ Breakdown (sums to total): Everything else (system + tools + conv): 31.0K -------- Total context: 52.5K / 200.0K (26.3%) + Cost per turn (cache read @ $1.50/M): $0.0788 ``` `static` rows are re-sent every turn. `read` rows entered the window once, when a tool read them. "Everything else" is the remainder after subtracting the listed files from the total the API actually reported — system prompt, tool -schemas, and the conversation itself. +schemas, and the conversation itself. The last line prices the steady state: +every turn re-sends the whole window as a cache read, so that is the floor for +one more turn at `COST_RATE_USD_M`. ## How it finds your session @@ -64,7 +67,7 @@ If it can't resolve a session it prints the running-session list and exits `2`. | Flag | What it does | |---|---| | *(none)* | The breakdown above, for the current session. | -| `--all` | List running Claude Code sessions: PID, cwd, session id. | +| `--all` | List running Claude Code sessions: PID, start time, cwd, session id. | | `--session ` | Use this session id (prefix match). | | `--transcript ` | Use this transcript file directly. | | `--prune` | Report auto-memory files that look abandoned. | @@ -130,7 +133,7 @@ distinctive filename is judged well and a generic one (`notes.md`) is not. | `STALE_DAYS` | `60` | `--prune`: unmodified-for-N-days threshold. | | `RECENT_DAYS` | `30` | `--prune`: unseen-in-transcripts-for-N-days threshold. | | `PRUNE_HINT_MIN` | `10` | Topic-memory count above which the default view suggests `--prune`. | -| `COST_RATE_USD_M` | `1.50` | Read, but currently unused — see below. | +| `COST_RATE_USD_M` | `1.50` | Per-1M-token cache-read price used for the cost line. Must be a number. | Without `CLAUDE_CTX_MAX` the window is inferred from the `model` in `~/.claude/settings.json`: a `[1m]` alias gets 1,000,000, anything else 200,000. @@ -142,7 +145,7 @@ that is wrong for yours. | Code | Meaning | |---|---| | `0` | Ran. Under budget, in `--budget` mode. | -| `1` | Over budget, or an unknown argument. | +| `1` | Over budget, an unknown argument, or a non-numeric `COST_RATE_USD_M`. | | `2` | Could not resolve a session. | ## Requirements @@ -157,17 +160,17 @@ Nothing here spawns `claude`, spends anything, or writes any file. produced by a tokenizer. Treat them as relative sizes for spotting the big rows, not as billing figures. The one exact number on the screen is "Total context", which comes from the last API response's own usage. -- **The per-file number on `read` rows is not per-file.** The counting step - never narrows the transcript to the file it is pricing, so every `read` row - reports the same value — the total size of *all* tool output in the session — - and their sum is correspondingly inflated. Which files were read is right; - how big each one was is not. Static rows are measured on disk and are - unaffected, as are `--budget` and `--prune`. +- **A `read` row measures the tool result, not the file on disk.** The size + comes from the `tool_result` the `Read` returned, which is what actually + entered the window — line-number prefixes and any appended notice included — + so it runs a little over the file's own byte count. A partial read (`offset` / + `limit`) is measured as the slice that was returned, and a file read more than + once is counted at its largest single result rather than the sum, on the + assumption that the re-read covered the same ground. - **Only the `Read` tool counts.** Output from Grep, Glob, Bash, and everything else lands in "Everything else" rather than getting a row. -- **No cost figure is printed, despite `--help`.** The usage text advertises a - "cost per turn" and a `COST_RATE_USD_M` rate to compute it with. The formatter - exists in the script but nothing calls it, so setting the variable changes no - output. Read the help line as a plan, not a feature. -- **`--all` prints `startedAt` as stored.** When the harness writes it as a - number rather than a timestamp, that column shows the raw number. +- **The cost line is an estimate at one rate you supply.** It assumes the whole + window is billed as a cache read every turn and multiplies by + `COST_RATE_USD_M`, whose `1.50` default is an Opus-class figure that will go + stale. It is not a bill, and it does not know about output tokens, cache + writes, or your plan. diff --git a/examples/claude-loaded/claude-loaded b/examples/claude-loaded/claude-loaded index 0b8742d..1c7b58d 100755 --- a/examples/claude-loaded/claude-loaded +++ b/examples/claude-loaded/claude-loaded @@ -45,6 +45,10 @@ FORCE_SESSION="" die() { echo "claude-loaded: $*" >&2; exit 1; } +# RATE is interpolated into an awk program (fmt_cost), so a non-numeric value is +# an awk syntax error at best and injected awk code at worst. +case "$RATE" in ''|.|*[!0-9.]*|*.*.*) die "COST_RATE_USD_M must be a number: $RATE" ;; esac + c() { [ "$USE_COLOR" = 0 ] && { printf "%s" "$2"; return; } case "$1" in @@ -139,7 +143,15 @@ list_sessions() { pid=$(jq -r '.pid // empty' "$sf") [ -d "/proc/$pid" ] || continue cwd=$(jq -r '.cwd // empty' "$sf") - started=$(jq -r '.startedAt // empty' "$sf" | cut -dT -f2 | cut -d. -f1) + # startedAt is written either as an ISO-8601 string or as epoch milliseconds; + # slicing on "T" is a no-op on the number and printed it raw. + started=$(jq -r '.startedAt // empty' "$sf") + case "$started" in + '') ;; + *[!0-9]*) started=${started#*T}; started=${started%%.*}; started=${started%Z} ;; + *) [ "${#started}" -ge 12 ] && started=$((started / 1000)) + started=$(date -d "@$started" +%H:%M:%S 2>/dev/null || echo "$started") ;; + esac sid=$(jq -r '.sessionId // empty' "$sf") printf "%-8s %-10s %-50s %s\n" "$pid" "$started" "${cwd:0:50}" "${sid:0:8}" done @@ -385,26 +397,48 @@ for _mf in "$MEM_DIR"/*.md; do MEM_TOPIC_CHARS=$((MEM_TOPIC_CHARS + $(wc -c <"$_mf" 2>/dev/null | tr -d ' '))) done -# Files Read during the session +# Files Read during the session. +# +# WHY the two-pass join: a tool_result carries only its tool_use_id, never the +# path it came from. The path lives in the matching tool_use block of the +# preceding assistant message. Pass 1 maps id -> path for Read calls, pass 2 +# maps id -> result size, and awk joins them, keeping the largest result per +# path (a file read twice is in context once). Sizing a path by re-scanning +# every tool_result instead — with no filter tying results to that path — +# charged EVERY path the total of ALL tool output in the session, so every read +# row printed the same inflated number and their sum overshot the real total. declare -A READ_FILES -while IFS= read -r path; do +while IFS=$'\t' read -r path chars; do [ -z "$path" ] && continue case "$path" in /*) ;; *) continue ;; esac - chars=$(jq -r --arg p "$path" ' - select(.message.content) | - .message.content[]? | - select(.type=="tool_result") | - .content // "" | - if type=="array" then map(.text // "") | join("") else tostring end - ' "$TRANSCRIPT" 2>/dev/null | awk 'BEGIN{n=0} {n+=length($0)} END{print n}') - prev=${READ_FILES[$path]:-0} - [ "${chars:-0}" -gt "$prev" ] && READ_FILES[$path]=${chars:-0} -done < <(jq -r ' - select(.message.content) | - .message.content[]? | - select(.type=="tool_use" and .name=="Read") | - .input.file_path // empty -' "$TRANSCRIPT" 2>/dev/null | sort -u) + READ_FILES[$path]=${chars:-0} +done < <( + { + jq -r ' + select(.message.content) | + .message.content[]? | + select(.type=="tool_use" and .name=="Read") | + select(.input.file_path != null) | + "U\t" + .id + "\t" + (.input.file_path | tostring) + ' "$TRANSCRIPT" 2>/dev/null + jq -r ' + select(.message.content) | + .message.content[]? | + select(.type=="tool_result") | + "R\t" + .tool_use_id + "\t" + ( + (.content // "") | + (if type=="array" then map(.text // "") | join("") else tostring end) | + length | tostring + ) + ' "$TRANSCRIPT" 2>/dev/null + } | awk -F'\t' ' + $1=="U" { id2path[$2]=$3; if (!($3 in best)) best[$3]=0; next } + $1=="R" && ($2 in id2path) { + p=id2path[$2]; if ($3+0 > best[p]) best[p]=$3+0; next + } + END { for (p in best) printf "%s\t%d\n", p, best[p] } + ' +) declare -a ROWS for f in "${STATIC_FILES[@]:-}"; do @@ -516,6 +550,10 @@ if [ "$TOTAL" -gt 0 ]; then printf " %-42s " "Total context:" c "$pct_color" "$(printf '%8s' "$(fmt_tok $TOTAL)")" printf " / %s (%s%%)\n" "$(fmt_tok $CTX_MAX)" "$pct" + # Steady state: the whole window is re-sent as a cache read every turn, so the + # per-turn floor is TOTAL at the cache-read rate. An estimate at the configured + # rate, not a bill — set COST_RATE_USD_M when your model prices differently. + printf " %-42s %8s\n" "Cost per turn (cache read @ \$$RATE/M):" "$(fmt_cost "$TOTAL")" echo c dim " Sum thresholds — Static: "; c green "<5K"; c dim " "; c yellow "<15K"; c dim " "; c red "≥15K" echo