* feat(devin): read usage from sessions.db Devin keeps every request in sessions.db message_nodes; the transcript exports hold only a few sessions. Usage now comes from the store, one source per non-hidden session, read in node_id keyset batches with only the needed JSON fields pulled. Sibling retries share a request_id and dedup on it; off-main-chain requests count. SQLITE_BUSY is rethrown so the refresh retries. Transcripts are read only when the database is missing or has no message_nodes, so the two never double count. The legacy metadata.metrics path no longer carves cache reads out of an input_tokens count that never included them. sessions.db sources rely on the WAL-folded fingerprint instead of a forced reparse. Parse version sessions-db-v1; daily cache v43 with devin in PENDING_REDERIVE, since transcript-era days dated steps on the session's last activity. * fix(devin): take only text user content as the prompt * test(daily-cache): include devin in pending re-derive expectations
9.2 KiB
Devin
Cognition Devin CLI local usage tracking.
- Source:
src/providers/devin.ts - Loading: eager (
src/providers/index.ts) - Test:
tests/providers/devin.test.ts,tests/providers/devin-sessions-db.test.ts
Where it reads from
Devin CLI data lives under:
~/.local/share/devin/cli/
The usage source is the CLI's SQLite store, opened read-only:
~/.local/share/devin/cli/sessions.db
Transcript JSON is an export-only snapshot of a subset of that store:
~/.local/share/devin/cli/transcripts/*.json
When sessions.db opens and has a message_nodes table, transcripts are not
read at all, so the two never double count. Transcripts are parsed only when the
database is missing or unusable (no SQLite, older build without
message_nodes); sessions.db then still enriches them when it can.
sessions.db usage
One source per non-hidden session: <sessions.db path>:<session id>. The
parser reads message_nodes for that session in node_id keyset batches and
pulls only the JSON fields it needs, so tool output never reaches JS.
Each assistant node's chat_message.metadata supplies request_id,
generation_model, created_at (the call time; the row's created_at is not)
and metrics. metrics.input_tokens already excludes cache reads, so nothing
is carved out. cache_read_tokens and cache_creation_tokens may be null.
Devin stores each retry of a request as a sibling node with the same
request_id and identical metrics, so the request id is the dedup key. Nodes
off sessions.main_chain_id are distinct requests and count. compactor and
cache_keepalive requests are real calls and count too.
Tools come from chat_message.tool_calls[].name. The task text is the nearest
ancestor user node with metadata.is_user_input = true (a cache_keepalive
"continue" is not one), then sessions.title, then the first prompt_history
row. A locked database (SQLITE_BUSY) is rethrown so the refresh retries
instead of reading the store as empty.
Configuration
None. Devin is priced from per-step tokens like every other provider, so it needs no config to appear in CLI/UI results.
Storage format
Transcript root is a JSON object following the ATIF-v1.7 trajectory schema,
with Devin-specific additions such as per-step metadata and extra. The
parser does not validate schema_version; it only requires a parseable object
with steps[].
Core fields include session_id, agent.model_name, agent.extra (Devin
backend/permission info), final_metrics, and steps[].
Steps now support two metric sources. The parser checks step.metrics first
(the standard ATIF location) and falls back to step.metadata.metrics (the
legacy Devin location). committed_acu_cost is ignored wherever it appears.
Messages can be a plain string or an array of ContentPart objects (text or
image), following the ATIF v1.6+ multimodal content model. The parser
normalises both forms when extracting user messages.
Each counted step can provide:
step_idmetrics.prompt_tokens(ormetadata.metrics.input_tokens)metrics.completion_tokens(ormetadata.metrics.output_tokens)metrics.extra.cache_creation_input_tokens(ormetadata.metrics.cache_creation_tokens)metrics.cached_tokens(ormetadata.metrics.cache_read_tokens)metadata.created_atmetadata.generation_model(orextra.generation_model)metadata.request_idtool_calls[].function_nameobservation.results[](tool output; not parsed for usage)
User steps (source === "user" or metadata.is_user_input === true) are skipped. Non-user
steps are included only if they have positive token usage.
Pricing
Per step, from tokens and the model id, through calculateCost() — the same
pricing tables every other provider uses. The pricing id is
metadata.generation_model (or extra.generation_model), falling back to
step.model_name, agent.model_name, then sessions.model; a MODEL_*
placeholder is never used for pricing. gpt-5-3-codex style ids are rewritten
to gpt-5.3-codex before lookup, so they hit their own row instead of
collapsing to the base gpt-5 price.
Transcript metrics.prompt_tokens is OpenAI-style, with the cached tokens
counted inside it, so the cached share is subtracted from input rather than
billed twice. Devin's own metadata.metrics.input_tokens (in sessions.db and
legacy transcripts) excludes cache reads and is used as is.
A model with no pricing row (today: swe-2-high, swe-2-max, swe-2-medium,
compactor) costs $0 and warns once,
like any other unpriced model. It is never silently priced off a neighbouring
row.
src/parser.ts preserves Devin's provider-supplied costUSD instead of
re-pricing it, because the call carries Devin's display model name.
sessions.db session columns
The provider reads these columns from sessions:
| Column | Use |
|---|---|
id |
source key; for transcripts, joined with session_id (discovery uses the filename before .json) |
working_directory |
projectPath and derived project name |
model |
model fallback |
title |
task text; project name fallback (falls back to the session's first prompt_history row when empty) |
created_at |
timestamp fallback |
last_activity_at |
preferred session timestamp fallback |
hidden |
skip hidden sessions |
prompt_history supplies the task text for a session Devin has not titled yet.
tool_call_state is not parsed.
Timestamps
Step timestamps come from metadata.created_at, falling back to
sessions.last_activity_at, then sessions.created_at.
Transcript step timestamps are passed through as ATIF string timestamps.
Numeric normalization is only applied to sessions.db timestamps:
- less than
10_000_000_000: seconds - otherwise: milliseconds
Model Resolution
Model names (display and pricing) resolve in this order:
step.metadata.generation_modelstep.model_nametranscript.agent.model_namesessions.modeldevin
Caching
No provider-level cache.
A sessions.db source is fingerprinted with its -wal sibling, so any write
to the store reparses every session (about a second for 85 sessions). A
transcript source is always reparsed by src/parser.ts, because sessions.db
can change without the transcript JSON fingerprint changing.
Deduplication
devin:<sessionId>:<request_id> for sessions.db (node-<node_id> when a
node has no request id), devin:<sessionId>:<step.step_id> for transcripts.
The provider name is part of the key via the devin: prefix.
Quirks
committed_acu_costis null in current Devin builds and is ignored; cost comes from tokens.- Token metrics can live in
step.metrics(standard ATIF) orstep.metadata.metrics(legacy Devin). The provider checksstep.metricsfirst, falling back tometadata. - Step messages can be a plain string or an array of
ContentPartobjects (text/image). The parser normalises both when extracting user messages. - Real transcripts mark the user turn with
source: "user"; older ones setmetadata.is_user_input. Either marks a step as the user's, so it is skipped and used as task text. - Hidden sessions from
sessions.dbare skipped in discovery and parsing. - Tool names come directly from
tool_calls[].function_name; the provider assumes valid ATIF tool-call records. - If SQLite is unavailable or
sessions.dbcannot be opened, the provider still parses transcripts without enrichment.
When fixing a bug here
-
For usage total bugs, compare against the store's distinct requests:
sqlite3 -readonly ~/.local/share/devin/cli/sessions.db "SELECT count(DISTINCT json_extract(chat_message,'$.metadata.request_id')) FROM message_nodes WHERE json_extract(chat_message,'$.role')='assistant'" -
For cost bugs, check which pricing row the step's
generation_modelhits; an unpriced model costs $0 by design. -
If project/model/timestamp metadata is wrong, inspect
sessions.db, not the transcript. -
If a hidden session appears, check the
hiddencolumn. Discovery can only hide sessions whose transcript filename matchessessions.id; parsing uses the transcriptsession_idwhen present. -
Run
tests/providers/devin.test.tsandtests/providers/devin-sessions-db.test.tsafter parser changes. It covers token pricing against the pricing tables, timestamp parsing, deduplication, hidden sessions,sessions.dbenrichment, ATIF v1.7 multimodal messages, andstep.metricsvsmetadata.metricspriority.