diff --git a/README.md b/README.md index 52eaf87..daf3e65 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,19 @@ The installer builds the Rust CLI with `cargo`, installs `tree-ring`, then shows a short terminal onboarding screen with ASCII rings and the next useful commands. It does not initialize memory unless `--init` is passed. +Initialization creates the SQLite store and non-destructive agent-awareness +files in the memory root: + +- `.tree-ring/AGENTS.md`: DOX-style Tree Ring Memory guidance and root + `AGENTS.md` merge notes. +- `.tree-ring/SKILL.md`: portable skill instructions for agent runtimes. +- `.tree-ring/CLI.md`: quick command reference for recall, remember, forget, + maintain, and TUI usage. + +Existing awareness files are left untouched. Tree Ring Memory does not modify a +project's root `AGENTS.md`; merge the generated guidance manually when you want +DOX-aware agents to see it before entering `.tree-ring/`. + Useful installer options: ```bash @@ -106,6 +119,7 @@ The `tree-ring` command is the Rust CLI. ```bash tree-ring init tree-ring remember "Use protocol-first design." --event-type decision --tag architecture +tree-ring evidence "Snapshot invalidation fixed stale unread chat state." --outcome promoted --evidence-ref evals/chat-state/run-042 --score 0.91 tree-ring recall "protocol design" tree-ring forget mem_example --mode delete --reason "example cleanup" tree-ring export --output memories.jsonl @@ -119,6 +133,35 @@ tree-ring maintain --apply-expired --repair-fts The CLI stores memory in `.tree-ring/` by default. +## Evidence Loop + +The Revolve-inspired loop is exposed through `tree-ring evidence`. It records +evaluated outcomes as memory with an evidence reference instead of treating +claims as durable truth without support. + +```bash +tree-ring evidence "Snapshot invalidation fixed stale unread chat state." \ + --outcome promoted \ + --evidence-ref evals/chat-state/run-042 \ + --project agent-ui \ + --score 0.91 + +tree-ring evidence "Aggressive caching caused stale multi-chat state." \ + --outcome rejected \ + --evidence-ref evals/cache-branch/run-013 \ + --project agent-ui +``` + +Outcome mapping: + +- `promoted` -> `heartwood`, durable `evaluation_promotion` +- `rejected` -> `scar`, durable `evaluation_rejection` +- `deferred` -> `seed`, `evaluation_hypothesis` +- `observed` -> `outer`, `evaluation_result` + +This is not a replacement for Revolve records. Use source refs that point back +to real evaluations, checkpoints, PRs, issues, logs, or run artifacts. + `tree-ring export` writes newline-delimited JSON. The first line is a `tree_ring_memory_export` header with schema and plugin version metadata; each remaining line is a `memory_event` envelope. The command excludes sensitive and @@ -252,6 +295,11 @@ for the synthetic workload. - `skills/tree-ring-memory/SKILL.md` gives agents portable guidance for when to recall, remember, redact, forget, or avoid memory capture. - `templates/dox/AGENTS.md` is a DOX-style project contract template for repos that want Tree Ring Memory rules alongside source code. - `docs/integrations/agent-skill.md` explains how to use both without making memory more authoritative than local project docs. +- `tree-ring init` and `tree-ring welcome --init` copy local guidance into `.tree-ring/AGENTS.md`, `.tree-ring/SKILL.md`, and `.tree-ring/CLI.md` without overwriting existing files. + +For DOX-style project awareness, merge the relevant generated `.tree-ring/AGENTS.md` +sections into the project root `AGENTS.md`. The CLI intentionally does not +rewrite root project contracts automatically. ## Brand Assets diff --git a/crates/tree-ring-memory-cli/src/agent_awareness.rs b/crates/tree-ring-memory-cli/src/agent_awareness.rs new file mode 100644 index 0000000..bf6a11d --- /dev/null +++ b/crates/tree-ring-memory-cli/src/agent_awareness.rs @@ -0,0 +1,166 @@ +use serde::Serialize; +use std::fs; +use std::path::{Path, PathBuf}; + +const SKILL_TEMPLATE: &str = include_str!("../../../skills/tree-ring-memory/SKILL.md"); +const DOX_TEMPLATE: &str = include_str!("../../../templates/dox/AGENTS.md"); +const CLI_REFERENCE: &str = r#"# Tree Ring Memory CLI Quick Reference + +Tree Ring Memory is a local-first memory lifecycle layer for AI agents. + +Use memory deliberately: + +- recall before substantial project work +- remember durable decisions, lessons, warnings, and user preferences +- use scars for failures that should not be repeated +- use seeds for future work and hypotheses +- forget, redact, or supersede stale or sensitive memory + +Core commands: + +```bash +tree-ring init +tree-ring recall "project startup warnings" +tree-ring remember "Use project-scoped recall before risky changes." --event-type lesson --scope project +tree-ring evidence "Snapshot invalidation fixed stale unread chat state." --outcome promoted --evidence-ref evals/chat-state/run-042 --score 0.91 +tree-ring evidence "Aggressive caching caused stale multi-chat state." --outcome rejected --evidence-ref evals/cache-branch/run-013 +tree-ring forget mem_example --mode redact --reason "remove sensitive detail" +tree-ring consolidate --period-type manual --dry-run +tree-ring maintain +tree-ring tui +``` + +Safety rules: + +- Do not store secrets, credentials, private keys, or raw chain-of-thought. +- Prefer concise, source-linked summaries over transcript capture. +- Treat local source files, tests, explicit user instructions, and root `AGENTS.md` files as authoritative. +- When memory and source docs disagree, re-read source docs and update or forget stale memory. +"#; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct AgentAwarenessReport { + pub created: Vec, + pub existing: Vec, +} + +pub fn ensure_agent_awareness(root: &Path) -> Result { + fs::create_dir_all(root).map_err(|err| err.to_string())?; + let mut report = AgentAwarenessReport { + created: Vec::new(), + existing: Vec::new(), + }; + + write_if_missing(&root.join("AGENTS.md"), &agent_contract(root), &mut report)?; + write_if_missing(&root.join("SKILL.md"), SKILL_TEMPLATE, &mut report)?; + write_if_missing(&root.join("CLI.md"), CLI_REFERENCE, &mut report)?; + + Ok(report) +} + +fn write_if_missing( + path: &Path, + content: &str, + report: &mut AgentAwarenessReport, +) -> Result<(), String> { + if path.exists() { + report.existing.push(path.to_path_buf()); + return Ok(()); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|err| err.to_string())?; + } + fs::write(path, content).map_err(|err| err.to_string())?; + report.created.push(path.to_path_buf()); + Ok(()) +} + +fn agent_contract(root: &Path) -> String { + format!( + r#"# Tree Ring Memory Agent Instructions + +This directory contains Tree Ring Memory's local project memory store. + +Memory is a recall aid. It does not replace project source files, tests, root +`AGENTS.md` files, explicit user instructions, DOX contracts, or other local +project documentation. + +## Memory Root + +```text +{root} +``` + +Do not commit local memory databases or exports unless the project explicitly +requires sanitized fixtures. + +## Agent Skill + +Read `SKILL.md` before using Tree Ring Memory. It explains when to recall, +remember, redact, forget, consolidate, and avoid memory capture. + +## CLI Reference + +Read `CLI.md` for local commands. Common commands: + +```bash +tree-ring --root {root} recall "project startup warnings" +tree-ring --root {root} remember "Use project-scoped recall before risky changes." --event-type lesson --scope project +tree-ring --root {root} forget mem_example --mode redact --reason "remove sensitive detail" +tree-ring --root {root} tui +``` + +## DOX Integration + +If this project uses DOX-style `AGENTS.md` traversal, merge the relevant +sections from the template below into the project root `AGENTS.md`. Tree Ring +Memory does not overwrite root project contracts automatically. + +--- + +{DOX_TEMPLATE} +"#, + root = shell_path(root) + ) +} + +fn shell_path(path: &Path) -> String { + path.display().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn init_writes_agent_awareness_files_without_overwriting() { + let dir = tempdir().unwrap(); + let root = dir.path().join(".tree-ring"); + + let first = ensure_agent_awareness(&root).unwrap(); + assert_eq!(first.created.len(), 3); + assert!(root.join("AGENTS.md").exists()); + assert!(root.join("SKILL.md").exists()); + assert!(root.join("CLI.md").exists()); + + fs::write(root.join("CLI.md"), "custom").unwrap(); + let second = ensure_agent_awareness(&root).unwrap(); + assert!(second.existing.iter().any(|path| path.ends_with("CLI.md"))); + assert_eq!(fs::read_to_string(root.join("CLI.md")).unwrap(), "custom"); + } + + #[test] + fn generated_agents_file_mentions_skill_cli_and_dox() { + let dir = tempdir().unwrap(); + let root = dir.path().join(".tree-ring"); + + ensure_agent_awareness(&root).unwrap(); + let agents = fs::read_to_string(root.join("AGENTS.md")).unwrap(); + + assert!(agents.contains("SKILL.md")); + assert!(agents.contains("CLI.md")); + assert!(agents.contains("DOX Integration")); + assert!(agents.contains("Tree Ring Memory Project Contract")); + } +} diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs index cd097e5..73ebf97 100644 --- a/crates/tree-ring-memory-cli/src/main.rs +++ b/crates/tree-ring-memory-cli/src/main.rs @@ -4,14 +4,15 @@ use std::fs; use std::path::PathBuf; use tree_ring_memory_core::plan_maintenance; use tree_ring_memory_core::sensitivity::SensitivityGuard; -use tree_ring_memory_core::MemoryEvent; use tree_ring_memory_core::{ audit_memories, consolidate_memories, decode_jsonl, normalize_import_events, AuditReport, ConsolidationPeriod, ConsolidationReport, ConsolidationRequest, MaintenanceReport, MaintenanceRequest, }; +use tree_ring_memory_core::{MemoryEvent, MemoryLink}; use tree_ring_memory_sqlite::{MemoryRetriever, SQLiteMemoryStore}; +mod agent_awareness; mod tui; mod welcome; @@ -57,6 +58,29 @@ enum Command { #[arg(long = "tag")] tags: Vec, }, + #[command(about = "record an evidence-backed improvement-loop outcome")] + Evidence { + summary: String, + #[arg( + long, + default_value = "observed", + help = "observed, promoted, rejected, or deferred" + )] + outcome: String, + #[arg( + long, + help = "file path, run id, checkpoint id, PR, issue, or eval ref" + )] + evidence_ref: String, + #[arg(long, help = "optional project scope")] + project: Option, + #[arg(long, help = "optional extra context")] + details: Option, + #[arg(long, help = "optional numeric evaluation score")] + score: Option, + #[arg(long = "tag")] + tags: Vec, + }, #[command(about = "recall memories")] Recall { query: String, @@ -293,6 +317,8 @@ fn run(cli: Cli) -> Result<(), String> { match cli.command { Command::Init => { + let awareness = agent_awareness::ensure_agent_awareness(&cli.root) + .map_err(|err| err.to_string())?; if cli.json { println!( "{}", @@ -301,11 +327,13 @@ fn run(cli: Cli) -> Result<(), String> { "root": cli.root, "sqlite_path": db_path, "message": "Tree Ring Memory initialized", + "agent_awareness": awareness, }) ); } else { println!("Tree Ring Memory initialized at {}", cli.root.display()); println!("No cloud sync; secret-like memory is blocked by default."); + print_agent_awareness_summary(&awareness); } } Command::Remember { @@ -344,6 +372,37 @@ fn run(cli: Cli) -> Result<(), String> { println!("{}", event.id); } } + Command::Evidence { + summary, + outcome, + evidence_ref, + project, + details, + score, + tags, + } => { + let event = evidence_event( + summary, + outcome, + evidence_ref, + project, + details, + score, + tags, + )?; + store.put(&event).map_err(|err| err.to_string())?; + if cli.json { + println!( + "{}", + serde_json::to_string(&event).map_err(|err| err.to_string())? + ); + } else { + println!( + "{} [{}] {} evidence={}", + event.id, event.ring, event.summary, event.source.ref_ + ); + } + } Command::Recall { query, project, @@ -546,6 +605,120 @@ fn consolidation_request( }) } +fn evidence_event( + summary: String, + outcome: String, + evidence_ref: String, + project: Option, + details: Option, + score: Option, + tags: Vec, +) -> Result { + if summary.trim().is_empty() { + return Err("evidence summary is required".to_string()); + } + if evidence_ref.trim().is_empty() { + return Err("evidence-ref is required".to_string()); + } + if let Some(score) = score { + if !(0.0..=1.0).contains(&score) { + return Err("evidence score must be between 0 and 1".to_string()); + } + } + + let normalized_outcome = outcome.trim().to_ascii_lowercase(); + let (ring, event_type, salience, confidence, retention) = match normalized_outcome.as_str() { + "promoted" | "promotion" => ( + "heartwood", + "evaluation_promotion", + 0.86, + score.unwrap_or(0.84).max(0.75), + "durable", + ), + "rejected" | "rejection" => ( + "scar", + "evaluation_rejection", + 0.90, + score.unwrap_or(0.78), + "durable", + ), + "deferred" | "seed" | "hypothesis" => ( + "seed", + "evaluation_hypothesis", + 0.68, + score.unwrap_or(0.60), + "normal", + ), + "observed" | "observation" | "result" => ( + "outer", + "evaluation_result", + 0.72, + score.unwrap_or(0.70), + "normal", + ), + _ => { + return Err( + "evidence outcome must be observed, promoted, rejected, or deferred".to_string(), + ) + } + }; + + let guard = SensitivityGuard::default(); + let values = [&summary, &outcome, &evidence_ref] + .into_iter() + .chain(project.iter()) + .chain(details.iter()) + .chain(tags.iter()) + .map(String::as_str); + let detected_sensitivity = guard + .detect_text_sensitivity(values) + .map_err(|err| err.to_string())?; + + let mut event = MemoryEvent::new(summary.trim(), event_type).map_err(|err| err.to_string())?; + event.ring = ring.to_string(); + event.scope = "eval".to_string(); + event.project = project; + event.details = evidence_details(&normalized_outcome, score, details); + event.source.source_type = "evidence".to_string(); + event.source.ref_ = evidence_ref.trim().to_string(); + event.tags = evidence_tags(normalized_outcome.as_str(), tags); + event.salience = salience; + event.confidence = confidence.clamp(0.0, 1.0); + event.retention = retention.to_string(); + event.links.push(MemoryLink { + link_type: "evidence".to_string(), + target: event.source.ref_.clone(), + }); + if detected_sensitivity != "normal" { + event.sensitivity = detected_sensitivity; + } + event.validate().map_err(|err| err.to_string())?; + Ok(event) +} + +fn evidence_details(outcome: &str, score: Option, details: Option) -> String { + let mut lines = vec![format!("Outcome: {outcome}")]; + if let Some(score) = score { + lines.push(format!("Score: {score:.3}")); + } + if let Some(details) = details { + let trimmed = details.trim(); + if !trimmed.is_empty() { + lines.push(trimmed.to_string()); + } + } + lines.join("\n") +} + +fn evidence_tags(outcome: &str, mut tags: Vec) -> Vec { + tags.push("evidence".to_string()); + tags.push("improvement-loop".to_string()); + tags.push(format!("outcome:{outcome}")); + tags.sort(); + tags.dedup(); + tags +} + fn print_audit_report(report: &AuditReport, json_output: bool) -> Result<(), String> { if json_output { println!( @@ -641,6 +814,22 @@ fn print_maintenance_report(report: &MaintenanceReport, json_output: bool) -> Re Ok(()) } +fn print_agent_awareness_summary(report: &agent_awareness::AgentAwarenessReport) { + if !report.created.is_empty() { + println!("Agent awareness files created:"); + for path in &report.created { + println!(" {}", path.display()); + } + } + if !report.existing.is_empty() { + println!("Agent awareness files already present:"); + for path in &report.existing { + println!(" {}", path.display()); + } + } + println!("If this repo uses DOX, merge .tree-ring/AGENTS.md guidance into the project root AGENTS.md when you want agents to see it before entering .tree-ring."); +} + #[cfg(test)] mod tests { use super::*; @@ -658,6 +847,9 @@ mod tests { .unwrap(); assert!(root.join("memory.sqlite").exists()); + assert!(root.join("AGENTS.md").exists()); + assert!(root.join("SKILL.md").exists()); + assert!(root.join("CLI.md").exists()); } #[test] @@ -723,6 +915,88 @@ mod tests { .unwrap(); } + #[test] + fn evidence_promotion_becomes_heartwood_with_evidence_source() { + let dir = tempdir().unwrap(); + let root = dir.path().join(".tree-ring"); + + run(Cli { + root: root.clone(), + json: false, + command: Command::Evidence { + summary: "Snapshot invalidation fixed stale unread chat state.".to_string(), + outcome: "promoted".to_string(), + evidence_ref: "evals/chat-state/run-042".to_string(), + project: Some("agent-ui".to_string()), + details: Some("Passed regression suite and manual replay.".to_string()), + score: Some(0.91), + tags: vec!["chat".to_string()], + }, + }) + .unwrap(); + + let store = SQLiteMemoryStore::open(root.join("memory.sqlite")).unwrap(); + let memories = store.list_all(false).unwrap(); + assert_eq!(memories.len(), 1); + let memory = &memories[0]; + assert_eq!(memory.ring, "heartwood"); + assert_eq!(memory.scope, "eval"); + assert_eq!(memory.event_type, "evaluation_promotion"); + assert_eq!(memory.retention, "durable"); + assert_eq!(memory.source.source_type, "evidence"); + assert_eq!(memory.source.ref_, "evals/chat-state/run-042"); + assert!(memory.tags.contains(&"improvement-loop".to_string())); + assert!(memory.tags.contains(&"outcome:promoted".to_string())); + } + + #[test] + fn evidence_rejection_becomes_scar() { + let dir = tempdir().unwrap(); + let root = dir.path().join(".tree-ring"); + + run(Cli { + root: root.clone(), + json: false, + command: Command::Evidence { + summary: "Aggressive caching caused stale multi-chat state.".to_string(), + outcome: "rejected".to_string(), + evidence_ref: "evals/cache-branch/run-013".to_string(), + project: Some("agent-ui".to_string()), + details: None, + score: Some(0.82), + tags: Vec::new(), + }, + }) + .unwrap(); + + let store = SQLiteMemoryStore::open(root.join("memory.sqlite")).unwrap(); + let memories = store.list_all(false).unwrap(); + assert_eq!(memories[0].ring, "scar"); + assert_eq!(memories[0].event_type, "evaluation_rejection"); + assert_eq!(memories[0].retention, "durable"); + } + + #[test] + fn evidence_rejects_invalid_scores() { + let dir = tempdir().unwrap(); + let err = run(Cli { + root: dir.path().join(".tree-ring"), + json: false, + command: Command::Evidence { + summary: "Invalid evidence score".to_string(), + outcome: "observed".to_string(), + evidence_ref: "evals/run".to_string(), + project: None, + details: None, + score: Some(2.0), + tags: Vec::new(), + }, + }) + .unwrap_err(); + + assert_eq!(err, "evidence score must be between 0 and 1"); + } + #[test] fn remember_classifies_sensitive_metadata_before_default_recall() { let dir = tempdir().unwrap(); diff --git a/crates/tree-ring-memory-cli/src/tui/mod.rs b/crates/tree-ring-memory-cli/src/tui/mod.rs index b3078c5..dacf32d 100644 --- a/crates/tree-ring-memory-cli/src/tui/mod.rs +++ b/crates/tree-ring-memory-cli/src/tui/mod.rs @@ -7,6 +7,7 @@ mod render; mod rings; mod store_watch; mod stream; +mod theme; use std::path::PathBuf; use std::time::Duration; diff --git a/crates/tree-ring-memory-cli/src/tui/render.rs b/crates/tree-ring-memory-cli/src/tui/render.rs index b6b9171..63d320d 100644 --- a/crates/tree-ring-memory-cli/src/tui/render.rs +++ b/crates/tree-ring-memory-cli/src/tui/render.rs @@ -1,12 +1,13 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; +use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph, Wrap}; +use ratatui::widgets::{List, ListItem, Paragraph, Wrap}; use ratatui::Frame; use super::app::{App, AppMode}; use super::input::command_help; use super::rings::{ambient_tree_lines, exploded_ring_lines, ring_style}; +use super::theme; pub fn render(frame: &mut Frame<'_>, app: &App) { let area = frame.area(); @@ -47,18 +48,21 @@ fn render_header(frame: &mut Frame<'_>, area: Rect, app: &App) { AppMode::Watch => "watch", }; let header = Paragraph::new(Line::from(vec![ + Span::styled("TREE RING MEMORY", theme::brand()), + Span::styled(format!(" mode:{mode}"), theme::accent()), + Span::styled(format!(" total:{}", app.dashboard.total), theme::title()), Span::styled( - "TREE RING MEMORY", - Style::default() - .fg(Color::LightYellow) - .add_modifier(Modifier::BOLD), + format!(" private:{}", app.dashboard.sensitive_total), + if app.dashboard.sensitive_total > 0 { + theme::warning() + } else { + theme::dim() + }, ), - Span::raw(format!(" mode:{mode}")), - Span::raw(format!(" total:{}", app.dashboard.total)), - Span::raw(format!(" private:{}", app.dashboard.sensitive_total)), - Span::raw(format!(" status: {}", app.status)), + Span::styled(" status: ", theme::dim()), + Span::raw(app.status.clone()), ])) - .block(Block::default().borders(Borders::ALL)); + .block(theme::plain_panel()); frame.render_widget(header, area); } @@ -88,18 +92,14 @@ fn render_body(frame: &mut Frame<'_>, area: Rect, app: &App) { fn render_ambient(frame: &mut Frame<'_>, area: Rect, app: &App) { let paragraph = Paragraph::new(ambient_tree_lines(&app.dashboard, app.tick)) - .block( - Block::default() - .title("Ambient Rings") - .borders(Borders::ALL), - ) + .block(theme::panel("Ambient Rings")) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); } fn render_exploded(frame: &mut Frame<'_>, area: Rect, app: &App) { let paragraph = Paragraph::new(exploded_ring_lines(&app.dashboard, app.selected_ring)) - .block(Block::default().title("/rings").borders(Borders::ALL)) + .block(theme::panel("/rings")) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); } @@ -112,21 +112,34 @@ fn render_ring_hud(frame: &mut Frame<'_>, area: Rect, app: &App) { .enumerate() .map(|(index, stats)| { let selected = if index == app.selected_ring { ">" } else { " " }; + let selector_style = if index == app.selected_ring { + theme::secondary_accent().add_modifier(Modifier::BOLD) + } else { + theme::dim() + }; let line = Line::from(vec![ - Span::raw(selected), + Span::styled(selected, selector_style), Span::styled(format!(" {:<10}", stats.ring), ring_style(stats)), - Span::raw(format!( - " {:>4} avg {:.2}/{:.2} private {}", - stats.total, - stats.average_confidence, - stats.average_salience, - stats.sensitive_count - )), + Span::styled( + format!(" {:>4}", stats.total), + if index == app.selected_ring { + theme::selected() + } else { + theme::title() + }, + ), + Span::styled( + format!( + " avg {:.2}/{:.2} private {}", + stats.average_confidence, stats.average_salience, stats.sensitive_count + ), + theme::dim(), + ), ]); ListItem::new(line) }) .collect(); - let list = List::new(items).block(Block::default().title("Rings").borders(Borders::ALL)); + let list = List::new(items).block(theme::panel("Rings")); frame.render_widget(list, area); } @@ -154,7 +167,7 @@ fn render_results(frame: &mut Frame<'_>, area: Rect, app: &App) { } else if app.results.is_empty() { vec![ListItem::new(Line::from(Span::styled( "No matching memory.", - Style::default().fg(Color::DarkGray), + theme::dim(), )))] } else { app.results @@ -171,7 +184,7 @@ fn render_results(frame: &mut Frame<'_>, area: Rect, app: &App) { }) .collect() }; - let list = List::new(items).block(Block::default().title(title).borders(Borders::ALL)); + let list = List::new(items).block(theme::panel(title)); frame.render_widget(list, area); } @@ -186,11 +199,29 @@ fn memory_item<'a>( let score = score .map(|score| format!(" score={score:.3}")) .unwrap_or_default(); + let selected_row = index == selected; + let memory_style = if selected_row { + theme::selected() + } else { + Style::default() + }; ListItem::new(Line::from(vec![ - Span::raw(marker.to_string()), - Span::styled(format!(" [{ring}] "), Style::default().fg(Color::LightCyan)), - Span::raw(truncate(summary, 80)), - Span::styled(score, Style::default().fg(Color::DarkGray)), + Span::styled( + marker.to_string(), + if selected_row { + theme::secondary_accent().add_modifier(Modifier::BOLD) + } else { + theme::dim() + }, + ), + Span::styled( + format!(" [{ring}] "), + Style::default() + .fg(theme::ring_color(ring, 0.0)) + .add_modifier(Modifier::BOLD), + ), + Span::styled(truncate(summary, 80), memory_style), + Span::styled(score, theme::dim()), ])) } @@ -203,23 +234,28 @@ fn render_detail(frame: &mut Frame<'_>, area: Rect, app: &App) { "[sensitive details hidden]".to_string() }; lines.push(Line::from(vec![ - Span::styled("id ", Style::default().fg(Color::DarkGray)), + Span::styled("id ", theme::dim()), Span::raw(memory.id.clone()), ])); lines.push(Line::from(vec![ - Span::styled("ring ", Style::default().fg(Color::DarkGray)), - Span::raw(memory.ring.clone()), - Span::styled(" type ", Style::default().fg(Color::DarkGray)), + Span::styled("ring ", theme::dim()), + Span::styled( + memory.ring.clone(), + Style::default() + .fg(theme::ring_color(&memory.ring, 0.0)) + .add_modifier(Modifier::BOLD), + ), + Span::styled(" type ", theme::dim()), Span::raw(memory.event_type.clone()), ])); lines.push(Line::from(vec![ - Span::styled("confidence ", Style::default().fg(Color::DarkGray)), - Span::raw(format!("{:.2}", memory.confidence)), - Span::styled(" salience ", Style::default().fg(Color::DarkGray)), - Span::raw(format!("{:.2}", memory.salience)), + Span::styled("confidence ", theme::dim()), + Span::styled(format!("{:.2}", memory.confidence), theme::accent()), + Span::styled(" salience ", theme::dim()), + Span::styled(format!("{:.2}", memory.salience), theme::secondary_accent()), ])); lines.push(Line::from(vec![ - Span::styled("source ", Style::default().fg(Color::DarkGray)), + Span::styled("source ", theme::dim()), Span::raw(format!( "{} {}", memory.source.source_type, memory.source.ref_ @@ -239,40 +275,36 @@ fn render_detail(frame: &mut Frame<'_>, area: Rect, app: &App) { if app.mode == AppMode::Command { lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled("/", Style::default().fg(Color::LightYellow)), + Span::styled("/", theme::brand()), Span::raw(app.command_buffer.clone()), ])); } else if app.mode == AppMode::Search { lines.push(Line::from("")); lines.push(Line::from(vec![ - Span::styled("search ", Style::default().fg(Color::LightYellow)), + Span::styled("search ", theme::brand()), Span::raw(app.search_query.clone()), ])); } if !app.live_events.is_empty() { lines.push(Line::from("")); - lines.push(Line::from(Span::styled( - "live", - Style::default() - .fg(Color::LightMagenta) - .add_modifier(Modifier::BOLD), - ))); + lines.push(Line::from(Span::styled("live", theme::live()))); for event in app.live_events.iter().rev().take(3) { - lines.push(Line::from(format!( - "{} {}", - event.ring.as_deref().unwrap_or("-"), - event.safe_label() - ))); + let ring = event.ring.as_deref().unwrap_or("-"); + lines.push(Line::from(vec![ + Span::styled( + format!("{ring:<10} "), + Style::default() + .fg(theme::ring_color(ring, 0.0)) + .add_modifier(Modifier::BOLD), + ), + Span::raw(event.safe_label()), + ])); } } let paragraph = Paragraph::new(lines) - .block( - Block::default() - .title("Detail / Actions") - .borders(Borders::ALL), - ) + .block(theme::panel("Detail / Actions")) .wrap(Wrap { trim: true }); frame.render_widget(paragraph, area); } @@ -285,7 +317,8 @@ fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &App) { command_help() ); let footer = Paragraph::new(text) - .block(Block::default().title("Actions").borders(Borders::ALL)) + .style(theme::accent()) + .block(theme::panel("Actions")) .wrap(Wrap { trim: true }); frame.render_widget(footer, area); } @@ -295,32 +328,25 @@ fn render_confirmation(frame: &mut Frame<'_>, area: Rect, prompt: &str) { let paragraph = Paragraph::new(vec![ Line::from(Span::styled( "Confirm Tree Ring Memory action", - Style::default() - .fg(Color::LightRed) - .add_modifier(Modifier::BOLD), + theme::warning(), )), Line::from(""), Line::from(prompt.to_string()), ]) - .block(Block::default().borders(Borders::ALL)) + .block(theme::plain_panel().border_style(theme::warning())) .wrap(Wrap { trim: true }); frame.render_widget(paragraph, area); } fn render_compact(frame: &mut Frame<'_>, area: Rect, app: &App) { - let mut lines = vec![Line::from(Span::styled( - "TREE RING MEMORY", - Style::default() - .fg(Color::LightYellow) - .add_modifier(Modifier::BOLD), - ))]; + let mut lines = vec![Line::from(Span::styled("TREE RING MEMORY", theme::brand()))]; lines.extend(ambient_tree_lines(&app.dashboard, app.tick)); lines.push(Line::from(format!( "total {} | q quit | / command", app.dashboard.total ))); let paragraph = Paragraph::new(lines) - .block(Block::default().borders(Borders::ALL)) + .block(theme::plain_panel()) .wrap(Wrap { trim: false }); frame.render_widget(paragraph, area); } diff --git a/crates/tree-ring-memory-cli/src/tui/rings.rs b/crates/tree-ring-memory-cli/src/tui/rings.rs index b1faf9f..e788b21 100644 --- a/crates/tree-ring-memory-cli/src/tui/rings.rs +++ b/crates/tree-ring-memory-cli/src/tui/rings.rs @@ -2,20 +2,10 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use super::model::{DashboardStats, RingStats}; +use super::theme; pub fn ring_color(ring: &str, warning_level: f64) -> Color { - if warning_level > 0.75 { - return Color::LightRed; - } - match ring { - "cambium" => Color::LightYellow, - "outer" => Color::LightMagenta, - "inner" => Color::Cyan, - "heartwood" => Color::Yellow, - "scar" => Color::Red, - "seed" => Color::LightCyan, - _ => Color::Gray, - } + theme::ring_color(ring, warning_level) } pub fn ring_style(stats: &RingStats) -> Style { @@ -93,27 +83,43 @@ pub fn ambient_tree_lines(dashboard: &DashboardStats, tick: u64) -> Vec Vec> { let mut lines = vec![Line::from(Span::styled( "EXPLODED RINGS".to_string(), - Style::default() - .fg(Color::White) - .add_modifier(Modifier::BOLD), + theme::title(), ))]; for (index, stats) in dashboard.rings.iter().enumerate() { let marker = if index == selected_ring { ">" } else { " " }; let bar = ring_bar(stats.total, dashboard.total); let top_types = stats.top_event_types(2).join(", "); + let marker_style = if index == selected_ring { + theme::secondary_accent().add_modifier(Modifier::BOLD) + } else { + theme::dim() + }; lines.push(Line::from(vec![ - Span::raw(format!("{marker} ")), + Span::styled(format!("{marker} "), marker_style), Span::styled(format!("{:<10}", stats.ring), ring_style(stats)), - Span::raw(format!(" {:>4} ", stats.total)), + Span::styled( + format!(" {:>4} ", stats.total), + if index == selected_ring { + theme::selected() + } else { + theme::title() + }, + ), Span::styled(bar, ring_style(stats)), - Span::raw(format!( - " conf {:.2} sal {:.2} private {}", - stats.average_confidence, stats.average_salience, stats.sensitive_count - )), + Span::styled( + format!( + " conf {:.2} sal {:.2} private {}", + stats.average_confidence, stats.average_salience, stats.sensitive_count + ), + theme::dim(), + ), ])); if !top_types.is_empty() { - lines.push(Line::from(Span::raw(format!(" top: {top_types}")))); + lines.push(Line::from(vec![ + Span::styled(" top: ", theme::dim()), + Span::styled(top_types, theme::accent()), + ])); } } diff --git a/crates/tree-ring-memory-cli/src/tui/theme.rs b/crates/tree-ring-memory-cli/src/tui/theme.rs new file mode 100644 index 0000000..fa07033 --- /dev/null +++ b/crates/tree-ring-memory-cli/src/tui/theme.rs @@ -0,0 +1,100 @@ +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::{Block, Borders}; + +pub const NAVY: Color = Color::Rgb(0, 31, 52); +pub const TEAL: Color = Color::Rgb(22, 156, 166); +pub const PINK: Color = Color::Rgb(239, 65, 103); +pub const ORANGE: Color = Color::Rgb(255, 125, 34); +pub const YELLOW: Color = Color::Rgb(255, 194, 69); +pub const CREAM: Color = Color::Rgb(255, 245, 213); +pub const CORAL: Color = Color::Rgb(255, 101, 83); +pub const MUTED: Color = Color::Rgb(121, 135, 145); + +pub fn title() -> Style { + Style::default().fg(CREAM).add_modifier(Modifier::BOLD) +} + +pub fn brand() -> Style { + Style::default().fg(YELLOW).add_modifier(Modifier::BOLD) +} + +pub fn accent() -> Style { + Style::default().fg(TEAL) +} + +pub fn secondary_accent() -> Style { + Style::default().fg(PINK) +} + +pub fn warning() -> Style { + Style::default().fg(CORAL).add_modifier(Modifier::BOLD) +} + +pub fn dim() -> Style { + Style::default().fg(MUTED) +} + +pub fn selected() -> Style { + Style::default() + .fg(CREAM) + .bg(NAVY) + .add_modifier(Modifier::BOLD) +} + +pub fn live() -> Style { + Style::default().fg(TEAL).add_modifier(Modifier::BOLD) +} + +pub fn panel<'a>(title: impl Into) -> Block<'a> { + Block::default() + .title(title.into()) + .borders(Borders::ALL) + .border_style(Style::default().fg(TEAL)) + .title_style(title_style()) +} + +pub fn plain_panel<'a>() -> Block<'a> { + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(TEAL)) +} + +fn title_style() -> Style { + Style::default().fg(YELLOW).add_modifier(Modifier::BOLD) +} + +pub fn ring_color(ring: &str, warning_level: f64) -> Color { + if warning_level > 0.75 { + return CORAL; + } + match ring { + "cambium" => TEAL, + "outer" => PINK, + "inner" => ORANGE, + "heartwood" => YELLOW, + "scar" => CORAL, + "seed" => Color::Rgb(82, 210, 202), + _ => MUTED, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_core_rings_to_brand_palette() { + assert_eq!(ring_color("cambium", 0.0), TEAL); + assert_eq!(ring_color("outer", 0.0), PINK); + assert_eq!(ring_color("inner", 0.0), ORANGE); + assert_eq!(ring_color("heartwood", 0.0), YELLOW); + assert_eq!(ring_color("scar", 0.0), CORAL); + assert_eq!(ring_color("seed", 0.0), Color::Rgb(82, 210, 202)); + assert_eq!(ring_color("unknown", 0.0), MUTED); + } + + #[test] + fn warning_overrides_ring_color() { + assert_eq!(ring_color("heartwood", 1.0), CORAL); + } +} diff --git a/crates/tree-ring-memory-cli/src/welcome.rs b/crates/tree-ring-memory-cli/src/welcome.rs index c7fd39a..ebb7bea 100644 --- a/crates/tree-ring-memory-cli/src/welcome.rs +++ b/crates/tree-ring-memory-cli/src/welcome.rs @@ -5,6 +5,8 @@ use std::thread; use std::time::Duration; use tree_ring_memory_sqlite::SQLiteMemoryStore; +use crate::agent_awareness::{ensure_agent_awareness, AgentAwarenessReport}; + const RESET: &str = "\x1b[0m"; const TEAL: &str = "\x1b[38;5;37m"; const PINK: &str = "\x1b[38;5;204m"; @@ -15,11 +17,12 @@ const BOLD: &str = "\x1b[1m"; pub fn run(root: &Path, init: bool, no_animation: bool, json_output: bool) -> Result<(), String> { let db_path = root.join("memory.sqlite"); - let initialized = if init { + let (initialized, awareness) = if init { SQLiteMemoryStore::open(&db_path).map_err(|err| err.to_string())?; - true + let awareness = ensure_agent_awareness(root)?; + (true, Some(awareness)) } else { - db_path.exists() + (db_path.exists(), None) }; if json_output { @@ -31,6 +34,7 @@ pub fn run(root: &Path, init: bool, no_animation: bool, json_output: bool) -> Re "sqlite_path": db_path, "initialized": initialized, "init_requested": init, + "agent_awareness": awareness, "next": next_commands(root), }) ); @@ -41,7 +45,7 @@ pub fn run(root: &Path, init: bool, no_animation: bool, json_output: bool) -> Re if color && !no_animation { animate_intro()?; } - print_static_welcome(root, initialized, init, color); + print_static_welcome(root, initialized, init, awareness.as_ref(), color); Ok(()) } @@ -58,7 +62,13 @@ fn animate_intro() -> Result<(), String> { Ok(()) } -fn print_static_welcome(root: &Path, initialized: bool, init_requested: bool, color: bool) { +fn print_static_welcome( + root: &Path, + initialized: bool, + init_requested: bool, + awareness: Option<&AgentAwarenessReport>, + color: bool, +) { for line in ring_frame(7, color) { println!("{line}"); } @@ -81,6 +91,21 @@ fn print_static_welcome(root: &Path, initialized: bool, init_requested: bool, co ); } println!("Local-first by default. Secret-like memory is blocked before storage."); + if let Some(awareness) = awareness { + println!(); + println!("{}", paint("Agent awareness", YELLOW, color)); + if awareness.created.is_empty() { + println!(" Existing guidance found in the memory root."); + } else { + for path in &awareness.created { + println!(" created {}", path.display()); + } + } + println!(" Read SKILL.md for agent behavior and CLI.md for commands."); + println!( + " Merge AGENTS.md guidance into the project root AGENTS.md for DOX-aware agents." + ); + } println!(); println!("{}", paint("Next useful commands", YELLOW, color)); for command in next_commands(root) { @@ -153,6 +178,9 @@ mod tests { run(&root, true, true, false).unwrap(); assert!(root.join("memory.sqlite").exists()); + assert!(root.join("AGENTS.md").exists()); + assert!(root.join("SKILL.md").exists()); + assert!(root.join("CLI.md").exists()); } #[test] @@ -163,5 +191,8 @@ mod tests { run(&root, true, true, true).unwrap(); assert!(root.join("memory.sqlite").exists()); + assert!(root.join("AGENTS.md").exists()); + assert!(root.join("SKILL.md").exists()); + assert!(root.join("CLI.md").exists()); } } diff --git a/docs/integrations/agent-skill.md b/docs/integrations/agent-skill.md index c1bcea8..d62773a 100644 --- a/docs/integrations/agent-skill.md +++ b/docs/integrations/agent-skill.md @@ -5,6 +5,15 @@ Tree Ring Memory ships two integration aids for agent workflows: - `skills/tree-ring-memory/SKILL.md`: a portable agent skill that teaches an agent when to recall, remember, redact, forget, and avoid memory capture. - `templates/dox/AGENTS.md`: a DOX-style project contract template for repos that want Tree Ring Memory guidance alongside source code. +`tree-ring init` and `tree-ring welcome --init` also create local copies in the +configured memory root: + +- `.tree-ring/AGENTS.md` +- `.tree-ring/SKILL.md` +- `.tree-ring/CLI.md` + +Existing files are not overwritten. + ## Skill Usage Use the skill in agent runtimes that support local skills or instruction packs. @@ -29,16 +38,36 @@ Copy it to the project root as `AGENTS.md`, or merge its sections into an existi The contract intentionally says that Tree Ring Memory is not authoritative over source docs. Agents should still read local project instructions and source evidence directly. +The CLI does not modify a project root `AGENTS.md` automatically. Merge the +generated `.tree-ring/AGENTS.md` guidance manually when you want DOX-aware +agents to encounter Tree Ring Memory instructions before entering `.tree-ring/`. + ## Minimal CLI Flow ```bash tree-ring init tree-ring recall "project startup warnings" tree-ring remember "Use protocol-first design." --event-type decision --scope project --tag architecture +tree-ring evidence "Snapshot invalidation fixed stale unread chat state." --outcome promoted --evidence-ref evals/chat-state/run-042 --score 0.91 tree-ring forget mem_example --mode redact --reason "remove sensitive detail" tree-ring maintain ``` +## Evidence-Driven Improvement + +Use `tree-ring evidence` when a lesson comes from an evaluation, checkpoint, +experiment, branch, incident, or reviewed run artifact. + +Outcome mapping: + +- `promoted` creates durable heartwood from supported evidence. +- `rejected` creates a scar when a failed or rolled-back approach has reusable warning value. +- `deferred` creates a seed for a promising but unresolved option. +- `observed` creates an outer-ring evaluation result. + +Plain `remember` is still appropriate for user preferences, explicit decisions, +and project lessons that do not come from a formal evaluated outcome. + ## Safety Rule When in doubt, do not store the memory. diff --git a/skills/tree-ring-memory/SKILL.md b/skills/tree-ring-memory/SKILL.md index 60a2725..a237b7e 100644 --- a/skills/tree-ring-memory/SKILL.md +++ b/skills/tree-ring-memory/SKILL.md @@ -55,6 +55,17 @@ Store a memory when the information is likely to help future work: Keep memory concise. Store the lesson, decision, or warning, not the full conversation. +Use `tree-ring evidence` instead of plain `remember` when the lesson comes from +an evaluation, checkpoint, experiment, branch, incident, or reviewed run +artifact. + +Evidence outcome mapping: + +- `promoted`: durable heartwood from supported evidence +- `rejected`: scar for reusable failed or rolled-back approaches +- `deferred`: seed for promising unresolved options +- `observed`: outer-ring evaluation result + ## Ring Selection Use these rings: @@ -108,6 +119,7 @@ Set project and scope deliberately: - use agent scope for agent-profile behavior - use global scope only for durable user preferences or cross-project guidance - include source references such as file paths, issue ids, PR ids, run ids, or docs paths +- use `tree-ring evidence ... --evidence-ref ` for evaluated outcomes Memory does not replace source documents. If a repo has `AGENTS.md`, project docs, tests, architectural records, or host-specific instruction files, read those sources directly and treat them as authoritative. diff --git a/templates/dox/AGENTS.md b/templates/dox/AGENTS.md index 452afd6..8507c56 100644 --- a/templates/dox/AGENTS.md +++ b/templates/dox/AGENTS.md @@ -43,6 +43,12 @@ Remember only meaningful information: Do not store full transcripts, scratchpad notes, raw chain-of-thought, secrets, credentials, or sensitive personal details. +Use `tree-ring evidence` for evaluated outcomes from runs, checkpoints, +experiments, incidents, PRs, issues, or reviewed artifacts. Promotions should +become heartwood only when the evidence supports durable reuse. Rejections with +reusable warning value should become scars. Deferred possibilities should become +seeds. + ## Ring Mapping - Use `cambium` for active task context.