use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
#[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 => "⚠️",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EpisodeEntry {
pub id: String,
pub timestamp: DateTime<Utc>,
pub task: String,
pub action: String,
pub result: String,
pub outcome: EpisodeOutcome,
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,
}
}
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()
}
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(", "),
)
}
}
pub struct EpisodeRecorder {
knowledge_path: String,
}
impl EpisodeRecorder {
pub fn new(knowledge_path: &str) -> Self {
Self {
knowledge_path: knowledge_path.to_string(),
}
}
pub fn default_path() -> Self {
Self::new("KNOWLEDGE.md")
}
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(())
}
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(¤t_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');
}
}
if in_episode && !current_body.is_empty() {
let tags = extract_tags_from_tile(¤t_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)
}
}
#[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> {
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()
}
}
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);
assert!(entry.tags.contains(&"connect".to_string()) || entry.tags.contains(&"identity".to_string()));
}
}