libnoa 0.4.0

AI-native distributed version control
Documentation
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ModelRecord {
    pub model_id: String,
    pub upload: u64,
    pub download: u64,
    pub cache: Option<u64>,
}

#[derive(Debug, Clone, Default)]
pub struct SessionSummary {
    pub models: Vec<ModelRecord>,
    pub yolo: bool,
}

pub fn default_chat_log_dir() -> Option<PathBuf> {
    if let Ok(p) = std::env::var("ENTELECHEIA_CHAT_LOG_DIR") {
        return Some(PathBuf::from(p));
    }
    if let Some(home) = dirs_or_home() {
        return Some(home.join(".config").join("entelecheia").join("chat_logs"));
    }
    None
}

fn dirs_or_home() -> Option<PathBuf> {
    if let Ok(h) = std::env::var("HOME") {
        if !h.is_empty() {
            return Some(PathBuf::from(h));
        }
    }
    None
}

pub fn yolo_active_marker() -> Option<PathBuf> {
    let candidates = [
        PathBuf::from("/run/entelecheia/yolo_active"),
        PathBuf::from("/tmp/entelecheia-yolo-active"),
    ];
    candidates.into_iter().find(|p| p.exists())
}

pub fn summarize_chat_log_dir(dir: &Path, lookback_secs: u64) -> SessionSummary {
    let entries = match std::fs::read_dir(dir) {
        Ok(e) => e.flatten().collect::<Vec<_>>(),
        Err(_) => return SessionSummary::default(),
    };
    let mut logs: Vec<(std::time::SystemTime, PathBuf)> = entries
        .into_iter()
        .filter_map(|e| {
            let name = e.file_name().to_string_lossy().to_string();
            if !(name.starts_with("chat#") && name.ends_with(".log")) {
                return None;
            }
            e.metadata()
                .ok()
                .and_then(|m| m.modified().ok())
                .map(|t| (t, e.path()))
        })
        .collect();
    logs.sort_by_key(|b| std::cmp::Reverse(b.0));

    let now = std::time::SystemTime::now();
    let cutoff: Option<std::time::Duration> = if lookback_secs > 0 {
        Some(std::time::Duration::from_secs(lookback_secs))
    } else {
        None
    };
    let now_age = now
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default();

    let mut models_map: std::collections::HashMap<String, ModelRecord> =
        std::collections::HashMap::new();
    let mut yolo = false;

    let max_files = if lookback_secs == 0 { 4 } else { 6 };
    let mut found_model_data = false;
    for (_mtime, path) in logs.iter().take(max_files) {
        if let Some(window) = cutoff {
            if let Ok(file_time) = std::fs::metadata(path).and_then(|m| m.modified()) {
                if let Ok(file_age) = file_time.duration_since(std::time::UNIX_EPOCH) {
                    if now_age.saturating_sub(file_age) > window {
                        continue;
                    }
                }
            }
        }
        let content = match std::fs::read_to_string(path) {
            Ok(c) => c,
            Err(_) => continue,
        };
        if content.contains("YOLO cruise control") || content.contains("YOLO auto") {
            yolo = true;
        }
        let entries = parse_chat_log_entries(&content);
        let log_total_tokens: u64 = entries
            .iter()
            .map(|e| e.upload.saturating_add(e.download))
            .sum();
        let has_models = entries.iter().any(|e| !e.model.is_empty()) && log_total_tokens > 0;
        for entry in &entries {
            if entry.model.is_empty() {
                continue;
            }
            let rec = models_map
                .entry(entry.model.clone())
                .or_insert_with(|| ModelRecord {
                    model_id: entry.model.clone(),
                    ..Default::default()
                });
            rec.upload = rec.upload.saturating_add(entry.upload);
            rec.download = rec.download.saturating_add(entry.download);
            if let Some(c) = entry.cache {
                rec.cache = Some(rec.cache.unwrap_or(0).saturating_add(c));
            }
        }
        if has_models {
            found_model_data = true;
            if lookback_secs == 0 {
                break;
            }
        }
    }
    if !found_model_data {
        models_map.clear();
    }

    let mut models: Vec<ModelRecord> = models_map.into_values().collect();
    models.sort_by(|a, b| a.model_id.cmp(&b.model_id));
    if models.is_empty() && yolo_active_marker().is_some() {
        yolo = true;
    }
    SessionSummary { models, yolo }
}

#[derive(Debug, Default)]
struct ParsedEntry {
    model: String,
    upload: u64,
    download: u64,
    cache: Option<u64>,
}

fn parse_chat_log_entries(content: &str) -> Vec<ParsedEntry> {
    let mut out = Vec::new();
    let mut lines = content.lines().peekable();
    while let Some(line) = lines.next() {
        let trimmed = line.trim();
        if trimmed != "+++" {
            continue;
        }
        let mut header = String::new();
        for hline in lines.by_ref() {
            if hline.trim() == "+++" {
                break;
            }
            header.push_str(hline);
            header.push('\n');
        }
        if header.contains("model =") {
            if let Some(entry) = parse_header(&header) {
                out.push(entry);
            }
        }
    }
    out
}

fn parse_header(header: &str) -> Option<ParsedEntry> {
    let map: std::collections::HashMap<String, String> = toml::from_str::<toml::Value>(header)
        .ok()
        .and_then(|v| v.as_table().cloned())?
        .into_iter()
        .filter_map(|(k, v)| {
            let s = match &v {
                toml::Value::String(s) => s.clone(),
                toml::Value::Integer(i) => i.to_string(),
                toml::Value::Boolean(b) => b.to_string(),
                toml::Value::Float(f) => f.to_string(),
                _ => return None,
            };
            Some((k, s))
        })
        .collect();
    let model = map.get("model").cloned()?;
    if model.is_empty() || model == "null" {
        return None;
    }
    let upload = map
        .get("input_tokens")
        .and_then(|s| s.parse::<u64>().ok())
        .unwrap_or(0);
    let download = map
        .get("output_tokens")
        .and_then(|s| s.parse::<u64>().ok())
        .unwrap_or(0);
    let cache = map
        .get("cache_read_input_tokens")
        .or_else(|| map.get("cached_tokens"))
        .or_else(|| map.get("cache_tokens"))
        .and_then(|s| s.parse::<u64>().ok())
        .filter(|c| *c > 0);
    Some(ParsedEntry {
        model,
        upload,
        download,
        cache,
    })
}