collet 0.1.1

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Session benchmark logging.
//!
//! Each completed agent task is appended as a JSON line to
//! `~/.collet/logs/bench.jsonl`.
//!
//! Entries older than `bench_retain_days` are pruned automatically on session end.

use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::Path;

// ---------------------------------------------------------------------------
// Data model
// ---------------------------------------------------------------------------

/// One agent-task record written at `AgentEvent::Done`.
#[derive(Debug, Serialize, Deserialize)]
pub struct BenchEntry {
    /// Unix timestamp (seconds) when the task completed.
    pub ts: u64,
    /// Session ID (UUID).
    pub session_id: String,
    /// Last 2 path segments of the working directory.
    pub project: String,
    /// First 200 chars of the user's task message.
    pub task: String,
    /// Model name used for this task.
    pub model: String,
    /// Agent mode: "code" | "ask" | "arch".
    pub mode: String,
    /// Number of agent iterations.
    pub iter: u32,
    /// Wall-clock seconds elapsed.
    pub secs: u64,
    /// Total tool calls made.
    pub tools: u32,
    /// Successful tool calls.
    pub tools_ok: u32,
    /// Input (prompt) tokens consumed this task.
    pub tokens_in: u64,
    /// Output (completion) tokens produced this task.
    pub tokens_out: u64,
    /// API call count this task.
    pub api_calls: u32,
    /// Cache hit rate 0–100 (rounded).
    pub cache_pct: u8,
    /// Context usage percentage 0–100 at task end.
    pub ctx_pct: u8,
    /// Number of context compactions this task.
    pub compactions: u32,
    /// Whether the task was cancelled by the user.
    pub cancelled: bool,
    /// S2C: Compaction quality metrics (populated when compaction occurred).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub compaction_quality: Option<CompactionQuality>,

    // ── Agent performance metrics ──
    /// Average tool call latency in ms.
    #[serde(default)]
    pub tool_latency_avg_ms: f64,
    /// Maximum tool call latency in ms.
    #[serde(default)]
    pub tool_latency_max_ms: u64,
    /// Average API call latency in ms.
    #[serde(default)]
    pub api_latency_avg_ms: f64,
    /// Maximum API call latency in ms.
    #[serde(default)]
    pub api_latency_max_ms: u64,
    /// Tool success rate 0.0–100.0.
    #[serde(default = "default_success_rate")]
    pub tool_success_rate: f32,
    /// Average tokens consumed per iteration.
    #[serde(default)]
    pub tokens_per_iteration: f64,
    /// Average tool calls per iteration.
    #[serde(default)]
    pub tools_per_iteration: f64,
    /// Top 3 most-used tools [(name, count)].
    #[serde(default)]
    pub top_tools: Vec<(String, u32)>,

    /// Continuation round (0 = initial execution, 1+ = auto-continuation).
    #[serde(default)]
    pub continuation_round: u32,

    // ── Outcome & provider (added for telemetry) ──
    /// Task outcome: "success" | "timeout" | "iteration_limit" | "circuit_breaker" | "cancelled".
    #[serde(default = "default_outcome")]
    pub outcome: String,
    /// Provider name used for this task (e.g. "OpenAI", "Z.ai").
    #[serde(default)]
    pub provider: String,
}

fn default_success_rate() -> f32 {
    100.0
}

fn default_outcome() -> String {
    "success".to_string()
}

/// Compaction quality metrics — measures information preservation after compaction.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct CompactionQuality {
    /// Fraction of modified file paths preserved in the compacted context (0.0–1.0).
    pub preserved_files: f32,
    /// Fraction of decision sentences preserved (0.0–1.0).
    pub preserved_decisions: f32,
    /// Fraction of error history preserved (0.0–1.0).
    pub preserved_errors: f32,
    /// Token reduction ratio (1.0 = no reduction, 0.5 = halved).
    pub token_reduction: f32,
}

// ---------------------------------------------------------------------------
// I/O helpers
// ---------------------------------------------------------------------------

/// Append one entry to the JSONL benchmark log.
///
/// Creates the parent directory and file if they don't exist.
pub fn append_entry(path: &Path, entry: &BenchEntry) -> std::io::Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent)?;
    }
    let mut file = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)?;
    let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
    writeln!(file, "{line}")
}

/// Remove entries older than `retain_days` from the JSONL log.
///
/// Rewrites the file in-place keeping only recent lines.
/// Silently ignores I/O errors (logging failure must never crash the TUI).
pub fn prune_old_entries(path: &Path, retain_days: u32) {
    if !path.exists() || retain_days == 0 {
        return;
    }
    let cutoff = unix_now().saturating_sub(retain_days as u64 * 86_400);
    let Ok(content) = std::fs::read_to_string(path) else {
        return;
    };
    let kept: Vec<&str> = content
        .lines()
        .filter(|line| {
            serde_json::from_str::<BenchEntry>(line)
                .map(|e| e.ts >= cutoff)
                .unwrap_or(true) // keep unparseable lines to avoid data loss
        })
        .collect();

    let new_content = if kept.is_empty() {
        String::new()
    } else {
        kept.join("\n") + "\n"
    };
    let _ = std::fs::write(path, new_content);
}

// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------

/// Current Unix timestamp in seconds.
pub fn unix_now() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0)
}

/// Shorten a working directory path to its last 2 components.
///
/// e.g. `/Users/foo/workspace/my-project` → `workspace/my-project`
pub fn short_path(working_dir: &str) -> String {
    let path = std::path::Path::new(working_dir);
    let parts: Vec<&str> = path
        .components()
        .filter_map(|c| c.as_os_str().to_str())
        .collect();
    match parts.len() {
        0 => working_dir.to_string(),
        1 => parts[0].to_string(),
        n => format!("{}/{}", parts[n - 2], parts[n - 1]),
    }
}