unlost 0.20.3

Unlost - Local-first code memory for a workspace.
Documentation
use chrono::TimeZone;
use colored::Colorize;

fn truncate_chars(s: &str, max_chars: usize) -> String {
    let mut it = s.chars();
    let head: String = it.by_ref().take(max_chars).collect();
    if it.next().is_some() {
        format!("{}...", head)
    } else {
        head
    }
}

fn squash_whitespace(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut in_ws = false;
    for ch in s.chars() {
        if ch.is_whitespace() {
            if !in_ws {
                out.push(' ');
                in_ws = true;
            }
        } else {
            in_ws = false;
            out.push(ch);
        }
    }
    out.trim().to_string()
}

pub fn run(
    path: String,
    limit: usize,
    since: Option<String>,
    until: Option<String>,
) -> anyhow::Result<()> {
    let ws = crate::workspace::get_or_create_workspace_paths(std::path::Path::new(&path))?;

    let since_ms = match since {
        Some(ref s) => crate::util::parse_time_filter(s)?,
        None => None,
    };
    let until_ms = match until {
        Some(ref u) => crate::util::parse_time_filter(u)?,
        None => None,
    };

    let all_interventions = crate::metrics::get_recent_interventions(&ws.metrics_jsonl, 1000)?;

    // Note: don't apply `limit` until after we collapse identical adjacent items.
    let filtered: Vec<_> = all_interventions
        .into_iter()
        .filter(|iv| {
            let after = since_ms.map(|s| iv.ts_ms >= s).unwrap_or(true);
            let before = until_ms.map(|u| iv.ts_ms <= u).unwrap_or(true);
            after && before
        })
        .collect();

    if filtered.is_empty() {
        println!(
            "{} {}",
            "No interventions found for workspace:".dimmed(),
            ws.id
        );
        return Ok(());
    }

    println!("{} {}", "workspace:".dimmed(), ws.id.bold());
    println!();
    println!("{}", "Recent friction interventions:".dimmed());

    let now = crate::workspace::now_ms();
    let blacklist = [
        "NOTE",
        "REASON",
        "DECISION",
        "INTENT",
        "NEXT_STEPS",
        "RATIONALE",
        "SYMBOLS",
        "USER",
        "ASSISTANT",
        "SYSTEM",
        "SUCCESS",
        "FAILURE",
        "ERROR",
        "WARNING",
    ];

    #[derive(Clone)]
    struct Row {
        fingerprint: String,
        ts_str: String,
        ago_str: String,
        duration_str: String,
        severity: String,
        diagnosis: String,
        emotion_str: String,
        topic_preview: Option<String>,
        symbols_str: Option<String>,
        count: usize,
    }

    let mut rows: Vec<Row> = Vec::new();
    for iv in filtered.iter() {
        let ts_str = chrono::Utc
            .timestamp_millis_opt(iv.ts_ms)
            .single()
            .map(|dt| dt.format("%H:%M").to_string())
            .unwrap_or_else(|| "--:--".to_string());

        let ago_str = crate::util::format_elapsed_time(iv.ts_ms, now);

        let duration_str = if let Some(start) = iv.watch_start_ts {
            let dur_mins = (iv.ts_ms - start) / 60000;
            if dur_mins > 0 {
                format!("Intervened after {}m", dur_mins)
            } else {
                "Intervened".to_string()
            }
        } else {
            "Intervened".to_string()
        };

        let diagnosis = crate::metrics::get_diagnosis(&iv.cause, &iv.top_channels);
        let severity = crate::metrics::get_severity_label(iv.intensity).to_string();

        let emotion_str = if let Some(ref e) = iv.user_emotion {
            if crate::governor::FRICTION_EMOTIONS.contains(&e.as_str()) {
                format!(" (user {})", e)
            } else {
                "".to_string()
            }
        } else {
            "".to_string()
        };

        let mut clean_symbols = Vec::new();
        for s in &iv.symbols {
            let trimmed = s.trim_matches(|c: char| c == ':' || c == '.' || c == ',');
            if blacklist.iter().any(|&b| b == trimmed.to_uppercase()) {
                continue;
            }
            if trimmed.contains('/') && !trimmed.contains('.') {
                let parts: Vec<&str> = trimmed.split('/').collect();
                if parts.iter().any(|&p| {
                    p == "read" || p == "write" || p == "inspect" || p == "call" || p == "tool"
                }) {
                    continue;
                }
            }
            if !trimmed.is_empty() {
                clean_symbols.push(trimmed.to_string());
            }
        }

        let symbols_str = if clean_symbols.is_empty() {
            None
        } else if clean_symbols.len() > 3 {
            Some(format!(
                "{}, {} and {} others",
                clean_symbols[0],
                clean_symbols[1],
                clean_symbols.len() - 2
            ))
        } else {
            Some(clean_symbols.join(", "))
        };

        let topic = squash_whitespace(iv.topic.as_deref().unwrap_or(""));
        let topic_preview = if topic.is_empty() {
            None
        } else {
            Some(truncate_chars(&topic, 200))
        };

        let fingerprint = format!(
            "{}|{}|{}|{}|{}|{}",
            ts_str,
            duration_str,
            severity,
            diagnosis,
            emotion_str,
            topic_preview.as_deref().unwrap_or("")
        );
        let fingerprint = if let Some(ref sym) = symbols_str {
            format!("{}|{}", fingerprint, sym)
        } else {
            fingerprint
        };

        if let Some(last) = rows.last_mut() {
            if last.fingerprint == fingerprint {
                last.count += 1;
                continue;
            }
        }

        rows.push(Row {
            fingerprint,
            ts_str,
            ago_str,
            duration_str,
            severity,
            diagnosis,
            emotion_str,
            topic_preview,
            symbols_str,
            count: 1,
        });
    }

    rows.truncate(limit);
    for (i, row) in rows.iter().enumerate() {
        let sev = match row.severity.as_str() {
            "Acute" => row.severity.red().bold(),
            "Strong" => row.severity.yellow().bold(),
            _ => row.severity.cyan().bold(),
        };

        let repeat = if row.count > 1 {
            format!(" x{}", row.count).dimmed().to_string()
        } else {
            String::new()
        };

        let emotion = if row.emotion_str.contains("disapproval") {
            row.emotion_str.red().to_string()
        } else if row.emotion_str.contains("frustr") || row.emotion_str.contains("anger") {
            row.emotion_str.yellow().to_string()
        } else {
            row.emotion_str.dimmed().to_string()
        };

        println!(
            "  {} {} {} {} {} {}{}{}",
            format!("{}.", i + 1).dimmed(),
            row.ts_str.bold(),
            format!("({})", row.ago_str).dimmed(),
            "|".dimmed(),
            format!("{}:", row.duration_str).dimmed(),
            format!("{} - {}", sev, row.diagnosis.bold()),
            emotion,
            repeat
        );

        if let Some(ref topic) = row.topic_preview {
            println!("     {} {}", "Topic:".dimmed(), topic);
        }
        if let Some(ref symbols) = row.symbols_str {
            println!("     {} {}", "Symbols:".dimmed(), symbols);
        }
    }

    Ok(())
}