skiagram_core/model.rs
1//! Agent-agnostic domain model (CLAUDE.md §6).
2//!
3//! Everything here is what adapters normalize INTO; nothing here knows about any
4//! specific agent's on-disk format.
5
6use jiff::Timestamp;
7use serde::{Deserialize, Serialize};
8use std::path::PathBuf;
9
10/// Lightweight pointer to one session file on disk, produced by `Adapter::discover`.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct SessionRef {
13 pub path: PathBuf,
14 /// Adapter id, e.g. `"claude-code"`.
15 pub agent: String,
16 /// Project label as the agent records it (for Claude Code: the URL-encoded
17 /// cwd directory name, e.g. `F--skiagram`).
18 pub project: Option<String>,
19 pub size_bytes: u64,
20 pub modified: Option<Timestamp>,
21}
22
23/// One normalized agent conversation/run.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct Session {
26 pub id: String,
27 pub agent: String,
28 pub project: Option<String>,
29 /// Most frequently seen (non-synthetic) assistant model, if any.
30 pub model: Option<String>,
31 /// For sub-agent transcripts (e.g. Claude Code's
32 /// `<project>/<parent-session-uuid>/subagents/agent-*.jsonl`): the parent
33 /// session id this spend is attributed to (CLAUDE.md §8.3).
34 pub parent_session: Option<String>,
35 pub started_at: Option<Timestamp>,
36 pub ended_at: Option<Timestamp>,
37 pub events: Vec<Event>,
38 /// Sub-agents spawned BY this session (`Task`/`Agent` tool calls).
39 pub sub_agents: Vec<SubAgent>,
40 /// Lines that failed to parse or had an unrecognized shape. Skipped leniently,
41 /// surfaced so format drift is visible instead of silent.
42 pub skipped_lines: u64,
43}
44
45/// What one normalized event represents.
46///
47/// Note: some agents (Claude Code) attach tool calls to `Assistant` events rather
48/// than emitting standalone `ToolCall` events; the variant exists for agents that
49/// log them separately.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51pub enum EventKind {
52 User,
53 Assistant,
54 ToolCall,
55 ToolResult,
56 System,
57 Compaction,
58 SubAgentSpawn,
59 /// Non-message context injected into the window: deferred-tool listings,
60 /// skill listings, MCP-server instruction blocks, IDE/file context, reminders
61 /// (Claude Code `attachment` lines). Carries no token usage; used by
62 /// context-bloat attribution (`analysis::context`).
63 Attachment,
64}
65
66/// One line/turn of a session.
67#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
68pub struct Event {
69 pub kind: EventKind,
70 pub ts: Option<Timestamp>,
71 /// API request id. SEVERAL events may share one request id (one line per
72 /// content block) — accounting MUST dedup on it first (CLAUDE.md §8.1).
73 pub request_id: Option<String>,
74 pub model: Option<String>,
75 pub usage: Option<Usage>,
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
77 pub tool_calls: Vec<ToolCall>,
78 /// True when the event belongs to a sub-agent (sidechain) transcript.
79 pub sidechain: bool,
80 /// Short display-only snippet of visible text.
81 pub content_summary: Option<String>,
82 /// Approximate chars of generated content (text + thinking + tool-call JSON).
83 /// Heuristic input for thinking attribution / context estimation — never billed.
84 pub content_chars: u64,
85 /// Chars of extended-thinking text in this event — a SUBSET of `content_chars`
86 /// (0 when none, or when the thinking block was encrypted and thus
87 /// unmeasurable). Lets context attribution separate thinking from text.
88 #[serde(default, skip_serializing_if = "is_zero")]
89 pub thinking_chars: u64,
90 /// The event carried thinking blocks (possibly encrypted/redacted, i.e. with
91 /// no measurable text).
92 pub has_thinking: bool,
93 /// For a `ToolResult` event: the `tool_use_id` it answers. Lets the result's
94 /// bytes be attributed back to the tool / MCP server that produced them
95 /// (the matching `ToolCall` carries the same id in `ToolCall::id`).
96 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub tool_use_id: Option<String>,
98 /// For an `Attachment` event: its category — e.g. `"deferred_tools_delta"`,
99 /// `"skill_listing"`, `"mcp_instructions_delta"`, `"task_reminder"`.
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub attachment_kind: Option<String>,
102 /// For an `Attachment` event: how many named items it added to the window
103 /// (deferred tools, skills, MCP servers). 0 elsewhere.
104 #[serde(default, skip_serializing_if = "is_zero")]
105 pub item_count: u64,
106}
107
108/// `serde(skip_serializing_if)` predicate: keeps zero counters out of JSON/snapshots.
109fn is_zero(n: &u64) -> bool {
110 *n == 0
111}
112
113/// Token usage as reported by the agent.
114///
115/// Every field is `Option`: `None` means UNKNOWN, which is not the same as zero
116/// (CLAUDE.md §8.5). Renderers must show unknowns as such.
117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
118pub struct Usage {
119 pub input: Option<u64>,
120 pub output: Option<u64>,
121 /// Total cache-creation (write) tokens.
122 pub cache_creation: Option<u64>,
123 /// 5-minute-TTL share of `cache_creation`, when the agent reports the split.
124 /// Priced differently from the 1h share (see `pricing`).
125 pub cache_creation_5m: Option<u64>,
126 /// 1-hour-TTL share of `cache_creation` (priced 2x input vs 1.25x for 5m).
127 pub cache_creation_1h: Option<u64>,
128 pub cache_read: Option<u64>,
129 /// Extended-thinking (reasoning) tokens, ONLY when the agent reports them as a
130 /// SEPARATE, disjoint count (Codex `reasoning_output_tokens`, kept out of
131 /// `output`). Claude Code does NOT report them separately — there, thinking is
132 /// already INCLUDED in `output` (verified v2.1.178), so this stays `None` to
133 /// avoid double-counting in [`Usage::known_total`]; thinking is surfaced as a
134 /// char-measured attribution of `output` in `analysis::dedup` instead
135 /// (CLAUDE.md §8.2).
136 pub thinking: Option<u64>,
137}
138
139impl Usage {
140 /// True when no field carries any information.
141 pub fn is_empty(&self) -> bool {
142 self.input.is_none()
143 && self.output.is_none()
144 && self.cache_creation.is_none()
145 && self.cache_read.is_none()
146 && self.thinking.is_none()
147 }
148
149 /// Sum of all KNOWN token counts (the 5m/1h fields are a breakdown of
150 /// `cache_creation` and are not added again).
151 pub fn known_total(&self) -> u64 {
152 self.input.unwrap_or(0)
153 + self.output.unwrap_or(0)
154 + self.cache_creation.unwrap_or(0)
155 + self.cache_read.unwrap_or(0)
156 + self.thinking.unwrap_or(0)
157 }
158
159 /// Field-wise maximum, treating `None` as "no information" (not zero).
160 ///
161 /// This is the dedup merge rule: lines of one request either repeat identical
162 /// usage or grow monotonically while streaming, so MAX recovers the final
163 /// per-request value in both cases.
164 pub fn merge_max(self, other: Usage) -> Usage {
165 fn max_opt(a: Option<u64>, b: Option<u64>) -> Option<u64> {
166 match (a, b) {
167 (Some(x), Some(y)) => Some(x.max(y)),
168 (x, None) => x,
169 (None, y) => y,
170 }
171 }
172 Usage {
173 input: max_opt(self.input, other.input),
174 output: max_opt(self.output, other.output),
175 cache_creation: max_opt(self.cache_creation, other.cache_creation),
176 cache_creation_5m: max_opt(self.cache_creation_5m, other.cache_creation_5m),
177 cache_creation_1h: max_opt(self.cache_creation_1h, other.cache_creation_1h),
178 cache_read: max_opt(self.cache_read, other.cache_read),
179 thinking: max_opt(self.thinking, other.thinking),
180 }
181 }
182}
183
184/// One tool invocation by the assistant.
185#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
186pub struct ToolCall {
187 pub id: String,
188 pub name: String,
189 /// Serialized size of the tool input (a proxy for the output tokens the call
190 /// itself cost; the *result* size lands on the matching `ToolResult` event).
191 pub input_bytes: u64,
192 /// MCP server the tool belongs to, parsed from `mcp__<server>__<tool>` names.
193 pub server: Option<String>,
194}
195
196impl ToolCall {
197 /// `mcp__github__search_issues` -> `Some("github")`; plain tools -> `None`.
198 pub fn server_from_name(name: &str) -> Option<String> {
199 let rest = name.strip_prefix("mcp__")?;
200 rest.split("__").next().map(str::to_string)
201 }
202}
203
204/// A sub-agent spawned via the `Task` (legacy) / `Agent` (current) tool.
205#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
206pub struct SubAgent {
207 /// The `tool_use` id of the spawning call (its `ToolResult` carries the
208 /// sub-agent's report).
209 pub tool_call_id: String,
210 pub agent_type: Option<String>,
211 pub description: Option<String>,
212 pub ts: Option<Timestamp>,
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn merge_max_treats_none_as_unknown() {
221 let a = Usage {
222 input: Some(100),
223 output: None,
224 ..Usage::default()
225 };
226 let b = Usage {
227 input: Some(40),
228 output: Some(7),
229 ..Usage::default()
230 };
231 let m = a.merge_max(b);
232 assert_eq!(m.input, Some(100));
233 assert_eq!(m.output, Some(7));
234 assert_eq!(m.cache_read, None, "unknown stays unknown, not zero");
235 }
236
237 #[test]
238 fn mcp_server_is_parsed_from_tool_name() {
239 assert_eq!(
240 ToolCall::server_from_name("mcp__github__search_issues").as_deref(),
241 Some("github")
242 );
243 assert_eq!(ToolCall::server_from_name("Read"), None);
244 }
245}