Skip to main content

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}