kimetsu-brain 1.5.1

Project + user-scope memory, hybrid retrieval (lexical + cosine), ambient context, secret redaction at ingest for kimetsu.
Documentation
//! v1.5: dropped-capsule sidecar for the `retrieval.regret` signal.
//!
//! When the `UserPromptSubmit` hook (`brain_context_hook` in the CLI)
//! runs retrieval, some memory capsules score above zero but are EXCLUDED
//! by the relevance floor — they were "dropped". If a dropped capsule's
//! memory is later *cited* by the model, that is a **regret**: the floor
//! was too aggressive and we missed useful context. That error signal
//! feeds the Self-Tuning Brain (v1.5 ROI ledger).
//!
//! Architecture note: the hook process and the MCP / pipeline process that
//! records citations are **different processes**. The bridge is this
//! rolling JSON sidecar on disk, keyed by PROJECT (not session) so both
//! sides can derive the same path from the repo root via
//! `kimetsu_core::paths::user_cache_dir_for`.
//!
//! Narrow scope:
//! - Capped at [`MAX_ENTRIES`] entries.
//! - Only entries within the last [`WINDOW_SECS`] are kept (pruned on write).
//! - All I/O is best-effort; callers swallow errors.
//!
//! The pure functions (`prune_window`, `match_and_remove`) accept `now_secs`
//! as a parameter so they are unit-testable without touching the clock.

use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};

use serde::{Deserialize, Serialize};

/// Rolling window: entries older than 2 hours are pruned.
pub const WINDOW_SECS: u64 = 2 * 60 * 60;
/// Hard cap on the number of entries retained after pruning.
pub const MAX_ENTRIES: usize = 200;

/// A single dropped-capsule record.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DroppedEntry {
    pub memory_id: String,
    pub dropped_at: u64,
}

/// The full sidecar structure persisted to disk.
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct DroppedCapsuleState {
    #[serde(default)]
    pub entries: Vec<DroppedEntry>,
}

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

/// Resolve the path for the dropped-capsule sidecar given the project
/// user cache dir (`kimetsu_core::paths::user_cache_dir_for(&repo_root)`).
pub fn sidecar_path(cache_dir: &Path) -> std::path::PathBuf {
    cache_dir.join("dropped-recent.json")
}

// ── Pure functions (testable without fs/clock) ────────────────────────────────

/// Remove entries older than `window_secs` before `now_secs` and cap at
/// [`MAX_ENTRIES`]. Stable insertion order is preserved (oldest first).
pub fn prune_window(
    entries: Vec<DroppedEntry>,
    now_secs: u64,
    window_secs: u64,
) -> Vec<DroppedEntry> {
    let cutoff = now_secs.saturating_sub(window_secs);
    let mut pruned: Vec<DroppedEntry> = entries
        .into_iter()
        .filter(|e| e.dropped_at >= cutoff)
        .collect();
    if pruned.len() > MAX_ENTRIES {
        let excess = pruned.len() - MAX_ENTRIES;
        pruned.drain(0..excess);
    }
    pruned
}

/// Check if `memory_id` appears in `entries`. If yes, remove it in-place
/// and return the matching entry. Returns `None` if not found.
pub fn match_and_remove(entries: &mut Vec<DroppedEntry>, memory_id: &str) -> Option<DroppedEntry> {
    entries
        .iter()
        .position(|e| e.memory_id == memory_id)
        .map(|pos| entries.remove(pos))
}

// ── I/O helpers ───────────────────────────────────────────────────────────────

/// Best-effort load — any error (missing / corrupt) yields an empty state.
pub fn load(path: &Path) -> DroppedCapsuleState {
    fs::read_to_string(path)
        .ok()
        .and_then(|text| serde_json::from_str(&text).ok())
        .unwrap_or_default()
}

/// Best-effort save — failures are swallowed.
pub fn save(path: &Path, state: &DroppedCapsuleState) {
    if let Some(parent) = path.parent() {
        let _ = fs::create_dir_all(parent);
    }
    if let Ok(text) = serde_json::to_string(state) {
        let _ = fs::write(path, text);
    }
}

/// Append new dropped memory ids to the sidecar (best-effort, write-back).
///
/// Loads the existing sidecar, appends each `memory_id` with `dropped_at`,
/// prunes the 2-hour window and the 200-entry cap, then writes back.
pub fn append_dropped(cache_dir: &Path, memory_ids: impl Iterator<Item = String>, dropped_at: u64) {
    let path = sidecar_path(cache_dir);
    let mut state = load(&path);
    for id in memory_ids {
        state.entries.push(DroppedEntry {
            memory_id: id,
            dropped_at,
        });
    }
    state.entries = prune_window(state.entries, dropped_at, WINDOW_SECS);
    save(&path, &state);
}

/// Best-effort regret check: look up `memory_id` in the sidecar,
/// prune stale entries, remove the matching entry (if found), write
/// back, and return the matching [`DroppedEntry`] so the caller can
/// emit a `retrieval.regret` event.
///
/// Returns `None` when the sidecar can't be read, the memory is not
/// present, or the entry is already outside the window.
pub fn take_if_dropped(cache_dir: &Path, memory_id: &str, now: u64) -> Option<DroppedEntry> {
    let path = sidecar_path(cache_dir);
    let mut state = load(&path);
    state.entries = prune_window(state.entries, now, WINDOW_SECS);
    let found = match_and_remove(&mut state.entries, memory_id);
    if found.is_some() {
        save(&path, &state);
    }
    found
}

#[cfg(test)]
mod tests {
    use super::*;

    fn entry(id: &str, dropped_at: u64) -> DroppedEntry {
        DroppedEntry {
            memory_id: id.to_string(),
            dropped_at,
        }
    }

    #[test]
    fn prune_window_removes_old_entries() {
        let now = 1_000_000u64;
        let entries = vec![
            entry("old", now - WINDOW_SECS - 1), // outside window
            entry("fresh", now - 60),            // inside window
        ];
        let pruned = prune_window(entries, now, WINDOW_SECS);
        assert_eq!(pruned.len(), 1);
        assert_eq!(pruned[0].memory_id, "fresh");
    }

    #[test]
    fn prune_window_caps_at_max_entries() {
        let now = 1_000_000u64;
        // One more than the cap, all fresh.
        let entries: Vec<DroppedEntry> = (0..=MAX_ENTRIES)
            .map(|i| entry(&format!("m{i}"), now - 10))
            .collect();
        let pruned = prune_window(entries, now, WINDOW_SECS);
        assert_eq!(pruned.len(), MAX_ENTRIES);
        // Oldest entry (m0) should have been evicted.
        assert!(!pruned.iter().any(|e| e.memory_id == "m0"));
    }

    #[test]
    fn prune_window_keeps_boundary_entry() {
        let now = 1_000_000u64;
        let entries = vec![
            entry("boundary", now - WINDOW_SECS), // exactly at cutoff — kept
            entry("outside", now - WINDOW_SECS - 1), // one second older — pruned
        ];
        let pruned = prune_window(entries, now, WINDOW_SECS);
        assert_eq!(pruned.len(), 1);
        assert_eq!(pruned[0].memory_id, "boundary");
    }

    #[test]
    fn match_and_remove_finds_and_removes() {
        let mut entries = vec![entry("a", 100), entry("b", 200), entry("c", 300)];
        let found = match_and_remove(&mut entries, "b");
        assert!(found.is_some());
        assert_eq!(found.unwrap().memory_id, "b");
        assert_eq!(entries.len(), 2);
        assert!(!entries.iter().any(|e| e.memory_id == "b"));
    }

    #[test]
    fn match_and_remove_returns_none_when_absent() {
        let mut entries = vec![entry("a", 100)];
        assert!(match_and_remove(&mut entries, "missing").is_none());
        assert_eq!(entries.len(), 1);
    }

    #[test]
    fn match_and_remove_on_empty_is_safe() {
        let mut entries: Vec<DroppedEntry> = vec![];
        assert!(match_and_remove(&mut entries, "x").is_none());
    }

    #[test]
    fn prune_window_empty_input_is_safe() {
        let pruned = prune_window(vec![], 1_000_000, WINDOW_SECS);
        assert!(pruned.is_empty());
    }
}