crush

package
v0.1.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 4, 2026 License: MIT Imports: 14 Imported by: 0

Documentation

Overview

Package crush implements a COST-ONLY adapter for Crush (charmbracelet/crush).

Crush keeps one SQLite database per project at <project>/.crush/crush.db and indexes every project it has ever run in at <global-data>/projects.json. The adapter reads the index, opens each database read-only (mode=ro), and emits ONE usage event per session per growth of that session's accumulated cost.

Tokens are not available, and the columns that look like tokens are traps

sessions.prompt_tokens / completion_tokens are ASSIGNED, not accumulated. Crush's own writer (internal/agent/agent.go, updateSessionTokenCounters) is:

if usage.OutputTokens != 0 { session.CompletionTokens = usage.OutputTokens }
if p := usage.InputTokens + usage.CacheReadTokens; p != 0 { session.PromptTokens = p }

`=`, not `+=`. The column holds the LAST turn's context size — for a long session it is roughly the context window, and it is the same number whether the session ran two turns or two hundred. Verified on this machine's live database: one session of two messages carries prompt_tokens=15290 for a prompt of six words. Two further writers make it neither a total nor a last value consistently: summarisation sets PromptTokens = 0 outright, and the title-generation path goes through UpdateTitleAndUsage, whose SQL is `prompt_tokens = prompt_tokens + ?`. The column cannot be read as either thing, so this adapter never reads it as usage. It appears in the audit payload under a name that says what it is, and nowhere else.

There is no per-message fallback: messages.parts carries Text / ToolCall / ToolResult / Finish / ShellCommand parts and Finish holds {Reason, Time, Message, Details} — nothing numeric. Confirmed empty of usage on the live database.

Only sessions.cost accumulates (`session.Cost += cost`), so cost is the one honest figure and this adapter reports cost alone: every event carries zero tokens. That is a real measurement of dollars, not an absence of one.

A cost of zero is unmeasured, not free

Crush zeroes the charge for a step whose usage the provider did not report (`estimated`) and for any model configured FlatRate, and it prices from its own catalog — a local or unlisted provider therefore accumulates exactly 0.0. This adapter emits NOTHING for such a session. A zero-token, zero-cost row would assert that a session which really did spend something was free, and usage_events is append-only, so that claim could never be withdrawn. The live session on this machine is exactly this case: an ollama model, 15290 assigned prompt tokens, cost 0.0, and no event.

Sub-agent cost is rolled into the parent, so children are never counted

runSubAgent finishes with `parentSession.Cost += childSession.Cost` while the child row KEEPS its own cost. Summing sessions.cost over every row therefore counts every sub-agent's spend twice; Crush's own reporting avoids it with `WHERE parent_session_id IS NULL` on every stats query, and so does this adapter. The rollup is best-effort in Crush (a failure is logged and the run continues), so a lost rollup leaves that child's cost uncounted here — understating, which is the only direction this ledger tolerates.

Growth, watermarks, and why the watermark never moves backwards

The accumulator lives in the source, not in an append-only log, so the adapter stores the micro-USD already accounted for per session in its checkpoint state and appends only the growth. The watermark is the maximum ever observed and is never lowered: Crush's own writes are read-modify-write under separate locks (the agent loop saves the whole session row while runSubAgent independently adds a child's cost to it), so a lost update can make the stored value drop. Re-emitting after a drop would charge the same dollars twice; holding the watermark can only under-report.

Deliberately NOT set: UsageEvent.Model

Crush names a model per MESSAGE, and this adapter does read it — into the audit payload — but the ledger's model column is left EMPTY on purpose. The collector re-prices every event it stores, and pricing a charge of zero tokens against a model the price table knows returns (0, "embedded-...", ok=true), which overwrites the harness-reported cost with a stamped 0 — verified against pricing. An empty model is not looked up at all (pricing.lookupKeys returns nothing for an empty name), so the cost survives. Provider is stamped, since a lookup keyed on provider alone never happens. When pricing learns to refuse a zero-token charge outright, the model can be stamped here and the audit payload keeps it in the meantime.

CRITICAL: strictly read-only. Every database is opened mode=ro with query_only, never immutable=1 — Crush writes these files while it runs, and SQLite documents wrong results when an immutable-flagged file changes.

Honest caveat, measured rather than assumed: Crush runs journal_mode=WAL, and SQLite cannot read a WAL database without its shared-memory index, so a mode=ro connection to one whose sidecars are absent CREATES crush.db-shm and a zero-length crush.db-wal and leaves them behind. No row changes, the database file stays byte-identical with its mtime unmoved, and the WAL holds no frames — it is SQLite's coordination state, not a write to the agent's data. immutable=1 is the only flag that would suppress it and it is forbidden here for the reason above. The same applies to every SQLite source in this project. See TestReadingIsObservationalOnAWalDatabase, which measures exactly that, and note the operational consequence: on a genuinely read-only DIRECTORY the read FAILS (SQLITE_READONLY_DIRECTORY) rather than degrading quietly.

Index

Constants

View Source
const (
	// GlobalDataEnv names Crush's own global data directory. Crush joins
	// crush.json (and therefore projects.json) DIRECTLY onto it, with no
	// "crush" segment of its own.
	GlobalDataEnv = "CRUSH_GLOBAL_DATA"
	// XDGDataHomeEnv is the second rung of Crush's own resolution order:
	// $XDG_DATA_HOME/crush. It moves what this adapter READS, which is why it
	// is exported and must be registered in cmd.discoveryEnv.
	XDGDataHomeEnv = "XDG_DATA_HOME"

	// PriceSourceReported labels a cost this adapter took from the harness
	// itself rather than from any price table. price_source is an open
	// vocabulary read as an opaque label.
	PriceSourceReported = "crush-session-cost"
)

Variables

This section is empty.

Functions

func New

func New() adapter.Adapter

New returns a Crush adapter.

Types

type Adapter

type Adapter struct{}

Adapter reads Crush's per-project session databases. Read-only.

func (Adapter) Capabilities

func (Adapter) Capabilities() model.ToolCapability

Capabilities declares what this project can say about Crush.

Cost is VENDOR-reported and it is the ONLY thing this adapter reports: one event per growth of sessions.cost, zero tokens, stamped PriceSourceReported so collect.stampCost cannot overwrite it. There is no activity at all — this adapter references model.ActivityEvent nowhere.

func (Adapter) Collect

func (a Adapter) Collect(ctx context.Context, src adapter.Source) (adapter.Observation, error)

Collect reads a single Crush database in full.

func (Adapter) CollectIncremental

func (a Adapter) CollectIncremental(ctx context.Context, src adapter.Source, cp *model.SourceCheckpoint) (adapter.Observation, error)

CollectIncremental applies the file-stamp gate and appends each session's cost growth since the stored watermark.

Every failure that stops the read returns NO events and NO checkpoint. That is safe in a way it would not be for an append-only source: Crush's cost lives in a current value that stays in the database until it is read, so a deferred pass loses nothing and the next one re-reads the same accumulator. Emitting a partially enriched row instead would put a permanent claim in an append-only ledger to avoid a delay that costs nothing.

An individual malformed ROW is the exception: the rows around it are charged and the checkpoint lands, with the error reported alongside. The watermark of a row that could not be read is carried forward rather than dropped — see carryUnreadable, which is what stops a later readable pass from charging that session's whole accumulator a second time.

func (Adapter) Discover

func (a Adapter) Discover(ctx context.Context, cfg adapter.DiscoverConfig) ([]adapter.Source, error)

Discover reads the projects index and returns one source per existing <data_dir>/crush.db. A missing index is not an error — Crush has simply never run on this machine. The index is authoritative: Crush registers every working directory it starts in, so nothing is gained by crawling the disk for databases it does not know about.

func (Adapter) DisplayName

func (Adapter) DisplayName() string

DisplayName returns the human-friendly name.

func (Adapter) ID

func (Adapter) ID() string

ID returns the stable tool identifier.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL