Agent history: sync, check, question, show¶
Two small tools turn local agent session history into one DuckDB database and read
it back, so an agent or a person can answer questions about agentic usage with ad
hoc SQL. The Python distribution habemus-papadum-agent-history
(pdum.agent_history, command pdum-history) owns the schema and the readers: it
ingests Codex homes and Claude Code configuration directories read-only and
publishes into a local DuckDB file or a MotherDuck database, incrementally. The
TypeScript package @habemus-papadum/agent-history (command agent-history) owns
everything that only needs a DuckDB connection: invariant checks, the analysis
question set, a session list and a terminal renderer for one session, against
either kind of target. The roadmap records what
was deliberately left for later.
uv run pdum-history sync --example > "$(uv run python -c 'from pdum.agent_history.config import default_config_path as p; print(p())')"
uv run pdum-history sync # every source on this machine into every target
uv run pdum-history sync --target local # one target
uv run --env-file .env pdum-history sync --target cloud --full # MotherDuck, re-reading everything
uv run pdum-history ingest claude --db ~/history/claude.duckdb # one source, one fresh file
cd typescript && pnpm -C packages/agent-history cli check ~/history/agents.duckdb
cd typescript && pnpm -C packages/agent-history cli sessions ~/history/agents.duckdb --limit 10
cd typescript && pnpm -C packages/agent-history cli show ~/history/agents.duckdb 89620c57 --blobs
cd typescript && pnpm -C packages/agent-history cli sessions ~/history/agents.duckdb --lineage 89620c57
cd typescript && pnpm -C packages/agent-history cli questions ~/history/agents.duckdb --tz America/New_York --show
cd typescript && MOTHERDUCK_TOKEN=... pnpm -C packages/agent-history cli check md:agent_history
After pnpm -r build, node packages/agent-history/dist/cli.js runs the same
commands without tsx.
Answering a question with the toolkit¶
This is the runbook for an agent, or a person, asked something about agentic usage.
- Refresh:
uv run --env-file .env pdum-history sync(or--target localwhen MotherDuck is not needed). It only reads files that changed, so running it first is cheap and makes "as of now" true. - Trust the data before quoting it:
agent-history check <db>must end with0 failing. A failingzerocheck is a planning item, not something to work around in a query. - Start from the standard set:
agent-history questions <db> --showanswers the recurring questions (cost by month, cache behaviour, tool failures, lineages, duty cycle) with the price snapshot's date stamped on every priced figure. - Go ad hoc with
agent-history sql <db> "...". The tables are inpdum-history schema; the grain is thread, turn, response, item, withusage_eventsas the deduplicated usage view andlineage_idas the unit a person calls a session. Preferitems.texttoitem_blobsfor text search on MotherDuck. - Look at individual sessions with
sessions,sessions --lineage,showandshow --blobs, and leave notes withtagso the next reader finds them. - Say what the numbers cannot: list prices are a proxy for spend, Codex usage before September 2026 comes from counter deltas, Codex command durations are process-start times, and Claude's own cost ledger includes requests the transcript never shows. The section "What the data can and cannot say" has the full list.
Sync: one command for the machine¶
pdum-history sync reads one YAML file that says what this machine ingests and
where it publishes, and does every pair in one run. Sources are Codex homes, Claude
configuration directories, or every profile of a launcher (profiles: all lists
the launcher's profile directory; a Codex profile is a Codex home, a Claude profile
keeps its native configuration under claude/). Targets are local DuckDB files and
MotherDuck databases (md:<database>, token from MOTHERDUCK_TOKEN or the
variable token_env names, so uv run --env-file .env is enough). The file lives
at the platform user configuration directory under pdum-agent-history/history.yaml,
or wherever --config or PDUM_AGENT_HISTORY_CONFIG points; sync --example
prints a starting point.
window_days: 183
sources:
- agent: codex
home: ~/.codex
- agent: codex
profiles: all
- agent: claude
dir: ~/.claude
- agent: claude
profiles: all
targets:
- name: local
duckdb: ~/history/agents.duckdb
- name: cloud
motherduck: agent_history
token_env: MOTHERDUCK_BROWSER_TOKEN
A run is incremental. The unit of work is one transcript file: a Claude session or
sub-agent file, a Codex rollout with its catalogue row. The target's ingest_ledger
remembers each unit's size, modification time and fingerprint (the catalogue's
updated_at and archived flag for Codex) per source, so unchanged files are
skipped, and new or grown files are parsed into a staging database and published by
replacing exactly that thread's rows inside one transaction. Codex logs are
appended from the target's newest log timestamp. A file that has vanished from the
agent's store is marked gone in the ledger and its rows stay: the agents' own
stores forget, this one does not, and removing history is never a side effect of a
run. After each source is published, fork edges and lineage are recomputed from the
store for that source (see Lineage below), which takes milliseconds because it reads
only thread_links and threads.
--full re-reads every present file and is the way to rebuild after a schema
change: a target holding another schema version is refused otherwise, and with
--full its tables are dropped and recreated (a tagsdb schema holding
annotations is left alone). pdum-history ingest codex|claude --db <file> still
builds one fresh file from one source; with --sync it is incremental too, and
with an md: target it always is.
Several machines publish into the same target. Every row carries the source_id
of the agent, machine and directory it came from, each machine writes only its own
rows and ledger entries, and provenance has one row per source, so a MotherDuck
database fed by a laptop and a desktop needs no coordination beyond both running
sync.
What the ingesters read and write¶
| Source | Inputs | Facts | Blobs |
|---|---|---|---|
| Codex home | state_5.sqlite catalogue, thread_history_1.sqlite projection, logs_2.sqlite, every rollout JSONL under sessions/ and archived_sessions/ |
threads, turns with model and effort from turn_context, one response per token_usage_record, token_count events with rate limits and credits, items, events, raw response items, logs, fork edges from the rollout's first line |
full item JSON in item_blobs |
| Claude Code directory | projects/<cwd>/<session>.jsonl and every file under <session>/subagents/, including workflow agents |
threads per session and per sub-agent, turns derived from prompts, one response per API message id, items from content blocks paired with their tool results, events for compaction, cost snapshots, API requests and workflow records, fork edges from session_id markers, continued-in records and fork-context-ref |
tool calls with their results and message blocks in item_blobs |
Both write the tables of one schema. Every row carries source and origin;
the usage_events view returns per-response usage where it was recorded and
positive counter deltas otherwise, and leaves out lines a file copied from
another session or a parent (origin = 'copied-context'). The contract is
exported by pdum-history schema --json; the TypeScript package keeps a copy in
typescript/packages/agent-history/schema/agent-history.schema.json, a Python
test compares the two, and CI diffs them. Bump SCHEMA_VERSION when a table,
column or view changes and regenerate the copy.
Provenance and tags¶
Every fact row carries a source_id, and a provenance row describes each
source once: a host_id minted on first use and kept under the launcher's own
data directory (pdum-agent-history/host.json, overridable with
PDUM_AGENT_HISTORY_HOST_ID), the hostname as a label that may change, the
operating-system user, the agent kind, the root directory read, the agent
versions seen, the window cutoff and the schema and ingester versions. Thread,
turn, response and item ids are already unique across machines, so files from
several machines merge by union with source_id as the discriminator. Codex
threads also carry a normalized repo from their git origin; Claude transcripts
record no origin, so their repo is null and the working directory stands in.
Tags live in a separate file beside the history file, codex.tags.duckdb for
codex.duckdb, with one tags table keyed by subject kind and stable id. They
are written by agent-history tag, never by an ingester, so they survive a
rebuild of the history file and a later move to MotherDuck:
agent-history tag ~/history/codex.duckdb thread 01a140ad project=pencil-lab reviewed
agent-history tags ~/history/codex.duckdb --kind thread
agent-history sessions ~/history/codex.duckdb --tag project=pencil-lab
agent-history show ~/history/codex.duckdb 01a140ad # header lists the thread's tags
agent-history sql ~/history/codex.duckdb "select * from tagsdb.tags"
Lineage: a session is not a file¶
Claude Code starts a new file whenever a session is continued after compaction or
forked, copies the inherited transcript into it with ids and timestamps intact, and
never writes a parent link. Codex names the fork parent in a rollout's first line.
Both ingesters write fork_edges and set three columns on threads: forked_from_id
(the direct predecessor), lineage_id (the root of the chain, a thread's own id on a
root) and lineage_depth. A sub-agent belongs to its session's lineage, so one
lineage_id is what a person means by "a session": every file it was continued into
and every agent it launched. created_at is the thread's first own message; the
inherited prefix dates the edge, not the thread.
Edge kind |
evidence |
Meaning |
|---|---|---|
continuation |
marker, marker+continued-in, continued-in |
A Claude session continued or forked from the parent. Since Claude Code 2.1.199 every copied message keeps its producer's id in session_id, and the ingester checks that each marked uuid really exists in the named file, so the marker decides ownership and names the direct parent. From 2.1.220 the parent also writes a continued-in record, which confirms the edge or stands alone when the successor file is gone. |
subagent-fork |
fork-context-ref |
A sub-agent that inherits its session's context by reference; inherited_records is the referenced length and fork_point_ts the parent message it starts after. |
fork |
rollout |
A Codex thread whose session_meta names forked_from_id. |
A continued session answers the prompt it inherited with responses of its own, so an
inherited turn says nothing about its responses: a message is a copy only when its
marker says so, or when it sits before the file's first own-marked message. Files
without any marker are taken at face value and counted by the
claude-threads-without-markers check; here those are Agent SDK sessions
(sdk-ts, sdk-cli), which write no markers and copy nothing. One case needs the
store: parallel sub-agents launched by one call can begin with the same responses
after their own prompt, and their files cannot say so. After each publish the
earliest-started thread of a source keeps such a response and the other copies are
marked, so usage counts it once and the duplicate check stays at zero. Copied responses and items carry
origin = 'copied-context' and their session_id names the producer, which is why a
lineage's usage is the plain sum over its threads without double counting.
agent-history sessions --lineage <id> lists one lineage with its sub-agents, show
prints continues and continued in lines, and question Q23 ranks lineages.
Policy: window, drift, invariants¶
The ingesters model the last 183 days by default (--window-days, 0 for all).
Older threads appear in coverage with in_window = false and are otherwise
skipped; older storage formats are counted, not adapted. agent-history check
prints invariants. Those marked zero must be zero: schema version, unknown
line types inside the window, duplicate response ids, orphan rows, threads
without a lineage, fork edges whose child started before its parent. Those marked
info are numbers worth a glance: copied context, open tool items, counter
decreases, continued lineages, edges with a missing endpoint, Claude files without
markers, Claude cost snapshots that disagree with the transcript. A failing check is a
planning item for a separate session, not something to patch inline.
What the data can and cannot say¶
- Codex records usage per model response only since September 2026; older threads expose cumulative counters, and the delta fallback reproduces the per-response totals within about two percent in aggregate.
- Codex command items from unified exec complete at process startup, so their
item duration is zero for most commands. Tool wall time comes from the raw
call and output pairs in
response_items. - A Codex home that only ran
codex exechas rollouts but no materialized projection. Its items are then built from the rollout'sitem_completedevents (origin = 'rollout'), after normalizing the core's item shape to the v2 shape; the summary notes say when that happened. - Claude Code files copy earlier context verbatim when a session continues, ids
included. The
session_idmarker on each message settles what a file owns (see Lineage above), and ownership never needs another file, which is what makes one file the unit of incremental work. Sub-agents reuse their parent's prompt id for their own first turn, so turn ids are never used for ownership. Acontinued-inrecord is stamped when the successor starts, often days later, so it neither ends the thread nor extends its last turn. - Claude's own
cost-stateledger starts with the session process and is carried into the sessions that continue it, so the check compares a snapshot with the responses of its ancestor chain, each ancestor up to the branch point, between the ledger's start and the snapshot. Snapshots carry no timestamp, so the last response before one stands in. With no away summary and no compaction in the chain, most snapshots then agree; otherwise the ledger reads higher by roughly one context's worth of cache reads per away summary or compaction (correlation with the excess 0.82 and 0.64 here), because the summarisation requests behind them, like title generation and retries, are never written as responses. Some sessions also restart the ledger when resumed in a new process. The check stays aninfocount. - Cost is a list-price proxy from a trimmed snapshot of the LiteLLM price table
(
src/litellm-snapshot.jsonin the TypeScript package), which carries the one-hour cache-write rate for Claude models and the long-context rate for the GPT-6 models. Every priced output is stamped withlitellm <fetch date>;agent-history prices refreshrewrites the snapshot andprices show <model>prints the resolved rates. Claude usage is split into five-minute and one-hour cache writes (cache_write_1h_input_tokens), which matters: about a third of Claude cache-creation tokens here are one-hour tier, and flat pricing would understate spend by roughly nine percent. Models absent from the table are reported as an unpriced share, never imputed. - Claude writes one record per content block with the usage repeated, and
sub-agent files put placeholder output counts on earlier blocks, so the
ingester keeps the member with the most output per message id. Discarded
model-fallback attempts in the
iterationsledger land inwasted_output_tokens.
Sizes and times measured on 17 October 2026¶
| Input | Files | Ingest | DuckDB file |
|---|---|---|---|
| Codex home, 221 rollouts, 791 MB | 221 | 10 s | 397 MB |
| Claude directory, 119 sessions and 430 sub-agent files, 1.3 GB | 549 | 22 s | 891 MB |
Both, plus a launcher profile, in one sync to one file |
766 | 35 s | 1.3 GB |
The same sync again with nothing changed |
0 | 0.5 s | |
The same three sources, sync --full into MotherDuck |
766 | 103 s | 0.44 GB reported |
The same sync into MotherDuck with three files changed |
3 | 13 s | |
agent-history check against MotherDuck |
2.7 s |
Blobs are most of the file. Every question in the set answers in milliseconds
except full-text search over blobs, which takes under a second locally and
nine to twelve seconds on a Standard MotherDuck instance; every other statement
there costs 25 to 80 ms of round trip, so a sync into MotherDuck spends about a
minute uploading blobs and a few seconds on everything else. Spike 036 in the
experiments workspace holds the measurements.