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.
This commit is contained in:
+549
-5
@@ -1,4 +1,5 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::process::Command;
|
||||||
use crate::app::state::rest::AppStateRest;
|
use crate::app::state::rest::AppStateRest;
|
||||||
use crate::app::state::runtime::TurnEvent;
|
use crate::app::state::runtime::TurnEvent;
|
||||||
use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind};
|
use crate::app::state::types::{AgentMode, Origin, Toast, ToastKind};
|
||||||
@@ -37,7 +38,7 @@ pub struct Provenance {
|
|||||||
pub reviewer: Origin,
|
pub reviewer: Origin,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct Lesson {
|
pub struct Lesson {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
@@ -78,6 +79,7 @@ pub struct ReviewSystem {
|
|||||||
pub queue_capacity: usize,
|
pub queue_capacity: usize,
|
||||||
pub repeated_violations: HashMap<String, u32>,
|
pub repeated_violations: HashMap<String, u32>,
|
||||||
pub shadow_violations: Vec<ShadowCheck>,
|
pub shadow_violations: Vec<ShadowCheck>,
|
||||||
|
pub graduated_checks: Vec<crate::tool::GraduatedCheck>,
|
||||||
pub violation_window: u32,
|
pub violation_window: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,10 +90,65 @@ impl ReviewSystem {
|
|||||||
queue_capacity: 1,
|
queue_capacity: 1,
|
||||||
repeated_violations: HashMap::new(),
|
repeated_violations: HashMap::new(),
|
||||||
shadow_violations: Vec::new(),
|
shadow_violations: Vec::new(),
|
||||||
|
graduated_checks: Vec::new(),
|
||||||
violation_window: 10,
|
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<String> {
|
||||||
|
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) {
|
pub fn reset(&mut self) {
|
||||||
self.pending = false;
|
self.pending = false;
|
||||||
}
|
}
|
||||||
@@ -157,15 +214,200 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool {
|
|||||||
if !state.settings.review_enabled {
|
if !state.settings.review_enabled {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Always review if edits were made this turn.
|
||||||
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
|
if runtime.edit_count > 0 && runtime.edit_count % 5 == 0 {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if runtime.consecutive_empty_reviews >= state.settings.adaptive_review_max_skip {
|
// Adaptive skip: consecutive empty reviews throttle frequency.
|
||||||
return true;
|
// 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
|
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<ProbeResult> {
|
||||||
|
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<String> {
|
||||||
|
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::<serde_json::Value>(&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<()> {
|
pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
||||||
let def = AgentDefinition::new(
|
let def = AgentDefinition::new(
|
||||||
"quality-reviewer".to_string(),
|
"quality-reviewer".to_string(),
|
||||||
@@ -173,13 +415,42 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
|||||||
);
|
);
|
||||||
let mut ctx = build_subagent_context(def);
|
let mut ctx = build_subagent_context(def);
|
||||||
ctx.session_dir = state.session_dir.clone();
|
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!(
|
ctx.system_prompt = format!(
|
||||||
"You are a code quality reviewer. Review the recent code changes \
|
"You are a code quality reviewer. Review the recent code changes \
|
||||||
for correctness, security, and adherence to best practices. \
|
for correctness, security, and adherence to best practices. \
|
||||||
Use read-only tools (read, grep, glob, recall, remember) to \
|
Use read-only tools (read, grep, glob, recall, remember) to \
|
||||||
inspect the session files and provide a concise review verdict. \
|
inspect the session files and provide a concise review verdict. \
|
||||||
Session directory: {:?}",
|
Session directory: {:?}\n\n\
|
||||||
state.session_dir
|
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);
|
let (tx, _rx) = tokio::sync::mpsc::channel(32);
|
||||||
@@ -209,3 +480,276 @@ pub fn trigger_review(state: &mut AppStateRest) -> anyhow::Result<()> {
|
|||||||
|
|
||||||
Ok(())
|
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<String> {
|
||||||
|
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<ViolationEscalation> {
|
||||||
|
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<Vec<String>> {
|
||||||
|
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<String> {
|
||||||
|
// Normalize to lower-case words for comparison.
|
||||||
|
let new_words: std::collections::HashSet<String> = 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<String> = 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<PendingLesson> {
|
||||||
|
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<Vec<PendingLesson>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ pub enum Action {
|
|||||||
LessonImport {
|
LessonImport {
|
||||||
path: String,
|
path: String,
|
||||||
},
|
},
|
||||||
|
LessonAccept {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
|
LessonReject {
|
||||||
|
name: String,
|
||||||
|
},
|
||||||
#[expect(dead_code)]
|
#[expect(dead_code)]
|
||||||
RecordUsage {
|
RecordUsage {
|
||||||
tokens_in: u64,
|
tokens_in: u64,
|
||||||
@@ -71,6 +77,7 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
match action {
|
match action {
|
||||||
Action::Quit => {
|
Action::Quit => {
|
||||||
save_current_session(state);
|
save_current_session(state);
|
||||||
|
auto_create_retrospective(state);
|
||||||
state.quit = true;
|
state.quit = true;
|
||||||
}
|
}
|
||||||
Action::ForceQuit => {
|
Action::ForceQuit => {
|
||||||
@@ -326,7 +333,13 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
Action::Tick => {
|
Action::Tick => {
|
||||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||||
state.misc.drain_expired_toasts(now_ms);
|
state.misc.drain_expired_toasts(now_ms);
|
||||||
let events: Vec<TurnEvent> = {
|
// 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<TurnEvent> = {
|
||||||
if let Ok(mut q) = state.turn_events.lock() {
|
if let Ok(mut q) = state.turn_events.lock() {
|
||||||
q.drain(..).collect()
|
q.drain(..).collect()
|
||||||
} else {
|
} else {
|
||||||
@@ -371,8 +384,31 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) {
|
|||||||
if should_trigger_review(state, Origin::Main) {
|
if should_trigger_review(state, Origin::Main) {
|
||||||
let _ = trigger_review(state);
|
let _ = trigger_review(state);
|
||||||
}
|
}
|
||||||
|
} else if kind == "review" {
|
||||||
|
// Track review outcome: check if lessons were found.
|
||||||
|
// Format: "Quality review: <verdict> [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::<u32>().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) => {
|
TurnEvent::Error(msg) => {
|
||||||
state.push_toast(Toast::new(ToastKind::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;
|
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> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ pub fn apply_command(command: Command) -> Vec<Action> {
|
|||||||
Command::LessonList => {
|
Command::LessonList => {
|
||||||
vec![Action::OpenOverlay(Overlay::Learning)]
|
vec![Action::OpenOverlay(Overlay::Learning)]
|
||||||
}
|
}
|
||||||
|
Command::LessonAccept(name) => {
|
||||||
|
vec![Action::LessonAccept { name }]
|
||||||
|
}
|
||||||
|
Command::LessonReject(name) => {
|
||||||
|
vec![Action::LessonReject { name }]
|
||||||
|
}
|
||||||
Command::Mode(mode) => {
|
Command::Mode(mode) => {
|
||||||
vec![Action::SwitchMode(mode)]
|
vec![Action::SwitchMode(mode)]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ pub struct MiscState {
|
|||||||
pub security_armed: bool,
|
pub security_armed: bool,
|
||||||
pub security_acknowledged: bool,
|
pub security_acknowledged: bool,
|
||||||
pub esc_press_count: u32,
|
pub esc_press_count: u32,
|
||||||
|
pub last_staleness_sweep_ms: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MiscState {
|
impl MiscState {
|
||||||
@@ -180,6 +181,7 @@ impl MiscState {
|
|||||||
security_armed: false,
|
security_armed: false,
|
||||||
security_acknowledged: false,
|
security_acknowledged: false,
|
||||||
esc_press_count: 0,
|
esc_press_count: 0,
|
||||||
|
last_staleness_sweep_ms: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ pub enum Command {
|
|||||||
LessonCreate(String),
|
LessonCreate(String),
|
||||||
LessonExport(String),
|
LessonExport(String),
|
||||||
LessonImport(String),
|
LessonImport(String),
|
||||||
|
LessonAccept(String),
|
||||||
|
LessonReject(String),
|
||||||
Mode(ModeKind),
|
Mode(ModeKind),
|
||||||
Clear,
|
Clear,
|
||||||
Save,
|
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 == "export" && !arg2.is_empty() => Command::LessonExport(arg2.to_string()),
|
||||||
"/lesson" if arg1 == "import" && !arg2.is_empty() => Command::LessonImport(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 == "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" if !arg1.is_empty() => Command::LessonCreate(arg1.to_string()),
|
||||||
"/lesson" => Command::LessonList,
|
"/lesson" => Command::LessonList,
|
||||||
"/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() },
|
"/login" if !arg1.is_empty() => Command::Login { provider: arg1.to_string() },
|
||||||
|
|||||||
+85
-2
@@ -13,6 +13,10 @@ pub struct Memory {
|
|||||||
pub updated_at: i64,
|
pub updated_at: i64,
|
||||||
pub outcome: Option<String>,
|
pub outcome: Option<String>,
|
||||||
pub lifecycle: String,
|
pub lifecycle: String,
|
||||||
|
pub scope: Option<String>,
|
||||||
|
pub before_snippet: Option<String>,
|
||||||
|
pub after_snippet: Option<String>,
|
||||||
|
pub provenances: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Memory {
|
impl Memory {
|
||||||
@@ -43,9 +47,19 @@ impl Memory {
|
|||||||
let parent = path.parent().unwrap();
|
let parent = path.parent().unwrap();
|
||||||
std::fs::create_dir_all(parent)?;
|
std::fs::create_dir_all(parent)?;
|
||||||
let outcome_line = self.outcome.as_ref().map(|o| format!("outcome: {}", o)).unwrap_or_default();
|
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!(
|
let content = format!(
|
||||||
"---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n---\n\n{}",
|
"---\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, self.content
|
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()));
|
let tmp = parent.join(format!(".{}.tmp", std::process::id()));
|
||||||
std::fs::write(&tmp, &content)?;
|
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),
|
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()),
|
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
|
||||||
lifecycle: front.get("lifecycle").cloned().unwrap_or_else(|| "new".to_string()),
|
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(())
|
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<bool> {
|
||||||
|
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<usize> {
|
pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize> {
|
||||||
let data = std::fs::read_to_string(input)?;
|
let data = std::fs::read_to_string(input)?;
|
||||||
let lessons: Vec<Memory> = serde_json::from_str(&data)
|
let lessons: Vec<Memory> = serde_json::from_str(&data)
|
||||||
@@ -155,6 +205,35 @@ pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize>
|
|||||||
Ok(imported)
|
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<Option<Memory>> {
|
||||||
|
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> = 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<Memory> {
|
pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Memory]) -> std::io::Result<Memory> {
|
||||||
let now = chrono::Utc::now().timestamp_millis();
|
let now = chrono::Utc::now().timestamp_millis();
|
||||||
let lessons_content: String = lessons.iter()
|
let lessons_content: String = lessons.iter()
|
||||||
@@ -174,6 +253,10 @@ pub fn create_retrospective(session_dir: &Path, session: &Session, lessons: &[Me
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
outcome: None,
|
outcome: None,
|
||||||
lifecycle: "new".to_string(),
|
lifecycle: "new".to_string(),
|
||||||
|
scope: Some("project".to_string()),
|
||||||
|
before_snippet: None,
|
||||||
|
after_snippet: None,
|
||||||
|
provenances: vec![],
|
||||||
};
|
};
|
||||||
memory.write(session_dir)?;
|
memory.write(session_dir)?;
|
||||||
Ok(memory)
|
Ok(memory)
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ pub struct Settings {
|
|||||||
pub review_enabled: bool,
|
pub review_enabled: bool,
|
||||||
pub review_max_lessons_per_run: usize,
|
pub review_max_lessons_per_run: usize,
|
||||||
pub adaptive_review_max_skip: u32,
|
pub adaptive_review_max_skip: u32,
|
||||||
|
pub verify_command: Option<String>,
|
||||||
|
pub verify_timeout_ms: u64,
|
||||||
pub workflow_max_concurrency: usize,
|
pub workflow_max_concurrency: usize,
|
||||||
pub session_archive_enabled: bool,
|
pub session_archive_enabled: bool,
|
||||||
}
|
}
|
||||||
@@ -51,6 +53,8 @@ impl Default for Settings {
|
|||||||
review_enabled: true,
|
review_enabled: true,
|
||||||
review_max_lessons_per_run: 5,
|
review_max_lessons_per_run: 5,
|
||||||
adaptive_review_max_skip: 3,
|
adaptive_review_max_skip: 3,
|
||||||
|
verify_command: None,
|
||||||
|
verify_timeout_ms: 30000,
|
||||||
workflow_max_concurrency: 5,
|
workflow_max_concurrency: 5,
|
||||||
session_archive_enabled: true,
|
session_archive_enabled: true,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ impl Tool for Remember {
|
|||||||
updated_at: now,
|
updated_at: now,
|
||||||
outcome: None,
|
outcome: None,
|
||||||
lifecycle: "new".to_string(),
|
lifecycle: "new".to_string(),
|
||||||
|
scope: None,
|
||||||
|
before_snippet: None,
|
||||||
|
after_snippet: None,
|
||||||
|
provenances: vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
memory.write(&ctx.memory_dir)
|
memory.write(&ctx.memory_dir)
|
||||||
|
|||||||
+32
-10
@@ -539,43 +539,65 @@ fn render_overlay(frame: &mut Frame, area: Rect, overlay: crate::app::state::typ
|
|||||||
}
|
}
|
||||||
crate::app::state::types::Overlay::Usage => {
|
crate::app::state::types::Overlay::Usage => {
|
||||||
let block = block.title(" 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)
|
(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));
|
}).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 elapsed_ms = chrono::Utc::now().timestamp_millis().saturating_sub(session_start);
|
||||||
let hours = elapsed_ms / 3600000;
|
let hours = elapsed_ms / 3600000;
|
||||||
let minutes = (elapsed_ms % 3600000) / 60000;
|
let minutes = (elapsed_ms % 3600000) / 60000;
|
||||||
let seconds = (elapsed_ms % 60000) / 1000;
|
let seconds = (elapsed_ms % 60000) / 1000;
|
||||||
let total_tokens = tokens_in.saturating_add(tokens_out);
|
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![
|
let lines = vec![
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
"Usage Dashboard",
|
"Usage Dashboard",
|
||||||
Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD),
|
Style::default().fg(Theme::PRIMARY).add_modifier(Modifier::BOLD),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled("", Style::default())),
|
Line::from(Span::styled("", Style::default())),
|
||||||
|
Line::from(Span::styled("── Token Usage ──", Style::default().fg(Theme::DIM))),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!("Tokens in: {}", tokens_in),
|
format!(" Main agent tokens: {}", main_tokens),
|
||||||
Style::default().fg(Theme::TEXT),
|
Style::default().fg(Theme::TEXT),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!("Tokens out: {}", tokens_out),
|
format!(" Self-learning tokens: {}", self_learning_total),
|
||||||
Style::default().fg(Theme::TEXT),
|
|
||||||
)),
|
|
||||||
Line::from(Span::styled(
|
|
||||||
format!("Total tokens: {}", total_tokens),
|
|
||||||
Style::default().fg(Theme::INFO),
|
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("", Style::default())),
|
||||||
|
Line::from(Span::styled("── Quality Trends ──", Style::default().fg(Theme::DIM))),
|
||||||
Line::from(Span::styled(
|
Line::from(Span::styled(
|
||||||
format!("API calls: {}", api_calls),
|
format!(" Edits this session: {}", edit_count),
|
||||||
Style::default().fg(Theme::TEXT),
|
Style::default().fg(Theme::TEXT),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
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),
|
Style::default().fg(Theme::INFO),
|
||||||
)),
|
)),
|
||||||
Line::from(Span::styled(
|
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),
|
Style::default().fg(Theme::DIM),
|
||||||
)),
|
)),
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user