1use anyhow::{Result, bail};
2use serde::Serialize;
3use serde_json::Value;
4use std::collections::{BTreeMap, BTreeSet};
5
6use tsift_quality::runtime_churn::{RestartChurnState, RestartChurnSummary};
7
8const MAX_LARGEST_TURNS: usize = 5;
9const MAX_RUNTIME_EVENTS: usize = 8;
10const MAX_GUARDRAILS: usize = 8;
11const MAX_LOOP_CLUSTERS: usize = 8;
12const MAX_FILE_READ_DIAGNOSTICS: usize = 8;
13const MAX_PROMPT_CACHE_TIMELINE: usize = 8;
14const MAX_PROMPT_CACHE_DIAGNOSTICS: usize = 6;
15const MAX_COMMANDS_PER_BUNDLE: usize = 6;
16const PROMPT_BUDGET_WARN_TOKENS: u64 = 100_000;
17const CACHED_RATIO_WARN_PERCENT: f64 = 90.0;
18const CACHED_RATIO_WARN_PROMPT_TOKENS: u64 = 50_000;
19const PROMPT_CACHE_CANDIDATE_TOKENS: u64 = 16_000;
20const PROMPT_CACHE_GOOD_HIT_PERCENT: f64 = 75.0;
21const PROMPT_CACHE_TREND_DELTA_PERCENT: f64 = 5.0;
22const PROMPT_CACHE_RATIO_DROP_WARN_PERCENT: f64 = 20.0;
23const PROMPT_CACHE_CREATION_SPIKE_WARN_PERCENT: f64 = 20.0;
24const PROMPT_CACHE_READ_CREATE_REGRESSION_RATIO: f64 = 2.0;
25const RESTART_LOOP_WARN_OCCURRENCES: usize = 3;
26const NOOP_CLOSEOUT_WARN_OCCURRENCES: usize = 3;
27const DEFAULT_FULL_FILE_READ_TOKENS: u64 = 4_000;
28const ESTIMATED_TOKENS_PER_SOURCE_LINE: u64 = 18;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
31#[serde(rename_all = "snake_case")]
32pub enum SessionCostSource {
33 ClaudeJsonl,
34 CodexJsonl,
35 AgentDocLog,
36}
37
38impl SessionCostSource {
39 pub fn parse(raw: &str) -> Result<Self> {
40 match raw.trim().to_ascii_lowercase().as_str() {
41 "claude" | "claude-jsonl" => Ok(Self::ClaudeJsonl),
42 "codex" | "codex-jsonl" => Ok(Self::CodexJsonl),
43 "agent-doc-log" | "agent_doc_log" | "log" => Ok(Self::AgentDocLog),
44 other => bail!(
45 "unsupported session-cost source `{other}`; expected claude-jsonl, codex-jsonl, or agent-doc-log"
46 ),
47 }
48 }
49
50 pub fn as_str(self) -> &'static str {
51 match self {
52 Self::ClaudeJsonl => "claude_jsonl",
53 Self::CodexJsonl => "codex_jsonl",
54 Self::AgentDocLog => "agent_doc_log",
55 }
56 }
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
60pub struct SessionCostTurn {
61 pub label: String,
62 pub prompt_tokens: u64,
63 pub cached_input_tokens: u64,
64 pub cache_creation_input_tokens: u64,
65 pub output_tokens: u64,
66 pub reasoning_output_tokens: u64,
67 pub total_tokens: u64,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
71pub struct SessionCostRuntimeEvent {
72 pub event: String,
73 pub occurrences: usize,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77pub struct SessionCostGuardrail {
78 pub kind: String,
79 pub severity: String,
80 pub message: String,
81 pub guidance: String,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct SessionCostPromptCachePlan {
86 pub status: String,
87 pub feasible: bool,
88 pub observed_cached_input_tokens: u64,
89 pub observed_cache_creation_tokens: u64,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub observed_cached_input_ratio: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub analytics: Option<SessionCostPromptCacheAnalytics>,
94 pub invariants: Vec<String>,
95 pub provider_adapters: Vec<SessionCostPromptCacheProvider>,
96 pub actions: Vec<SessionCostPromptCacheAction>,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
100pub struct SessionCostPromptCacheProvider {
101 pub provider: String,
102 pub status: String,
103 pub requirements: Vec<String>,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
107pub struct SessionCostPromptCacheAction {
108 pub kind: String,
109 pub severity: String,
110 pub message: String,
111 pub guidance: String,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115pub struct SessionCostPromptCacheAnalytics {
116 pub sample_count: usize,
117 pub effective: bool,
118 pub trend: String,
119 pub total_prompt_tokens: u64,
120 pub total_cached_input_tokens: u64,
121 pub total_cache_creation_tokens: u64,
122 pub net_cached_input_tokens: i64,
123 pub timeline_truncated: bool,
124 #[serde(skip_serializing_if = "Option::is_none")]
125 pub average_cached_input_ratio: Option<String>,
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub first_cached_input_ratio: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 pub last_cached_input_ratio: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub cached_input_ratio_delta: Option<String>,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub cache_read_to_creation_ratio: Option<String>,
134 #[serde(skip_serializing_if = "Vec::is_empty", default)]
135 pub diagnostics: Vec<SessionCostPromptCacheDiagnostic>,
136 pub timeline: Vec<SessionCostPromptCacheTimelineEntry>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
140pub struct SessionCostPromptCacheDiagnostic {
141 pub kind: String,
142 pub severity: String,
143 pub label: String,
144 pub message: String,
145 pub likely_causes: Vec<String>,
146 pub guidance: String,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
150pub struct SessionCostPromptCacheTimelineEntry {
151 pub label: String,
152 pub prompt_tokens: u64,
153 pub cached_input_tokens: u64,
154 pub cache_creation_input_tokens: u64,
155 #[serde(skip_serializing_if = "Option::is_none")]
156 pub cached_input_ratio: Option<String>,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 pub cache_creation_ratio: Option<String>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
162pub struct SessionCostLoopCluster {
163 pub kind: String,
164 pub label: String,
165 pub occurrences: usize,
166 pub max_consecutive: usize,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
170pub struct SessionCostFileReadDiagnostic {
171 pub path: String,
172 pub range: String,
173 pub occurrences: usize,
174 pub estimated_tokens: u64,
175 pub duplicate_estimated_tokens: u64,
176 pub follow_up_commands: Vec<String>,
177}
178
179#[derive(Debug, Clone, Default)]
180pub struct SessionCostGuardrailInput {
181 pub largest_prompt_turn_tokens: u64,
182 pub largest_prompt_turn_label: Option<String>,
183 pub prompt_tokens: u64,
184 pub cached_input_ratio: Option<f64>,
185 pub fresh_restart_occurrences: usize,
186 pub auto_trigger_timeout_occurrences: usize,
187 pub ctrl_d_restart_loop_occurrences: usize,
188 pub noop_closeout_occurrences: usize,
189 pub max_restart_count: Option<usize>,
190}
191
192#[derive(Debug, Clone, PartialEq, Serialize)]
193pub struct SessionCostReport {
194 pub source: String,
195 pub record_count: usize,
196 pub usage_samples: usize,
197 pub prompt_tokens: u64,
198 pub cached_input_tokens: u64,
199 pub cache_creation_input_tokens: u64,
200 pub output_tokens: u64,
201 pub reasoning_output_tokens: u64,
202 pub total_tokens: u64,
203 #[serde(skip_serializing_if = "Option::is_none")]
204 pub cached_input_ratio: Option<f64>,
205 pub largest_turn_total_tokens: u64,
206 pub runtime_event_groups: usize,
207 pub total_runtime_events: usize,
208 pub restart_churn_groups: usize,
209 #[serde(skip_serializing_if = "Option::is_none")]
210 pub max_restart_count: Option<usize>,
211 pub largest_turns: Vec<SessionCostTurn>,
212 pub runtime_events: Vec<SessionCostRuntimeEvent>,
213 #[serde(skip_serializing_if = "Vec::is_empty", default)]
214 pub loop_clusters: Vec<SessionCostLoopCluster>,
215 #[serde(skip_serializing_if = "Vec::is_empty", default)]
216 pub file_read_diagnostics: Vec<SessionCostFileReadDiagnostic>,
217 #[serde(skip_serializing_if = "Vec::is_empty", default)]
218 pub restart_churn: Vec<RestartChurnSummary>,
219 #[serde(skip_serializing_if = "Vec::is_empty", default)]
220 pub guardrails: Vec<SessionCostGuardrail>,
221 #[serde(skip_serializing_if = "Option::is_none")]
222 pub prompt_cache_plan: Option<SessionCostPromptCachePlan>,
223 #[serde(skip_serializing_if = "Vec::is_empty", default)]
224 pub warnings: Vec<String>,
225}
226
227#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)]
228struct UsageTotals {
229 prompt_tokens: u64,
230 cached_input_tokens: u64,
231 cache_creation_input_tokens: u64,
232 output_tokens: u64,
233 reasoning_output_tokens: u64,
234 total_tokens: u64,
235}
236
237impl UsageTotals {
238 fn delta_from(self, previous: Self) -> Self {
239 Self {
240 prompt_tokens: self.prompt_tokens.saturating_sub(previous.prompt_tokens),
241 cached_input_tokens: self
242 .cached_input_tokens
243 .saturating_sub(previous.cached_input_tokens),
244 cache_creation_input_tokens: self
245 .cache_creation_input_tokens
246 .saturating_sub(previous.cache_creation_input_tokens),
247 output_tokens: self.output_tokens.saturating_sub(previous.output_tokens),
248 reasoning_output_tokens: self
249 .reasoning_output_tokens
250 .saturating_sub(previous.reasoning_output_tokens),
251 total_tokens: self.total_tokens.saturating_sub(previous.total_tokens),
252 }
253 }
254
255 fn is_zero(self) -> bool {
256 self.prompt_tokens == 0
257 && self.cached_input_tokens == 0
258 && self.cache_creation_input_tokens == 0
259 && self.output_tokens == 0
260 && self.reasoning_output_tokens == 0
261 && self.total_tokens == 0
262 }
263}
264
265#[derive(Debug, Default)]
266struct CostState {
267 warnings: Vec<String>,
268 usage_turns: Vec<SessionCostTurn>,
269 runtime_events: BTreeMap<String, usize>,
270 seen_document_cycle_events: BTreeSet<(String, String)>,
271 total_runtime_events: usize,
272 max_restart_count: Option<usize>,
273 restart_churn: RestartChurnState,
274 pending_commands: Vec<String>,
275 loop_signals: Vec<LoopSignal>,
276 file_read_signals: Vec<FileReadSignal>,
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
280struct LoopSignal {
281 kind: LoopClusterKind,
282 label: String,
283}
284
285#[derive(Debug, Clone, PartialEq, Eq)]
286struct FileReadSignal {
287 path: String,
288 range: String,
289 start: Option<usize>,
290 lines: Option<usize>,
291 estimated_tokens: u64,
292}
293
294#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
295enum LoopClusterKind {
296 PromptRepeat,
297 CommandBundle,
298 CloseoutChurn,
299}
300
301impl LoopClusterKind {
302 fn as_str(self) -> &'static str {
303 match self {
304 Self::PromptRepeat => "prompt_repeat",
305 Self::CommandBundle => "command_bundle",
306 Self::CloseoutChurn => "closeout_churn",
307 }
308 }
309}
310
311#[derive(Debug, Clone)]
312enum TranscriptBlock {
313 Text { role: Option<String>, text: String },
314 ToolUse { name: String, input: Value },
315}
316
317pub fn compute(input: &str, source_hint: Option<&str>) -> Result<SessionCostReport> {
318 if input.trim().is_empty() {
319 bail!(
320 "no session-cost input provided; pass --input <file> or pipe transcript/log data on stdin"
321 );
322 }
323
324 let source = resolve_source(input, source_hint)?;
325 let mut state = CostState::default();
326 let record_count = input.lines().filter(|line| !line.trim().is_empty()).count();
327
328 match source {
329 SessionCostSource::ClaudeJsonl => ingest_claude_jsonl(input, &mut state)?,
330 SessionCostSource::CodexJsonl => ingest_codex_jsonl(input, &mut state)?,
331 SessionCostSource::AgentDocLog => ingest_agent_doc_log(input, &mut state),
332 }
333
334 let usage_samples = state.usage_turns.len();
335 let mut prompt_tokens = 0_u64;
336 let mut cached_input_tokens = 0_u64;
337 let mut cache_creation_input_tokens = 0_u64;
338 let mut output_tokens = 0_u64;
339 let mut reasoning_output_tokens = 0_u64;
340 let mut total_tokens = 0_u64;
341 let mut largest_turn_total_tokens = 0_u64;
342 for turn in &state.usage_turns {
343 prompt_tokens += turn.prompt_tokens;
344 cached_input_tokens += turn.cached_input_tokens;
345 cache_creation_input_tokens += turn.cache_creation_input_tokens;
346 output_tokens += turn.output_tokens;
347 reasoning_output_tokens += turn.reasoning_output_tokens;
348 total_tokens += turn.total_tokens;
349 largest_turn_total_tokens = largest_turn_total_tokens.max(turn.total_tokens);
350 }
351
352 let cached_input_ratio = (prompt_tokens > 0).then_some(
353 ((cached_input_tokens as f64) / (prompt_tokens as f64) * 10_000.0).round() / 100.0,
354 );
355 let largest_prompt_turn = state
356 .usage_turns
357 .iter()
358 .max_by(|left, right| {
359 left.prompt_tokens
360 .cmp(&right.prompt_tokens)
361 .then(left.label.cmp(&right.label))
362 })
363 .map(|turn| (turn.prompt_tokens, turn.label.clone()));
364 let noop_closeout_occurrences = state
365 .runtime_events
366 .get("commit_already_current")
367 .copied()
368 .unwrap_or(0);
369 flush_pending_commands(&mut state);
370 let loop_clusters = collect_loop_clusters(&state.loop_signals);
371 let file_read_diagnostics = collect_file_read_diagnostics(&state.file_read_signals);
372 let prompt_cache_plan = derive_prompt_cache_plan(
373 prompt_tokens,
374 cached_input_tokens,
375 cache_creation_input_tokens,
376 cached_input_ratio,
377 &state.usage_turns,
378 );
379
380 let mut largest_turns = state.usage_turns;
381 largest_turns.sort_by(|left, right| {
382 right
383 .total_tokens
384 .cmp(&left.total_tokens)
385 .then(right.prompt_tokens.cmp(&left.prompt_tokens))
386 .then(left.label.cmp(&right.label))
387 });
388 largest_turns.truncate(MAX_LARGEST_TURNS);
389
390 let mut runtime_events = state
391 .runtime_events
392 .into_iter()
393 .map(|(event, occurrences)| SessionCostRuntimeEvent { event, occurrences })
394 .collect::<Vec<_>>();
395 runtime_events.sort_by(|left, right| {
396 right
397 .occurrences
398 .cmp(&left.occurrences)
399 .then(left.event.cmp(&right.event))
400 });
401 let runtime_event_groups = runtime_events.len();
402 runtime_events.truncate(MAX_RUNTIME_EVENTS);
403 let restart_churn_groups = state.restart_churn.groups();
404 let restart_churn = state.restart_churn.summaries();
405 let guardrails = derive_guardrails(&SessionCostGuardrailInput {
406 largest_prompt_turn_tokens: largest_prompt_turn.as_ref().map_or(0, |turn| turn.0),
407 largest_prompt_turn_label: largest_prompt_turn.as_ref().map(|turn| turn.1.clone()),
408 prompt_tokens,
409 cached_input_ratio,
410 fresh_restart_occurrences: count_restart_family(&restart_churn, "fresh_restart"),
411 auto_trigger_timeout_occurrences: count_restart_family(
412 &restart_churn,
413 "auto_trigger_timeout",
414 ),
415 ctrl_d_restart_loop_occurrences: count_restart_family(
416 &restart_churn,
417 "ctrl_d_restart_loop",
418 ),
419 noop_closeout_occurrences,
420 max_restart_count: state.max_restart_count,
421 });
422
423 if usage_samples == 0 && runtime_event_groups == 0 {
424 state
425 .warnings
426 .push("no cost or runtime signals were detected in the provided input".to_string());
427 }
428
429 Ok(SessionCostReport {
430 source: source.as_str().to_string(),
431 record_count,
432 usage_samples,
433 prompt_tokens,
434 cached_input_tokens,
435 cache_creation_input_tokens,
436 output_tokens,
437 reasoning_output_tokens,
438 total_tokens,
439 cached_input_ratio,
440 largest_turn_total_tokens,
441 runtime_event_groups,
442 total_runtime_events: state.total_runtime_events,
443 restart_churn_groups,
444 max_restart_count: state.max_restart_count,
445 largest_turns,
446 runtime_events,
447 loop_clusters,
448 file_read_diagnostics,
449 restart_churn,
450 guardrails,
451 prompt_cache_plan,
452 warnings: state.warnings,
453 })
454}
455
456pub fn derive_guardrails(input: &SessionCostGuardrailInput) -> Vec<SessionCostGuardrail> {
457 let mut guardrails = Vec::new();
458
459 if input.largest_prompt_turn_tokens >= PROMPT_BUDGET_WARN_TOKENS {
460 let label = input
461 .largest_prompt_turn_label
462 .as_deref()
463 .map(|label| format!(" at {label}"))
464 .unwrap_or_default();
465 guardrails.push(SessionCostGuardrail {
466 kind: "prompt_budget".to_string(),
467 severity: "warn".to_string(),
468 message: format!(
469 "largest prompt turn reached {} tokens{label}",
470 input.largest_prompt_turn_tokens
471 ),
472 guidance:
473 "compact the session or split the task before another large turn resends the same context"
474 .to_string(),
475 });
476 }
477
478 if input.prompt_tokens >= CACHED_RATIO_WARN_PROMPT_TOKENS
479 && input
480 .cached_input_ratio
481 .is_some_and(|ratio| ratio >= CACHED_RATIO_WARN_PERCENT)
482 {
483 guardrails.push(SessionCostGuardrail {
484 kind: "cache_resend".to_string(),
485 severity: "warn".to_string(),
486 message: format!(
487 "cached input ratio was {:.2}% across {} prompt tokens",
488 input.cached_input_ratio.unwrap_or_default(),
489 input.prompt_tokens
490 ),
491 guidance:
492 "compact or restart the session when most prompt spend is cached context instead of new work"
493 .to_string(),
494 });
495 }
496
497 let restart_signal_count = input.fresh_restart_occurrences
498 + input.auto_trigger_timeout_occurrences
499 + input.ctrl_d_restart_loop_occurrences;
500 if restart_signal_count >= RESTART_LOOP_WARN_OCCURRENCES
501 || input.ctrl_d_restart_loop_occurrences > 0
502 || input.auto_trigger_timeout_occurrences > 0
503 {
504 let max_restart = input
505 .max_restart_count
506 .map(|count| format!(" max_restart={count}."))
507 .unwrap_or_default();
508 guardrails.push(SessionCostGuardrail {
509 kind: "restart_loop".to_string(),
510 severity: "warn".to_string(),
511 message: format!(
512 "restart churn detected: fresh_restart={} auto_trigger_timeout={} ctrl_d_restart_loop={}.{}",
513 input.fresh_restart_occurrences,
514 input.auto_trigger_timeout_occurrences,
515 input.ctrl_d_restart_loop_occurrences,
516 max_restart
517 )
518 .trim()
519 .to_string(),
520 guidance:
521 "fix the startup/retry issue before another restart, or compact and reopen cleanly instead of looping"
522 .to_string(),
523 });
524 }
525
526 if input.noop_closeout_occurrences >= NOOP_CLOSEOUT_WARN_OCCURRENCES {
527 guardrails.push(SessionCostGuardrail {
528 kind: "noop_closeout".to_string(),
529 severity: "warn".to_string(),
530 message: format!(
531 "commit_already_current appeared {} times",
532 input.noop_closeout_occurrences
533 ),
534 guidance:
535 "compact the document or avoid reopening it without new edits when closeouts are mostly no-ops"
536 .to_string(),
537 });
538 }
539
540 guardrails.truncate(MAX_GUARDRAILS);
541 guardrails
542}
543
544fn derive_prompt_cache_plan(
545 prompt_tokens: u64,
546 cached_input_tokens: u64,
547 cache_creation_input_tokens: u64,
548 cached_input_ratio: Option<f64>,
549 usage_turns: &[SessionCostTurn],
550) -> Option<SessionCostPromptCachePlan> {
551 let usage_samples = usage_turns.len();
552 if usage_samples == 0 {
553 return None;
554 }
555
556 let observed = cached_input_tokens > 0 || cache_creation_input_tokens > 0;
557 let candidate = prompt_tokens >= PROMPT_CACHE_CANDIDATE_TOKENS;
558 if !observed && !candidate {
559 return None;
560 }
561
562 let mut actions = Vec::new();
563 if !observed {
564 actions.push(SessionCostPromptCacheAction {
565 kind: "enable_provider_cache".to_string(),
566 severity: "recommend".to_string(),
567 message: format!(
568 "prompt volume reached {prompt_tokens} tokens without observed cache reads"
569 ),
570 guidance: "add a provider adapter that keeps stable context byte-identical and passes the provider cache hint on each turn"
571 .to_string(),
572 });
573 } else if cached_input_ratio.is_some_and(|ratio| ratio < PROMPT_CACHE_GOOD_HIT_PERCENT) {
574 actions.push(SessionCostPromptCacheAction {
575 kind: "improve_cache_hit_rate".to_string(),
576 severity: "recommend".to_string(),
577 message: format!(
578 "cached input ratio was {:.2}% across {prompt_tokens} prompt tokens",
579 cached_input_ratio.unwrap_or_default()
580 ),
581 guidance:
582 "move volatile timestamps, generated headers, and one-off compaction prompts after the cached prefix"
583 .to_string(),
584 });
585 } else {
586 actions.push(SessionCostPromptCacheAction {
587 kind: "preserve_cache_shape".to_string(),
588 severity: "info".to_string(),
589 message: format!(
590 "cache reads were observed across {cached_input_tokens} input tokens"
591 ),
592 guidance:
593 "keep the stable prefix and append-only transcript shape intact while adding new tools or context"
594 .to_string(),
595 });
596 }
597
598 if cache_creation_input_tokens > cached_input_tokens && cached_input_tokens > 0 {
599 actions.push(SessionCostPromptCacheAction {
600 kind: "reduce_cache_rewrites".to_string(),
601 severity: "recommend".to_string(),
602 message: format!(
603 "cache creation tokens ({cache_creation_input_tokens}) exceeded cache read tokens ({cached_input_tokens})"
604 ),
605 guidance:
606 "check for prefix churn before each model call; repeated writes can erase the economics of prompt caching"
607 .to_string(),
608 });
609 }
610
611 Some(SessionCostPromptCachePlan {
612 status: if observed { "observed" } else { "candidate" }.to_string(),
613 feasible: true,
614 observed_cached_input_tokens: cached_input_tokens,
615 observed_cache_creation_tokens: cache_creation_input_tokens,
616 observed_cached_input_ratio: cached_input_ratio.map(|ratio| format!("{ratio:.2}%")),
617 analytics: derive_prompt_cache_analytics(
618 usage_turns,
619 prompt_tokens,
620 cached_input_tokens,
621 cache_creation_input_tokens,
622 cached_input_ratio,
623 ),
624 invariants: vec![
625 "place stable system/developer context before per-turn content".to_string(),
626 "treat conversation history as append-only until an intentional compaction boundary"
627 .to_string(),
628 "exclude volatile timestamps, random ids, and transient instructions from the cached prefix"
629 .to_string(),
630 "run compaction against the same live prefix whenever the provider cache is still warm"
631 .to_string(),
632 ],
633 provider_adapters: vec![
634 SessionCostPromptCacheProvider {
635 provider: "anthropic".to_string(),
636 status: "explicit_breakpoints".to_string(),
637 requirements: vec![
638 "attach cache_control to the stable system block".to_string(),
639 "attach cache_control to the final tool definition when tools are sent"
640 .to_string(),
641 "attach cache_control to the last two user-role messages; skip one-off compaction instructions"
642 .to_string(),
643 ],
644 },
645 SessionCostPromptCacheProvider {
646 provider: "openai".to_string(),
647 status: "cache_key".to_string(),
648 requirements: vec![
649 "derive prompt_cache_key from the stable thread/session id".to_string(),
650 "keep prefixes byte-identical across consecutive calls for the same key".to_string(),
651 ],
652 },
653 SessionCostPromptCacheProvider {
654 provider: "self_hosted_or_edge".to_string(),
655 status: "affinity_required".to_string(),
656 requirements: vec![
657 "route consecutive calls for the same cache key to the same replica when the provider cache is replica-local"
658 .to_string(),
659 ],
660 },
661 ],
662 actions,
663 })
664}
665
666fn derive_prompt_cache_analytics(
667 usage_turns: &[SessionCostTurn],
668 prompt_tokens: u64,
669 cached_input_tokens: u64,
670 cache_creation_input_tokens: u64,
671 cached_input_ratio: Option<f64>,
672) -> Option<SessionCostPromptCacheAnalytics> {
673 if usage_turns.is_empty() {
674 return None;
675 }
676
677 let first_ratio = usage_turns
678 .first()
679 .and_then(|turn| percent_ratio(turn.cached_input_tokens, turn.prompt_tokens));
680 let last_ratio = usage_turns
681 .last()
682 .and_then(|turn| percent_ratio(turn.cached_input_tokens, turn.prompt_tokens));
683 let ratio_delta = first_ratio
684 .zip(last_ratio)
685 .map(|(first, last)| last - first);
686 let trend = prompt_cache_trend(usage_turns.len(), ratio_delta).to_string();
687 let effective = cached_input_ratio.is_some_and(|ratio| ratio >= PROMPT_CACHE_GOOD_HIT_PERCENT)
688 && cached_input_tokens >= cache_creation_input_tokens;
689 let cache_read_to_creation_ratio = (cache_creation_input_tokens > 0).then(|| {
690 format!(
691 "{:.2}x",
692 (cached_input_tokens as f64) / (cache_creation_input_tokens as f64)
693 )
694 });
695 let timeline = prompt_cache_timeline(usage_turns);
696 let diagnostics = derive_prompt_cache_diagnostics(
697 usage_turns,
698 cached_input_tokens,
699 cache_creation_input_tokens,
700 );
701
702 Some(SessionCostPromptCacheAnalytics {
703 sample_count: usage_turns.len(),
704 effective,
705 trend,
706 total_prompt_tokens: prompt_tokens,
707 total_cached_input_tokens: cached_input_tokens,
708 total_cache_creation_tokens: cache_creation_input_tokens,
709 net_cached_input_tokens: signed_token_delta(
710 cached_input_tokens,
711 cache_creation_input_tokens,
712 ),
713 timeline_truncated: usage_turns.len() > MAX_PROMPT_CACHE_TIMELINE,
714 average_cached_input_ratio: cached_input_ratio.map(format_percent),
715 first_cached_input_ratio: first_ratio.map(format_percent),
716 last_cached_input_ratio: last_ratio.map(format_percent),
717 cached_input_ratio_delta: ratio_delta.map(format_signed_percent),
718 cache_read_to_creation_ratio,
719 diagnostics,
720 timeline,
721 })
722}
723
724fn derive_prompt_cache_diagnostics(
725 usage_turns: &[SessionCostTurn],
726 cached_input_tokens: u64,
727 cache_creation_input_tokens: u64,
728) -> Vec<SessionCostPromptCacheDiagnostic> {
729 let mut diagnostics = Vec::new();
730
731 for pair in usage_turns.windows(2) {
732 let previous = &pair[0];
733 let current = &pair[1];
734 let Some(previous_ratio) =
735 percent_ratio(previous.cached_input_tokens, previous.prompt_tokens)
736 else {
737 continue;
738 };
739 let Some(current_ratio) = percent_ratio(current.cached_input_tokens, current.prompt_tokens)
740 else {
741 continue;
742 };
743 let drop = previous_ratio - current_ratio;
744 if drop >= PROMPT_CACHE_RATIO_DROP_WARN_PERCENT {
745 diagnostics.push(SessionCostPromptCacheDiagnostic {
746 kind: "cached_ratio_drop".to_string(),
747 severity: "warn".to_string(),
748 label: current.label.clone(),
749 message: format!(
750 "cached input ratio dropped from {} to {} at {}",
751 format_percent(previous_ratio),
752 format_percent(current_ratio),
753 current.label
754 ),
755 likely_causes: vec![
756 "stable prefix bytes changed before the cache boundary".to_string(),
757 "prompt_cache_key or thread/session id changed".to_string(),
758 "replica-local cache affinity was lost".to_string(),
759 ],
760 guidance:
761 "compare the prefix, tool set, cache key, compaction boundary, and routing between the previous turn and this turn"
762 .to_string(),
763 });
764 }
765 }
766
767 for turn in usage_turns {
768 let Some(creation_ratio) =
769 percent_ratio(turn.cache_creation_input_tokens, turn.prompt_tokens)
770 else {
771 continue;
772 };
773 if turn.cache_creation_input_tokens > 0
774 && creation_ratio >= PROMPT_CACHE_CREATION_SPIKE_WARN_PERCENT
775 {
776 diagnostics.push(SessionCostPromptCacheDiagnostic {
777 kind: "cache_creation_spike".to_string(),
778 severity: "warn".to_string(),
779 label: turn.label.clone(),
780 message: format!(
781 "cache creation was {} of prompt tokens at {}",
782 format_percent(creation_ratio),
783 turn.label
784 ),
785 likely_causes: vec![
786 "provider created a fresh cached prefix instead of reusing the warm prefix"
787 .to_string(),
788 "system, developer, or tool block changed before the cache boundary"
789 .to_string(),
790 "compaction or transient instructions entered the cached prefix".to_string(),
791 ],
792 guidance:
793 "inspect the cached prefix and provider breakpoint placement for this turn before treating the cache as effective"
794 .to_string(),
795 });
796 }
797 }
798
799 if cache_creation_input_tokens > 0 {
800 let read_to_creation = (cached_input_tokens as f64) / (cache_creation_input_tokens as f64);
801 if read_to_creation < PROMPT_CACHE_READ_CREATE_REGRESSION_RATIO {
802 diagnostics.push(SessionCostPromptCacheDiagnostic {
803 kind: "read_create_regression".to_string(),
804 severity: "recommend".to_string(),
805 label: "session".to_string(),
806 message: format!(
807 "cache read/create ratio was {read_to_creation:.2}x ({cached_input_tokens} read tokens, {cache_creation_input_tokens} creation tokens)"
808 ),
809 likely_causes: vec![
810 "cached prefix is being rewritten too often for warm reuse".to_string(),
811 "volatile values are inside the cached prefix".to_string(),
812 "cache key or replica routing is changing between turns".to_string(),
813 ],
814 guidance:
815 "stabilize the prefix/key/routing path until cache reads clearly exceed creation work"
816 .to_string(),
817 });
818 }
819 }
820
821 diagnostics.truncate(MAX_PROMPT_CACHE_DIAGNOSTICS);
822 diagnostics
823}
824
825fn prompt_cache_timeline(
826 usage_turns: &[SessionCostTurn],
827) -> Vec<SessionCostPromptCacheTimelineEntry> {
828 let selected = if usage_turns.len() <= MAX_PROMPT_CACHE_TIMELINE {
829 usage_turns.iter().collect::<Vec<_>>()
830 } else {
831 let tail_count = MAX_PROMPT_CACHE_TIMELINE.saturating_sub(1);
832 let mut selected = Vec::with_capacity(MAX_PROMPT_CACHE_TIMELINE);
833 if let Some(first) = usage_turns.first() {
834 selected.push(first);
835 }
836 selected.extend(usage_turns.iter().skip(usage_turns.len() - tail_count));
837 selected
838 };
839
840 selected
841 .into_iter()
842 .map(|turn| SessionCostPromptCacheTimelineEntry {
843 label: turn.label.clone(),
844 prompt_tokens: turn.prompt_tokens,
845 cached_input_tokens: turn.cached_input_tokens,
846 cache_creation_input_tokens: turn.cache_creation_input_tokens,
847 cached_input_ratio: percent_ratio(turn.cached_input_tokens, turn.prompt_tokens)
848 .map(format_percent),
849 cache_creation_ratio: percent_ratio(
850 turn.cache_creation_input_tokens,
851 turn.prompt_tokens,
852 )
853 .map(format_percent),
854 })
855 .collect()
856}
857
858fn prompt_cache_trend(sample_count: usize, ratio_delta: Option<f64>) -> &'static str {
859 if sample_count < 2 {
860 return "single_sample";
861 }
862 let Some(delta) = ratio_delta else {
863 return "insufficient_data";
864 };
865 if delta >= PROMPT_CACHE_TREND_DELTA_PERCENT {
866 "improving"
867 } else if delta <= -PROMPT_CACHE_TREND_DELTA_PERCENT {
868 "declining"
869 } else {
870 "stable"
871 }
872}
873
874fn percent_ratio(numerator: u64, denominator: u64) -> Option<f64> {
875 (denominator > 0)
876 .then_some(((numerator as f64) / (denominator as f64) * 10_000.0).round() / 100.0)
877}
878
879fn format_percent(value: f64) -> String {
880 format!("{value:.2}%")
881}
882
883fn format_signed_percent(value: f64) -> String {
884 format!("{value:+.2}%")
885}
886
887fn signed_token_delta(read_tokens: u64, creation_tokens: u64) -> i64 {
888 if read_tokens >= creation_tokens {
889 i64::try_from(read_tokens - creation_tokens).unwrap_or(i64::MAX)
890 } else {
891 -i64::try_from(creation_tokens - read_tokens).unwrap_or(i64::MAX)
892 }
893}
894
895fn resolve_source(input: &str, source_hint: Option<&str>) -> Result<SessionCostSource> {
896 if let Some(raw) = source_hint {
897 return SessionCostSource::parse(raw);
898 }
899
900 let non_empty = input
901 .lines()
902 .map(str::trim)
903 .filter(|line| !line.is_empty())
904 .collect::<Vec<_>>();
905 if non_empty.is_empty() {
906 bail!(
907 "no session-cost input provided; pass --input <file> or pipe transcript/log data on stdin"
908 );
909 }
910
911 if non_empty
912 .iter()
913 .all(|line| line.starts_with('{') && serde_json::from_str::<Value>(line).is_ok())
914 {
915 for line in &non_empty {
916 let value = serde_json::from_str::<Value>(line).unwrap_or(Value::Null);
917 if value
918 .get("message")
919 .and_then(|message| message.get("usage"))
920 .is_some()
921 {
922 return Ok(SessionCostSource::ClaudeJsonl);
923 }
924 if value.get("type").and_then(Value::as_str) == Some("event_msg")
925 && value
926 .get("payload")
927 .and_then(|payload| payload.get("type"))
928 .and_then(Value::as_str)
929 == Some("token_count")
930 {
931 return Ok(SessionCostSource::CodexJsonl);
932 }
933 }
934 if non_empty.iter().any(|line| line.contains("\"parentUuid\"")) {
935 return Ok(SessionCostSource::ClaudeJsonl);
936 }
937 if non_empty
938 .iter()
939 .any(|line| line.contains("\"response_item\"") || line.contains("\"turn_context\""))
940 {
941 return Ok(SessionCostSource::CodexJsonl);
942 }
943 }
944
945 if non_empty
946 .iter()
947 .all(|line| line.starts_with('[') && line.contains(']'))
948 {
949 return Ok(SessionCostSource::AgentDocLog);
950 }
951
952 bail!(
953 "could not auto-detect session-cost input; pass --source claude-jsonl, codex-jsonl, or agent-doc-log"
954 )
955}
956
957fn ingest_claude_jsonl(input: &str, state: &mut CostState) -> Result<()> {
958 let mut seen_keys = BTreeSet::new();
959 for (index, raw_line) in input.lines().enumerate() {
960 let trimmed = raw_line.trim();
961 if trimmed.is_empty() {
962 continue;
963 }
964 let value = match serde_json::from_str::<Value>(trimmed) {
965 Ok(value) => value,
966 Err(_) => {
967 state.warnings.push(format!(
968 "skipping malformed Claude transcript jsonl line {}",
969 index + 1
970 ));
971 continue;
972 }
973 };
974 let Some(message) = value.get("message") else {
975 collect_claude_loop_signals(&value, state);
976 continue;
977 };
978 collect_claude_loop_signals(&value, state);
979 if message.get("role").and_then(Value::as_str) != Some("assistant") {
980 continue;
981 }
982 let Some(usage) = message.get("usage") else {
983 continue;
984 };
985
986 let key = message
987 .get("id")
988 .and_then(Value::as_str)
989 .or_else(|| value.get("requestId").and_then(Value::as_str))
990 .or_else(|| value.get("uuid").and_then(Value::as_str))
991 .map(|value| value.to_string())
992 .unwrap_or_else(|| format!("line-{}", index + 1));
993 if !seen_keys.insert(key.clone()) {
994 continue;
995 }
996
997 let prompt_tokens = usage_u64(usage, "input_tokens")
998 + usage_u64(usage, "cache_creation_input_tokens")
999 + usage_u64(usage, "cache_read_input_tokens");
1000 let cached_input_tokens = usage_u64(usage, "cache_read_input_tokens");
1001 let cache_creation_input_tokens = usage_u64(usage, "cache_creation_input_tokens");
1002 let output_tokens = usage_u64(usage, "output_tokens");
1003 let total_tokens = prompt_tokens + output_tokens;
1004 if prompt_tokens == 0 && output_tokens == 0 {
1005 continue;
1006 }
1007
1008 state.usage_turns.push(SessionCostTurn {
1009 label: value
1010 .get("timestamp")
1011 .and_then(Value::as_str)
1012 .map(|value| value.to_string())
1013 .unwrap_or(key),
1014 prompt_tokens,
1015 cached_input_tokens,
1016 cache_creation_input_tokens,
1017 output_tokens,
1018 reasoning_output_tokens: 0,
1019 total_tokens,
1020 });
1021 }
1022 Ok(())
1023}
1024
1025fn ingest_codex_jsonl(input: &str, state: &mut CostState) -> Result<()> {
1026 let mut previous = UsageTotals::default();
1027 let mut seen_cumulative_snapshots = BTreeSet::<UsageTotals>::new();
1028 let mut saw_token_count = false;
1029 for (index, raw_line) in input.lines().enumerate() {
1030 let trimmed = raw_line.trim();
1031 if trimmed.is_empty() {
1032 continue;
1033 }
1034 let value = match serde_json::from_str::<Value>(trimmed) {
1035 Ok(value) => value,
1036 Err(_) => {
1037 state.warnings.push(format!(
1038 "skipping malformed Codex transcript jsonl line {}",
1039 index + 1
1040 ));
1041 continue;
1042 }
1043 };
1044 match value.get("type").and_then(Value::as_str) {
1045 Some("response_item") => {
1046 collect_codex_response_item_loop_signals(&value, index + 1, state)
1047 }
1048 Some("event_msg") => collect_codex_event_msg_loop_signals(&value, index + 1, state),
1049 _ => {}
1050 }
1051 if value.get("type").and_then(Value::as_str) != Some("event_msg") {
1052 continue;
1053 }
1054 let Some(payload) = value.get("payload") else {
1055 continue;
1056 };
1057 if payload.get("type").and_then(Value::as_str) != Some("token_count") {
1058 continue;
1059 }
1060 saw_token_count = true;
1061
1062 let Some(total) = payload
1063 .get("info")
1064 .and_then(|info| info.get("total_token_usage"))
1065 else {
1066 state.warnings.push(format!(
1067 "codex token_count event on line {} did not include info.total_token_usage",
1068 index + 1
1069 ));
1070 continue;
1071 };
1072 let cumulative = codex_usage_totals(total);
1073 let duplicate_snapshot = !seen_cumulative_snapshots.insert(cumulative);
1074 let delta = if duplicate_snapshot {
1075 UsageTotals::default()
1076 } else if let Some(last) = payload
1077 .get("info")
1078 .and_then(|info| info.get("last_token_usage"))
1079 .map(codex_usage_totals)
1080 .filter(|last| !last.is_zero())
1081 {
1082 last
1083 } else if previous.is_zero() {
1084 cumulative
1085 } else {
1086 cumulative.delta_from(previous)
1087 };
1088 previous = cumulative;
1089 if delta.is_zero() {
1090 continue;
1091 }
1092
1093 state.usage_turns.push(SessionCostTurn {
1094 label: value
1095 .get("timestamp")
1096 .and_then(Value::as_str)
1097 .map(|value| value.to_string())
1098 .unwrap_or_else(|| format!("line-{}", index + 1)),
1099 prompt_tokens: delta.prompt_tokens,
1100 cached_input_tokens: delta.cached_input_tokens,
1101 cache_creation_input_tokens: 0,
1102 output_tokens: delta.output_tokens,
1103 reasoning_output_tokens: delta.reasoning_output_tokens,
1104 total_tokens: delta
1105 .total_tokens
1106 .max(delta.prompt_tokens + delta.output_tokens),
1107 });
1108 }
1109
1110 if !saw_token_count {
1111 state.warnings.push(
1112 "codex transcript did not contain any token_count events; no token cost summary could be derived"
1113 .to_string(),
1114 );
1115 }
1116 Ok(())
1117}
1118
1119fn ingest_agent_doc_log(input: &str, state: &mut CostState) {
1120 for raw_line in input.lines() {
1121 let trimmed = raw_line.trim();
1122 if trimmed.is_empty() {
1123 continue;
1124 }
1125 let Some((_, after_bracket)) = trimmed.split_once("] ") else {
1126 continue;
1127 };
1128 let detail = after_bracket.trim();
1129 let Some(event_name) = detail.split_whitespace().next() else {
1130 continue;
1131 };
1132 let normalized = normalize_runtime_event(event_name, detail);
1133 let closeout_event = is_closeout_runtime_event(event_name, &normalized);
1134 if should_count_runtime_event(event_name, detail, &normalized, state) {
1135 *state.runtime_events.entry(normalized.clone()).or_default() += 1;
1136 state.total_runtime_events += 1;
1137 if closeout_event {
1138 push_closeout_signal(&normalized, state);
1139 }
1140 }
1141 state.restart_churn.observe(event_name, detail);
1142 if let Some(restart_count) =
1143 extract_field(detail, "restart_count").and_then(|value| value.parse::<usize>().ok())
1144 {
1145 state.max_restart_count = Some(
1146 state
1147 .max_restart_count
1148 .map_or(restart_count, |current| current.max(restart_count)),
1149 );
1150 }
1151 }
1152}
1153
1154fn collect_claude_loop_signals(value: &Value, state: &mut CostState) {
1155 let mut blocks = Vec::new();
1156 collect_transcript_blocks(value, &mut blocks);
1157 if blocks.is_empty() && is_ignorable_claude_record(value) {
1158 return;
1159 }
1160 for block in blocks {
1161 match block {
1162 TranscriptBlock::Text { role, text } => {
1163 let user_bias = role
1164 .as_deref()
1165 .is_some_and(|value| value.eq_ignore_ascii_case("user"));
1166 collect_text_loop_signals(&text, user_bias, state);
1167 }
1168 TranscriptBlock::ToolUse { name, input } => {
1169 collect_tool_use_loop_signals(&name, &input, state);
1170 }
1171 }
1172 }
1173}
1174
1175fn collect_codex_response_item_loop_signals(
1176 value: &Value,
1177 line_number: usize,
1178 state: &mut CostState,
1179) {
1180 let Some(payload) = value.get("payload") else {
1181 return;
1182 };
1183 match payload.get("type").and_then(Value::as_str) {
1184 Some("message") => {
1185 let Some(content) = payload.get("content").and_then(Value::as_array) else {
1186 return;
1187 };
1188 for item in content {
1189 let Some(text) = item
1190 .get("text")
1191 .and_then(Value::as_str)
1192 .or_else(|| item.get("content").and_then(Value::as_str))
1193 else {
1194 continue;
1195 };
1196 collect_text_loop_signals(text, false, state);
1197 }
1198 }
1199 Some("function_call") => {
1200 let name = payload
1201 .get("name")
1202 .and_then(Value::as_str)
1203 .unwrap_or("function_call");
1204 let Some(arguments) = payload.get("arguments").and_then(Value::as_str) else {
1205 return;
1206 };
1207 let input = serde_json::from_str::<Value>(arguments).unwrap_or_else(|_| {
1208 state.warnings.push(format!(
1209 "codex function_call arguments on line {} were not valid JSON; loop extraction may be incomplete",
1210 line_number
1211 ));
1212 Value::String(arguments.to_string())
1213 });
1214 collect_tool_use_loop_signals(name, &input, state);
1215 }
1216 _ => {}
1217 }
1218}
1219
1220fn collect_codex_event_msg_loop_signals(value: &Value, _line_number: usize, state: &mut CostState) {
1221 let Some(payload) = value.get("payload") else {
1222 return;
1223 };
1224 match payload.get("type").and_then(Value::as_str) {
1225 Some("user_message") => {
1226 if let Some(message) = payload.get("message").and_then(Value::as_str) {
1227 collect_text_loop_signals(message, true, state);
1228 }
1229 }
1230 Some("agent_message") => {
1231 if let Some(message) = payload.get("message").and_then(Value::as_str) {
1232 collect_text_loop_signals(message, false, state);
1233 }
1234 }
1235 Some("exec_command_end") => {
1236 if let Some(command) = extract_raw_codex_exec_command(payload) {
1237 collect_file_read_command_signals(&command, state);
1238 }
1239 if let Some(command) = extract_codex_exec_command(payload) {
1240 push_command(command, state);
1241 }
1242 if let Some(output) = payload
1243 .get("aggregated_output")
1244 .and_then(Value::as_str)
1245 .or_else(|| payload.get("stdout").and_then(Value::as_str))
1246 {
1247 collect_text_loop_signals(output, false, state);
1248 }
1249 }
1250 _ => {}
1251 }
1252}
1253
1254fn collect_tool_use_loop_signals(name: &str, input: &Value, state: &mut CostState) {
1255 collect_file_read_tool_signals(name, input, state);
1256 if let Some(command) = extract_raw_tool_command(name, input) {
1257 collect_file_read_command_signals(&command, state);
1258 }
1259 if let Some(command) = extract_tool_command(name, input) {
1260 push_command(command, state);
1261 }
1262 if let Some(text) = extract_tool_text(input) {
1263 collect_text_loop_signals(&text, false, state);
1264 }
1265}
1266
1267fn collect_file_read_tool_signals(name: &str, input: &Value, state: &mut CostState) {
1268 let lower = name.to_ascii_lowercase();
1269 if !matches!(lower.as_str(), "read" | "file_read" | "read_file") {
1270 return;
1271 }
1272 let Value::Object(map) = input else {
1273 return;
1274 };
1275 let Some(path) = ["file_path", "path"]
1276 .iter()
1277 .find_map(|key| map.get(*key).and_then(Value::as_str))
1278 .map(normalize_file_read_path)
1279 .filter(|path| !path.is_empty())
1280 else {
1281 return;
1282 };
1283 let start = ["offset", "start", "line"]
1284 .iter()
1285 .find_map(|key| map.get(*key).and_then(Value::as_u64))
1286 .and_then(|value| usize::try_from(value).ok())
1287 .filter(|value| *value > 0);
1288 let lines = ["limit", "lines", "line_count"]
1289 .iter()
1290 .find_map(|key| map.get(*key).and_then(Value::as_u64))
1291 .and_then(|value| usize::try_from(value).ok())
1292 .filter(|value| *value > 0);
1293 push_file_read_signal(path, start, lines, state);
1294}
1295
1296fn collect_file_read_command_signals(command: &str, state: &mut CostState) {
1297 if let Some(signal) = parse_file_read_command(command) {
1298 state.file_read_signals.push(signal);
1299 }
1300}
1301
1302fn parse_file_read_command(command: &str) -> Option<FileReadSignal> {
1303 let tokens = shell_words(command);
1304 let head = tokens.first()?.as_str();
1305 match head {
1306 "cat" | "bat" | "batcat" | "nl" => {
1307 let path = first_non_option_arg(&tokens[1..])?;
1308 Some(file_read_signal(
1309 normalize_file_read_path(path),
1310 "full".to_string(),
1311 None,
1312 None,
1313 ))
1314 }
1315 "sed" => parse_sed_file_read(&tokens),
1316 "head" => parse_head_file_read(&tokens),
1317 "tail" => parse_tail_file_read(&tokens),
1318 _ => None,
1319 }
1320}
1321
1322fn parse_sed_file_read(tokens: &[String]) -> Option<FileReadSignal> {
1323 let mut expr = None::<String>;
1324 let mut path = None::<String>;
1325 let mut skip_next = false;
1326 for token in tokens.iter().skip(1) {
1327 if skip_next {
1328 skip_next = false;
1329 continue;
1330 }
1331 if token == "-n" {
1332 continue;
1333 }
1334 if token == "-e" {
1335 skip_next = true;
1336 continue;
1337 }
1338 if expr.is_none() && parse_sed_range(token).is_some() {
1339 expr = Some(token.clone());
1340 continue;
1341 }
1342 if !token.starts_with('-') {
1343 path = Some(token.clone());
1344 }
1345 }
1346 let expr = expr?;
1347 let path = path?;
1348 let (start, lines) = parse_sed_range(&expr)?;
1349 Some(file_read_signal(
1350 normalize_file_read_path(&path),
1351 format!("{}-{}", start, start + lines - 1),
1352 Some(start),
1353 Some(lines),
1354 ))
1355}
1356
1357fn parse_sed_range(expr: &str) -> Option<(usize, usize)> {
1358 let trimmed = expr.trim_matches(['\'', '"']).trim();
1359 let body = trimmed.strip_suffix('p')?;
1360 let (start_raw, end_raw) = body.split_once(',')?;
1361 let start = start_raw.trim().parse::<usize>().ok()?;
1362 let lines = if let Some(relative) = end_raw.trim().strip_prefix('+') {
1363 relative.trim().parse::<usize>().ok()?.saturating_add(1)
1364 } else {
1365 let end = end_raw.trim().parse::<usize>().ok()?;
1366 end.checked_sub(start)?.saturating_add(1)
1367 };
1368 (lines > 0).then_some((start, lines))
1369}
1370
1371fn parse_head_file_read(tokens: &[String]) -> Option<FileReadSignal> {
1372 let mut lines = 10_usize;
1373 let mut path = None::<String>;
1374 let mut index = 1_usize;
1375 while index < tokens.len() {
1376 let token = &tokens[index];
1377 if token == "-n" || token == "--lines" {
1378 index += 1;
1379 lines = tokens.get(index)?.parse::<usize>().ok()?;
1380 } else if let Some(value) = token.strip_prefix("-n") {
1381 lines = value.parse::<usize>().ok()?;
1382 } else if token.starts_with('-') && token[1..].chars().all(|ch| ch.is_ascii_digit()) {
1383 lines = token[1..].parse::<usize>().ok()?;
1384 } else if !token.starts_with('-') {
1385 path = Some(token.clone());
1386 }
1387 index += 1;
1388 }
1389 let path = path?;
1390 Some(file_read_signal(
1391 normalize_file_read_path(&path),
1392 format!("head:{lines}"),
1393 Some(1),
1394 Some(lines),
1395 ))
1396}
1397
1398fn parse_tail_file_read(tokens: &[String]) -> Option<FileReadSignal> {
1399 let mut lines = 10_usize;
1400 let mut path = None::<String>;
1401 let mut index = 1_usize;
1402 while index < tokens.len() {
1403 let token = &tokens[index];
1404 if token == "-n" || token == "--lines" {
1405 index += 1;
1406 lines = tokens.get(index)?.parse::<usize>().ok()?;
1407 } else if let Some(value) = token.strip_prefix("-n") {
1408 lines = value.trim_start_matches('+').parse::<usize>().ok()?;
1409 } else if token.starts_with('-') && token[1..].chars().all(|ch| ch.is_ascii_digit()) {
1410 lines = token[1..].parse::<usize>().ok()?;
1411 } else if !token.starts_with('-') {
1412 path = Some(token.clone());
1413 }
1414 index += 1;
1415 }
1416 let path = path?;
1417 Some(file_read_signal(
1418 normalize_file_read_path(&path),
1419 format!("tail:{lines}"),
1420 None,
1421 Some(lines),
1422 ))
1423}
1424
1425fn first_non_option_arg(tokens: &[String]) -> Option<&str> {
1426 tokens
1427 .iter()
1428 .find(|token| !token.starts_with('-'))
1429 .map(String::as_str)
1430}
1431
1432fn push_file_read_signal(
1433 path: String,
1434 start: Option<usize>,
1435 lines: Option<usize>,
1436 state: &mut CostState,
1437) {
1438 let range = match (start, lines) {
1439 (Some(start), Some(lines)) => format!("{}-{}", start, start + lines - 1),
1440 (Some(start), None) => format!("{start}-end"),
1441 (None, Some(lines)) => format!("window:{lines}"),
1442 (None, None) => "full".to_string(),
1443 };
1444 state
1445 .file_read_signals
1446 .push(file_read_signal(path, range, start, lines));
1447}
1448
1449fn file_read_signal(
1450 path: String,
1451 range: String,
1452 start: Option<usize>,
1453 lines: Option<usize>,
1454) -> FileReadSignal {
1455 FileReadSignal {
1456 path,
1457 range,
1458 start,
1459 lines,
1460 estimated_tokens: estimate_file_read_tokens(lines),
1461 }
1462}
1463
1464fn estimate_file_read_tokens(lines: Option<usize>) -> u64 {
1465 lines
1466 .map(|lines| (lines as u64).saturating_mul(ESTIMATED_TOKENS_PER_SOURCE_LINE))
1467 .unwrap_or(DEFAULT_FULL_FILE_READ_TOKENS)
1468 .max(80)
1469}
1470
1471fn collect_file_read_diagnostics(signals: &[FileReadSignal]) -> Vec<SessionCostFileReadDiagnostic> {
1472 let mut grouped = BTreeMap::<(String, String), FileReadDiagnosticBuilder>::new();
1473 for signal in signals {
1474 let entry = grouped
1475 .entry((signal.path.clone(), signal.range.clone()))
1476 .or_insert_with(|| FileReadDiagnosticBuilder {
1477 path: signal.path.clone(),
1478 range: signal.range.clone(),
1479 start: signal.start,
1480 lines: signal.lines,
1481 occurrences: 0,
1482 estimated_tokens: 0,
1483 max_single_read_tokens: 0,
1484 });
1485 entry.occurrences += 1;
1486 entry.estimated_tokens = entry
1487 .estimated_tokens
1488 .saturating_add(signal.estimated_tokens);
1489 entry.max_single_read_tokens = entry.max_single_read_tokens.max(signal.estimated_tokens);
1490 entry.start = entry.start.or(signal.start);
1491 entry.lines = entry.lines.or(signal.lines);
1492 }
1493
1494 let mut diagnostics = grouped
1495 .into_values()
1496 .filter(|entry| entry.occurrences >= 2)
1497 .map(|entry| {
1498 let duplicate_estimated_tokens = entry
1499 .estimated_tokens
1500 .saturating_sub(entry.max_single_read_tokens);
1501 SessionCostFileReadDiagnostic {
1502 path: entry.path.clone(),
1503 range: entry.range.clone(),
1504 occurrences: entry.occurrences,
1505 estimated_tokens: entry.estimated_tokens,
1506 duplicate_estimated_tokens,
1507 follow_up_commands: file_read_follow_up_commands(
1508 &entry.path,
1509 entry.start,
1510 entry.lines,
1511 ),
1512 }
1513 })
1514 .collect::<Vec<_>>();
1515 diagnostics.sort_by(|left, right| {
1516 right
1517 .duplicate_estimated_tokens
1518 .cmp(&left.duplicate_estimated_tokens)
1519 .then(right.occurrences.cmp(&left.occurrences))
1520 .then(left.path.cmp(&right.path))
1521 .then(left.range.cmp(&right.range))
1522 });
1523 diagnostics.truncate(MAX_FILE_READ_DIAGNOSTICS);
1524 diagnostics
1525}
1526
1527#[derive(Debug)]
1528struct FileReadDiagnosticBuilder {
1529 path: String,
1530 range: String,
1531 start: Option<usize>,
1532 lines: Option<usize>,
1533 occurrences: usize,
1534 estimated_tokens: u64,
1535 max_single_read_tokens: u64,
1536}
1537
1538fn file_read_follow_up_commands(
1539 path: &str,
1540 start: Option<usize>,
1541 lines: Option<usize>,
1542) -> Vec<String> {
1543 let start = start.unwrap_or(1);
1544 let lines = lines.unwrap_or(120).max(1);
1545 vec![
1546 format!(
1547 "tsift source-read {} --start {} --lines {} --budget normal",
1548 shell_quote(path),
1549 start,
1550 lines
1551 ),
1552 format!("tsift summarize --file {}", shell_quote(path)),
1553 ]
1554}
1555
1556fn normalize_file_read_path(raw: &str) -> String {
1557 raw.trim()
1558 .trim_matches(['\'', '"'])
1559 .trim_start_matches("./")
1560 .to_string()
1561}
1562
1563fn shell_words(command: &str) -> Vec<String> {
1564 let mut words = Vec::new();
1565 let mut current = String::new();
1566 let mut quote = None::<char>;
1567 let mut escaped = false;
1568
1569 for ch in command.chars() {
1570 if escaped {
1571 current.push(ch);
1572 escaped = false;
1573 continue;
1574 }
1575 if ch == '\\' {
1576 escaped = true;
1577 continue;
1578 }
1579 if let Some(quote_ch) = quote {
1580 if ch == quote_ch {
1581 quote = None;
1582 } else {
1583 current.push(ch);
1584 }
1585 continue;
1586 }
1587 if ch == '\'' || ch == '"' {
1588 quote = Some(ch);
1589 continue;
1590 }
1591 if ch.is_whitespace() {
1592 if !current.is_empty() {
1593 words.push(std::mem::take(&mut current));
1594 }
1595 continue;
1596 }
1597 current.push(ch);
1598 }
1599 if !current.is_empty() {
1600 words.push(current);
1601 }
1602 words
1603}
1604
1605fn shell_quote(value: &str) -> String {
1606 if value
1607 .chars()
1608 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':'))
1609 {
1610 return value.to_string();
1611 }
1612 format!("'{}'", value.replace('\'', "'\\''"))
1613}
1614
1615fn collect_text_loop_signals(text: &str, user_bias: bool, state: &mut CostState) {
1616 for raw_line in text.lines() {
1617 let trimmed = raw_line.trim();
1618 if trimmed.is_empty() || looks_like_instruction_ballast(trimmed) {
1619 continue;
1620 }
1621 let prompt_candidate = trimmed
1622 .strip_prefix("❯ ")
1623 .or_else(|| trimmed.strip_prefix("> "))
1624 .unwrap_or(trimmed)
1625 .trim();
1626 if looks_like_prompt_target(prompt_candidate, user_bias || prompt_candidate != trimmed) {
1627 push_prompt_signal(prompt_candidate, state);
1628 continue;
1629 }
1630 for (kind, detail) in detect_closeout(trimmed) {
1631 push_closeout_signal(&format!("{kind}: {detail}"), state);
1632 }
1633 }
1634}
1635
1636fn push_prompt_signal(text: &str, state: &mut CostState) {
1637 flush_pending_commands(state);
1638 push_loop_signal(LoopClusterKind::PromptRepeat, text, state);
1639}
1640
1641fn push_closeout_signal(text: &str, state: &mut CostState) {
1642 flush_pending_commands(state);
1643 push_loop_signal(LoopClusterKind::CloseoutChurn, text, state);
1644}
1645
1646fn push_command(command: String, state: &mut CostState) {
1647 let normalized = normalize_whitespace(&command);
1648 if normalized.is_empty() {
1649 return;
1650 }
1651 if state
1652 .pending_commands
1653 .last()
1654 .is_some_and(|existing| existing == &normalized)
1655 {
1656 return;
1657 }
1658 state.pending_commands.push(normalized);
1659}
1660
1661fn flush_pending_commands(state: &mut CostState) {
1662 if state.pending_commands.is_empty() {
1663 return;
1664 }
1665 let label = truncate_detail(
1666 &state
1667 .pending_commands
1668 .iter()
1669 .take(MAX_COMMANDS_PER_BUNDLE)
1670 .cloned()
1671 .collect::<Vec<_>>()
1672 .join(" -> "),
1673 220,
1674 );
1675 state.pending_commands.clear();
1676 push_loop_signal(LoopClusterKind::CommandBundle, &label, state);
1677}
1678
1679fn push_loop_signal(kind: LoopClusterKind, label: &str, state: &mut CostState) {
1680 let normalized = truncate_detail(&normalize_whitespace(label), 220);
1681 if normalized.is_empty() {
1682 return;
1683 }
1684 state.loop_signals.push(LoopSignal {
1685 kind,
1686 label: normalized,
1687 });
1688}
1689
1690fn collect_loop_clusters(signals: &[LoopSignal]) -> Vec<SessionCostLoopCluster> {
1691 let mut summary = BTreeMap::<(LoopClusterKind, String), (usize, usize)>::new();
1692 let mut previous = None::<(LoopClusterKind, String)>;
1693 let mut streak = 0_usize;
1694
1695 for signal in signals {
1696 let key = (signal.kind, signal.label.clone());
1697 let entry = summary.entry(key.clone()).or_insert((0, 0));
1698 entry.0 += 1;
1699 if previous.as_ref() == Some(&key) {
1700 streak += 1;
1701 } else {
1702 previous = Some(key.clone());
1703 streak = 1;
1704 }
1705 entry.1 = entry.1.max(streak);
1706 }
1707
1708 let mut clusters = summary
1709 .into_iter()
1710 .filter_map(|((kind, label), (occurrences, max_consecutive))| {
1711 (occurrences >= 2).then_some(SessionCostLoopCluster {
1712 kind: kind.as_str().to_string(),
1713 label,
1714 occurrences,
1715 max_consecutive,
1716 })
1717 })
1718 .collect::<Vec<_>>();
1719 clusters.sort_by(|left, right| {
1720 right
1721 .occurrences
1722 .cmp(&left.occurrences)
1723 .then(right.max_consecutive.cmp(&left.max_consecutive))
1724 .then(left.kind.cmp(&right.kind))
1725 .then(left.label.cmp(&right.label))
1726 });
1727 clusters.truncate(MAX_LOOP_CLUSTERS);
1728 clusters
1729}
1730
1731fn is_ignorable_claude_record(value: &Value) -> bool {
1732 value.get("attachment").is_some()
1733 || value.get("toolUseResult").is_some()
1734 || (value.get("message").is_none()
1735 && value.get("content").is_none()
1736 && value.get("text").is_none())
1737}
1738
1739fn collect_transcript_blocks(value: &Value, out: &mut Vec<TranscriptBlock>) {
1740 if let Some(message) = value.get("message") {
1741 collect_message_blocks(message, out);
1742 return;
1743 }
1744 collect_message_blocks(value, out);
1745}
1746
1747fn collect_message_blocks(value: &Value, out: &mut Vec<TranscriptBlock>) {
1748 let role = value
1749 .get("role")
1750 .and_then(Value::as_str)
1751 .map(|value| value.to_string());
1752 if let Some(content) = value.get("content") {
1753 match content {
1754 Value::String(text) => out.push(TranscriptBlock::Text {
1755 role,
1756 text: text.to_string(),
1757 }),
1758 Value::Array(items) => {
1759 for item in items {
1760 collect_content_block(role.clone(), item, out);
1761 }
1762 }
1763 _ => {}
1764 }
1765 } else if let Some(text) = value.get("text").and_then(Value::as_str) {
1766 out.push(TranscriptBlock::Text {
1767 role,
1768 text: text.to_string(),
1769 });
1770 }
1771}
1772
1773fn collect_content_block(role: Option<String>, value: &Value, out: &mut Vec<TranscriptBlock>) {
1774 match value.get("type").and_then(Value::as_str) {
1775 Some("text") => {
1776 if let Some(text) = value.get("text").and_then(Value::as_str) {
1777 out.push(TranscriptBlock::Text {
1778 role,
1779 text: text.to_string(),
1780 });
1781 }
1782 }
1783 Some("tool_use") => {
1784 let name = value
1785 .get("name")
1786 .and_then(Value::as_str)
1787 .unwrap_or("tool_use")
1788 .to_string();
1789 let input = value.get("input").cloned().unwrap_or(Value::Null);
1790 out.push(TranscriptBlock::ToolUse { name, input });
1791 }
1792 Some("tool_result") => match value.get("content") {
1793 Some(Value::String(text)) => out.push(TranscriptBlock::Text {
1794 role,
1795 text: text.to_string(),
1796 }),
1797 Some(Value::Array(items)) => {
1798 for item in items {
1799 collect_content_block(role.clone(), item, out);
1800 }
1801 }
1802 _ => {}
1803 },
1804 _ => {
1805 if let Some(text) = value.get("text").and_then(Value::as_str) {
1806 out.push(TranscriptBlock::Text {
1807 role,
1808 text: text.to_string(),
1809 });
1810 }
1811 }
1812 }
1813}
1814
1815fn extract_tool_command(name: &str, input: &Value) -> Option<String> {
1816 let normalized = extract_raw_tool_command(name, input)?;
1817 looks_like_command(&normalized).then_some(normalized)
1818}
1819
1820fn extract_raw_tool_command(name: &str, input: &Value) -> Option<String> {
1821 if !matches!(
1822 name.to_ascii_lowercase().as_str(),
1823 "bash" | "exec_command" | "shell" | "terminal" | "sh"
1824 ) {
1825 return None;
1826 }
1827
1828 match input {
1829 Value::Object(map) => {
1830 for key in ["command", "cmd", "shell_command"] {
1831 if let Some(raw) = map.get(key).and_then(Value::as_str) {
1832 let normalized = normalize_whitespace(raw);
1833 if !normalized.is_empty() {
1834 return Some(normalized);
1835 }
1836 }
1837 }
1838 None
1839 }
1840 Value::String(raw) => {
1841 let normalized = normalize_whitespace(raw);
1842 (!normalized.is_empty()).then_some(normalized)
1843 }
1844 _ => None,
1845 }
1846}
1847
1848fn extract_tool_text(input: &Value) -> Option<String> {
1849 match input {
1850 Value::Object(map) => {
1851 for key in ["text", "output", "stderr", "stdout", "content", "message"] {
1852 if let Some(raw) = map.get(key).and_then(Value::as_str) {
1853 return Some(raw.to_string());
1854 }
1855 }
1856 None
1857 }
1858 Value::String(raw) => Some(raw.to_string()),
1859 _ => None,
1860 }
1861}
1862
1863fn extract_codex_exec_command(payload: &Value) -> Option<String> {
1864 let normalized = extract_raw_codex_exec_command(payload)?;
1865 looks_like_command(&normalized).then_some(normalized)
1866}
1867
1868fn extract_raw_codex_exec_command(payload: &Value) -> Option<String> {
1869 if let Some(parsed) = payload.get("parsed_cmd").and_then(Value::as_array) {
1870 for item in parsed {
1871 if let Some(command) = item.get("cmd").and_then(Value::as_str) {
1872 let normalized = normalize_whitespace(command);
1873 if !normalized.is_empty() {
1874 return Some(normalized);
1875 }
1876 }
1877 }
1878 }
1879
1880 if let Some(command) = payload.get("command").and_then(Value::as_array)
1881 && let Some(last) = command.last().and_then(Value::as_str)
1882 {
1883 let normalized = normalize_whitespace(last);
1884 if !normalized.is_empty() {
1885 return Some(normalized);
1886 }
1887 }
1888 None
1889}
1890
1891fn looks_like_prompt_target(text: &str, user_bias: bool) -> bool {
1892 let trimmed = text.trim();
1893 if trimmed.is_empty()
1894 || looks_like_markdown_heading(trimmed)
1895 || looks_like_slash_command_example(trimmed)
1896 || trimmed == "#"
1897 || trimmed.starts_with("#!")
1898 || trimmed.starts_with("#[")
1899 || trimmed.starts_with("/**")
1900 || trimmed.starts_with("*/")
1901 || trimmed.starts_with("//")
1902 || trimmed.starts_with("###")
1903 || trimmed.starts_with("<!--")
1904 || trimmed.starts_with("- [")
1905 || trimmed == "###"
1906 {
1907 return false;
1908 }
1909
1910 if trimmed.starts_with("do ")
1911 || trimmed.starts_with('#')
1912 || looks_like_slash_prompt_target(trimmed)
1913 || trimmed.ends_with('?')
1914 {
1915 return true;
1916 }
1917
1918 if user_bias
1919 && (trimmed.contains("commit + push")
1920 || trimmed.contains("run tests")
1921 || trimmed.contains("build + install")
1922 || trimmed.contains("#spec-test"))
1923 {
1924 return true;
1925 }
1926
1927 false
1928}
1929
1930fn looks_like_instruction_ballast(text: &str) -> bool {
1931 let trimmed = strip_common_prefixes(text.trim());
1932 if trimmed.is_empty() {
1933 return false;
1934 }
1935
1936 looks_like_markdown_heading(trimmed)
1937 || looks_like_slash_command_example(trimmed)
1938 || looks_like_frontmatter_prompt_preset(trimmed)
1939 || looks_like_completed_backlog_archive(trimmed)
1940 || trimmed.starts_with("<!-- tsift:")
1941 || trimmed.starts_with("<!-- /tsift:")
1942 || looks_like_instruction_label(trimmed)
1943}
1944
1945fn looks_like_markdown_heading(text: &str) -> bool {
1946 let trimmed = text.trim_start();
1947 let heading_level = trimmed.chars().take_while(|ch| *ch == '#').count();
1948 heading_level > 0
1949 && heading_level <= 6
1950 && trimmed
1951 .chars()
1952 .nth(heading_level)
1953 .is_some_and(|ch| ch.is_whitespace())
1954}
1955
1956fn looks_like_slash_command_example(text: &str) -> bool {
1957 let trimmed = text.trim();
1958 trimmed.starts_with('/')
1959 && trimmed.contains('<')
1960 && trimmed.contains('>')
1961 && !trimmed.contains('`')
1962}
1963
1964fn looks_like_slash_prompt_target(text: &str) -> bool {
1965 let Some(first_token) = text.split_whitespace().next() else {
1966 return false;
1967 };
1968 first_token.starts_with('/') && !first_token[1..].contains('/')
1969}
1970
1971fn looks_like_instruction_label(text: &str) -> bool {
1972 let trimmed = text.trim();
1973 if !trimmed.starts_with("**") {
1974 return false;
1975 }
1976 let Some(label_end) = trimmed[2..].find("**") else {
1977 return false;
1978 };
1979 let label = &trimmed[..label_end + 4];
1980 if label.len() <= 4 {
1981 return false;
1982 }
1983 let remainder = trimmed[label_end + 4..]
1984 .trim_start_matches([' ', ':', '-', '—'])
1985 .trim_start();
1986 if remainder.is_empty() {
1987 return false;
1988 }
1989 let lower = remainder.to_ascii_lowercase();
1990 matches!(
1991 lower.split_whitespace().next(),
1992 Some("run")
1993 | Some("use")
1994 | Some("treat")
1995 | Some("respond")
1996 | Some("print")
1997 | Some("prefer")
1998 | Some("preserve")
1999 | Some("show")
2000 | Some("complete")
2001 | Some("append")
2002 | Some("when")
2003 | Some("if")
2004 )
2005}
2006
2007fn strip_common_prefixes(text: &str) -> &str {
2008 text.strip_prefix("❯ ")
2009 .or_else(|| text.strip_prefix("- "))
2010 .or_else(|| text.strip_prefix("* "))
2011 .or_else(|| text.strip_prefix("> "))
2012 .unwrap_or(text)
2013 .trim()
2014}
2015
2016fn looks_like_frontmatter_prompt_preset(text: &str) -> bool {
2017 let trimmed = strip_common_prefixes(text.trim());
2018 if trimmed == "prompt_presets:" || trimmed.starts_with("prompt_presets:") {
2019 return true;
2020 }
2021 let Some((key, _)) = trimmed.split_once(':') else {
2022 return false;
2023 };
2024 let key = key.trim().trim_matches(['"', '\'']);
2025 key.starts_with('#') && key.len() > 1 && key[1..].chars().all(is_prompt_preset_char)
2026}
2027
2028fn is_prompt_preset_char(ch: char) -> bool {
2029 ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')
2030}
2031
2032fn looks_like_completed_backlog_archive(text: &str) -> bool {
2033 let stripped = strip_common_prefixes(text.trim());
2034 let Some(date) = stripped.get(..10) else {
2035 return false;
2036 };
2037 date.chars().enumerate().all(|(index, ch)| match index {
2038 4 | 7 => ch == '-',
2039 _ => ch.is_ascii_digit(),
2040 }) && stripped[10..].contains("[#")
2041}
2042
2043fn looks_like_command(text: &str) -> bool {
2044 if text.is_empty()
2045 || text.contains('\n')
2046 || text.contains("://")
2047 || text.starts_with('/')
2048 || text.starts_with("###")
2049 {
2050 return false;
2051 }
2052
2053 let head = text.split_whitespace().next().unwrap_or_default();
2054 matches!(
2055 head,
2056 "agent-doc"
2057 | "cargo"
2058 | "git"
2059 | "make"
2060 | "pytest"
2061 | "python"
2062 | "uv"
2063 | "tsift"
2064 | "npm"
2065 | "pnpm"
2066 | "yarn"
2067 | "bash"
2068 | "zsh"
2069 | "rg"
2070 | "grep"
2071 | "./scripts/run_benchmark.sh"
2072 ) || head.starts_with("./")
2073}
2074
2075fn detect_closeout(text: &str) -> Vec<(String, String)> {
2076 let mut out = Vec::new();
2077 let normalized = normalize_whitespace(strip_common_prefixes(text));
2078 let lower = normalized.to_ascii_lowercase();
2079
2080 if normalized.starts_with("document_cycle ") {
2081 let phase = extract_field(&normalized, "phase");
2082 let event = extract_field(&normalized, "event");
2083 if phase == Some("committed")
2084 && let Some(event) = event
2085 {
2086 out.push((
2087 "commit".to_string(),
2088 format!("document_cycle phase=committed event={event}"),
2089 ));
2090 }
2091 return dedupe_pairs(out);
2092 }
2093
2094 if lower.contains("verification passed") || lower.starts_with("verification in ") {
2095 out.push((
2096 "verification".to_string(),
2097 truncate_detail(&normalized, 220),
2098 ));
2099 }
2100 if lower.contains("cargo build")
2101 || lower.contains("make check")
2102 || lower.contains("cargo test")
2103 || lower.contains("pytest")
2104 {
2105 out.push((
2106 "verification".to_string(),
2107 truncate_detail(&normalized, 220),
2108 ));
2109 }
2110 if lower.contains("cargo install") || lower.contains("installed") {
2111 out.push(("install".to_string(), truncate_detail(&normalized, 220)));
2112 }
2113 if lower.contains("committed and pushed") {
2114 out.push(("push".to_string(), truncate_detail(&normalized, 220)));
2115 } else if lower.contains("committed") {
2116 out.push(("commit".to_string(), truncate_detail(&normalized, 220)));
2117 }
2118 if lower.contains("tsift --version") || lower.contains("tsift v0.") {
2119 out.push(("version".to_string(), truncate_detail(&normalized, 220)));
2120 }
2121 if lower.contains("agent-doc finalize") || lower.contains("session-check") {
2122 out.push(("closeout".to_string(), truncate_detail(&normalized, 220)));
2123 }
2124
2125 dedupe_pairs(out)
2126}
2127
2128fn is_closeout_runtime_event(event_name: &str, normalized: &str) -> bool {
2129 event_name == "document_cycle"
2130 || matches!(
2131 normalized,
2132 "preflight_started"
2133 | "response_captured"
2134 | "commit_staging"
2135 | "commit_success"
2136 | "commit_already_current"
2137 | "snapshot_save"
2138 | "write_origin"
2139 | "ipc_write_attempt"
2140 | "ipc_write_consumed"
2141 | "out_of_band_write"
2142 )
2143}
2144
2145fn dedupe_pairs(items: Vec<(String, String)>) -> Vec<(String, String)> {
2146 let mut seen = BTreeSet::new();
2147 let mut deduped = Vec::new();
2148 for item in items {
2149 if seen.insert(item.clone()) {
2150 deduped.push(item);
2151 }
2152 }
2153 deduped
2154}
2155
2156fn normalize_whitespace(raw: &str) -> String {
2157 raw.split_whitespace().collect::<Vec<_>>().join(" ")
2158}
2159
2160fn truncate_detail(text: &str, max_chars: usize) -> String {
2161 if text.chars().count() <= max_chars {
2162 return text.to_string();
2163 }
2164 let mut truncated = String::new();
2165 for ch in text.chars().take(max_chars.saturating_sub(1)) {
2166 truncated.push(ch);
2167 }
2168 truncated.push('…');
2169 truncated
2170}
2171
2172fn normalize_runtime_event(event_name: &str, detail: &str) -> String {
2173 if event_name == "document_cycle"
2174 && let Some(document_event) = extract_field(detail, "event")
2175 {
2176 return document_event.to_string();
2177 }
2178 if matches!(
2179 event_name,
2180 "claude_start" | "codex_start" | "claude_restart" | "codex_restart"
2181 ) && let Some(mode) = extract_field(detail, "mode")
2182 {
2183 return format!("{event_name}:{mode}");
2184 }
2185 event_name.to_string()
2186}
2187
2188fn should_count_runtime_event(
2189 event_name: &str,
2190 detail: &str,
2191 normalized: &str,
2192 state: &mut CostState,
2193) -> bool {
2194 if event_name == "document_cycle"
2195 && let Some(cycle) = extract_field(detail, "cycle")
2196 {
2197 return state
2198 .seen_document_cycle_events
2199 .insert((cycle.to_string(), normalized.to_string()));
2200 }
2201 true
2202}
2203
2204fn usage_u64(value: &Value, key: &str) -> u64 {
2205 value.get(key).and_then(Value::as_u64).unwrap_or(0)
2206}
2207
2208fn codex_usage_totals(value: &Value) -> UsageTotals {
2209 UsageTotals {
2210 prompt_tokens: usage_u64(value, "input_tokens"),
2211 cached_input_tokens: usage_u64(value, "cached_input_tokens"),
2212 cache_creation_input_tokens: 0,
2213 output_tokens: usage_u64(value, "output_tokens"),
2214 reasoning_output_tokens: usage_u64(value, "reasoning_output_tokens"),
2215 total_tokens: usage_u64(value, "total_tokens"),
2216 }
2217}
2218
2219fn count_restart_family(restart_churn: &[RestartChurnSummary], family: &str) -> usize {
2220 restart_churn
2221 .iter()
2222 .find(|entry| entry.family == family)
2223 .map_or(0, |entry| entry.occurrences)
2224}
2225
2226fn extract_field<'a>(detail: &'a str, key: &str) -> Option<&'a str> {
2227 let needle = format!("{key}=");
2228 let start = detail.find(&needle)? + needle.len();
2229 let remainder = &detail[start..];
2230 let end = remainder
2231 .find(char::is_whitespace)
2232 .unwrap_or(remainder.len());
2233 Some(remainder[..end].trim_matches('"'))
2234}
2235
2236#[cfg(test)]
2237mod tests {
2238 use super::*;
2239
2240 #[test]
2241 fn auto_detects_claude_jsonl_and_dedupes_usage_by_message_id() {
2242 let input = concat!(
2243 r#"{"timestamp":"2026-05-05T00:00:01Z","requestId":"req-1","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":9,"cache_creation_input_tokens":300,"cache_read_input_tokens":1200,"output_tokens":10}}}"#,
2244 "\n",
2245 r#"{"timestamp":"2026-05-05T00:00:02Z","requestId":"req-1","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":9,"cache_creation_input_tokens":300,"cache_read_input_tokens":1200,"output_tokens":10}}}"#,
2246 "\n",
2247 r#"{"timestamp":"2026-05-05T00:00:03Z","requestId":"req-2","message":{"id":"msg-2","role":"assistant","usage":{"input_tokens":12,"cache_creation_input_tokens":0,"cache_read_input_tokens":800,"output_tokens":8}}}"#,
2248 "\n"
2249 );
2250
2251 let report = compute(input, None).unwrap();
2252 assert_eq!(report.source, "claude_jsonl");
2253 assert_eq!(report.usage_samples, 2);
2254 assert_eq!(report.prompt_tokens, 2321);
2255 assert_eq!(report.cached_input_tokens, 2000);
2256 assert_eq!(report.cache_creation_input_tokens, 300);
2257 assert_eq!(report.output_tokens, 18);
2258 assert_eq!(report.total_tokens, 2339);
2259 assert_eq!(report.cached_input_ratio, Some(86.17));
2260 }
2261
2262 #[test]
2263 fn codex_jsonl_uses_cumulative_deltas_and_skips_duplicate_snapshots() {
2264 let input = concat!(
2265 r#"{"timestamp":"2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":10,"total_tokens":1050}}}}"#,
2266 "\n",
2267 r#"{"timestamp":"2026-05-05T00:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1600,"cached_input_tokens":1400,"output_tokens":90,"reasoning_output_tokens":20,"total_tokens":1690}}}}"#,
2268 "\n",
2269 r#"{"timestamp":"2026-05-05T00:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1600,"cached_input_tokens":1400,"output_tokens":90,"reasoning_output_tokens":20,"total_tokens":1690}}}}"#,
2270 "\n"
2271 );
2272
2273 let report = compute(input, Some("codex-jsonl")).unwrap();
2274 assert_eq!(report.usage_samples, 2);
2275 assert_eq!(report.prompt_tokens, 1600);
2276 assert_eq!(report.cached_input_tokens, 1400);
2277 assert_eq!(report.output_tokens, 90);
2278 assert_eq!(report.reasoning_output_tokens, 20);
2279 assert_eq!(report.total_tokens, 1690);
2280 assert_eq!(report.largest_turn_total_tokens, 1050);
2281 assert_eq!(report.largest_turns[0].total_tokens, 1050);
2282 assert_eq!(report.largest_turns[1].total_tokens, 640);
2283 }
2284
2285 #[test]
2286 fn codex_jsonl_prefers_last_usage_for_interleaved_cumulative_streams() {
2287 let input = concat!(
2288 r#"{"timestamp":"2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":10,"total_tokens":1050},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":10,"total_tokens":1050}}}}"#,
2289 "\n",
2290 r#"{"timestamp":"2026-05-05T00:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":500,"cached_input_tokens":450,"output_tokens":20,"reasoning_output_tokens":5,"total_tokens":520},"last_token_usage":{"input_tokens":500,"cached_input_tokens":450,"output_tokens":20,"reasoning_output_tokens":5,"total_tokens":520}}}}"#,
2291 "\n",
2292 r#"{"timestamp":"2026-05-05T00:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1600,"cached_input_tokens":1400,"output_tokens":90,"reasoning_output_tokens":20,"total_tokens":1690},"last_token_usage":{"input_tokens":600,"cached_input_tokens":500,"output_tokens":40,"reasoning_output_tokens":10,"total_tokens":640}}}}"#,
2293 "\n",
2294 r#"{"timestamp":"2026-05-05T00:00:04Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":900,"cached_input_tokens":800,"output_tokens":45,"reasoning_output_tokens":10,"total_tokens":945},"last_token_usage":{"input_tokens":400,"cached_input_tokens":350,"output_tokens":25,"reasoning_output_tokens":5,"total_tokens":425}}}}"#,
2295 "\n",
2296 r#"{"timestamp":"2026-05-05T00:00:05Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":900,"cached_input_tokens":800,"output_tokens":45,"reasoning_output_tokens":10,"total_tokens":945},"last_token_usage":{"input_tokens":400,"cached_input_tokens":350,"output_tokens":25,"reasoning_output_tokens":5,"total_tokens":425}}}}"#,
2297 "\n"
2298 );
2299
2300 let report = compute(input, Some("codex-jsonl")).unwrap();
2301 assert_eq!(report.usage_samples, 4);
2302 assert_eq!(report.prompt_tokens, 2500);
2303 assert_eq!(report.cached_input_tokens, 2200);
2304 assert_eq!(report.output_tokens, 135);
2305 assert_eq!(report.reasoning_output_tokens, 30);
2306 assert_eq!(report.total_tokens, 2635);
2307 assert_eq!(report.largest_turn_total_tokens, 1050);
2308 }
2309
2310 #[test]
2311 fn prompt_cache_plan_summarizes_effectiveness_over_time() {
2312 let input = concat!(
2313 r#"{"timestamp":"2026-05-05T00:00:01Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":1000,"cached_input_tokens":100,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":100,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050}}}}"#,
2314 "\n",
2315 r#"{"timestamp":"2026-05-05T00:00:02Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":2000,"cached_input_tokens":600,"output_tokens":100,"reasoning_output_tokens":0,"total_tokens":2100},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":500,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050}}}}"#,
2316 "\n",
2317 r#"{"timestamp":"2026-05-05T00:00:03Z","type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":3000,"cached_input_tokens":1500,"output_tokens":150,"reasoning_output_tokens":0,"total_tokens":3150},"last_token_usage":{"input_tokens":1000,"cached_input_tokens":900,"output_tokens":50,"reasoning_output_tokens":0,"total_tokens":1050}}}}"#,
2318 "\n",
2319 );
2320
2321 let report = compute(input, Some("codex-jsonl")).unwrap();
2322 let analytics = report
2323 .prompt_cache_plan
2324 .as_ref()
2325 .and_then(|plan| plan.analytics.as_ref())
2326 .expect("prompt cache analytics should be present");
2327
2328 assert_eq!(analytics.sample_count, 3);
2329 assert!(!analytics.effective);
2330 assert_eq!(analytics.trend, "improving");
2331 assert_eq!(
2332 analytics.average_cached_input_ratio.as_deref(),
2333 Some("50.00%")
2334 );
2335 assert_eq!(
2336 analytics.first_cached_input_ratio.as_deref(),
2337 Some("10.00%")
2338 );
2339 assert_eq!(analytics.last_cached_input_ratio.as_deref(), Some("90.00%"));
2340 assert_eq!(
2341 analytics.cached_input_ratio_delta.as_deref(),
2342 Some("+80.00%")
2343 );
2344 assert_eq!(analytics.net_cached_input_tokens, 1500);
2345 assert_eq!(analytics.timeline.len(), 3);
2346 assert_eq!(
2347 analytics.timeline[2].cached_input_ratio.as_deref(),
2348 Some("90.00%")
2349 );
2350 }
2351
2352 #[test]
2353 fn prompt_cache_plan_classifies_likely_invalidation_diagnostics() {
2354 let input = concat!(
2355 r#"{"timestamp":"2026-05-05T00:00:01Z","message":{"id":"msg-1","role":"assistant","usage":{"input_tokens":1000,"cache_creation_input_tokens":0,"cache_read_input_tokens":9000,"output_tokens":50}}}"#,
2356 "\n",
2357 r#"{"timestamp":"2026-05-05T00:00:02Z","message":{"id":"msg-2","role":"assistant","usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#,
2358 "\n",
2359 r#"{"timestamp":"2026-05-05T00:00:03Z","message":{"id":"msg-3","role":"assistant","usage":{"input_tokens":3000,"cache_creation_input_tokens":6000,"cache_read_input_tokens":1000,"output_tokens":50}}}"#,
2360 "\n",
2361 );
2362
2363 let report = compute(input, Some("claude-jsonl")).unwrap();
2364 let diagnostics = &report
2365 .prompt_cache_plan
2366 .as_ref()
2367 .and_then(|plan| plan.analytics.as_ref())
2368 .expect("prompt cache analytics should be present")
2369 .diagnostics;
2370
2371 assert!(diagnostics.iter().any(|diagnostic| {
2372 diagnostic.kind == "cached_ratio_drop"
2373 && diagnostic.label == "2026-05-05T00:00:02Z"
2374 && diagnostic
2375 .likely_causes
2376 .iter()
2377 .any(|cause| cause.contains("prompt_cache_key"))
2378 }));
2379 assert!(diagnostics.iter().any(|diagnostic| {
2380 diagnostic.kind == "cache_creation_spike" && diagnostic.message.contains("60.00%")
2381 }));
2382 assert!(diagnostics.iter().any(|diagnostic| {
2383 diagnostic.kind == "read_create_regression" && diagnostic.message.contains("0.92x")
2384 }));
2385 }
2386
2387 #[test]
2388 fn agent_doc_log_summarizes_runtime_churn() {
2389 let input = "\
2390[1776452736] claude_start mode=fresh restart_count=0
2391[1776528398] claude_start mode=fresh_restart restart_count=1
2392[1776528446] auto_trigger_timeout (no prompt after 30s)
2393[1776528450] ctrl_d_restart_fresh restart_count=2
2394[1776528582] claude_start mode=fresh_restart restart_count=2
2395[1776528599] codex_start mode=continue restart_count=3
2396[1776528601] user_quit_after_ctrl_d
2397[1776528602] commit_already_current file=tasks/software/tsift.md basis=head
2398[1776528603] commit_already_current file=tasks/software/tsift.md basis=head
2399[1776528604] commit_already_current file=tasks/software/tsift.md basis=head
2400";
2401
2402 let report = compute(input, Some("agent-doc-log")).unwrap();
2403 assert_eq!(report.source, "agent_doc_log");
2404 assert_eq!(report.usage_samples, 0);
2405 assert_eq!(report.runtime_event_groups, 7);
2406 assert_eq!(report.total_runtime_events, 10);
2407 assert_eq!(report.restart_churn_groups, 4);
2408 assert_eq!(report.max_restart_count, Some(3));
2409 assert!(
2410 report
2411 .runtime_events
2412 .iter()
2413 .any(|event| event.event == "claude_start:fresh_restart" && event.occurrences == 2)
2414 );
2415 assert!(
2416 report
2417 .runtime_events
2418 .iter()
2419 .any(|event| event.event == "auto_trigger_timeout" && event.occurrences == 1)
2420 );
2421 assert!(
2422 report
2423 .restart_churn
2424 .iter()
2425 .any(|entry| entry.family == "fresh_restart" && entry.occurrences == 3)
2426 );
2427 assert!(
2428 report
2429 .restart_churn
2430 .iter()
2431 .any(|entry| entry.family == "ctrl_d_restart_loop" && entry.occurrences == 1)
2432 );
2433 assert!(
2434 report
2435 .restart_churn
2436 .iter()
2437 .any(|entry| entry.family == "quit_after_eof" && entry.occurrences == 1)
2438 );
2439 assert!(
2440 report
2441 .guardrails
2442 .iter()
2443 .any(|guardrail| guardrail.kind == "restart_loop")
2444 );
2445 assert!(
2446 report
2447 .guardrails
2448 .iter()
2449 .any(|guardrail| guardrail.kind == "noop_closeout")
2450 );
2451 assert!(
2452 report
2453 .loop_clusters
2454 .iter()
2455 .any(|cluster| cluster.kind == "closeout_churn"
2456 && cluster.label == "commit_already_current"
2457 && cluster.occurrences == 3)
2458 );
2459 }
2460
2461 #[test]
2462 fn agent_doc_log_dedupes_document_cycle_runtime_events_by_cycle() {
2463 let input = "\
2464[1777603275] document_cycle phase=response_captured cycle=cycle-1 event=response_captured capture_id=cycle-1
2465[1777603276] document_cycle phase=committed cycle=cycle-1 event=commit_success capture_id=cycle-1
2466[1777603403] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
2467[1777603404] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
2468[1777603405] document_cycle phase=committed cycle=cycle-1 event=commit_already_current capture_id=cycle-1
2469[1777603500] document_cycle phase=preflight_started cycle=cycle-2 event=preflight_started
2470[1777603600] document_cycle phase=committed cycle=cycle-2 event=commit_already_current
2471[1777603601] document_cycle phase=committed cycle=cycle-2 event=commit_already_current
2472[1777603700] document_cycle phase=committed cycle=cycle-3 event=commit_already_current
2473";
2474
2475 let report = compute(input, Some("agent-doc-log")).unwrap();
2476
2477 assert_eq!(report.total_runtime_events, 6);
2478 assert!(
2479 report
2480 .runtime_events
2481 .iter()
2482 .any(|event| event.event == "commit_already_current" && event.occurrences == 3)
2483 );
2484 assert!(
2485 report
2486 .runtime_events
2487 .iter()
2488 .any(|event| event.event == "commit_success" && event.occurrences == 1)
2489 );
2490 assert!(
2491 report
2492 .runtime_events
2493 .iter()
2494 .any(|event| event.event == "response_captured" && event.occurrences == 1)
2495 );
2496 assert!(
2497 report
2498 .guardrails
2499 .iter()
2500 .any(|guardrail| guardrail.kind == "noop_closeout")
2501 );
2502 assert!(
2503 report
2504 .loop_clusters
2505 .iter()
2506 .any(|cluster| cluster.kind == "closeout_churn"
2507 && cluster.label == "commit_already_current"
2508 && cluster.occurrences == 3)
2509 );
2510 }
2511
2512 #[test]
2513 fn codex_jsonl_surfaces_prompt_and_command_loop_clusters() {
2514 let input = concat!(
2515 r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push"}}"#,
2516 "\n",
2517 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
2518 "\n",
2519 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
2520 "\n",
2521 r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
2522 "\n",
2523 r#"{"type":"event_msg","payload":{"type":"user_message","message":"do [#looprank]. spec-test-build-install-commit-push"}}"#,
2524 "\n",
2525 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo test\"}"}}"#,
2526 "\n",
2527 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cargo build --release\"}"}}"#,
2528 "\n",
2529 r#"{"type":"event_msg","payload":{"type":"agent_message","message":"Committed and pushed in `src/tsift` as `abc123`."}}"#,
2530 "\n"
2531 );
2532
2533 let report = compute(input, Some("codex-jsonl")).unwrap();
2534
2535 assert!(
2536 report
2537 .loop_clusters
2538 .iter()
2539 .any(|cluster| cluster.kind == "prompt_repeat"
2540 && cluster.label == "do [#looprank]. spec-test-build-install-commit-push"
2541 && cluster.occurrences == 2)
2542 );
2543 assert!(
2544 report
2545 .loop_clusters
2546 .iter()
2547 .any(|cluster| cluster.kind == "command_bundle"
2548 && cluster.label == "cargo test -> cargo build --release"
2549 && cluster.occurrences == 2)
2550 );
2551 assert!(report.loop_clusters.iter().any(|cluster| {
2552 cluster.kind == "closeout_churn"
2553 && cluster
2554 .label
2555 .contains("Committed and pushed in `src/tsift`")
2556 && cluster.occurrences == 2
2557 }));
2558 }
2559
2560 #[test]
2561 fn codex_jsonl_surfaces_repeated_file_read_diagnostics() {
2562 let input = concat!(
2563 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,220p' src/session_cost.rs\"}"}}"#,
2564 "\n",
2565 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"sed -n '1,220p' src/session_cost.rs\"}"}}"#,
2566 "\n",
2567 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cat src/main.rs\"}"}}"#,
2568 "\n",
2569 r#"{"type":"response_item","payload":{"type":"function_call","name":"exec_command","arguments":"{\"cmd\":\"cat src/main.rs\"}"}}"#,
2570 "\n"
2571 );
2572
2573 let report = compute(input, Some("codex-jsonl")).unwrap();
2574
2575 assert!(report.file_read_diagnostics.iter().any(|diagnostic| {
2576 diagnostic.path == "src/session_cost.rs"
2577 && diagnostic.range == "1-220"
2578 && diagnostic.occurrences == 2
2579 && diagnostic.duplicate_estimated_tokens == 3_960
2580 && diagnostic.follow_up_commands.iter().any(|command| {
2581 command == "tsift source-read src/session_cost.rs --start 1 --lines 220 --budget normal"
2582 })
2583 }));
2584 assert!(report.file_read_diagnostics.iter().any(|diagnostic| {
2585 diagnostic.path == "src/main.rs"
2586 && diagnostic.range == "full"
2587 && diagnostic.duplicate_estimated_tokens == 4_000
2588 && diagnostic
2589 .follow_up_commands
2590 .iter()
2591 .any(|command| command == "tsift summarize --file src/main.rs")
2592 }));
2593 }
2594
2595 #[test]
2596 fn claude_jsonl_surfaces_repeated_native_read_tool_diagnostics() {
2597 let input = concat!(
2598 r#"{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","offset":40,"limit":80}}]}}"#,
2599 "\n",
2600 r#"{"message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","offset":40,"limit":80}}]}}"#,
2601 "\n"
2602 );
2603
2604 let report = compute(input, Some("claude-jsonl")).unwrap();
2605
2606 assert_eq!(report.file_read_diagnostics.len(), 1);
2607 let diagnostic = &report.file_read_diagnostics[0];
2608 assert_eq!(diagnostic.path, "src/lib.rs");
2609 assert_eq!(diagnostic.range, "40-119");
2610 assert_eq!(diagnostic.occurrences, 2);
2611 assert_eq!(diagnostic.duplicate_estimated_tokens, 1_440);
2612 assert!(diagnostic.follow_up_commands.iter().any(|command| {
2613 command == "tsift source-read src/lib.rs --start 40 --lines 80 --budget normal"
2614 }));
2615 }
2616
2617 #[test]
2618 fn derive_guardrails_flags_large_prompt_turns() {
2619 let guardrails = derive_guardrails(&SessionCostGuardrailInput {
2620 largest_prompt_turn_tokens: 140_000,
2621 largest_prompt_turn_label: Some("2026-05-05T00:00:01Z".to_string()),
2622 ..SessionCostGuardrailInput::default()
2623 });
2624
2625 assert!(
2626 guardrails
2627 .iter()
2628 .any(|guardrail| guardrail.kind == "prompt_budget")
2629 );
2630 }
2631
2632 #[test]
2633 fn derive_guardrails_flags_cached_resend_ratio() {
2634 let guardrails = derive_guardrails(&SessionCostGuardrailInput {
2635 prompt_tokens: 80_000,
2636 cached_input_ratio: Some(96.0),
2637 ..SessionCostGuardrailInput::default()
2638 });
2639
2640 assert!(
2641 guardrails
2642 .iter()
2643 .any(|guardrail| guardrail.kind == "cache_resend")
2644 );
2645 }
2646
2647 #[test]
2648 fn derive_guardrails_ignores_restart_count_without_churn() {
2649 let guardrails = derive_guardrails(&SessionCostGuardrailInput {
2650 max_restart_count: Some(3),
2651 ..SessionCostGuardrailInput::default()
2652 });
2653
2654 assert!(
2655 guardrails
2656 .iter()
2657 .all(|guardrail| guardrail.kind != "restart_loop")
2658 );
2659 }
2660}