Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,28 @@ The benchmark becomes a meaningful quality gate after you record verified
ground truth. See [`docs/task-collection.md`](docs/task-collection.md) and
[`docs/task-benchmarking.md`](docs/task-benchmarking.md).

### See how well the prompt cache is working

```sh
statsai report cache
statsai report cache --from 2026-06-01 --to 2026-06-07 --provider claude_code
statsai report cache --all --provider codex
statsai report cache --session session_1a2b --details
statsai report cache --timeline --json
```

The cache report shows how much logical input (ordinary input, cache writes,
and cache reads) was served from the prompt cache, how much context each model
call processed, and when reuse dropped. For Claude Code and Codex it also walks
each agent's calls in order: a call whose cached reads fall at least half, and
at least 4,096 tokens, below what the previous call left reusable is a
suspected cache loss. Losses are grouped by the gap since the previous call, so
drops after a pause read apart from drops during active use. It is a heuristic
over recorded token counts, not a provider-confirmed miss, and calls that
cannot be ordered or split are reported as unclassifiable rather than as
healthy. `--timeline` adds local 10-minute buckets and `--details` lists every
call with its evidence; per-call details never leave the device.

### Keep a durable conversation archive

Collect complete conversations into the local SQLite store, then search or
Expand Down Expand Up @@ -394,6 +416,7 @@ statsai report monthly --subscriptions
statsai report all-time --json --verbose
statsai report range --from 2026-01-01 --to 2026-03-31
statsai report range --from 2026-05-01 --json
statsai report cache --from 2026-05-01 --details
```

Normal scans use a per-source file signature cache. `--no-cache` forces a
Expand Down Expand Up @@ -439,6 +462,7 @@ statsai sync --sink http --since-last
statsai sync --sink http --verify
statsai sync --status
statsai schema sync-batch
statsai schema cache-report
```

HTTP sync uses the stored device session unless `--auth-token` or
Expand All @@ -459,6 +483,10 @@ curl -H "Authorization: Bearer $(cat ~/.statsai/daemon-token)" \
http://127.0.0.1:8765/accounts
```

`/reports/cache` returns the `statsai report cache --json` report and accepts
`from`, `to`, `all`, `provider`, `account`, `session`, and `timeline` query
parameters.

Browser-originated requests are rejected. Sync writes must use
`Content-Type: application/json` and stay below 8 MiB.

Expand Down
8 changes: 6 additions & 2 deletions crates/statsai-adapters/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@ pub(crate) const SCAN_CACHE_SIGNATURE_VERSION: &str = "scan-cache.v1";
// parent's name, and turns without a reported duration end at their last work.
// session-metadata.v36: sub-agents report the parent they borrow a name from,
// so the name is joined when the session is built.
pub(crate) const CODEX_SCAN_CACHE_PARSER_REVISION: &str = "session-metadata.v36";
// call-context.v38: turn events list their model calls, with request starts,
// compaction boundaries, and each call's model, for the prompt-cache report.
pub(crate) const CODEX_SCAN_CACHE_PARSER_REVISION: &str = "call-context.v38";
// session-metadata.v30: Claude user-message counts include only typed prompts,
// not tool results, meta lines, or compaction summaries. (v29: events carry the
// session's custom or AI title and the prompt that started their turn.)
pub(crate) const CLAUDE_SCAN_CACHE_PARSER_REVISION: &str = "session-metadata.v30";
// call-context.v31: events record their request start, sub-agent, and
// compaction boundary for the prompt-cache report.
pub(crate) const CLAUDE_SCAN_CACHE_PARSER_REVISION: &str = "call-context.v31";
// session-metadata.v21: events carry each session's user and assistant message
// counts. (v20: session rows report their message count as requests and are
// priced per message, so long-context tiers stop being decided session-wide.)
Expand Down
126 changes: 125 additions & 1 deletion crates/statsai-adapters/src/claude/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pub(crate) fn parse_claude_file(
// counted on the first snapshot have to ride along with that replacement.
let mut last_usage_users = HashMap::<String, (String, u64)>::new();
let mut turn_started_at = HashMap::<String, chrono::DateTime<Utc>>::new();
let mut call_streams = HashMap::<(String, Option<String>), ClaudeCallStream>::new();
loop {
let line_status =
read_bounded_jsonl_line(&mut reader, &mut line_bytes, MAX_JSONL_RECORD_BYTES)?;
Expand Down Expand Up @@ -84,6 +85,11 @@ pub(crate) fn parse_claude_file(
current_reasoning = reasoning;
}
let session_raw = claude_session_raw(&value, path);
let agent_hash = claude_agent_hash(&value);
call_streams
.entry((session_raw.clone(), agent_hash.clone()))
.or_default()
.observe(&value);
if let Some((rank, title)) = claude_session_title_from_value(&value) {
session_titles.remember(session_raw, rank, title);
continue;
Expand Down Expand Up @@ -176,7 +182,7 @@ pub(crate) fn parse_claude_file(
model,
usage,
runtime: message_count_runtime(user_messages, 1, 0),
session_raw,
session_raw: session_raw.clone(),
project,
event_kind: "claude_message_usage",
source_file: path,
Expand All @@ -189,6 +195,9 @@ pub(crate) fn parse_claude_file(
},
);
event.session.turn_started_at = prompted_at.filter(|prompted_at| *prompted_at <= timestamp);
event.context = call_streams
.get_mut(&(session_raw, agent_hash.clone()))
.and_then(|stream| stream.call_context(record_id.as_deref(), timestamp, agent_hash));
push_deduped(ctx.scan, ctx.seen, event, duplicate_selection);
}

Expand All @@ -204,6 +213,121 @@ pub(crate) fn parse_claude_file(
Ok(())
}

/// Prompt-cache context for one agent's calls in one session.
///
/// A request goes out when Claude Code records the prompt or tool result that
/// triggers it, so the latest such record before a response is the request
/// start. A streamed response is written as several records sharing one
/// provider-record id; all of them describe the same call, and so does any
/// later copy of it, which keeps the marks its first record got.
///
/// Transcripts are not in time order: compaction rewrites the messages it
/// keeps after its boundary, with their original timestamps. Everything here
/// therefore follows file order, and a record older than the compaction
/// boundary is one of those copies, not the call that follows it.
#[derive(Default)]
struct ClaudeCallStream {
last_input_at: Option<chrono::DateTime<Utc>>,
last_completed_at: Option<chrono::DateTime<Utc>>,
pending_compaction: Option<ClaudeCompaction>,
marks_by_record: HashMap<String, ClaudeCallMarks>,
}

#[derive(Clone, Copy)]
enum ClaudeCompaction {
At(chrono::DateTime<Utc>),
Unplaced,
}

#[derive(Clone, Copy)]
struct ClaudeCallMarks {
requested_at: Option<chrono::DateTime<Utc>>,
after_compaction: bool,
}

impl ClaudeCallStream {
fn observe(&mut self, value: &Value) {
if claude_record_is_compact_boundary(value) {
self.pending_compaction = Some(
timestamp_from_nested_value(value)
.map_or(ClaudeCompaction::Unplaced, ClaudeCompaction::At),
);
} else if claude_record_is_user(value) {
if let Some(timestamp) = timestamp_from_nested_value(value) {
self.last_input_at = Some(timestamp);
}
}
}

fn call_context(
&mut self,
record_id: Option<&str>,
completed_at: chrono::DateTime<Utc>,
agent_hash: Option<String>,
) -> Option<statsai_core::CallContext> {
let known = record_id.and_then(|record_id| self.marks_by_record.get(record_id));
let marks = match known {
Some(marks) => {
// A later record of a known call: another streamed part, or a
// copy compaction wrote with the original timestamp.
let marks = *marks;
self.last_completed_at = self.last_completed_at.max(Some(completed_at));
marks
}
None => {
// Input recorded before the previous response belongs to an
// earlier exchange, as when a call retries without new input.
let previous = self.last_completed_at;
let after_compaction = match self.pending_compaction {
Some(ClaudeCompaction::At(at)) if completed_at < at => false,
Some(_) => {
self.pending_compaction = None;
true
}
None => false,
};
let marks = ClaudeCallMarks {
requested_at: self.last_input_at.filter(|at| {
*at <= completed_at && previous.is_none_or(|previous| *at >= previous)
}),
after_compaction,
};
if let Some(record_id) = record_id {
self.marks_by_record.insert(record_id.to_string(), marks);
}
self.last_completed_at = Some(completed_at);
marks
}
};
let context = statsai_core::CallContext {
agent_hash,
requested_at: marks.requested_at,
after_compaction: marks.after_compaction,
calls: Vec::new(),
};
(context != statsai_core::CallContext::default()).then_some(context)
}
}

/// Sub-agent identity. Sub-agents write their own transcripts, marked as
/// sidechains, under the parent's session id.
fn claude_agent_hash(value: &Value) -> Option<String> {
if let Some(agent_id) = value
.get("agentId")
.and_then(Value::as_str)
.filter(|agent_id| !agent_id.trim().is_empty())
{
return Some(hash_text(&format!("claude_agent:{agent_id}"))[..24].to_string());
}
(value.get("isSidechain").and_then(Value::as_bool) == Some(true))
.then(|| hash_text("claude_agent:sidechain")[..24].to_string())
}

fn claude_record_is_compact_boundary(value: &Value) -> bool {
value.get("type").and_then(Value::as_str) == Some("system")
&& value.get("subtype").and_then(Value::as_str) == Some("compact_boundary")
}

/// Session names found across one source's transcripts.
///
/// A resumed or forked session copies earlier messages into a new transcript,
Expand Down
Loading
Loading