Skip to main content

tsift_agent_doc/
session_cost.rs

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}