Metadata-Version: 2.5
Name: chsum
Version: 3.0.0
Summary: Work logs and reload-ready context from coding-agent conversations. Deterministic: no model, nothing invented.
Author: InDate
License-Expression: MIT
License-File: LICENSE
Keywords: claude,claude-code,context,transcripts,work-log
Requires-Python: >=3.10
Description-Content-Type: text/markdown

# chsum

Work logs and reload-ready context from your coding-agent conversations —
Claude Code and Codex CLI, listed and digested side by side.

**Digests and listings are never generated by a model.** Every line of that output
is either copied verbatim from a transcript or computed from it, so nothing can be
invented. That matters because the output is designed to be pasted back into a
future Claude session, where a plausible-but-wrong sentence would become ground
truth. The one exception is labelled where it appears: `chsum recap` ends in
a timeline written by a model, printed beneath the verbatim record it was written
from.

**Start from the problem you have:**

- **"What did we do yesterday?"** or **"Pick up where we left off."** — `chsum digest
  --last` writes a short file and prints its path: your prompts in order, each with
  the row it sits on, the commands that did something, and the last exchange, all
  copied or computed, no model. `--stdout` prints it instead. → [`chsum digest`](#chsum-digest)
- **What did Claude do with what I said?** — `chsum recap` quotes each of your turns
  and accounts for the gap after it: files, commands, agents and failures computed
  from the transcript, then a model's bullets for that gap alone, printed beneath
  the record they describe. One `claude -p` call per turn, stored so a window is
  paid for once. → [`chsum recap`](#chsum-recap)
- **Claude is working in another terminal and you want to know what it has done so
  far.** — a bare `chsum recap` reads the live transcript from your last prompt
  onward, failures quoted first. → [`chsum recap`](#the-bare-run)
- **A new session needs the context of an old one, without a model paraphrasing
  it.** — `chsum digest <ref> --stdout` prints the digest as a paste-ready
  artifact. → [`chsum digest`](#chsum-digest)
- **Which session was that, and which of them went anywhere?** — bare `chsum` lists
  this project's sessions with duration, prompts, files, agents and notes, dead
  ends included. → [`chsum`](#chsum-1)
- **You remember what was said, not when.** — `chsum find "…"` searches the text of
  every conversation; `--notes` searches what you noted. → [`chsum find`](#chsum-find)
- **chsum printed `01a0acf9:31` and you want that record.** — `chsum where
  01a0acf9:31` prints the `sed` command for it, resolved to a real path. Pipe it
  to `sh` to run it. → [`chsum where`](#chsum-where)
- **You need the exact command Claude ran, or its full output.** — `chsum digest
  <ref> --commands` lists every Bash call in order; `--call <id>` prints one whole.
  → [`chsum digest`](#the-row-views)
- **A subagent did the work and its report is buried.** — `--agents` prints each
  report; `<ref>/<agent-id>` addresses the agent's own digest. → [`chsum digest`](#the-row-views)
- **Something just worked and you want to find that moment later.** — `! chsum note
  "…"` files a note against the message you are on, or an earlier one by id or
  phrase. → [`chsum note`](#chsum-note)
- **A session is titled after its first question, not what it became.** — `chsum
  name "…"` renames it, in chsum and in `/resume`. → [`chsum name`](#chsum-name)
- **What did I work on this week, across every project?** — `chsum --since 7d
  --all -n 0` lists every session, under day headings. → [`chsum`](#chsum-1)
- **You read transcripts in claude-history and want your notes there too.** —
  register `chsum annotations` as its annotator. → [`chsum annotations`](#chsum-annotations)
- **`recap`'s files-touched section misses edits made outside Edit/Write.** — opt in
  to git checkpoints; the plugin's hooks run `chsum hook`. → [`chsum hook`](#chsum-hook)

Coming from 2.x: [what changed](#upgrading-to-30).

The commands, one section each:

| command | what it is for |
|---|---|
| [`chsum`](#chsum-1) | list this project's sessions, newest activity first |
| [`chsum recap`](#chsum-recap) | what happened over a window of turns, with a model-written timeline |
| [`chsum digest`](#chsum-digest) | one conversation, verbatim and computed, plus row-level views |
| [`chsum where`](#chsum-where) | a locator to the `sed` command that prints that row |
| [`chsum checkpoints`](#chsum-hook) | the git checkpoint chains this repo holds, and dropping old ones |
| [`chsum find`](#chsum-find) | locate a conversation or a note by what it said |
| [`chsum note`](#chsum-note) | mark the moment that mattered |
| [`chsum name`](#chsum-name) | rename a session to what it actually was |
| [`chsum annotations`](#chsum-annotations) | the wire claude-history calls to read and write notes |
| [`chsum hook`](#chsum-hook) | what Claude Code's hooks run, for per-turn git checkpoints |

## The idea

Your own prompts already are a faithful record of what you were trying to do.
Extracted in order they read as the story of the session — most of what a summary
would have said, without the risk:

```markdown
**m1**
> can you open the app in a browser, it is running on 3000

**m7**
> audio.worker.js:267 [Worker] Initialization failed…

**m137**
> when I change the playback rate the pitch shifts with it

**m153**
> The rate control in the toolbar no longer applies while it plays
```

Blockquoting is functional, not cosmetic: a quoted reply containing `## Summary`
would otherwise forge a section of the digest. Everything else — dates, duration,
branch, files, commands — is parsed straight out of the transcript.

## Install

As a Claude Code plugin, which brings the skill, the hooks and the code:

```
/plugin marketplace add InDate/indate-tools
/plugin install chsum@indate-tools
```

One installed tree runs everything. The hooks import the `chsum.py` sitting
beside them, so nothing has to be on `PATH`; a plugin places no command there,
and a hook naming one would fail at every tool call on a machine without it.

Where no `chsum` command resolves, the SessionStart hook names the file to run —
`python3 <plugin>/chsum.py digest --last` — so a session reaches the tool
without a second copy.

For a command of your own, symlink the plugin's file once:

```sh
ln -s ~/.claude/plugins/cache/indate-tools/chsum/<version>/chsum.py ~/.local/bin/chsum
```

Working on chsum itself takes `pipx install --editable .` from the checkout,
which puts that checkout on `PATH` and leaves the plugin's copy unused.

Requirements:

- Python 3.10+. No third-party packages, no model, no network.
- [`claude-history`](https://github.com/raine/claude-history) on your `PATH` — for
  `chsum find` only. Every other command reads the transcripts directly and spends
  no subprocess.
- `claude` on your `PATH` — for `chsum recap`'s timeline only, which it writes
  through `claude -p`. Without it a recap still prints the verbatim record and a
  one-line notice.

### Letting Claude run it

Inside a session, `! chsum …` runs the command yourself. An agent has nobody
watching to approve a Bash call, so allow the command first, in
`~/.claude/settings.json` or a project's `.claude/settings.local.json`:

```json
{ "permissions": { "allow": ["Bash(chsum:*)"] } }
```

The whole command, not just `note`: everything chsum does is read transcripts you
already have, and what it writes lands in its own store (see
[Where chsum writes](#where-chsum-writes)). Narrow it to `Bash(chsum note:*)` if
you'd rather approve the reading commands case by case.

The plugin can't set this for you — permissions come from settings files, and a
plugin that allowlisted its own shell command would be granting itself something
you never agreed to.

## Picking a conversation, and a window of it

`recap` and `digest` take the same selectors, so a window you found in
one runs in the other unchanged:

```sh
chsum digest                          # the session you are in
chsum digest ch_3654a13c              # a named one, by the ref the listing prints
chsum digest --last                   # the most recent session that isn't this one
chsum digest --last 2                 # the one before that
chsum digest --file path/to/session.jsonl
chsum digest --messages 3 10          # your turns 3 to 10
chsum digest --messages -1            # your last turn, and everything after it
chsum digest --last 2 --messages -10 -1
```

A turn is one thing you typed — or one answer you picked from the question tool,
because a decision made by menu choice still steered the session — together with
everything that followed it up to your next turn. `1` is your first and `-1` your
last; one number names one turn and two name a run of them, both ends included.
The pair resolves before it is ordered, so `2 -2` and `-2 2` name the same window,
and an index past either end clamps. The header states what the numbers resolved
to — `-10 -1 — your turns 45–54 of 54` — because a signed number alone says
nothing about where in the conversation it landed.

`--last` orders by last activity, so a session you resumed yesterday beats one you
started last week; run from inside Claude Code, the session doing the running is
excluded. Everything scopes to the current project; `--all` widens it.

A subagent has an address of its own, `<parent-ref>/<agent-id>`, taken by
`digest`:

```sh
chsum digest ch_da4e99d42e5efab11ebdedc22fb65145/a728cd49179f1a356 --stdout
```

## `chsum`

Bare `chsum` lists the project's sessions, newest activity first:

```
Thu 06 Aug 2026                        dur    prompts  files  agents  notes  digest   recap
  ch_c120431a267b202aebf0b38f6c3c1b69  5h38m  78       14     -       ⚑2     current  3/12 · 2h ago
    ↳ Plan the import pipeline from the sample files

Wed 05 Aug 2026
  ch_da4e99d42e5efab11ebdedc22fb65145  3h03m  30       12     2       -      stale    -
    ↳ Set up the dev server
      a43c4ff4401ca693e  Quieten the test suite
      a81d77b6cba4a46b3  Fix the retry backoff
  ch_b99f11b7c257dafc8b93f53480ba3804  6s     1        0      -       -      -        -
    ↳ (untitled)
```

```sh
chsum                 # the five most recent in this project
chsum -n 25           # more of them; 0 for all
chsum --since 7d      # only the last week
chsum --all           # across every project, fifteen of them, each row named
```

Listing is the default because picking is the common case, and "most recent" is
often a session you abandoned after one prompt. Dead ends are listed, not hidden
— that a session went nowhere is the answer to "where did that work go". The
header counts them: *no activity* is no file, no notable command, no agent, and
one prompt. The session running right now is listed too, tagged in progress; its
numbers trail the conversation, since Claude Code is still appending.

Subagents are named, not just counted, because "3 agents" says nothing about a
session that delegated its work — and the id is the one `chsum digest
<ref>/<id> --stdout` takes. Five deep, then a count. Five sessions too, by
default, and fifteen under `--all`, which spans every project and so lands you
in one of them at five: the listing is usually read into a context window, and
`-n 0 --all` is the whole corpus. `--all` adds a `project` column, since a row
otherwise names no project.

A `digest` column states whether a digest file covers the session — `current`,
`stale` where the transcript holds records written after the file, `-` where
there is none. A `recap` column states the turn store's coverage — `3/12 · 2h
ago` is turns holding a breakdown over turns there are — and a `✎` marks a title
you gave with `chsum name` rather than Claude Code's.

Sessions chsum's own summariser records are left out: `recap` calls `claude -p`
from the store directory, and Claude Code files each of those as a session of
its own. A run from inside that directory still lists them.

## `chsum recap`

What Claude did with what you said. A digest gives your side of the conversation
and flat lists of files and commands; a recap takes a window of your turns and,
for each one, quotes it verbatim and then accounts for everything that happened
before you spoke again: the files touched, the commands run, the agents spawned,
the failures, all computed from the transcript, and beneath those a timeline
written by `claude -p --model haiku` from a verbatim extract of that one gap.
That timeline is the one section chsum prints that a model wrote. It is labelled
as such and printed beneath the record it was written from, so a wrong sentence
can be checked against what sits above it.

```sh
chsum recap                                    # this session, since the last recap
chsum recap --last                             # the previous session, since its last recap
chsum recap --last 2 --messages -10 -1         # the last ten turns of the session before that
chsum recap ch_3654a13c --messages 3 10        # turns 3 to 10 of a specific session
chsum recap --full                             # the whole session (same as --messages 1 -1)
chsum recap --messages 3 10 --dry-run          # what it would cost, no model call
chsum recap --messages 3 10 --no-cache         # call for every chunk, store nothing
chsum recap --messages 3 10 --invalidate       # summarise again, replacing what's stored
```

### The bare run

`chsum recap` with no arguments is the session you are in, caught mid-run. Run it
from a second terminal while Claude works. It picks up from the turn after the
last one already recapped, anchors on the last thing you typed, then shows
everything since — files changed, commands run, agents at work, and what went
wrong. Failures get their own section, quoted from the actual error text, because
that is usually what you came back to find out. Like the verbatim sections the
timeline is a snapshot: the transcript trails the live screen.

Run from that second terminal there is no env var to say which session is
running, so it lists the project's recent sessions and asks — newest at the
bottom, next to the prompt, and Enter takes that one. Inside the session itself,
or piped, there is no prompt to show: it picks.

### What it costs, and what it stores

The timeline is written by several small, independent `claude -p` calls, not one
big one — the events are split into chunks at your own turn boundaries (by size
alone on the bare run) and each chunk gets its own call, run in parallel, none of
them seeing another's material or output.

`--dry-run` makes no model call: you get the verbatim record as usual, then a
breakdown of what each chunk would cost, by where its tokens come from (edits,
command output, what Claude said) and the largest single events. Character counts
are measured off the exact text that would be piped in; the token figures are
chars/4, hence the `~`. It also states the fixed cost of the call itself —
`claude -p` sends its own system prompt and tool definitions unless told not to,
so chsum strips them (`--tools ""`, `--setting-sources ""`) and supplies its own
short instructions via `--system-prompt`, which brings that fixed cost down to
~158 tokens *per call*, counted once for every chunk that actually runs.

After a real run, the terminal gets one line of what it actually cost, summed
across every chunk call — total input, cache reads and cache writes separately,
output, elapsed, and how the estimate compared. Measured from the calls' own
accounting, not guessed. It goes to stderr like the rest of the progress
chatter, so a piped document is untouched:

```
haiku: 5,974 in (5,042 cache read) · 4,866 out · 56.0s · extract estimated ~3,211, harness ~2,763
```

Each turn's bullets are kept, so recapping a window twice costs nothing the
second time. The breakdown lands in the turn store (see
[Where chsum writes](#where-chsum-writes)) once that turn's gap has closed — a
later turn bounds it, or its last reply carries a `stop_reason` that ends the
exchange. Measured on one 3-turn window: 30.1s and two model calls, then 0.4s
and none, the same document both times.

Existing is not enough to reuse. A stored breakdown is read back only when it
names the same instructions, the same model, and the same events it was written
from; change any of the three and the turn is summarised again, with the old
breakdown kept beside the new one. `--dry-run` prices only what is missing, so
`0 calls would be made` means the window is already on disk. `--no-cache` neither
reads nor writes the store; `--invalidate` skips the read and still writes, so
the result replaces what was there for that window and nothing else.

### The document

A recap spans several of your turns, so its timeline is sliced onto them: each
turn quoted verbatim, and beneath it the bullets covering what happened before
you spoke again.

```
### You said (21:43)

> ok

- **21:45** Created a new `is_typed_prompt()` helper that filters out
  `<bash-…>` records, and updated five call sites to use it.
- **21:47** Tested the fix against a live session; chsum now reports
  `prompts: 0` for the dead 2-second session and skips it.

### You said (21:50)

> commit the work
```

Placement isn't guessed: each turn's events are their own chunk (or chunks, if
the gap was large), summarised by a call that sees only that turn's own material,
so its bullets slot straight under the turn they follow — there is nothing to
place after the fact, and nothing to get wrong by copying a time out of the
extract. The verbatim sections above settle any disagreement between a bullet and
what actually happened.

The per-turn files-touched bullets come from one of two sources, and the recap
says which, counted:

```
## Files touched — 4 of 6 turns from a checkpoint

*Files for the other 2 turns come from the transcript scan, which sees
`Edit`/`Write`/`MultiEdit` only and carries each edit's line range as recorded
at the time.*
```

A git checkpoint (see [`chsum hook`](#chsum-hook)) sees every change however it
was made and its line ranges are current; the transcript scan sees neither. The
counts are measured — `0 of 6` says no checkpoint covered any turn, and does not
say why, because a hook that never ran and a reflog entry that aged out look the
same from here.

## `chsum digest`

One conversation, verbatim and computed: your prompts in order, each with the
row it sits on; the files changed and the commands that did something; the last
exchange; and the `sed` line that opens any row. Every run writes a file and prints its
path — a bare one for the session you are in, a named one (a ref, `--last`,
`--file`) for that conversation — and `--stdout` prints the document instead.
One rule for every view below, not the digest alone.

https://github.com/user-attachments/assets/93f3cdac-cf82-403a-8333-5c8e42159fd7

```sh
chsum digest                                   # this session, written
chsum digest <ch_ref>                          # that conversation
chsum digest <ch_ref> --stdout                 # print it instead of writing
chsum digest --file path/to/session.jsonl      # address by file
chsum digest <ch_ref>/<agent-id>               # one subagent's own digest
chsum digest --list                            # this project's digest files
chsum digest --list --all                      # every project's
```

`--list` reads the digest directory rather than a conversation, so it takes no
ref: one row per file, newest first, carrying the ref that reproduces it, when
it was written, the session's duration, prompts and files, and whether the file
is `current` or `stale` against the transcript. `--all` adds a project column
and the digests whose transcript is gone, which carry no project to scope by.

| Section | Source |
|---|---|
| Frontmatter — ref, title, project, branch, start, duration, counts | computed |
| **Notable** — what you filed with `chsum note`, verbatim | copied |
| **What I asked for** — your prompts, verbatim, in order, each under a line naming its row and what the turn did | copied, counts parsed from tool calls |
| **Files changed** / **Commands run** | parsed from tool calls |
| **Delegated** — one line per subagent, with its address | parsed from sidecars |
| **Where I left off** — last prompt and last reply, verbatim | copied |
| — *found by two separate backward scans, so they may be far apart and are not a Q&A pair* | |
| **Drill down** — transcript and sidecar paths, and the `sed` that opens a row | computed |

Each prompt leads, with its metadata beneath it — the row it sits on, how long
the turn ran, and how many tool calls, files, commands and replies followed it:

```
> when I do --list on a note, it prints out the entire directory which looks terrible

*`49932ac7:37` · 19s · 2 commands · 1 reply*
```

The counts come from one walk of the transcript and its sidecars, so an agent's
work counts toward the turn that launched it, and no model is asked anything.

An agent digest has the same shape minus the intent trail — an agent gets one
instruction, so **Task** is a single block. A subagent's edits and commands fold
into its parent's totals — otherwise a session that delegated everything reads as
no activity — and files no parent turn touched are marked `(agent)`.

Every reference in a digest is a row: `1f271ca8:441` is line 441 of session
`1f271ca8…`'s transcript, and `1f271ca8/a190d601:87` is line 87 of that session's
`a190d601…` sidecar. **Drill down** expands both to full paths. Nothing needs a
second tool to resolve.

Output is budgeted, because it lands in a future context window: quotes clip,
lists cap. Every truncation is marked (`[+N chars, sed the row below]`, `…and N
more`) so you always know when you're seeing a fragment.

**Commands run** shows ten, and it shows them after a filter that drops
look-only commands (`ls`, `cat`, `grep`, and 27 more) and after a dedupe. Its
overflow line therefore carries two numbers and the invocation that opens the
rest:

```
- …and 25 more of these — `chsum digest ch_c77196cc… --commands` lists all 60 in order
```

### The row views

Each writes a file of its own, `<uuid>-<view>.md` beside the digest, and prints
the path; `--stdout` prints the document instead. The same rule the digest
itself follows. `chsum digest --list` names the view beside the session each
file belongs to.

```sh
chsum digest <ch_ref> --messages               # every message in order, whole
chsum digest <ch_ref> --tools                  # every tool call in order
chsum digest <ch_ref> --commands               # every Bash call in order
chsum digest <ch_ref> --call <id>              # one tool call whole, with its output
chsum digest <ch_ref> --agents                 # every subagent and what it reported back
chsum digest <ch_ref>/<agent-id> --commands    # narrowed to that sidecar
chsum digest <ch_ref>/<agent-id> --messages    # everything that agent said
chsum digest <ch_ref> --messages --stdout      # print it rather than write it
chsum digest <ch_ref> --messages > out.md      # or your own path
```

All three print in timestamp order across the transcript and its sidecars, with
nothing filtered, deduplicated or collapsed. `--tools` and `--commands` print one
line per row — the call id, a `<session>:<line>` locator, local time, the tool
name and the first line of the call. A row runs long and lets the terminal
soft-wrap it rather than folding at a space: a command broken across lines can
no longer be copied in one selection.

`--messages` prints its rows **whole**, on every ref. Messages are prose, and a
conversation clipped to a line each is the one thing this view cannot be used
for. There is no flag or size limit behind that: the turn numbers below already
select the part of a conversation you want, and a second way to ask for less
would only be a worse one.

Every view is broken by turn, each headed with the line the digest already
prints under that prompt:

```
*turn 8 · 16m · 6 tool calls · 5 files · 108 commands · 12 replies*
```

The head names the turn a row sits in, not the row that opens one, so a view
that keeps no messages — `--tools`, `--commands` — still shows its boundaries,
and a window opening mid-conversation says which turn it landed in. `--call <id>`
prints one tool call and its captured output whole, which is where the text a
clipped call row actually lives.

`--agents` lists every subagent the session ran and each report it sent back,
numbered where an agent returned more than once. A report is the agent's own
document and runs to thousands of characters, so it is clipped with the cut
marked; the row it came from names where the whole text is.

One or two numbers beside `--messages`, `--tools` or `--commands` narrow the view
to a window of your turns and print those rows whole, so a stretch of
conversation reads without leaving chsum:

```sh
chsum digest --last --messages -1              # your last turn, and everything after it
chsum digest <ch_ref> --messages 10 11         # your 10th and 11th turns
chsum digest <ch_ref> --tools -5 -1            # what Claude called across your last five
chsum digest <ch_ref>/<agent-id> --tools 2 -1  # an agent's own turns, not yours
```

On an agent ref the turns counted are the sidecar's own: the task it was handed
is turn 1, and a prompt sent to it while it worked opens the next. Most agents
have exactly one turn, so the numbers earn their keep on a `/btw` fork that was
talked to repeatedly.

Inside a window the rows a view steps over are listed rather than skipped
silently, one line each with its own locator, so a jump from `2500` to `2513`
reads as:

```
*⋯ 4 rows not in this view*
  - `dc215bdd:2503` — thinking
  - `dc215bdd:2504` — Bash, result 319 chars
  - `dc215bdd:2508` — Bash, result 536 chars
  - `dc215bdd:2512` — thinking
```

A tool call carries its tool and the size of what it returned, and its result row
folds into it. A `thinking` row is named and nothing more — the JSONL keeps its
signature and drops the text. Harness bookkeeping rows are left out.

A `## Sources` block at the top expands every locator to a path, `~`-relative so
it fits the width and still pastes into a shell. Where the view clipped its rows
it also gives a worked `sed` line, so a row reaches its raw record without chsum:

```
## Sources

- `f1b9bbc6`  `~/.claude/projects/…/f1b9bbc6-….jsonl`
- `f1b9bbc6/a190d601`  `~/.claude/projects/…/subagents/agent-a190d601….jsonl`

A row's whole text — `sed` the line its locator names:

- `sed -n '18p' ~/.claude/projects/…/f1b9bbc6-….jsonl | jq -r '…'`

- `01CPKjYaSD`  `f1b9bbc6:26`  11:30:28  Bash  `ls && wc -l chsum.py`
```

The `sed` line is an escape hatch for text a row clipped, so a view that prints
every row whole — `--messages`, or any turn window — leaves it out. One source prints as the path alone: there is nothing for a locator to pick
out, and the bare path is a line the terminal leaves intact to copy.

## Upgrading to 3.0

Two changes break a script written against 2.x.

**Every view writes a file and prints its path.** In 2.x a bare `chsum digest`
and the row views printed their document to stdout; now they write it and stdout
carries the path alone, so `chsum digest <ref> --messages > out.md` puts a path
in that file rather than the messages. `--stdout` restores the old behaviour:

```sh
chsum digest <ref> --messages --stdout > out.md    # the document, as 2.x gave it
code $(chsum digest <ref> --messages)              # what the path is for
```

**Checkpoints are kept until dropped.** 2.x committed each turn and reset it
away, leaving it in `HEAD`'s reflog to expire in about thirty days. 3.0 chains
them under `refs/chsum/<session>`, which git keeps — they survive a `gc`, the
reflog expiring, and the removal of the worktree they were written in, and they
no longer clear themselves. `chsum checkpoints --prune 30d` is the retention
that was previously accidental, and `chsum checkpoints --migrate` rebuilds
checkpoints written by 2.x so they gain the same durability. Both are described
under [`chsum hook`](#chsum-hook).

## Which tools' sessions it reads

Claude Code sessions, from `~/.claude/projects`, and Codex CLI sessions, from
`~/.codex/sessions`. Both appear in every listing, digest and search. A `source`
column appears where the rows disagree and stays absent where one tool wrote
them all; `chsum --source codex` narrows a listing to one tool.

Claude Code writes the record shape chsum reads, so its transcripts are read
where they lie. A Codex rollout is written once into that shape under
`<data dir>/chsum/translated/codex/`, and rebuilt whenever the rollout grows or
the translation rule changes. Every line number a drill-down prints names that
translated file, so `sed -n '1454p' <path>` lands on the record the digest
quoted.

Three properties of a Codex rollout show up in the output, and each is the file
speaking rather than a fault in the reading:

- **No titles.** Codex records no title for a thread, so a row reads
  `(untitled)` until `chsum name` gives it one. That name holds in chsum; it
  does not travel back to Codex, which never reads the translated copy.
- **`0s` durations.** A thread imported into Codex carries one timestamp on
  every record, stamped when the import landed. Duration is the spread of the
  stamps, so such a session reports `0s` however many turns it holds. Of 102
  rollouts measured, 46 are imports.
- **No reasoning.** Codex encrypts model reasoning into `encrypted_content`,
  sealed server-side with no key on the machine. The plaintext `summary` beside
  it, present on roughly a third, becomes a thinking block. chsum skips thinking
  rows for both tools, so nothing printed changes.

Rollouts older than seven days are rewritten as `.jsonl.zst` by recent Codex
versions. Reading one needs a zstd decoder, and chsum holds no dependency
outside the standard library, so compressed rollouts are left alone.

Adding a third tool costs one file in `chsum/sources/`: where its sessions sit,
which directory each ran in, and a translation to the common record shape. No
reader below that layer names a format.

## `chsum where`

Every locator chsum prints — `01a0acf9:31` beside a turn, `f1b9bbc6:26` on a
row — names a line in a file. This turns one into the command that prints it:

```
$ chsum where 01a0acf9:31
sed -n '31p' /Users/joshua/.local/share/chsum/translated/codex/…/01a0acf9-….jsonl | jq
```

The command alone goes to stdout, so it pipes and substitutes:

```sh
chsum where 01a0acf9:31 | sh          # print that record
vim $(chsum where 01a0acf9 | awk '{print $4}')
```

The file it resolved to, and where the line sits in it, go to stderr — beside
the command on a terminal, out of the way in a pipe.

Four forms, all of them things chsum printed:

| you have | you type |
|---|---|
| one row | `chsum where 01a0acf9:31` |
| a run of rows | `chsum where 01a0acf9:31-40` |
| a subagent's row | `chsum where 01a0acf9/a9f0f78b:3` |
| a session, no row yet | `chsum where 01a0acf9` — prints the path and the template |
| a tool call id | `chsum where toolu_01V7rDx5Le` — resolves to the row it sits on |
| a checkpoint stamp | `chsum where <session>:<call-id>` — both halves, as the reflog holds them |

A `ch_…` ref works wherever a session id does. A line past the end of the file
stops and says how many rows the file holds, rather than handing back a `sed`
command that prints nothing.

A checkpoint commit is stamped `chsum-checkpoint: <session> @ <time> <call-id>`,
so a line read out of `git reflog` reaches the call that caused it:

```sh
git reflog --format='%gs' | grep chsum-checkpoint | head -1 |
  sed 's/.*checkpoint: \([^ ]*\) @ [^ ]* \(.*\)/\1:\2/' |
  xargs chsum where | sh
```

A bare id works too — `chsum where toolu_01V7rDx5Le` searches this project, then
every project. Naming the session beside it reads that one file directly.

## `chsum find`

Locate a conversation, or a note, by what it said. This is the one command that
runs `claude-history`, for its embedding index.

```sh
chsum find "playback rate pitch shift"         # locate a conversation
chsum find --notes "backoff"                   # notes whose text matches
chsum find --notes "backoff" --all             # across every project
chsum find "ENOENT" --lexical                  # fast, for identifiers and error text
```

`--hybrid` (default) and `--semantic` are best for conceptual recall but are slow:
tens of seconds warm, and **several minutes on the very first run** while the
embedding index builds. Use `--lexical` (sub-second) for identifiers, filenames,
and error strings, or `--exact` for exact tokens. `--all` searches every project.

Each hit carries its score, the project, and the passage that matched, clipped
around the query term. Where the ranking could not run — an unbuilt embedding
index, a transcript with no searchable metadata — the run says so rather than
printing the hits alone: `warning: semantic unavailable — …`, and a line reading
`8 hits ranked by position only (0.0147–0.0164)`, which means each score is a
reciprocal rank and the spread measures position, not how well a passage matched.

On a terminal the results are padded columns, coloured per project, with the
query term picked out in the excerpt. Everywhere else — a pipe, a capture, a
chat — the same results are markdown, so `grep` and a reading model get whole
records rather than clipped columns. Every listing follows that rule:
`sessions`, `note --list`, `recap --list` and `digest --list` print a table or a
bullet per row when stdout is not a terminal.

## `chsum note`

Files a note against the moment you're in, so the digest says which part
mattered — extraction can tell you what changed, not which of it was the point.
`chsum annotate` and `chsum mark` are the same command.

```sh
! chsum note "the backoff approach, after two dead ends"
```

It prints nothing. The note goes into chsum's own store, filed under the turn of
yours whose gap holds the message it points at, with the file, the row and the
first line of that message stamped beside it. The transcript is never written
to. Run outside a session it still files the note, against the newest transcript
of this project.

### Noting an earlier moment

List recent messages and name one:

```sh
! chsum note --recent 20
47dca7e9  06:27  you     can we retry with backoff instead of failing
be74e21f  06:40  claude  A fixed delay collides — every client retries in the same second
a27a1c9c  06:41  claude  Edit: client.py
0b2f4db2  06:42  claude  Bash: python3 -m pytest -x
! chsum note --at be74e21f "the thundering-herd point, explained properly"
```

Everything that happened, in order: both sides' messages *and* every tool call,
so you can note the edit or the command rather than the sentence near it. Tool
results are left out — a note resolves to the message containing the action
either way. Ids come from the transcript itself, and `--at <line>` takes a bare
row number too.

Or name the message by something it said:

```sh
! chsum note --match "worth knowing exactly where it dies" "the timeout gap"
```

Matching folds case, punctuation, and markdown away — `currently no` finds
`Currently **no** —`, because nobody retypes the asterisks. Notes still quote the
original bytes. If more than one message matches, chsum lists the candidates and
files nothing: asking to note a phrase puts that phrase in your own prompt too,
so "newest wins" would keep noting the request instead of its subject. `chsum
note`'s own calls are excluded from matching — its tool call is recorded before
the command runs, so otherwise every search would find itself.

### Noting while an agent is working

`!` is not available while you are addressing an agent — what you type goes to the
agent as a message. Two things do work:

- Note it afterwards, from the session: `chsum note --match "<phrase the agent
  said>"` searches the sidecars too, so the agent's own words are addressable.
- Ask the agent to note it as it goes, with the command allowed as described under
  [Install](#letting-claude-run-it). Its notes fold into the parent, like its
  edits and commands, tagged `agent <id>` with the row in that sidecar — sidecars'
  rows don't number against the parent's. Either side can delete the other's.

### Where notes show up

As **Notable** at the top of the digest, verbatim, with the message they point at;
as a `⚑` count in the listing; in `chsum find --notes <query>`; and
in claude-history's viewer (see [`chsum annotations`](#chsum-annotations)).

### Listing, locating and deleting

```sh
! chsum note --list
id            created
dde43c3c#1    2026-09-04  the timeout gap, stated plainly
  ↳ Currently **no** — and worth knowing exactly where it dies.
dde43c3c#2    2026-09-04  Testing
  ↳ Left in place — it records the state that prompted the change.

! chsum note --list --all             # every project, with a project column
! chsum note --list --full            # whole text, whole targeted message
! chsum recap --list                  # the bullets `recap` wrote, same ids
! chsum note --show dde43c3c#1        # where it landed, with what surrounds it
! chsum note --delete dde43c3c#2      # takes several ids at once
```

Every listing covers this project and `--all` widens it to every project. The
project column `--all` adds is painted a colour per project, so rows from one
project group by eye down a listing that crosses several.

The store holds two kinds and each has its own listing: `chsum note --list` is
your notes, `chsum recap --list` is the bullets `recap` wrote. A bullet is a
model's and a note is yours, and one listing holding both reads as one kind of
thing. An id is the turn's uuid and a number issued once — a bullet that `recap`
rewrites takes a fresh number, and a note keeps its own, so an id you copied out
still names the same text later. `--show` and `--delete` take an id of either
kind; deleting a recap bullet means the next recap of that turn prints without
it.

`--show` takes the same id and answers where: the file, the row in it, the time,
the agent when the message is a subagent's, then the targeted message whole and
`--context N` records either side (3 by default). It resolves across the
project, so an id from any session of it works.

**Marks made before 2.2** were recorded as a marker line in the transcript.
Nothing reads those any more; the transcripts keep them, and `claude-history`
still finds their text as conversation.

## `chsum name`

Sessions are titled by Claude Code, from the first thing you said — so a session
that started as one question and became a day's work is filed under the question.
`chsum name` fixes that:

```sh
chsum name "retry: design + build"                 # the session you're in
chsum name ch_3654a13c "retry: design + build"     # one from last week
chsum name --list                                  # renamed in this project
chsum name --list --all                            # renamed anywhere
chsum name --clear                                 # back to Claude Code's title
chsum name --no-resume "…"                         # rename in chsum only
```

Your name wins everywhere chsum shows a title — listing, digest — and is
flagged `✎` in the listing, because whose reading of the session it is matters.

It also lands in `/resume`. Claude Code's title is an `ai-title` record it appends
to the transcript as the conversation grows, dozens per session, last one wins;
`chsum name` appends one more of exactly that shape. Never a rewrite of a line
already written — the one thing chsum adds to a transcript, and it is added the
way Claude Code adds it.

That is why the name is also kept in the store's `names.json`: rename a session
that is still running and Claude Code will title it again ten minutes later.
chsum keeps yours; `/resume` may drift back. `--clear` undoes a rename by
appending Claude Code's own title back as another record, so `/resume` reverts
too. `--no-resume` renames in chsum only and leaves the transcript alone.

## `chsum annotations`

The wire [claude-history](https://github.com/raine/claude-history) calls when it
renders and searches annotations — not a command you type. Register chsum as an
annotator in `~/.config/claude-history/config.toml`:

```toml
[annotations]
write_to = "chsum"

[annotators.chsum]
command = "chsum annotations"
```

Every note and every recap bullet then shows in its viewer at the row it points
at and matches in `claude-history agent search`, tagged by kind. A note typed in
its viewer (`a`) is filed by chsum the same way `chsum note` files one, and `d`
there deletes through chsum. `chsum annotations read|write|delete` takes one JSON
object on stdin and answers with one on stdout.

A note carries `created` and `modified`, RFC 3339, which the viewer prints beside
it. A write stores the stamps it is sent and takes chsum's clock where it is sent
none. A write carrying `replaces` supersedes the note that id names: the same id
comes back where the text changed under the same turn, and claude-history issues
no delete; a different id comes back where the new targets belong under another
turn, and claude-history drops the superseded note. `delete` answers
`{"deleted": false}` for an id the store does not hold — a non-zero exit would
drop chsum from the merge and take every other note on that transcript with it.

<img src="https://raw.githubusercontent.com/InDate/chsum/main/meta/chsum_notes_in_claude-history.webp" alt="chsum notes shown at their rows in claude-history's viewer" width="800" />

## `chsum hook`

What Claude Code runs from the plugin's `hooks/hooks.json` — not a command you
type. It exists so `recap`'s files-touched section can come from git rather than
from the transcript.

```
chsum hook stop             # on every Stop: checkpoint the working tree
chsum hook session-start    # on every SessionStart: raise the opt-in, once
```

The hooks are declared by the plugin, which installs globally, so they run in
every repository — and do nothing until that repository opts in. The gate is
`.git/chsum-checkpoint`, containing `enabled` or `declined`; it is per-checkout
and never committed. When the file is absent, the SessionStart hook asks Claude
to put the question to you once, and the answer is what writes the file.

Once enabled, each tool call that changes the tree is committed under
`refs/chsum/<session>`, chained onto the previous checkpoint and tagged
`chsum-checkpoint: <session> @ <timestamp> <tool-call-id>`. The commit is built
with `git commit-tree` from a throwaway index, so **`HEAD`, your index and your
working tree are never written** — nothing here can leave a checkpoint as your
branch tip, and your own commit hooks never fire. It shows in no normal git
command: `git log`, `git branch` and `git status` are unchanged. `git log --all`
does list it, since that means every ref.

`recap` and `chsum digest --writes` read the chain and diff consecutive
checkpoints, which sees every change however it was made and carries current
line ranges; the transcript scan sees `Edit`/`Write`/`MultiEdit` only.

Because each checkpoint is parented on the last, `git show <checkpoint>` is that
call's own diff and `git log refs/chsum/<session>` is the session's history. A
ref is a gc root, so a chain survives `git gc`, survives the reflog expiring,
and survives the removal of the worktree it was written in — all three of which
lost the old reflog-based checkpoints.

That durability is why retention is asked for rather than waited for:

```sh
chsum checkpoints                   # the chains this repo holds, and the gate
chsum checkpoints --enable          # turn checkpointing on for this project
chsum checkpoints --disable         # off; chains already recorded stay readable
chsum checkpoints --prune 30d       # drop chains older than 30 days
chsum checkpoints --prune 30d --dry-run
```

The gate is per project and per worktree, held in the git directory. `--enable`
and `--disable` are both decisions, so either one stops the session-start prompt
asking again.

Dropping a chain leaves the transcript untouched and makes its commits
unreachable, which git reclaims on its next `gc`.

Checkpoints written by an older chsum sit in `HEAD`'s reflog instead. They are
still read — the two sources merge on the stamp each checkpoint carries — but
they keep the old fragility: unreachable, so a `gc` takes them, and held in the
worktree's own reflog, so `git worktree remove` takes them with it. Rebuild them
as chains to keep them:

```sh
chsum checkpoints --migrate --dry-run   # sessions still reflog-only, and counts
chsum checkpoints --migrate             # rebuild each as a chain
```

A project still holding reflog-only checkpoints is told so once at session
start, the same way the opt-in is raised, and the message names this command.

Each checkpoint is rebuilt from the tree and message it already carries, so the
content is identical and only the shas change — a parent is part of what a sha
hashes, so an existing commit cannot be re-parented. Nothing downstream resolves
a checkpoint by sha; `--writes`, `recap` and `chsum where <call-id>` all match on
the session and call id in the message. The reflog entries stay where they are,
and a second run finds nothing to do.

## Where chsum writes

Digests, your session names and the turn store share one directory:

```
<data dir>/chsum/
  digests/<uuid>.md                              `chsum digest <ref>`'s output (`--out` to change)
  digests/<uuid>-<view>.md                       a row view's output — messages, tools, commands, agents, call-<id>
  names.json                                     your names for sessions
  turns/<project>/<turn-uuid>.json               one turn's recap bullets and notes
  translated/<tool>/projects/<project>/<id>.jsonl   another tool's session, in the record shape chsum reads
```

`CHSUM_DIR` in the environment names the directory outright. Otherwise
`XDG_DATA_HOME` set puts it at `$XDG_DATA_HOME/chsum`; on Windows it is
`%LOCALAPPDATA%\chsum`; everywhere else `~/.local/share/chsum`, macOS included.
A `~/.chsum` from an earlier version moves there on the next run, once, with a
line on stderr naming both paths.

Nothing is written to a transcript, with one exception: `chsum name` appends an
`ai-title` record, the shape Claude Code itself appends. That append is skipped
for a translated session, whose file is rebuilt from its original and whose
original is never read back by chsum — the name holds in `names.json`, which is
what every listing reads.

## Which version am I running

```sh
chsum --version
chsum 2.0.0 (dc12598) · python 3.10.11 · darwin
```

The version comes from the checkout's own `pyproject.toml`, and the commit
beside it is what built it. An editable install freezes the packaged metadata at
install time and nothing refreshes it on its own, so after a version bump the
two can disagree — the line then says so and names the fix:

```
chsum 2.0.1 (1c8e002) · packaged metadata says 2.0.0 and no longer describes
what runs; `pipx install --editable . --force` from the checkout refreshes it
```

## Reporting something that looks wrong

`--debug` goes on the end of any command. It prints what that run read, ran and
resolved, beneath the normal output:

```sh
chsum digest ch_8b0a671d… --stdout --debug
```

```
--- chsum debug ---
invocation: chsum digest ch_8b0a671d… --stdout --debug
cwd: ~/src/chsum
projects: ~/.claude/projects/  (…/ below)
chsum 1.2.0 (9162a1d dirty) · python 3.10.11 · darwin · exit 0
files (1)
  ch_8b0a671d…  meta,turns  947.1K  671 recs  …/-Users-…-chsum/9a9e9ac5-….jsonl
procs (0)
steps (3)
  resolve_ref    via=argv ref=ch_8b0a671d…
  _parent_path   ref=ch_8b0a671d… uuid=9a9e9ac5-…
  _load_store    dir=-Users-…-chsum files=126 sessions=6
reproduce
  chsum digest ch_8b0a671d… --stdout
--- end chsum debug ---
```

No transcript text is copied — only paths, refs, record ids and counts — so the
block names the record behind a line that looks wrong rather than carrying it.
It assumes the reader is on the same machine: paste it into a session in the
chsum checkout and the files it names are still there to open.

`steps` is where a wrong line usually comes from. Each one is a resolution with
its inputs and its result, including the fallbacks that print nothing during a
normal run: `_stamp_turns … unstamped=126 stamped=126 transcripts=38` (turn files
from before the store was stamped, resolved against this project's transcripts),
`_turn_checkpoints … checkpoint=4 transcript=2` (two turns had no checkpoint
covering them), `_run_chunk … failed=1` (one summariser call died and its gap
says so). Repeated steps collapse in the middle, and `files` and `procs` cap at
24 rows with the remainder counted.

Without the flag nothing is recorded and nothing is printed.

## Notes on correctness

Several things here are non-obvious and were established by measuring, not assuming:

- **Duration excludes idle time.** Sessions get resumed hours or days later, so
  first-record-to-last-record wildly overstates effort — one session in the corpus
  reads as 92 hours. Gaps over 30 minutes are treated as "walked away".
- **Most "user" records aren't from you.** They're tool results, interrupts, and
  harness scaffolding. Those are filtered out; `prompts:` counts what you typed.
  A `!` run is something you did, not something you said, and is not a prompt.
- **An API error is not a reply.** A record carrying `isApiErrorMessage` — "Prompt
  is too long", a session limit — is how the session stopped, and is reported as
  the notice rather than quoted as the last thing Claude said.
- **Subagent transcripts** aren't conversations in their own right and never appear
  in the listing, matching `claude-history`'s discovery rules.
- **`claude-history` has no per-agent ref.** `--subagents` inlines agent messages
  into the parent read untagged, so they can't be sliced apart. Sidecars are
  parsed directly. Every digest reads the JSONL this way, parent included.
- **An agent's report is a record, not a tool result.** An async agent's own
  `tool_result` holds launch metadata; the report arrives later as a
  `<task-notification>` record, which is what `--agents` reads.
- **An agent's last message isn't necessarily its conclusion**, so the section is
  *Last thing it said*. An interrupted agent ends mid-thought.
- **Agent counts take the larger of two sources** — `Agent`/`Task` calls in the
  parent, and sidecars on disk. Sidecars go missing; an agent that spawns its own
  outnumbers the visible calls.
- **Scratch paths** (`/tmp`, scratchpads, plan files) are excluded from "files
  changed" so the work log shows real project changes.

## Prose, and where it's allowed

The `Summariser` seam at the bottom of `chsum.py` has its first backend:
`HaikuSummariser`, used only by `recap`, via `claude -p --model haiku` — no
SDK, no key handling, your existing Claude Code auth signs the call. A TL;DR is
the one thing extraction can't produce, and a running session is where you're
most likely to want one before the transcript catches up.

The rule for any backend: it gets the already-extracted material, and its output is
**additive** — layered on top of the verbatim record so a wrong sentence can always
be checked against the quotes beneath it.

If you do go local, note that the model in `mlx-community/DeepSeek-R1-Distill-Qwen-14B-MLX`
is **139 GB** of unquantised weights. The 4-bit build is `…-14B-4bit` at 8.32 GB. On a
16 GB machine the binding constraint is KV cache, not context length: this architecture
costs 192 KB/token at fp16 (96 KB with `kv_bits=8`), so after 8.32 GB of weights you get
roughly 18k–36k tokens of usable input, not the 131k the config advertises.
