1use std::fs;
9use std::io::{BufRead, BufReader};
10use std::path::{Path, PathBuf};
11
12use serde::Deserialize;
13use serde_json::Value;
14
15use super::{
16 EnvAlias, PressureObservation, TelemetryError, TelemetryResult, TokenUsage, compute_pct,
17 file_mtime_epoch_s, general_context_window, general_host_model, home_dir, is_recent,
18 read_file_bounded, read_file_to_string_bounded, resolve_env_string, resolve_env_u64,
19 string_key,
20};
21
22const ADAPTER_ID: &str = "claude";
23
24const CLAUDE_HOME_ALIASES: &[EnvAlias] = &[EnvAlias {
25 lifeloop: "LIFELOOP_CLAUDE_HOME",
26 ccd_compat: "CCD_CLAUDE_HOME",
27}];
28
29const CLAUDE_SESSION_ID_ALIASES: &[EnvAlias] = &[EnvAlias {
30 lifeloop: "LIFELOOP_CLAUDE_SESSION_ID",
31 ccd_compat: "CCD_CLAUDE_SESSION_ID",
32}];
33
34const CLAUDE_MODEL_ALIASES: &[EnvAlias] = &[EnvAlias {
35 lifeloop: "LIFELOOP_CLAUDE_MODEL",
36 ccd_compat: "CCD_CLAUDE_MODEL",
37}];
38
39const CLAUDE_CONTEXT_WINDOW_ALIASES: &[EnvAlias] = &[EnvAlias {
40 lifeloop: "LIFELOOP_CLAUDE_CONTEXT_WINDOW_TOKENS",
41 ccd_compat: "CCD_CLAUDE_CONTEXT_WINDOW_TOKENS",
42}];
43
44pub fn current(repo_root: &Path) -> TelemetryResult<Option<PressureObservation>> {
52 let project_dir = claude_home()?
53 .join("projects")
54 .join(project_slug(repo_root));
55 if !project_dir.is_dir() {
56 return Ok(None);
57 }
58
59 let session_id_override = resolve_env_string(CLAUDE_SESSION_ID_ALIASES);
60 let session_log = match session_id_override.as_deref() {
61 Some(session_id) => {
62 let path = project_dir.join(format!("{session_id}.jsonl"));
63 path.is_file().then_some(path)
64 }
65 None => latest_session_log(&project_dir)?,
66 };
67 let Some(session_log) = session_log else {
68 return Ok(None);
69 };
70
71 let observed_at_epoch_s = match file_mtime_epoch_s(&session_log)? {
72 Some(epoch_s) => epoch_s,
73 None => return Ok(None),
74 };
75 if session_id_override.is_none() && !is_recent(observed_at_epoch_s)? {
76 return Ok(None);
77 }
78
79 let Some(metrics) = latest_session_metrics(&session_log)? else {
80 return Ok(None);
81 };
82
83 let context_window =
84 resolve_env_u64(CLAUDE_CONTEXT_WINDOW_ALIASES).or_else(general_context_window);
85 let compacted = session_was_compacted(&project_dir, &session_log, &metrics.session_id)?;
86
87 Ok(Some(PressureObservation {
88 adapter_id: ADAPTER_ID.into(),
89 adapter_version: None,
90 observed_at_epoch_s,
91 model_name: metrics
92 .model_name
93 .or_else(|| resolve_env_string(CLAUDE_MODEL_ALIASES))
94 .or_else(general_host_model),
95 total_tokens: Some(metrics.latest_prompt_tokens),
96 context_window_tokens: context_window,
97 context_used_pct: compute_pct(metrics.latest_prompt_tokens, context_window),
98 compaction_signal: compacted,
99 usage: TokenUsage {
100 input_tokens: metrics.input_tokens,
101 output_tokens: metrics.output_tokens,
102 cache_creation_input_tokens: metrics.cache_creation_input_tokens,
103 cache_read_input_tokens: metrics.cache_read_input_tokens,
104 blended_total_tokens: None,
105 },
106 }))
107}
108
109pub fn parse_session_log(
115 bytes: &[u8],
116 observed_at_epoch_s: u64,
117 context_window_tokens: Option<u64>,
118) -> TelemetryResult<Option<PressureObservation>> {
119 let metrics = aggregate_session_metrics(bytes)?;
120 let Some(metrics) = metrics else {
121 return Ok(None);
122 };
123 Ok(Some(PressureObservation {
124 adapter_id: ADAPTER_ID.into(),
125 adapter_version: None,
126 observed_at_epoch_s,
127 model_name: metrics.model_name,
128 total_tokens: Some(metrics.latest_prompt_tokens),
129 context_window_tokens,
130 context_used_pct: compute_pct(metrics.latest_prompt_tokens, context_window_tokens),
131 compaction_signal: None,
132 usage: TokenUsage {
133 input_tokens: metrics.input_tokens,
134 output_tokens: metrics.output_tokens,
135 cache_creation_input_tokens: metrics.cache_creation_input_tokens,
136 cache_read_input_tokens: metrics.cache_read_input_tokens,
137 blended_total_tokens: None,
138 },
139 }))
140}
141
142fn claude_home() -> TelemetryResult<PathBuf> {
143 if let Some(path) = resolve_env_string(CLAUDE_HOME_ALIASES) {
144 return Ok(PathBuf::from(path));
145 }
146 Ok(home_dir()?.join(".claude"))
147}
148
149fn latest_session_log(project_dir: &Path) -> TelemetryResult<Option<PathBuf>> {
150 let mut newest: Option<(u64, PathBuf)> = None;
151 for entry in fs::read_dir(project_dir).map_err(TelemetryError::from)? {
152 let entry = entry.map_err(TelemetryError::from)?;
153 let path = entry.path();
154 let is_session_log = path
155 .file_name()
156 .and_then(|n| n.to_str())
157 .map(|n| n.ends_with(".jsonl"))
158 .unwrap_or(false);
159 if !is_session_log {
160 continue;
161 }
162 let mtime = match file_mtime_epoch_s(&path)? {
163 Some(m) => m,
164 None => continue,
165 };
166 match &newest {
167 Some((best, _)) if *best >= mtime => {}
168 _ => newest = Some((mtime, path)),
169 }
170 }
171 Ok(newest.map(|(_, p)| p))
172}
173
174fn latest_session_metrics(path: &Path) -> TelemetryResult<Option<ClaudeSessionMetrics>> {
175 let bytes = read_file_bounded(path, "Claude session log")?;
176 aggregate_session_metrics(&bytes)
177}
178
179fn aggregate_session_metrics(bytes: &[u8]) -> TelemetryResult<Option<ClaudeSessionMetrics>> {
180 let reader = BufReader::new(bytes);
181 let mut latest: Option<ClaudeSessionMetrics> = None;
182 for line in reader.lines() {
183 let line = line.map_err(TelemetryError::from)?;
184 let value: Value = match serde_json::from_str(&line) {
185 Ok(v) => v,
186 Err(_) => continue,
187 };
188 let model_name = string_key(
189 &value,
190 &["model", "model_name", "modelName", "display_name"],
191 );
192 let entry: ClaudeSessionEntry = match serde_json::from_value(value) {
193 Ok(e) => e,
194 Err(_) => continue,
195 };
196 let Some(message) = entry.message else {
197 continue;
198 };
199 let Some(usage) = message.usage else { continue };
200
201 let prompt_tokens = usage
202 .input_tokens
203 .saturating_add(usage.cache_creation_input_tokens)
204 .saturating_add(usage.cache_read_input_tokens);
205 if prompt_tokens == 0 && usage.output_tokens == 0 {
206 continue;
207 }
208 let Some(session_id) = entry.session_id else {
209 continue;
210 };
211
212 match &mut latest {
213 Some(m) => {
214 m.session_id = session_id;
215 m.latest_prompt_tokens = prompt_tokens;
216 m.input_tokens = m.input_tokens.saturating_add(usage.input_tokens);
217 m.output_tokens = m.output_tokens.saturating_add(usage.output_tokens);
218 m.cache_creation_input_tokens = m
219 .cache_creation_input_tokens
220 .saturating_add(usage.cache_creation_input_tokens);
221 m.cache_read_input_tokens = m
222 .cache_read_input_tokens
223 .saturating_add(usage.cache_read_input_tokens);
224 if model_name.is_some() {
225 m.model_name = model_name;
226 }
227 }
228 None => {
229 latest = Some(ClaudeSessionMetrics {
230 session_id,
231 latest_prompt_tokens: prompt_tokens,
232 input_tokens: usage.input_tokens,
233 output_tokens: usage.output_tokens,
234 cache_creation_input_tokens: usage.cache_creation_input_tokens,
235 cache_read_input_tokens: usage.cache_read_input_tokens,
236 model_name,
237 });
238 }
239 }
240 }
241 Ok(latest)
242}
243
244fn session_was_compacted(
245 project_dir: &Path,
246 session_log: &Path,
247 session_id: &str,
248) -> TelemetryResult<Option<bool>> {
249 let subagents_dir = project_dir.join(session_id).join("subagents");
250 if subagents_dir.is_dir() {
251 for entry in fs::read_dir(&subagents_dir).map_err(TelemetryError::from)? {
252 let entry = entry.map_err(TelemetryError::from)?;
253 let path = entry.path();
254 let is_acompact = path
255 .file_name()
256 .and_then(|n| n.to_str())
257 .map(|n| n.starts_with("agent-acompact-") && n.ends_with(".jsonl"))
258 .unwrap_or(false);
259 if is_acompact {
260 return Ok(Some(true));
261 }
262 }
263 }
264 let contents = read_file_to_string_bounded(session_log, "Claude session log")?;
265 if contents.contains("<command-name>/compact</command-name>") {
266 return Ok(Some(true));
267 }
268 Ok(None)
269}
270
271fn project_slug(repo_root: &Path) -> String {
272 repo_root
273 .canonicalize()
274 .unwrap_or_else(|_| repo_root.to_path_buf())
275 .to_string_lossy()
276 .replace('/', "-")
277}
278
279#[derive(Deserialize)]
280struct ClaudeSessionEntry {
281 #[serde(rename = "sessionId")]
282 session_id: Option<String>,
283 message: Option<ClaudeMessage>,
284}
285
286#[derive(Deserialize)]
287struct ClaudeMessage {
288 usage: Option<ClaudeUsage>,
289}
290
291#[derive(Deserialize)]
292struct ClaudeUsage {
293 #[serde(default)]
294 input_tokens: u64,
295 #[serde(default)]
296 output_tokens: u64,
297 #[serde(default)]
298 cache_creation_input_tokens: u64,
299 #[serde(default)]
300 cache_read_input_tokens: u64,
301}
302
303struct ClaudeSessionMetrics {
304 session_id: String,
305 latest_prompt_tokens: u64,
306 input_tokens: u64,
307 output_tokens: u64,
308 cache_creation_input_tokens: u64,
309 cache_read_input_tokens: u64,
310 model_name: Option<String>,
311}