thread-keeper
A local-first Python CLI that automatically collects the logs of how you work with AI coding agents (Claude Code, Codex, Cursor), normalizes them into one queryable SQLite store, and opens them in Jupyter — so you own your agent-usage data and can find where your instructions or setup are wasteful or inconsistent.
No web UI, no daemon, no cloud, no network calls. Just: collect → normalize → analyze.
The product name is thread-keeper. The PyPI / install distribution name is
thk. The Python import is threadkeeper.
How you run the CLI depends on the install path:
uv tool install thk/pip install thk→thkorthread-keeperon PATHuvx thk …→ one-shot; prefix every invocation withuvx thk- clone /
uv sync→uv run thread-keeper(oruv run thk)
See docs/PRD.md for the full product spec and
docs/contracts/ for the JSON Schemas of every shape that
crosses a boundary.
Install
# From PyPI (puts `thk` and `thread-keeper` on PATH):
uv tool install thk
# or: pip install thk
# From a clone (editable):
git clone <this-repo>
cd thread-keeper
uv sync
# Optional: put `thread-keeper` / `thk` on PATH for agent hooks (required
# before install-hooks if you want a bare name; install-hooks also writes an
# absolute path so SessionEnd works after `uv sync` alone).
uv tool install -e .
Requires Python ≥3.11 (pinned to 3.14 in .python-version) and
uv.
Quickstart
# Backfill your ENTIRE existing history from Claude Code / Codex / Cursor —
# no "only since install" cutoff.
#
# After `uv tool install thk` (or `pip install thk`):
thk collect --sweep
# One-shot without installing on PATH:
# uvx thk collect --sweep
# From a clone (no tool install):
# uv run thread-keeper collect --sweep
# See what's in the store.
thk status
# uvx thk status
# uv run thread-keeper status
# Optional: write SessionEnd / notify hook configs (backs up existing files
# first). Commands use an absolute path to this install so they work even
# when the CLI is not on PATH. Revert with `uninstall-hooks`.
thk install-hooks
# thk uninstall-hooks
# uvx thk install-hooks
# uv run thread-keeper install-hooks
import threadkeeper as tk
sessions = tk.sessions() # DataFrame + has_usage / cost_usd / token cols
sessions.loc[sessions["has_usage"]].sort_values("cost_usd", ascending=False)
tk.usage_long().groupby("model")["cost_usd"].sum()
sid = sessions.iloc[0]["id"]
messages = tk.messages(sid) # DataFrame, one row per normalized event
tk.cost_of(sessions.iloc[0]["usage"])
API note (cost_of / cost_breakdown_of): these return None when usage is
missing/empty or every model in the blob is unpriced (unknown ≠ $0; previously
empty usage returned 0.0). Prefer sessions["cost_usd"] / filter has_usage, or
handle None before arithmetic.
Starter analysis notebooks live in notebooks/ — see that
folder's README for setup with Jupyter.
Session timing columns
tk.sessions() splits each session's wall-clock into mutually-exclusive buckets
so you define "performance" yourself — throughput, latency, tool overhead —
instead of the library imposing one. Where a session has tool events and
message timestamps are monotonic in seq order,
human_idle_sec + model_sec + tool_exec_sec equals the message-timestamp span
(which is session_span_sec when started_at/ended_at match first/last
message times). Out-of-order clocks clip negative gaps to zero, so the sum can
undershoot.
| column | definition |
|---|---|
human_idle_sec |
Time waiting on you — Σ gaps before a genuine human message. Excluded from agent-active time. |
model_sec |
Agent working: model latency + generation + thinking, including API errors/retries. |
tool_exec_sec |
Tool run time (tool_use → tool_result). |
active_sec |
Agent-active wall clock = session_span_sec − human_idle_sec. |
session_span_sec |
ended_at − started_at. |
n_turns |
Count of genuine human turns. |
n_tool_calls |
Count of tool_use events. |
"Genuine human" excludes harness injections (Stop-hook feedback, task
notifications, isMeta, …), flagged per message by messages.injected — without
it, those injected user rows would be miscounted as human turns.
tool_exec_sec / n_tool_calls are NaN, never 0, for a source that cannot
reliably observe tools (e.g. Cursor) when a session shows none — an honest
"unmeasurable," not a false zero.
Compose your own metric from the raw columns — e.g. throughput on model time:
s = tk.sessions()
s["tokens_per_sec"] = s["output_tokens"] / s["model_sec"].where(s["model_sec"] > 0)
(These columns need injected populated; an existing store predating the field
backfills with thk collect --sweep --force — see CLI.)
CLI
thk and thread-keeper are the same CLI (both console scripts). Examples
below use thread-keeper; substitute thk after a PyPI tool install.
thread-keeper collect --source <claude-code|codex|cursor> --session-id <id> [--transcript <path>]
thread-keeper collect --source <claude-code|cursor> --from-stdin # Claude Code / Cursor SessionEnd (JSON on stdin)
thread-keeper collect --source codex --from-notify-argv # Codex notify (idle-heuristic)
thread-keeper collect --sweep [--force] [--source <...>] [--claude-root <dir>] [--codex-root <dir>] [--cursor-root <dir>] [--host <label>]
thread-keeper install-hooks [--tool <claude-code|cursor|codex> ...]
thread-keeper uninstall-hooks [--tool <claude-code|cursor|codex> ...]
thread-keeper status
- The fast path is what hooks invoke: either
--session-id/--transcript, or--from-stdin(Claude Code / CursorSessionEndJSON payload), or--from-notify-argv(Codexnotify, no session id — reparses the newest rollout). Always exits 0 (must never block the agent tool). --sweepis the backstop + cold-start backfill: walks all three sources' log dirs and ingests anything changed since the last run, including your entire pre-existing history on a fresh store.--force(with--sweep) re-parses every file, ignoring the change-detection watermark. Use it to backfill a parser/schema change into an existing store — e.g. after an upgrade that adds a per-message field:thread-keeper collect --sweep --force.- Source roots (
--claude-root/--codex-root/--cursor-root, or the matchingTHREAD_KEEPER_CLAUDE_ROOT/_CODEX_ROOT/_CURSOR_ROOTenv vars) plus--host <label>let you consolidate logs copied in from another laptop into the same store, correctly attributed by origin machine.
Configuration
| Setting | Flag | Env var | Default |
|---|---|---|---|
| Data dir | — | THREAD_KEEPER_DATA_DIR |
~/.thread-keeper/ |
| Claude Code root | --claude-root |
THREAD_KEEPER_CLAUDE_ROOT |
~/.claude/projects |
| Codex root | --codex-root |
THREAD_KEEPER_CODEX_ROOT |
~/.codex/sessions |
| Cursor root | --cursor-root |
THREAD_KEEPER_CURSOR_ROOT |
platform Cursor user dir |
The SQLite store lives at <data-dir>/thread-keeper.db; install-hooks
backs up any existing hook config it overwrites to
<data-dir>/backups/.
Design invariants
- Offline. No network calls anywhere, ever.
- Read-only on foreign logs. Cursor's
state.vscdbis always snapshot-copied (with its-wal/-shmfiles) before being read; the original is never opened read-write. - Whole-session invariant. A changed file is always re-parsed whole and replaces its prior session — never a partial/appended read.
- Idempotent. Re-running any collect is a no-op net of row identity; a
session rename and its origin
hostlabel both survive re-import.
See docs/PRD.md §6 for the full set of design principles.
Development
uv sync
uv run pytest
uv run thread-keeper --help
See CLAUDE.md for how this repo is meant to be extended
(spec-driven, one Req at a time, contracts used verbatim).
Credits / prior art
thread-keeper is a from-scratch Python redraw of the offline log-extraction core of Chronicle by @chizhangucb, a JS/Electron "time machine" for AI coding-agent sessions. Chronicle already solved parsing Claude Code, Codex, and Cursor's session logs offline; thread-keeper ports that extraction logic (schema, parsers, pricing tables) to Python and drops everything else (the desktop UI, live streaming, replay, sharing) to leave a lightweight, notebook-first collector. Thanks to @chizhangucb and the Chronicle project for the open-source reference implementation this work stands on.
Metadata
Release files for thk 0.0.5
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| thk-0.0.5.tar.gz | 29.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| thk-0.0.5-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 65.8 kB
Release files / thk-0.0.5.tar.gz
| Download URL | thk-0.0.5.tar.gz |
|---|---|
| Size | 29.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ad6c4bd4061a00bd3124c99a60de9bf4b992a147dae10dc40d9ca9b82e267ec7
|
|
BLAKE2b-256 checksum How to use checksums |
f88fe79331301960e79e73d9c31b11f6d7ddf60a7c8b3b47db007c8e72318f04
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / thk-0.0.5-py3-none-any.whl
| Download URL | thk-0.0.5-py3-none-any.whl |
|---|---|
| Size | 36.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
89075123d83d0e8ccfba53177194455d282a5f85b0ee1c5c5644cbe83761affa
|
|
BLAKE2b-256 checksum How to use checksums |
2829c17dfd202cbaab3a430195a3750b6dd3e0d81769d9358523d3e0a1b20a45
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.11.28 {"installer":{"name":"uv","version":"0.11.28","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|