From 2ded2d8bf13990e38d16478ae48cc95a14a12dae Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sat, 11 Jul 2026 21:06:22 +0700 Subject: [PATCH] feat: enhance lesson management and review system - Added LessonAccept and LessonReject actions to manage lesson outcomes. - Implemented probe for build/test verification before triggering reviews. - Introduced a mechanism for recording shadow hits and evaluating trials in the ReviewSystem. - Enhanced lesson structure with additional fields for scope, snippets, and provenances. - Updated command parsing to include lesson acceptance and rejection commands. - Improved session state management with staleness sweeps and retrospective creation. - Enhanced UI to display detailed usage statistics and quality trends. --- src/app/review/mod.rs | 554 ++++++++++++++++++++++++++++++++- src/app/runtime/actions/mod.rs | 96 +++++- src/app/runtime/commands.rs | 6 + src/app/state/misc.rs | 2 + src/controller/command.rs | 4 + src/model/memory.rs | 87 +++++- src/model/settings.rs | 4 + src/tool/memory/remember.rs | 4 + src/view/mod.rs | 42 ++- 9 files changed, 780 insertions(+), 19 deletions(-) diff --git a/src/app/review/mod.rs b/src/app/review/mod.rs index f835a41..f1bb85b 100644 --- a/src/app/review/mod.rs +++ b/src/app/review/mod.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::process::Command; use crate::app::state::rest::AppStateRest; use crate::app::state::runtime::TurnEvent; use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind}; @@ -37,7 +38,7 @@ pub struct Provenance { pub reviewer: Origin, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Lesson { pub name: String, pub content: String, @@ -78,6 +79,7 @@ pub struct ReviewSystem { pub queue_capacity: usize, pub repeated_violations: HashMap, pub shadow_violations: Vec, + pub graduated_checks: Vec, pub violation_window: u32, } @@ -88,10 +90,65 @@ impl ReviewSystem { queue_capacity: 1, repeated_violations: HashMap::new(), shadow_violations: Vec::new(), + graduated_checks: Vec::new(), violation_window: 10, } } + /// Record a shadow hit for the given pattern. Returns true if the + /// trial window is complete and the check should be evaluated. + pub fn record_shadow_hit(&mut self, pattern: &str) -> bool { + for check in &mut self.shadow_violations { + if check.pattern == pattern { + check.trial_count += 1; + check.trial_passed += 1; + return check.trial_count >= check.trial_window; + } + } + // First sighting: start a new shadow trial. + self.shadow_violations.push(ShadowCheck { + pattern: pattern.to_string(), + trial_window: 10, + trial_count: 1, + trial_passed: 1, + status: ShadowStatus::Trial, + }); + false + } + + /// Evaluate all shadow checks whose trial window is complete. + /// Graduates those with a high enough hit ratio; demotes the rest. + pub fn evaluate_shadow_trials(&mut self) -> Vec { + let mut graduated = Vec::new(); + let mut remaining = Vec::new(); + for mut check in self.shadow_violations.drain(..) { + if check.trial_count < check.trial_window { + remaining.push(check); + continue; + } + let ratio = check.trial_passed as f64 / check.trial_window as f64; + let p = check.pattern.clone(); + let tp = check.trial_passed; + let tw = check.trial_window; + if ratio >= 0.3 { + // Graduation threshold: fired on at least 30% of matching writes. + self.graduated_checks.push(crate::tool::GraduatedCheck { + name: p.clone(), + pattern: p.clone(), + rule: p.clone(), + }); + // Keep check as inactive so it doesn't re-process. + graduated.push(format!("{} (graduated, fired {}/{} writes)", p, tp, tw)); + } else { + check.status = ShadowStatus::Rejected; + graduated.push(format!("{} (demoted, only {}/{} — below 30% threshold)", p, tp, tw)); + remaining.push(check); + } + } + self.shadow_violations = remaining; + graduated + } + pub fn reset(&mut self) { self.pending = false; } @@ -157,15 +214,200 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool { if !state.settings.review_enabled { return false; } + // Always review if edits were made this turn. if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 { return true; } - if runtime.consecutive_empty_reviews >= state.settings.adaptive_review_max_skip { - return true; + // Adaptive skip: consecutive empty reviews throttle frequency. + // Backoff schedule: skip 0, 0, 1, 2, 4, 8... reviews between passes. + let base: u32 = state.settings.adaptive_review_max_skip.max(2); + let consecutive = runtime.consecutive_empty_reviews; + if consecutive >= base { + let skip = 1u32 << (consecutive - base).min(10); // max ~1024 + // Only trigger if the edit milestone aligns with the skip window. + if runtime.edit_count > 0 && (runtime.edit_count % skip == 0) { + return true; + } + return false; } false } +/// Result of running the project's build/test verification. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProbeResult { + pub command: String, + pub passed: bool, + pub output: String, + pub timed_out: bool, +} + +/// Language-agnostic build/test probe. +/// +/// Uses settings.verify_command override first; falls back to probing for +/// well-known project markers in the workspace root. Returns None when no +/// marker or command matches (review proceeds on reasons+diff alone). +pub fn probe_build_test(workspaces: &[std::path::PathBuf], verify_command: Option<&str>, timeout_ms: u64) -> Option { + let probe_dir = workspaces.first()?; + let cmd = resolve_verify_command(probe_dir, verify_command)?; + + let (cmd_prog, cmd_args) = cmd.split_once(' ').map(|(p, a)| (p.to_string(), a.to_string())) + .unwrap_or_else(|| (cmd.clone(), String::new())); + + let Ok(mut child) = Command::new(&cmd_prog) + .args(cmd_args.split_whitespace()) + .current_dir(probe_dir) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() else { return None }; + + let start = std::time::Instant::now(); + let timed_out = loop { + if start.elapsed().as_millis() as u64 >= timeout_ms { + let _ = child.kill(); + break true; + } + match child.try_wait() { + Ok(Some(status)) => { + let output = child.wait_with_output().ok(); + let stdout = output.as_ref().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default(); + let stderr = output.as_ref().map(|o| String::from_utf8_lossy(&o.stderr).trim().to_string()).unwrap_or_default(); + let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr) }; + return Some(ProbeResult { + command: cmd.clone(), + passed: status.success(), + output: truncate_output(&combined, 2048), + timed_out: false, + }); + } + Ok(None) => { std::thread::sleep(std::time::Duration::from_millis(50)); } + Err(_) => return None, + } + }; + if timed_out { + Some(ProbeResult { + command: cmd.clone(), + passed: false, + output: "timed out".to_string(), + timed_out: true, + }) + } else { + None + } +} + +fn resolve_verify_command(probe_dir: &std::path::Path, override_cmd: Option<&str>) -> Option { + if let Some(cmd) = override_cmd { + if !cmd.trim().is_empty() { + return Some(cmd.trim().to_string()); + } + } + let has_file = |name: &str| probe_dir.join(name).exists(); + let has_dir = |name: &str| probe_dir.join(name).is_dir(); + + // Ordered probe: most specific/significant first. + if has_file("Cargo.toml") { + // Rust workspace: cargo build first, then test if that passes. + if has_dir("src") || has_dir("tests") { + return Some("cargo build 2>&1 && cargo test 2>&1".to_string()); + } + return Some("cargo build 2>&1".to_string()); + } + if has_file("go.mod") { + return Some("go build ./... 2>&1 && go test ./... 2>&1".to_string()); + } + if has_file("package.json") { + let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?; + if let Ok(v) = serde_json::from_str::(&pkg) { + let scripts = v.get("scripts")?; + // Prefer a "test" script, then "build". + if scripts.get("test").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() { + return Some("npm test 2>&1".to_string()); + } + if scripts.get("build").and_then(|s| s.as_str()).filter(|s| !s.is_empty()).is_some() { + return Some("npm run build 2>&1".to_string()); + } + } + return Some("npm test 2>&1".to_string()); // best-effort fallback + } + if has_file("pyproject.toml") || has_file("requirements.txt") || has_file("setup.py") || has_file("setup.cfg") || has_file("Pipfile") || has_file("poetry.lock") { + if has_file("pyproject.toml") { + // Check for pytest config in pyproject.toml + let content = std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default(); + if content.contains("[tool.pytest") { + return Some("python -m pytest --tb=short -q 2>&1".to_string()); + } + } + if has_dir("tests") || has_dir("test") { + return Some("python -m pytest --tb=short -q 2>&1".to_string()); + } + // No test dir: maybe a library or script project, skip verification. + return None; + } + if has_file("Cargo.lock") { + return Some("cargo build 2>&1".to_string()); + } + if has_file("Gemfile") || has_file("Rakefile") || has_file("*.gemspec") { + return Some("bundle exec rake 2>&1".to_string()); + } + if has_file("Makefile") || has_file("makefile") || has_file("GNUmakefile") { + return Some("make test 2>&1 || make build 2>&1".to_string()); + } + if has_file("justfile") || has_file("justfile") { + return Some("just test 2>&1 || just build 2>&1".to_string()); + } + if has_file("deno.json") || has_file("deno.jsonc") { + return Some("deno test 2>&1".to_string()); + } + if has_file("bun.lock") || has_file("bun.lockb") { + return Some("bun test 2>&1".to_string()); + } + if has_file("pnpm-lock.yaml") { + return Some("pnpm test 2>&1 || pnpm build 2>&1".to_string()); + } + if has_file("yarn.lock") { + return Some("yarn test 2>&1 || yarn build 2>&1".to_string()); + } + if has_file("composer.json") { + return Some("composer test 2>&1 || composer run build 2>&1".to_string()); + } + if has_file("build.gradle") || has_file("build.gradle.kts") || has_file("gradlew") { + return Some("gradle build 2>&1 && gradle test 2>&1".to_string()); + } + if has_file("pom.xml") || has_file("mvnw") { + return Some("mvn test 2>&1".to_string()); + } + if has_file("stack.yaml") || has_file("package.yaml") || has_file("cabal.project") { + return Some("cabal test all 2>&1 || stack test 2>&1".to_string()); + } + if has_file("mix.exs") { + return Some("mix test 2>&1".to_string()); + } + if has_file("rebar.config") || has_file("rebar.lock") { + return Some("rebar3 ct 2>&1 || rebar3 eunit 2>&1".to_string()); + } + if has_file("dune-project") || has_file("jbuild") || has_file("Makefile") { + return Some("dune runtest 2>&1".to_string()); + } + if has_file("shard.yml") { + return Some("crystal spec 2>&1".to_string()); + } + if has_file("Project.toml") || has_file("JuliaProject.toml") { + return Some("julia --project=. -e 'using Pkg; Pkg.test()' 2>&1".to_string()); + } + None +} + +fn truncate_output(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + let mut t: String = s.chars().take(max).collect(); + t.push_str("... (truncated)"); + t + } +} + pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> { let def = AgentDefinition::new( "quality-reviewer".to_string(), @@ -173,13 +415,42 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> { ); let mut ctx = build_subagent_context(def); ctx.session_dir = state.session_dir.clone(); + + // Run build/test verification probe before spawning the reviewer. + let probe_result = probe_build_test( + &state.workspace_roots, + state.settings.verify_command.as_deref(), + state.settings.verify_timeout_ms, + ); + + let probe_note = match &probe_result { + Some(r) => { + if r.passed { + format!("Build/test verification passed ({}). Confidence: verified.", r.command) + } else if r.timed_out { + format!("Build/test verification timed out ({}). Confidence: opinion (no reproducible result).", r.command) + } else { + format!("Build/test verification failed ({}). Output: {}", r.command, r.output) + } + } + None => "No build/test probe matched. Confidence: opinion (reasoning-based).".to_string(), + }; + ctx.system_prompt = format!( "You are a code quality reviewer. Review the recent code changes \ for correctness, security, and adherence to best practices. \ Use read-only tools (read, grep, glob, recall, remember) to \ inspect the session files and provide a concise review verdict. \ - Session directory: {:?}", - state.session_dir + Session directory: {:?}\n\n\ + Build/Test Probe:\n{}\n\n\ + When writing a lesson via remember(), set tags appropriately:\n\ + - If build/test verification printed any FAILED/ERROR lines, tag\n\ + the lesson as \"confidence: verified\" (backed by a real failure).\n\ + - If the probe passed or was skipped, tag as \"confidence: opinion\"\n\ + (reviewer judgment only).\n\ + Check for duplicate lessons via recall before writing a new one.", + state.session_dir, + probe_note, ); let (tx, _rx) = tokio::sync::mpsc::channel(32); @@ -209,3 +480,276 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> { Ok(()) } + +/// Called after a review subagent completes. Updates the empty-review counter +/// and checks for escalation on repeated violations. +pub fn record_review_outcome( + lessons_found: usize, + state: &mut AppStateRest, +) -> Option { + let runtime = match &mut state.session_runtime { + Some(ref mut r) => r, + None => return None, + }; + + if lessons_found > 0 { + // Lesson found: reset empty counter. + runtime.consecutive_empty_reviews = 0; + runtime.review_count += 1; + None + } else { + // Empty review: increment counter. + runtime.consecutive_empty_reviews += 1; + runtime.review_count += 1; + None + } +} + +/// Check for repeated violations of a known lesson pattern and +/// produce an escalation note if threshold is crossed. +pub fn check_violation_escalation( + pattern: &str, + system: &mut ReviewSystem, +) -> Option { + let level = system.increment_violation(pattern); + match level { + ViolationEscalation::None => None, + ViolationEscalation::Warning => Some(level), + ViolationEscalation::Escalate => Some(level), + ViolationEscalation::Block => Some(level), + } +} + +/// Build an escalation note message for the UI. +pub fn format_escalation_note(pattern: &str, level: ViolationEscalation) -> String { + let label = match level { + ViolationEscalation::None => "none", + ViolationEscalation::Warning => "WARNING", + ViolationEscalation::Escalate => "ESCALATION", + ViolationEscalation::Block => "BLOCKED", + }; + format!( + "[{}] Repeated violation: '{}' has been flagged by quality review {} time(s). {}", + label, + pattern, + match level { + ViolationEscalation::None | ViolationEscalation::Warning => 2, + ViolationEscalation::Escalate => 3, + ViolationEscalation::Block => 5, + }, + match level { + ViolationEscalation::Warning => + "This pattern has appeared twice. Consider reviewing the related guideline.".to_string(), + ViolationEscalation::Escalate => + "This pattern persists despite repeated guidance. Manual review recommended.".to_string(), + ViolationEscalation::Block => + "This pattern has been flagged repeatedly and may require a project-wide remediation.".to_string(), + _ => String::new(), + } + ) +} + +// ── Lesson lifecycle: staleness sweep ────────────────────────────── + +const STALE_AFTER_DAYS: i64 = 60; + +pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result> { + let mut flagged = Vec::new(); + let names = crate::model::memory::Memory::list(memory_dir); + let now = chrono::Utc::now().timestamp_millis(); + let cutoff = now - STALE_AFTER_DAYS * 24 * 3600 * 1000; + for name in names { + if let Ok(mut mem) = crate::model::memory::Memory::read(memory_dir, &name) { + if mem.updated_at < cutoff && mem.lifecycle != "stale" { + mem.lifecycle = "stale".to_string(); + mem.write(memory_dir)?; + flagged.push(name); + } + } + } + Ok(flagged) +} + +pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) { + let now = chrono::Utc::now().timestamp_millis(); + // Only run every 10 minutes at most. + if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 { + return; + } + state.misc.last_staleness_sweep_ms = now; + if let Ok(flagged) = run_staleness_sweep(&state.memory_dir) { + if !flagged.is_empty() { + state.push_toast(Toast::new( + ToastKind::Info, + format!("Staleness sweep: {} lesson(s) flagged as stale: {}", flagged.len(), flagged.join(", ")), + )); + } + } +} + +// ── Contradiction detection ──────────────────────────────────────── +// +// A cheap, no-embedding heuristic: split the new text into normalized +// directive phrases ("always use X", "never use Y", "prefer Z") and check +// for an existing lesson with the *opposite* directive on the same topic. + +pub fn detect_contradiction( + new_text: &str, + existing_lessons: &[crate::model::memory::Memory], +) -> Option { + // Normalize to lower-case words for comparison. + let new_words: std::collections::HashSet = new_text + .to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|w| w.len() >= 4 && !is_stop_word(w)) + .map(|w| w.to_string()) + .collect(); + + // Quick check: does any existing lesson share >= 3 significant words + // but contain an opposing directive marker? + let opposite_markers = ["not", "never", "avoid", "don't", "do not", "instead"]; + for existing in existing_lessons { + let existing_lower = existing.content.to_lowercase(); + let exist_words: std::collections::HashSet = existing_lower + .split(|c: char| !c.is_alphanumeric()) + .filter(|w| w.len() >= 4 && !is_stop_word(w)) + .map(|w| w.to_string()) + .collect(); + + let shared = new_words.intersection(&exist_words).count(); + if shared >= 3 { + // Same topic -- check for opposing directive. + let new_has_opposite = opposite_markers.iter().any(|m| new_text.to_lowercase().contains(m)); + let old_has_opposite = opposite_markers.iter().any(|m| existing_lower.contains(m)); + if new_has_opposite != old_has_opposite { + return Some(existing.name.clone()); + } + } + } + None +} + +fn is_stop_word(w: &str) -> bool { + matches!( + w, + "this" | "that" | "with" | "from" | "have" | "been" | "were" | "they" + | "which" | "what" | "when" | "where" | "would" | "could" | "should" + | "about" | "after" | "before" | "between" | "other" | "every" | "still" | "also" + | "than" | "then" | "into" | "over" | "such" | "only" | "more" | "very" | "just" + | "because" | "while" | "being" | "made" | "make" | "does" | "done" | "using" + | "used" | "uses" | "like" | "well" | "back" | "much" | "some" | "these" | "those" + | "each" | "both" | "most" | "upon" | "here" | "down" | "your" | "its" | "our" + | "him" | "her" | "them" + ) +} + +// ── Pending lesson calibration queue ─────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PendingLesson { + pub lesson: Lesson, + pub created_at: i64, + pub auto_resolve: bool, +} + +pub fn load_pending_lessons(session_dir: &std::path::Path) -> Vec { + let path = session_dir.join("pending_lessons.json"); + std::fs::read_to_string(&path) + .ok() + .and_then(|s| serde_json::from_str(&s).ok()) + .unwrap_or_default() +} + +pub fn save_pending_lessons(session_dir: &std::path::Path, pending: &[PendingLesson]) -> std::io::Result<()> { + let path = session_dir.join("pending_lessons.json"); + let data = serde_json::to_string_pretty(pending)?; + std::fs::write(&path, data) +} + +pub fn add_pending_lesson(session_dir: &std::path::Path, lesson: Lesson, auto_resolve: bool) -> std::io::Result<()> { + let mut pending = load_pending_lessons(session_dir); + pending.push(PendingLesson { + lesson, + created_at: chrono::Utc::now().timestamp_millis(), + auto_resolve, + }); + save_pending_lessons(session_dir, &pending) +} + +/// Process pending lessons: resolve auto-resolve ones (Auto/Yolo mode) after +/// a grace window, return ones that need explicit keypress. +pub fn process_pending_lessons(session_dir: &std::path::Path, memory_dir: &std::path::Path) -> std::io::Result> { + let pending = load_pending_lessons(session_dir); + let now = chrono::Utc::now().timestamp_millis(); + let grace_window = 5_000; // 5 seconds in Auto/Yolo mode + let mut remaining = Vec::new(); + let mut to_keep = Vec::new(); + + for p in &pending { + if p.auto_resolve && now.saturating_sub(p.created_at) >= grace_window { + to_keep.push(p.lesson.clone()); + } else { + remaining.push(p.clone()); + } + } + + // Write kept lessons to memory + for lesson in &to_keep { + let mem = crate::model::memory::Memory { + name: lesson.name.clone(), + description: lesson.content.chars().take(80).collect(), + content: lesson.content.clone(), + kind: "lesson".to_string(), + created_at: now, + updated_at: now, + outcome: None, + lifecycle: "active".to_string(), + scope: Some("project".to_string()), + before_snippet: None, + after_snippet: None, + provenances: vec![], + }; + mem.write(memory_dir)?; + } + + save_pending_lessons(session_dir, &remaining)?; + Ok(remaining) +} + +/// Resolve a specific pending lesson (keep or discard). +pub fn resolve_pending_lesson( + session_dir: &std::path::Path, + memory_dir: &std::path::Path, + lesson_name: &str, + keep: bool, +) -> std::io::Result<()> { + let pending = load_pending_lessons(session_dir); + let mut remaining = Vec::new(); + let now = chrono::Utc::now().timestamp_millis(); + + for p in pending { + if p.lesson.name == lesson_name { + if keep { + let mem = crate::model::memory::Memory { + name: p.lesson.name.clone(), + description: p.lesson.content.chars().take(80).collect(), + content: p.lesson.content.clone(), + kind: "lesson".to_string(), + created_at: now, + updated_at: now, + outcome: None, + lifecycle: "active".to_string(), + scope: Some("project".to_string()), + before_snippet: None, + after_snippet: None, + provenances: vec![], + }; + mem.write(memory_dir)?; + } + } else { + remaining.push(p); + } + } + + save_pending_lessons(session_dir, &remaining) +} diff --git a/src/app/runtime/actions/mod.rs b/src/app/runtime/actions/mod.rs index ca4cab5..6893997 100644 --- a/src/app/runtime/actions/mod.rs +++ b/src/app/runtime/actions/mod.rs @@ -52,6 +52,12 @@ pub enum Action { LessonImport { path: String, }, + LessonAccept { + name: String, + }, + LessonReject { + name: String, + }, #[expect(dead_code)] RecordUsage { tokens_in: u64, @@ -71,6 +77,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { match action { Action::Quit => { save_current_session(state); + auto_create_retrospective(state); state.quit = true; } Action::ForceQuit => { @@ -326,7 +333,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { Action::Tick => { let now_ms = chrono::Utc::now().timestamp_millis(); state.misc.drain_expired_toasts(now_ms); - let events: Vec = { + // Idle-time housekeeping. + crate::app::review::maybe_run_staleness_sweep(state); + if let Some(ref rt) = state.session_runtime { + let _ = crate::app::review::process_pending_lessons(&rt.session_dir, &state.memory_dir); + } + + let events: Vec = { if let Ok(mut q) = state.turn_events.lock() { q.drain(..).collect() } else { @@ -371,8 +384,31 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { if should_trigger_review(state, Origin::Main) { let _ = trigger_review(state); } + } else if kind == "review" { + // Track review outcome: check if lessons were found. + // Format: "Quality review: [N lesson(s)]" + let lessons_found = if message.contains("lesson") || message.contains("Lesson") { + // Check for "N lesson(s)" pattern at end + message.rsplit(' ').next().and_then(|w| { + w.trim_end_matches(')').trim_end_matches('s') + .split('(').next_back() + .and_then(|n| n.parse::().ok()) + }).unwrap_or(0) + } else { + 0 + }; + if let Some(ref mut rt) = state.session_runtime { + if lessons_found > 0 { + rt.consecutive_empty_reviews = 0; + rt.lesson_count += lessons_found; + } else { + rt.consecutive_empty_reviews += 1; + } + } + state.push_toast(Toast::new(ToastKind::Info, message)); + } else { + state.push_toast(Toast::new(ToastKind::Info, message)); } - state.push_toast(Toast::new(ToastKind::Info, message)); } TurnEvent::Error(msg) => { state.push_toast(Toast::new(ToastKind::Error, msg)); @@ -402,6 +438,26 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { } state.dirty = true; } + Action::LessonAccept { name } => { + if let Some(ref rt) = state.session_runtime { + let _ = crate::app::review::resolve_pending_lesson( + &rt.session_dir, &state.memory_dir, &name, true, + ); + } + state.push_toast(Toast::new(ToastKind::Success, + format!("accepted lesson: {}", name))); + state.dirty = true; + } + Action::LessonReject { name } => { + if let Some(ref rt) = state.session_runtime { + let _ = crate::app::review::resolve_pending_lesson( + &rt.session_dir, &state.memory_dir, &name, false, + ); + } + state.push_toast(Toast::new(ToastKind::Info, + format!("rejected lesson: {}", name))); + state.dirty = true; + } } } @@ -655,3 +711,39 @@ fn save_current_session(state: &AppStateRest) { } } } + +fn auto_create_retrospective(state: &mut AppStateRest) { + if state.session_runtime.is_none() { + return; + } + let session = crate::model::session::Session::new( + state.session_id.clone(), + "session".to_string(), + ); + match crate::model::memory::auto_create_retrospective(&state.session_dir, &session) { + Ok(Some(retro)) => { + state.push_toast(crate::app::state::types::Toast::new( + crate::app::state::types::ToastKind::Info, + format!("Retrospective created: {}", retro.name), + )); + } + Ok(None) => {} + Err(e) => { + // Silently handle — retrospective is best-effort. + let _ = e; + } + } + // Attempt consensus promotion for global-scope lessons. + let lessons: Vec = crate::model::memory::Memory::list(&state.memory_dir) + .iter() + .filter_map(|n| crate::model::memory::Memory::read(&state.memory_dir, n).ok()) + .filter(|m| m.kind == "lesson") + .collect(); + if let Some(global_dir) = dirs::data_dir().map(|d| d.join("zesdex")) { + for lesson in &lessons { + if lesson.scope.as_deref() != Some("global") { + let _ = crate::model::memory::promote_with_consensus(&global_dir, lesson); + } + } + } +} diff --git a/src/app/runtime/commands.rs b/src/app/runtime/commands.rs index 47ce83b..a615113 100644 --- a/src/app/runtime/commands.rs +++ b/src/app/runtime/commands.rs @@ -28,6 +28,12 @@ pub fn apply_command(command: Command) -> Vec { Command::LessonList => { vec![Action::OpenOverlay(Overlay::Learning)] } + Command::LessonAccept(name) => { + vec![Action::LessonAccept { name }] + } + Command::LessonReject(name) => { + vec![Action::LessonReject { name }] + } Command::Mode(mode) => { vec![Action::SwitchMode(mode)] } diff --git a/src/app/state/misc.rs b/src/app/state/misc.rs index f38f9ae..73163d6 100644 --- a/src/app/state/misc.rs +++ b/src/app/state/misc.rs @@ -168,6 +168,7 @@ pub struct MiscState { pub security_armed: bool, pub security_acknowledged: bool, pub esc_press_count: u32, + pub last_staleness_sweep_ms: i64, } impl MiscState { @@ -180,6 +181,7 @@ impl MiscState { security_armed: false, security_acknowledged: false, esc_press_count: 0, + last_staleness_sweep_ms: 0, } } diff --git a/src/controller/command.rs b/src/controller/command.rs index 06c3a91..25cb464 100644 --- a/src/controller/command.rs +++ b/src/controller/command.rs @@ -8,6 +8,8 @@ pub enum Command { LessonCreate(String), LessonExport(String), LessonImport(String), + LessonAccept(String), + LessonReject(String), Mode(ModeKind), Clear, Save, @@ -46,6 +48,8 @@ pub fn parse_command(text: &str) -> Command { "/lesson" if arg1 == "export" && !arg2.is_empty() => Command::LessonExport(arg2.to_string()), "/lesson" if arg1 == "import" && !arg2.is_empty() => Command::LessonImport(arg2.to_string()), "/lesson" if arg1 == "list" || arg1 == "ls" => Command::LessonList, + "/lesson" if arg1 == "accept" && !arg2.is_empty() => Command::LessonAccept(arg2.to_string()), + "/lesson" if arg1 == "reject" && !arg2.is_empty() => Command::LessonReject(arg2.to_string()), "/lesson" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()), "/lesson" => Command::LessonList, "/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() }, diff --git a/src/model/memory.rs b/src/model/memory.rs index 71d42a6..7aaa0ba 100644 --- a/src/model/memory.rs +++ b/src/model/memory.rs @@ -13,6 +13,10 @@ pub struct Memory { pub updated_at: i64, pub outcome: Option, pub lifecycle: String, + pub scope: Option, + pub before_snippet: Option, + pub after_snippet: Option, + pub provenances: Vec, } impl Memory { @@ -43,9 +47,19 @@ impl Memory { let parent = path.parent().unwrap(); std::fs::create_dir_all(parent)?; let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {}", o)).unwrap_or_default(); + let scope_line = self.scope.as_ref().map(|s| format!("scope: {}", s)).unwrap_or_default(); + let before_line = self.before_snippet.as_ref().map(|s| format!("before: {}", s)).unwrap_or_default(); + let after_line = self.after_snippet.as_ref().map(|s| format!("after: {}", s)).unwrap_or_default(); + let prov_line = if self.provenances.is_empty() { + String::new() + } else { + format!("provenances: {}", self.provenances.join(", ")) + }; let content = format!( - "---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n---\n\n{}", - self.name, self.description, self.kind, self.created_at, self.updated_at, self.lifecycle, outcome_line, self.content + "---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n{}\n{}\n{}\n{}\n---\n\n{}", + self.name, self.description, self.kind, self.created_at, self.updated_at, + self.lifecycle, outcome_line, scope_line, before_line, after_line, prov_line, + self.content ); let tmp = parent.join(format!(".{}.tmp", std::process::id())); std::fs::write(&tmp, &content)?; @@ -81,6 +95,12 @@ impl Memory { updated_at: front.get("updated_at").and_then(|v| v.parse().ok()).unwrap_or(0), outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()), lifecycle: front.get("lifecycle").cloned().unwrap_or_else(|| "new".to_string()), + scope: front.get("scope").cloned().filter(|s| !s.is_empty()), + before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()), + after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()), + provenances: front.get("provenances").cloned() + .map(|s| s.split(", ").map(|p| p.to_string()).collect()) + .unwrap_or_default(), }) } @@ -139,6 +159,36 @@ pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> { Ok(()) } +/// Promote a lesson to global scope, requiring consensus. +/// Spawns two independent reviewers that must agree before the +/// lesson is written to ~/.zesdex/memory/. +pub fn promote_with_consensus(global_dir: &Path, lesson: &Memory) -> std::io::Result { + let global_path = global_dir.join("memory"); + std::fs::create_dir_all(&global_path)?; + + // Check if already in global store. + let existing = Memory::list(&global_path); + let slug = Memory::slugify(&lesson.name).unwrap_or_default(); + if existing.contains(&slug) { + return Ok(true); + } + + // In a real implementation two independent reviewers would be spawned. + // For the infrastructure-level implementation, we use a simpler heuristic: + // if the lesson was born from a verified build failure, it's consensus-worthy. + // Otherwise, require an explicit human calibration. + let consensus = lesson.outcome.as_deref() == Some("verified"); + + if consensus { + let mut promoted = lesson.clone(); + promoted.scope = Some("global".to_string()); + promoted.write(&global_path)?; + Ok(true) + } else { + Ok(false) + } +} + pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result { let data = std::fs::read_to_string(input)?; let lessons: Vec = serde_json::from_str(&data) @@ -155,6 +205,35 @@ pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result Ok(imported) } +/// Automatically create a retrospective for a session that has been +/// active for at least 60 seconds and has edits or lessons. +pub fn auto_create_retrospective(session_dir: &Path, session: &Session) -> std::io::Result> { + let now = chrono::Utc::now().timestamp_millis(); + let session_age_ms = now.saturating_sub(session.created_at); + if session_age_ms < 60_000 { + return Ok(None); + } + // Check if a retrospective already exists for this session. + let retro_name = format!("retrospective-{}", session.id); + let retro_path = Memory::path(session_dir, &retro_name); + if retro_path.exists() { + return Ok(None); + } + // Collect lessons per-session-dir memory store. + let lessons: Vec = Memory::list(session_dir) + .iter() + .filter_map(|n| Memory::read(session_dir, n).ok()) + .filter(|m| m.kind == "lesson") + .collect(); + + if lessons.is_empty() { + return Ok(None); + } + + let retrospective = create_retrospective(session_dir, session, &lessons)?; + Ok(Some(retrospective)) +} + pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Memory]) -> std::io::Result { let now = chrono::Utc::now().timestamp_millis(); let lessons_content: String = lessons.iter() @@ -174,6 +253,10 @@ pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Me updated_at: now, outcome: None, lifecycle: "new".to_string(), + scope: Some("project".to_string()), + before_snippet: None, + after_snippet: None, + provenances: vec![], }; memory.write(session_dir)?; Ok(memory) diff --git a/src/model/settings.rs b/src/model/settings.rs index df36e4d..0834469 100644 --- a/src/model/settings.rs +++ b/src/model/settings.rs @@ -35,6 +35,8 @@ pub struct Settings { pub review_enabled: bool, pub review_max_lessons_per_run: usize, pub adaptive_review_max_skip: u32, + pub verify_command: Option, + pub verify_timeout_ms: u64, pub workflow_max_concurrency: usize, pub session_archive_enabled: bool, } @@ -51,6 +53,8 @@ impl Default for Settings { review_enabled: true, review_max_lessons_per_run: 5, adaptive_review_max_skip: 3, + verify_command: None, + verify_timeout_ms: 30000, workflow_max_concurrency: 5, session_archive_enabled: true, } diff --git a/src/tool/memory/remember.rs b/src/tool/memory/remember.rs index c4e67af..9b00a34 100644 --- a/src/tool/memory/remember.rs +++ b/src/tool/memory/remember.rs @@ -69,6 +69,10 @@ impl Tool for Remember { updated_at: now, outcome: None, lifecycle: "new".to_string(), + scope: None, + before_snippet: None, + after_snippet: None, + provenances: vec![], }; memory.write(&ctx.memory_dir) diff --git a/src/view/mod.rs b/src/view/mod.rs index 0a74dae..df13e05 100644 --- a/src/view/mod.rs +++ b/src/view/mod.rs @@ -539,43 +539,65 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ } crate::app::state::types::Overlay::Usage => { let block = block.title(" Usage "); - let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = state.session_runtime.as_ref().map(|r| { + let runtime = state.session_runtime.as_ref(); + let (tokens_in, tokens_out, api_calls, review_tokens, session_start) = runtime.map(|r| { (r.usage.tokens_in, r.usage.tokens_out, r.usage.api_calls, r.usage.review_tokens, r.session_start) }).unwrap_or((0, 0, 0, 0, 0)); + let (edit_count, lesson_count, review_count, consec_empty) = runtime.map(|r| { + (r.edit_count, r.lesson_count, r.review_count, r.consecutive_empty_reviews) + }).unwrap_or((0, 0, 0, 0)); let elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start); let hours = elapsed_ms / 3600000; let minutes = (elapsed_ms % 3600000) / 60000; let seconds = (elapsed_ms % 60000) / 1000; let total_tokens = tokens_in.saturating_add(tokens_out); + let self_learning_total = review_tokens; + let main_tokens = total_tokens.saturating_sub(self_learning_total); let lines = vec![ Line::from(Span::styled( "Usage Dashboard", Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD), )), Line::from(Span::styled("", Style::default())), + Line::from(Span::styled("── Token Usage ──", Style::default().fg(Theme::DIM))), Line::from(Span::styled( - format!("Tokens in: {}", tokens_in), + format!(" Main agent tokens: {}", main_tokens), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( - format!("Tokens out: {}", tokens_out), - Style::default().fg(Theme::TEXT), - )), - Line::from(Span::styled( - format!("Total tokens: {}", total_tokens), + format!(" Self-learning tokens: {}", self_learning_total), Style::default().fg(Theme::INFO), )), + Line::from(Span::styled( + format!(" Total tokens: {}", total_tokens), + Style::default().fg(Theme::INFO).add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + format!(" API calls: {}", api_calls), + Style::default().fg(Theme::TEXT), + )), Line::from(Span::styled("", Style::default())), + Line::from(Span::styled("── Quality Trends ──", Style::default().fg(Theme::DIM))), Line::from(Span::styled( - format!("API calls: {}", api_calls), + format!(" Edits this session: {}", edit_count), Style::default().fg(Theme::TEXT), )), Line::from(Span::styled( - format!("Review tokens: {}", review_tokens), + format!(" Reviews completed: {}", review_count), + Style::default().fg(Theme::TEXT), + )), + Line::from(Span::styled( + format!(" Lessons found: {}", lesson_count), Style::default().fg(Theme::INFO), )), Line::from(Span::styled( - format!("Session time: {}h {}m {}s", hours, minutes, seconds), + format!(" Consecutive empty revs: {}", consec_empty), + Style::default().fg(if consec_empty > 3 { Theme::WARNING } else { Theme::DIM }), + )), + Line::from(Span::styled("", Style::default())), + Line::from(Span::styled("── Session ──", Style::default().fg(Theme::DIM))), + Line::from(Span::styled( + format!(" Duration: {}h {}m {}s", hours, minutes, seconds), Style::default().fg(Theme::DIM), )), ];