adapter

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: 6 Imported by: 0

Documentation

Overview

Package adapter defines the read-only interface every agent-CLI integration implements, plus the registry that wires them together.

It imports no concrete adapter and must not: a consumer who only wants the interface should not link fifteen file-format parsers, and the packages that implement this one importing it back is a cycle besides. The fan-out lives in adapter/all (Default). Plumbing our own adapters share hides under adapter/internal, so no helper becomes API surface by accident.

STRICTLY OBSERVATIONAL

This is the rule the whole package exists to keep. An adapter reads local files and databases the harness has ALREADY written, and does nothing else: no writes, no modification, no write locks, no rotation, no compaction, no deletion, not even a lock that would make the harness wait. A user's coding session must be unable to notice that this project is running.

Every SQLite source is opened mode=ro with query_only(1) and a busy timeout; immutable=1 is used NOWHERE, deliberately. A harness holds its own database open and writes it live, so an immutable reader can read a stale or empty picture rather than the file's real contents - measured on one install, a 4096-byte main file holding no rows at all while every row lived in the WAL beside it. mode=ro is the flag that cannot write; immutable=1 is the flag that lies.

THE SHAPE OF A READ

Discover locates sources under the configured roots; Collect reads one source and returns an Observation. An Observation carries up to three INDEPENDENT streams - usage events, activity, turn contexts - plus a checkpoint that must only be set once a read completed, since a partial read that advances the checkpoint skips its own remainder forever. The streams are independent because a source may report calls it reports no usage for, and usage it reports no calls for; none of them is derived from another.

Nothing in an Observation has a field for a prompt, a command, an argument or a file's contents. Activity and turn context are NAMES and counts, enforced by the model types rather than by a switch, and the raw audit payload is built from an allow-list of usage/model/identity fields. Content that has nowhere to land cannot leak.

Missing, partial and corrupt inputs are normal: an adapter returns what it could read plus a non-fatal error, and never aborts the collection cycle for the other fourteen.

WHAT A CAPABILITIES DECLARATION MEANS

Capabilities is a required method, so an adapter declares what this project can honestly say about its harness: where a cost figure came from (vendor stamp vs computed from a public rate card), whether a tool call can be joined to the turn that paid for it (exact join / recorded-but-unattributed / no activity at all), how the source reports reasoning tokens, and how well the adapter itself is verified (live against a real install, or fixture against constructed data). Tool MUST equal ID(), and every field MUST be set - a surface renders an empty field as an empty line, which reads as a rendering fault rather than as a missing fact.

It is a compiled declaration beside the code it describes, not a table somewhere else, because a second statement of a fact drifts from the first: a sixteenth adapter does not compile until it declares itself, which beats a guard test reminding someone to edit a map (issue #72, decision 1). Reasoning is filled from model.ReasoningReportFor rather than restated, so the pricing engine and this declaration cannot disagree about what a source reports.

It describes the ADAPTER, not an install. A declaration of "exact join" still yields no activity on a machine where the surface that carries it is switched off, the way Copilot's opt-in OTEL export can be.

THE INTERFACE IS FROZEN

Capabilities was the last required method (issue #72, decision 4). Every future capability arrives as an OPTIONAL interface discovered by type assertion, the way Incremental already is: the collector asserts for it and falls back to a full Collect when it is absent. The reason is external implementers - a new required method breaks every implementation outside this module at once, while an optional one costs an existing adapter nothing.

THE THREE NAMED BUG CLASSES

Every adapter is checked against these before it merges (CONTEXT.md). Each is named for a mistake that already happened here, and each misreports in the one direction this project promises never to go.

  • SPLIT-IDENTITY RECORDS. One usage identity spans several source records. Claude Code streams a single API response across transcript records that share a message id and PARTITIONS its tool_use blocks between them, so usage must collapse per message (keep-best) while calls and turn contexts UNION across it - reading the winning record's copy silently drops what its siblings carried. Cline is the same class inverted: its message document is rewritten whole on every save, so no byte offset survives, while the message ids inside it do not change.

  • CUMULATIVE-VS-EVENT COUNTING. A surface re-exports a running counter on a timer, and summing the exports multiplies the truth. Measured on a live Copilot export, a session that made exactly ONE tool call had produced 226 identical metric dataPoints; a span is written once per operation, so events come from spans and never from summing re-exports. The same trap sits on that harness's cost counters, which are session-wide totals.

  • ASSIGNED-NOT-ACCUMULATED COLUMNS. A column holds the LAST value rather than a running total, and reading it as a total misreports. Goose's sessions.total_tokens is assigned, so its adapter reads the purpose-built usage_ledger and never a token column of sessions; Crush names the two columns it refuses in the struct field names themselves.

The standing rule underneath all three: when a source offers no honest join, attribute NOTHING. Codex's token counts share no identity with its call records, so its calls are recorded unattributed - a timestamp-nearest match would invent an attribution the source does not support, and an invented number is worse than a missing one.

Index

Constants

View Source
const MetaNoUsage = "no_usage"

MetaNoUsage marks a discovered source that carries NO token usage — activity or turn context only.

It exists because "does this tool have a data source" is asked by surfaces that mean TOKENS: doctor decides whether to print a tool's enablement checklist from it, and the TUI's By-Tool footnote states whether a token source exists at all (issue #44). Copilot discovers one session-state source per session for its skills and hooks while its tokens come only from an OPT-IN OTEL export, so counting those would tell a user with the export switched off that their token source is present and would suppress the checklist that is the only way to turn it on. The value is unread; presence of the key is the mark.

Variables

View Source
var ErrSourceFormat = errors.New("adapter source format incompatible")

ErrSourceFormat marks a discovered source whose required writer/schema anchors no longer match a supported surface. Adapters wrap it with the tool, surface and incompatible anchor so callers can distinguish upstream drift from valid zero usage without learning a new error type per harness.

Functions

func CountUsageSources

func CountUsageSources(srcs []Source) int

CountUsageSources returns how many of these sources can produce usage events.

func FileStem

func FileStem(path string) string

FileStem returns the file name without directory or extension.

func IsDir

func IsDir(path string) bool

IsDir reports whether path exists and is a directory.

func NonNeg

func NonNeg(v int64) int64

NonNeg clamps a possibly-negative counter to zero.

func WalkEntryIsFile

func WalkEntryIsFile(d fs.DirEntry, path string) bool

WalkEntryIsFile reports whether a directory-walk entry is usable as a source file. A walk reports each entry by its OWN metadata, so a dangling symlink arrives looking exactly like an ordinary file: it is not a directory, and it carries whatever extension the discovery filter is looking for. Discovery that trusts that hands the collector a source whose every read fails with ENOENT, which surfaces as a permanent per-cycle error against a tree nobody is going to repair.

The common case costs no syscall: a plain file answers from the type bits the walk already carries. Only a symlink is resolved, because only a symlink can lie. Anything that is not ultimately a regular file is rejected, which also keeps a fifo out of a reader that would block on it forever.

Types

type Adapter

type Adapter interface {
	// ID is the stable tool identifier (model.ToolXxx).
	ID() string
	// DisplayName is the human-friendly name ("Claude Code").
	DisplayName() string
	// Discover locates sources under the configured roots. Read-only.
	Discover(ctx context.Context, cfg DiscoverConfig) ([]Source, error)
	// Collect reads a single source and returns its observations. Read-only.
	Collect(ctx context.Context, src Source) (Observation, error)
	// Capabilities declares what this project can actually say about the tool:
	// where a cost figure came from, whether a tool call can be joined to the
	// turn that paid for it, how the source reports reasoning tokens, and how
	// well the adapter is verified. Tool MUST equal ID(), and every field MUST
	// be set — a surface renders an empty field as an empty line, which reads as
	// a rendering fault rather than as a missing fact.
	//
	// It is a REQUIRED method rather than a table somewhere else because the
	// declaration is a statement about this code, and a statement kept beside
	// the code it describes cannot drift from it. A sixteenth adapter does not
	// compile until it declares itself, which beats a guard test reminding
	// someone to edit a map (issue #72, decision 1). The value type lives in
	// model so the dashboard reads it without importing this package.
	//
	// Reasoning is filled from model.ReasoningReportFor rather than restated:
	// there is ONE table of reasoning behaviour, and the pricing engine and this
	// declaration must never disagree about what a source reports.
	Capabilities() model.ToolCapability
}

Adapter reads one agent CLI's local usage data. Implementations MUST be read-only and must tolerate missing/partial/corrupt files without erroring the whole collection cycle (return best-effort results + a non-fatal error).

type DiscoverConfig

type DiscoverConfig struct {
	Home      string            // user home directory
	Overrides map[string]string // tool id -> explicit root path (optional)
}

DiscoverConfig carries discovery roots and per-tool path overrides.

func (DiscoverConfig) Root

func (c DiscoverConfig) Root(tool, def string) string

Root returns the discovery root for a tool: an explicit override if present, otherwise the user's home directory.

type Incremental

type Incremental interface {
	CollectIncremental(ctx context.Context, src Source, cp *model.SourceCheckpoint) (Observation, error)
}

Incremental is an optional Adapter capability: given the checkpoint stored after the previous cycle, the adapter may skip unchanged data and read only what is new, returning the updated checkpoint on the Observation. A nil checkpoint (first cycle, or checkpoint lost) MUST behave exactly like Collect — a full read. Correctness never depends on the checkpoint; it is purely a work-avoidance gate.

type Observation

type Observation struct {
	Events    []model.UsageEvent
	Snapshots []model.AggregateSnapshot
	// Activity is the agent ACTIVITY observed in the same read: which tool was
	// called, which skill was invoked, which hook fired. It is a second,
	// independent output stream, not a derivative of Events — a source may
	// report calls it reports no usage for, and vice versa.
	//
	// An adapter that can tie a call to the usage record it rode in on MUST set
	// ActivityEvent.UsageDedupKey to that event's DedupKey and CallsInTurn to
	// the number of calls sharing it; that pairing is the whole cost
	// attribution, and it is only sound when both come from the SAME provider
	// record. An adapter that cannot must leave the key empty rather than
	// guess: a timestamp-nearest match would invent an attribution the source
	// does not support.
	//
	// PRIVACY: names and counts only. Never put a tool's input anywhere in
	// here — model.ActivityEvent has no field that would hold one.
	Activity []model.ActivityEvent
	// TurnContexts records what each observed usage event was produced UNDER —
	// which subagent, which skill, which MCP tool and server, which plugin. It
	// is a third independent stream and a property of the TURN, not of a call:
	// at most one value per (usage event, dimension), keyed by that event's
	// DedupKey, never divided among anything.
	//
	// One usage event may appear here several times, once per DIMENSION, and
	// each of those rows names the turn's FULL cost because each answers a
	// different question. They are partitions, not shares. A consumer must pin
	// exactly one dimension per query; summing across them counts the same
	// tokens once per context the turn carried. See model.TurnContext.
	//
	// An adapter MUST only emit one for a usage event it is also emitting in
	// Events, and must leave the stream empty when its source records no such
	// thing rather than inferring a context from adjacency — "the last skill
	// call I saw" is a guess that would keep charging a skill long after it
	// returned.
	//
	// PRIVACY: the NAME only — agent type, skill, MCP server/tool, plugin.
	// Never inputs, arguments, prompts or results; model.TurnContext has no
	// field that would hold one.
	TurnContexts []model.TurnContext
	// Checkpoint, when non-nil, is the source's new incremental state and MUST
	// only be set once the read completed (a partial read that advances the
	// checkpoint would skip the unread remainder forever). The collector
	// persists it in the same transaction as this observation's data; nil
	// leaves any stored checkpoint untouched.
	Checkpoint *model.SourceCheckpoint
	// CodeChanges carries the latest per-turn line-count snapshots reported
	// by a harness. These mutable counts are independent of token accounting;
	// missing data must remain unknown. No source patch text is retained.
	CodeChanges []model.CodeChange
}

Observation is the result of reading a Source once. EventLevel adapters fill Events; Aggregate adapters fill Snapshots. An adapter may return both.

type Registry

type Registry struct {
	// contains filtered or unexported fields
}

Registry holds the set of available adapters.

func NewRegistry

func NewRegistry(as ...Adapter) *Registry

NewRegistry builds a registry from the given adapters.

func (*Registry) All

func (r *Registry) All() []Adapter

All returns every registered adapter.

func (*Registry) Capabilities

func (r *Registry) Capabilities() map[string]model.ToolCapability

Capabilities returns every registered adapter's own declaration, keyed by tool id. The adapter DECLARES and the registry AGGREGATES (issue #72, decision 1): there is no table anywhere that has to be edited when a sixteenth adapter arrives, because an adapter that does not declare itself does not compile.

Keyed by ad.ID() rather than by the declaration's own Tool field: the registry is the authority on which tool an adapter IS, and the two agreeing is a property worth testing rather than assuming. A caller that also has to describe tools NO adapter collects any more - a ledger is append-only, so rows outlive the adapter that wrote them - lays model.RetiredCapabilities() down first and lets this map overwrite it, so a tool that comes back to life is described by its adapter rather than by the list of the departed.

func (*Registry) Get

func (r *Registry) Get(id string) (Adapter, bool)

Get returns the adapter for id, if registered.

type Source

type Source struct {
	Tool  string            // owning tool id (model.ToolXxx)
	Class model.SourceClass // EventLevel or Aggregate
	Path  string            // primary path (file or db)
	Label string            // human-friendly label for `sources`
	Meta  map[string]string // adapter-specific extras (e.g. session id, dir)
}

Source is a concrete usage source discovered by an adapter — typically a file, directory, or database belonging to one agent CLI.

func (Source) CarriesUsage

func (s Source) CarriesUsage() bool

CarriesUsage reports whether this source can produce usage events. Sources say yes unless they mark themselves otherwise, so an adapter that never thinks about it behaves exactly as before.

Directories

Path Synopsis
Package agy implements an AGGREGATE adapter for the Antigravity CLI.
Package agy implements an AGGREGATE adapter for the Antigravity CLI.
Package all assembles the registry of every adapter this project ships.
Package all assembles the registry of every adapter this project ships.
Package claudecode implements the event-level adapter for the Claude Code CLI.
Package claudecode implements the event-level adapter for the Claude Code CLI.
Package clinecli implements the event-level adapter for the Cline CLI (`cline`, npm package "cline", Apache-2.0).
Package clinecli implements the event-level adapter for the Cline CLI (`cline`, npm package "cline", Apache-2.0).
Package codex implements an event-level adapter for the Codex CLI.
Package codex implements an event-level adapter for the Codex CLI.
Package copilot implements the event-level adapter for GitHub Copilot CLI.
Package copilot implements the event-level adapter for GitHub Copilot CLI.
Package crush implements a COST-ONLY adapter for Crush (charmbracelet/crush).
Package crush implements a COST-ONLY adapter for Crush (charmbracelet/crush).
Package dsh implements an event-level adapter for DSH, the DeepSeek Harness.
Package dsh implements an event-level adapter for DSH, the DeepSeek Harness.
Package goose implements the event-level adapter for the Goose CLI.
Package goose implements the event-level adapter for the Goose CLI.
Package hermes implements an AGGREGATE adapter for the Hermes CLI.
Package hermes implements an AGGREGATE adapter for the Hermes CLI.
internal
geminishape
Package geminishape parses the Gemini CLI telemetry file shape shared by the gemini and agy adapters, so a parser bug is fixed exactly once.
Package geminishape parses the Gemini CLI telemetry file shape shared by the gemini and agy adapters, so a parser bug is fixed exactly once.
tokenutil
Package tokenutil holds the shared token-accounting helpers used by adapters to normalise provider token counts into the project's UsageEvent model.
Package tokenutil holds the shared token-accounting helpers used by adapters to normalise provider token counts into the project's UsageEvent model.
Package kimicode implements an event-level adapter for Kimi Code (MoonshotAI/kimi-code, MIT).
Package kimicode implements an event-level adapter for Kimi Code (MoonshotAI/kimi-code, MIT).
Package opencode implements the event-level adapter for the opencode CLI.
Package opencode implements the event-level adapter for the opencode CLI.
Package pi implements event-level adapters for TWO harnesses that share ONE session format: Pi (earendil-works/pi, `@earendil-works/pi-coding-agent`) and OpenClaw (openclaw/openclaw), which ships pi's own session manager.
Package pi implements event-level adapters for TWO harnesses that share ONE session format: Pi (earendil-works/pi, `@earendil-works/pi-coding-agent`) and OpenClaw (openclaw/openclaw), which ships pi's own session manager.
Package qwencode implements an event-level adapter for Qwen Code.
Package qwencode implements an event-level adapter for Qwen Code.
Package reasonix implements an EVENT-LEVEL adapter for Reasonix (esengine/DeepSeek-Reasonix, MIT).
Package reasonix implements an EVENT-LEVEL adapter for Reasonix (esengine/DeepSeek-Reasonix, MIT).

Jump to

Keyboard shortcuts

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