plato-kernel 0.2.0

Plato Kernel - Event sourcing + Constraint-Theory + Git runtime
//! Episode Recorder — Semantic Muscle Memory
//!
//! Implements the PLATO "Dialogue Episode Recorder" concept: as agents act,
//! their successes and failures are tiled into a persistent `KNOWLEDGE.md`.
//! On future tasks, the runtime prepends matching tiles to the agent's context,
//! compressing complex troubleshooting into a vibe-check against prior prose.
//!
//! This turns ephemeral agent output into "long-term literature" — human-readable
//! AND machine-queryable.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::Path;

/// The outcome of an agent episode.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EpisodeOutcome {
    Success,
    Failure,
    Partial,
}

impl EpisodeOutcome {
    fn as_emoji(&self) -> &'static str {
        match self {
            Self::Success => "",
            Self::Failure => "",
            Self::Partial => "⚠️",
        }
    }
}

/// A single recorded episode — one agent action and its result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EpisodeEntry {
    pub id: String,
    pub timestamp: DateTime<Utc>,
    /// High-level task description (becomes the tile header).
    pub task: String,
    /// What the agent actually did.
    pub action: String,
    /// What happened as a result.
    pub result: String,
    pub outcome: EpisodeOutcome,
    /// Key terms for recall matching (extracted from task + action).
    pub tags: Vec<String>,
}

impl EpisodeEntry {
    pub fn new(task: &str, action: &str, result: &str, outcome: EpisodeOutcome) -> Self {
        let tags = Self::extract_tags(task, action);
        Self {
            id: uuid_v4_simple(),
            timestamp: Utc::now(),
            task: task.to_string(),
            action: action.to_string(),
            result: result.to_string(),
            outcome,
            tags,
        }
    }

    /// Extract content words as recall tags (skip stop words, min length 4).
    fn extract_tags(task: &str, action: &str) -> Vec<String> {
        const STOP: &[&str] = &["the", "and", "for", "with", "that", "this", "from", "into", "have", "been"];
        let combined = format!("{} {}", task, action).to_lowercase();
        let mut seen = std::collections::HashSet::new();
        combined
            .split(|c: char| !c.is_alphanumeric())
            .filter(|w| w.len() >= 4 && !STOP.contains(w))
            .map(|w| w.to_string())
            .filter(|w| seen.insert(w.clone()))
            .collect()
    }

    /// Render this episode as a Markdown tile (for appending to KNOWLEDGE.md).
    pub fn to_markdown_tile(&self) -> String {
        format!(
            "## {} {}\n\
             > *Recorded: {}*\n\
             > *ID: {}*\n\n\
             **Action:** {}\n\n\
             **Result:** {}\n\n\
             **Tags:** {}\n\n\
             ---\n",
            self.outcome.as_emoji(),
            self.task,
            self.timestamp.format("%Y-%m-%d %H:%M UTC"),
            self.id,
            self.action,
            self.result,
            self.tags.join(", "),
        )
    }
}

/// Manages KNOWLEDGE.md — the persistent episode store.
pub struct EpisodeRecorder {
    knowledge_path: String,
}

impl EpisodeRecorder {
    pub fn new(knowledge_path: &str) -> Self {
        Self {
            knowledge_path: knowledge_path.to_string(),
        }
    }

    /// Default path: `./KNOWLEDGE.md` relative to current directory.
    pub fn default_path() -> Self {
        Self::new("KNOWLEDGE.md")
    }

    /// Append an episode as a new tile to KNOWLEDGE.md.
    ///
    /// Creates the file with a header if it doesn't exist yet.
    pub fn record(&self, entry: &EpisodeEntry) -> std::io::Result<()> {
        let path = Path::new(&self.knowledge_path);

        if !path.exists() {
            let mut f = File::create(path)?;
            writeln!(f, "# KNOWLEDGE.md — Plato Episode Record\n")?;
            writeln!(f, "Auto-generated by the Episode Recorder. Each tile is one agent episode.")?;
            writeln!(f, "The runtime injects matching tiles as context for future similar tasks.\n")?;
            writeln!(f, "---\n")?;
        }

        let mut f = OpenOptions::new().append(true).open(path)?;
        writeln!(f, "{}", entry.to_markdown_tile())?;
        Ok(())
    }

    /// Recall episodes whose tags overlap with the given context words.
    ///
    /// Returns entries sorted by number of matching tags (most relevant first).
    pub fn recall_relevant(&self, context: &str) -> std::io::Result<Vec<RecalledEpisode>> {
        let path = Path::new(&self.knowledge_path);
        if !path.exists() {
            return Ok(Vec::new());
        }

        let context_words: std::collections::HashSet<String> = context
            .to_lowercase()
            .split(|c: char| !c.is_alphanumeric())
            .filter(|w| w.len() >= 4)
            .map(|w| w.to_string())
            .collect();

        let f = File::open(path)?;
        let reader = BufReader::new(f);

        let mut episodes: Vec<RecalledEpisode> = Vec::new();
        let mut current_header = String::new();
        let mut current_body = String::new();
        let mut in_episode = false;

        for line in reader.lines() {
            let line = line?;
            if line.starts_with("## ") {
                if in_episode && !current_body.is_empty() {
                    let tags = extract_tags_from_tile(&current_body);
                    let overlap = tags.intersection(&context_words).count();
                    if overlap > 0 {
                        episodes.push(RecalledEpisode {
                            header: current_header.clone(),
                            body: current_body.clone(),
                            relevance: overlap,
                        });
                    }
                }
                current_header = line[3..].to_string();
                current_body = String::new();
                in_episode = true;
            } else if in_episode {
                current_body.push_str(&line);
                current_body.push('\n');
            }
        }

        // Flush last
        if in_episode && !current_body.is_empty() {
            let tags = extract_tags_from_tile(&current_body);
            let overlap = tags.intersection(&context_words).count();
            if overlap > 0 {
                episodes.push(RecalledEpisode {
                    header: current_header,
                    body: current_body,
                    relevance: overlap,
                });
            }
        }

        episodes.sort_by(|a, b| b.relevance.cmp(&a.relevance));
        Ok(episodes)
    }
}

/// A recalled episode with its relevance score.
#[derive(Debug, Clone)]
pub struct RecalledEpisode {
    pub header: String,
    pub body: String,
    pub relevance: usize,
}

fn extract_tags_from_tile(body: &str) -> std::collections::HashSet<String> {
    // Extract from the **Tags:** line
    if let Some(tags_line) = body.lines().find(|l| l.starts_with("**Tags:**")) {
        tags_line
            .trim_start_matches("**Tags:**")
            .split(", ")
            .map(|t| t.trim().to_lowercase())
            .filter(|t| !t.is_empty())
            .collect()
    } else {
        std::collections::HashSet::new()
    }
}

/// Minimal UUID v4 generator that avoids pulling in the uuid crate here
/// (plato-kernel already has it; this module is intentionally self-contained).
fn uuid_v4_simple() -> String {
    use std::time::{SystemTime, UNIX_EPOCH};
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map(|d| d.subsec_nanos())
        .unwrap_or(0);
    format!("ep-{:08x}", nanos)
}

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

    #[test]
    fn test_entry_to_markdown() {
        let entry = EpisodeEntry::new(
            "Fix payment timeout",
            "Increased deadline to 30s",
            "Payments now succeed under load",
            EpisodeOutcome::Success,
        );
        let md = entry.to_markdown_tile();
        assert!(md.contains("✅ Fix payment timeout"));
        assert!(md.contains("**Tags:**"));
        assert!(md.contains("payment"));
    }

    #[test]
    fn test_record_and_recall() {
        let path = "/tmp/test_knowledge_plato.md";
        let _ = fs::remove_file(path);

        let recorder = EpisodeRecorder::new(path);
        let entry = EpisodeEntry::new(
            "Resolve constraint violation",
            "Updated constraint matrix for room access",
            "Identity can now enter room",
            EpisodeOutcome::Success,
        );
        recorder.record(&entry).unwrap();

        let recalled = recorder.recall_relevant("constraint matrix identity").unwrap();
        assert!(!recalled.is_empty());
        assert!(recalled[0].relevance > 0);

        let _ = fs::remove_file(path);
    }

    #[test]
    fn test_tags_extracted() {
        let entry = EpisodeEntry::new("connect identity room", "sent auth request", "ok", EpisodeOutcome::Success);
        // "connect", "identity", "room", "sent", "request" should appear
        assert!(entry.tags.contains(&"connect".to_string()) || entry.tags.contains(&"identity".to_string()));
    }
}