Skip to main content

skilllite_evolution/
lib.rs

1//! SkillLite Evolution: self-evolving prompts, skills, and memory.
2//!
3//! EVO-1: Feedback collection + evaluation system + structured memory.
4//! EVO-2: Prompt externalization + seed data mechanism.
5//! EVO-3: Evolution engine core + evolution prompt design.
6//! EVO-5: Polish + transparency (audit, degradation, CLI, time trends).
7//!
8//! Interacts with the agent through the [`EvolutionLlm`] trait for LLM completion.
9
10pub mod external_learner;
11pub mod feedback;
12pub mod memory_learner;
13pub mod prompt_learner;
14pub mod seed;
15pub mod skill_synth;
16
17use std::path::Path;
18use std::sync::atomic::{AtomicBool, Ordering};
19
20use anyhow::Result;
21use rusqlite::{params, Connection};
22use skilllite_core::config::env_keys::evolution as evo_keys;
23
24// ─── EvolutionLlm trait: agent integration ────────────────────────────────────
25
26/// Minimal message format for evolution LLM calls (no tool calling).
27#[derive(Debug, Clone)]
28pub struct EvolutionMessage {
29    pub role: String,
30    pub content: Option<String>,
31}
32
33impl EvolutionMessage {
34    pub fn user(content: &str) -> Self {
35        Self {
36            role: "user".to_string(),
37            content: Some(content.to_string()),
38        }
39    }
40
41    pub fn system(content: &str) -> Self {
42        Self {
43            role: "system".to_string(),
44            content: Some(content.to_string()),
45        }
46    }
47}
48
49/// LLM completion interface for evolution.
50///
51/// The agent implements this trait to provide LLM access. Evolution uses it
52/// for prompt learning, skill synthesis, and external knowledge extraction.
53#[async_trait::async_trait]
54pub trait EvolutionLlm: Send + Sync {
55    /// Non-streaming chat completion. Returns the assistant's text content.
56    async fn complete(
57        &self,
58        messages: &[EvolutionMessage],
59        model: &str,
60        temperature: f64,
61    ) -> Result<String>;
62}
63
64// ─── LLM response post-processing ────────────────────────────────────────────
65
66/// Strip reasoning/thinking blocks emitted by various models.
67/// Handles `<think>`, `<thinking>`, `<reasoning>` tags (DeepSeek, QwQ, open-source variants).
68/// Returns the content after the last closing tag, or the original string if none found.
69/// Should be called at the LLM layer so all downstream consumers get clean output.
70pub fn strip_think_blocks(content: &str) -> &str {
71    const CLOSING_TAGS: &[&str] = &["</think>", "</thinking>", "</reasoning>"];
72    const OPENING_TAGS: &[&str] = &[
73        "<think>",
74        "<think\n",
75        "<thinking>",
76        "<thinking\n",
77        "<reasoning>",
78        "<reasoning\n",
79    ];
80
81    // Case 1: find the last closing tag, take content after it
82    let mut best_end: Option<usize> = None;
83    for tag in CLOSING_TAGS {
84        if let Some(pos) = content.rfind(tag) {
85            let end = pos + tag.len();
86            if best_end.is_none_or(|bp| end > bp) {
87                best_end = Some(end);
88            }
89        }
90    }
91    if let Some(end) = best_end {
92        let after = content[end..].trim();
93        if !after.is_empty() {
94            return after;
95        }
96    }
97
98    // Case 2: unclosed think tag (model hit token limit mid-thought).
99    // Take content before the opening tag if it contains useful text.
100    if best_end.is_none() {
101        for tag in OPENING_TAGS {
102            if let Some(pos) = content.find(tag) {
103                let before = content[..pos].trim();
104                if !before.is_empty() {
105                    return before;
106                }
107            }
108        }
109    }
110
111    content
112}
113
114// ─── EVO-5: Evolution mode ───────────────────────────────────────────────────
115
116/// Which dimensions of evolution are enabled.
117#[derive(Debug, Clone, PartialEq)]
118pub enum EvolutionMode {
119    All,
120    PromptsOnly,
121    MemoryOnly,
122    SkillsOnly,
123    Disabled,
124}
125
126impl EvolutionMode {
127    pub fn from_env() -> Self {
128        match std::env::var("SKILLLITE_EVOLUTION").ok().as_deref() {
129            None | Some("1") | Some("true") | Some("") => Self::All,
130            Some("0") | Some("false") => Self::Disabled,
131            Some("prompts") => Self::PromptsOnly,
132            Some("memory") => Self::MemoryOnly,
133            Some("skills") => Self::SkillsOnly,
134            Some(other) => {
135                tracing::warn!(
136                    "Unknown SKILLLITE_EVOLUTION value '{}', defaulting to all",
137                    other
138                );
139                Self::All
140            }
141        }
142    }
143
144    pub fn is_disabled(&self) -> bool {
145        matches!(self, Self::Disabled)
146    }
147
148    pub fn prompts_enabled(&self) -> bool {
149        matches!(self, Self::All | Self::PromptsOnly)
150    }
151
152    pub fn memory_enabled(&self) -> bool {
153        matches!(self, Self::All | Self::MemoryOnly)
154    }
155
156    pub fn skills_enabled(&self) -> bool {
157        matches!(self, Self::All | Self::SkillsOnly)
158    }
159}
160
161// ─── SkillAction (used by should_evolve) ──────────────────────────────────────
162
163/// Action type for skill evolution.
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
165pub enum SkillAction {
166    #[default]
167    None,
168    Generate,
169    Refine,
170}
171
172// ─── Concurrency: evolution mutex ────────────────────────────────────────────
173
174static EVOLUTION_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
175
176pub fn try_start_evolution() -> bool {
177    EVOLUTION_IN_PROGRESS
178        .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
179        .is_ok()
180}
181
182pub fn finish_evolution() {
183    EVOLUTION_IN_PROGRESS.store(false, Ordering::SeqCst);
184}
185
186/// Result of attempting to run evolution. Distinguishes "skipped (busy)" from "no scope" from "ran (with or without changes)".
187#[derive(Debug, Clone)]
188pub enum EvolutionRunResult {
189    /// Another evolution run was already in progress; this invocation did not run.
190    SkippedBusy,
191    /// No evolution scope (e.g. thresholds not met, or evolution disabled).
192    NoScope,
193    /// Evolution ran. `Some(txn_id)` if changes were produced, `None` if run completed with no changes.
194    Completed(Option<String>),
195}
196
197impl EvolutionRunResult {
198    /// Returns the txn_id if evolution completed with changes.
199    pub fn txn_id(&self) -> Option<&str> {
200        match self {
201            Self::Completed(Some(id)) => Some(id.as_str()),
202            _ => None,
203        }
204    }
205}
206
207// ─── Atomic file writes (re-export from skilllite-fs) ─────────────────────────
208
209pub use skilllite_fs::atomic_write;
210
211// ─── 5.2 进化触发条件(从环境变量读取,默认与原硬编码一致)────────────────────────
212
213/// 进化触发阈值,均由环境变量配置,未设置时使用下列默认值。
214#[derive(Debug, Clone)]
215pub struct EvolutionThresholds {
216    pub cooldown_hours: f64,
217    pub recent_days: i64,
218    pub recent_limit: i64,
219    pub meaningful_min_tools: i64,
220    pub meaningful_threshold_skills: i64,
221    pub meaningful_threshold_memory: i64,
222    pub meaningful_threshold_prompts: i64,
223    pub failures_min_prompts: i64,
224    pub replans_min_prompts: i64,
225    pub repeated_pattern_min_count: i64,
226    pub repeated_pattern_min_success_rate: f64,
227}
228
229impl Default for EvolutionThresholds {
230    fn default() -> Self {
231        Self {
232            cooldown_hours: 1.0,
233            recent_days: 7,
234            recent_limit: 100,
235            meaningful_min_tools: 2,
236            meaningful_threshold_skills: 3,
237            meaningful_threshold_memory: 3,
238            meaningful_threshold_prompts: 5,
239            failures_min_prompts: 2,
240            replans_min_prompts: 2,
241            repeated_pattern_min_count: 3,
242            repeated_pattern_min_success_rate: 0.8,
243        }
244    }
245}
246
247/// 进化触发场景:不设或 default 时与原有默认行为完全一致。
248#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum EvolutionProfile {
250    /// 与不设 EVO_PROFILE 时一致(当前默认阈值)
251    Default,
252    /// 演示/内测:冷却短、阈值低,进化更频繁
253    Demo,
254    /// 生产/省成本:冷却长、阈值高,进化更少
255    Conservative,
256}
257
258impl EvolutionThresholds {
259    /// 预设:演示场景,进化更频繁
260    fn demo_preset() -> Self {
261        Self {
262            cooldown_hours: 0.25,
263            recent_days: 3,
264            recent_limit: 50,
265            meaningful_min_tools: 1,
266            meaningful_threshold_skills: 1,
267            meaningful_threshold_memory: 1,
268            meaningful_threshold_prompts: 2,
269            failures_min_prompts: 1,
270            replans_min_prompts: 1,
271            repeated_pattern_min_count: 2,
272            repeated_pattern_min_success_rate: 0.7,
273        }
274    }
275
276    /// 预设:保守场景,进化更少、省成本
277    fn conservative_preset() -> Self {
278        Self {
279            cooldown_hours: 4.0,
280            recent_days: 14,
281            recent_limit: 200,
282            meaningful_min_tools: 2,
283            meaningful_threshold_skills: 5,
284            meaningful_threshold_memory: 5,
285            meaningful_threshold_prompts: 8,
286            failures_min_prompts: 3,
287            replans_min_prompts: 3,
288            repeated_pattern_min_count: 4,
289            repeated_pattern_min_success_rate: 0.85,
290        }
291    }
292
293    pub fn from_env() -> Self {
294        let parse_i64 = |key: &str, default: i64| {
295            std::env::var(key)
296                .ok()
297                .and_then(|v| v.parse().ok())
298                .unwrap_or(default)
299        };
300        let parse_f64 = |key: &str, default: f64| {
301            std::env::var(key)
302                .ok()
303                .and_then(|v| v.parse().ok())
304                .unwrap_or(default)
305        };
306        let profile = match std::env::var(evo_keys::SKILLLITE_EVO_PROFILE)
307            .ok()
308            .as_deref()
309            .map(str::trim)
310            .filter(|s| !s.is_empty())
311        {
312            Some("demo") => EvolutionProfile::Demo,
313            Some("conservative") => EvolutionProfile::Conservative,
314            _ => EvolutionProfile::Default,
315        };
316        let base = match profile {
317            EvolutionProfile::Default => Self::default(),
318            EvolutionProfile::Demo => Self::demo_preset(),
319            EvolutionProfile::Conservative => Self::conservative_preset(),
320        };
321        Self {
322            cooldown_hours: parse_f64(evo_keys::SKILLLITE_EVO_COOLDOWN_HOURS, base.cooldown_hours),
323            recent_days: parse_i64(evo_keys::SKILLLITE_EVO_RECENT_DAYS, base.recent_days),
324            recent_limit: parse_i64(evo_keys::SKILLLITE_EVO_RECENT_LIMIT, base.recent_limit),
325            meaningful_min_tools: parse_i64(
326                evo_keys::SKILLLITE_EVO_MEANINGFUL_MIN_TOOLS,
327                base.meaningful_min_tools,
328            ),
329            meaningful_threshold_skills: parse_i64(
330                evo_keys::SKILLLITE_EVO_MEANINGFUL_THRESHOLD_SKILLS,
331                base.meaningful_threshold_skills,
332            ),
333            meaningful_threshold_memory: parse_i64(
334                evo_keys::SKILLLITE_EVO_MEANINGFUL_THRESHOLD_MEMORY,
335                base.meaningful_threshold_memory,
336            ),
337            meaningful_threshold_prompts: parse_i64(
338                evo_keys::SKILLLITE_EVO_MEANINGFUL_THRESHOLD_PROMPTS,
339                base.meaningful_threshold_prompts,
340            ),
341            failures_min_prompts: parse_i64(
342                evo_keys::SKILLLITE_EVO_FAILURES_MIN_PROMPTS,
343                base.failures_min_prompts,
344            ),
345            replans_min_prompts: parse_i64(
346                evo_keys::SKILLLITE_EVO_REPLANS_MIN_PROMPTS,
347                base.replans_min_prompts,
348            ),
349            repeated_pattern_min_count: parse_i64(
350                evo_keys::SKILLLITE_EVO_REPEATED_PATTERN_MIN_COUNT,
351                base.repeated_pattern_min_count,
352            ),
353            repeated_pattern_min_success_rate: parse_f64(
354                evo_keys::SKILLLITE_EVO_REPEATED_PATTERN_MIN_SUCCESS_RATE,
355                base.repeated_pattern_min_success_rate,
356            ),
357        }
358    }
359}
360
361// ─── Evolution scope ──────────────────────────────────────────────────────────
362
363#[derive(Debug, Default)]
364pub struct EvolutionScope {
365    pub skills: bool,
366    pub skill_action: SkillAction,
367    pub memory: bool,
368    pub prompts: bool,
369    pub decision_ids: Vec<i64>,
370}
371
372impl EvolutionScope {
373    /// 返回用于 evolution_run 日志展示的「进化方向」中文描述(供 evotown 等前端展示)
374    pub fn direction_label(&self) -> String {
375        let mut parts: Vec<&str> = Vec::new();
376        if self.prompts {
377            parts.push("规则与示例");
378        }
379        if self.skills {
380            parts.push("技能");
381        }
382        if self.memory {
383            parts.push("记忆");
384        }
385        if parts.is_empty() {
386            return String::new();
387        }
388        parts.join("、")
389    }
390}
391
392pub fn should_evolve(conn: &Connection) -> Result<EvolutionScope> {
393    should_evolve_impl(conn, EvolutionMode::from_env(), false)
394}
395
396pub fn should_evolve_with_mode(conn: &Connection, mode: EvolutionMode) -> Result<EvolutionScope> {
397    should_evolve_impl(conn, mode, false)
398}
399
400/// When force=true (e.g. manual `skilllite evolution run`), bypass decision thresholds.
401fn should_evolve_impl(
402    conn: &Connection,
403    mode: EvolutionMode,
404    force: bool,
405) -> Result<EvolutionScope> {
406    if mode.is_disabled() {
407        return Ok(EvolutionScope::default());
408    }
409
410    let thresholds = EvolutionThresholds::from_env();
411
412    let today_evolutions: i64 = conn
413        .query_row(
414            "SELECT COUNT(*) FROM evolution_log WHERE date(ts) = date('now')",
415            [],
416            |row| row.get(0),
417        )
418        .unwrap_or(0);
419    let max_per_day: i64 = std::env::var(evo_keys::SKILLLITE_MAX_EVOLUTIONS_PER_DAY)
420        .ok()
421        .and_then(|v| v.parse().ok())
422        .unwrap_or(20);
423    if today_evolutions >= max_per_day {
424        return Ok(EvolutionScope::default());
425    }
426
427    if !force {
428        let last_evo_hours: f64 = conn
429            .query_row(
430                "SELECT COALESCE(
431                    (julianday('now') - julianday(MAX(ts))) * 24,
432                    999.0
433                ) FROM evolution_log",
434                [],
435                |row| row.get(0),
436            )
437            .unwrap_or(999.0);
438        if last_evo_hours < thresholds.cooldown_hours {
439            return Ok(EvolutionScope::default());
440        }
441    }
442
443    let recent_condition = format!("ts >= datetime('now', '-{} days')", thresholds.recent_days);
444    let recent_limit = thresholds.recent_limit;
445
446    let (meaningful, failures, replans): (i64, i64, i64) = conn.query_row(
447        &format!(
448            "SELECT
449                COUNT(CASE WHEN total_tools >= {} THEN 1 END),
450                COUNT(CASE WHEN failed_tools > 0 THEN 1 END),
451                COUNT(CASE WHEN replans > 0 THEN 1 END)
452             FROM decisions WHERE {}",
453            thresholds.meaningful_min_tools, recent_condition
454        ),
455        [],
456        |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
457    )?;
458
459    let mut stmt = conn.prepare(&format!(
460        "SELECT id FROM decisions WHERE {} ORDER BY ts DESC LIMIT {}",
461        recent_condition, recent_limit
462    ))?;
463    let ids: Vec<i64> = stmt
464        .query_map([], |row| row.get(0))?
465        .filter_map(|r| r.ok())
466        .collect();
467
468    // Group by tool_sequence_key (new) when available; fall back to task_description for
469    // older decisions that predate the tool_sequence_key column.
470    // COALESCE(NULLIF(key,''), desc) ensures empty-string keys also fall back.
471    let repeated_patterns: i64 = conn
472        .query_row(
473            &format!(
474                "SELECT COUNT(*) FROM (
475                SELECT COALESCE(NULLIF(tool_sequence_key, ''), task_description) AS pattern_key,
476                       COUNT(*) AS cnt,
477                       SUM(CASE WHEN task_completed = 1 THEN 1 ELSE 0 END) AS successes
478                FROM decisions
479                WHERE {} AND (tool_sequence_key IS NOT NULL OR task_description IS NOT NULL)
480                  AND total_tools >= 1
481                GROUP BY pattern_key
482                HAVING cnt >= {} AND CAST(successes AS REAL) / cnt >= {}
483            )",
484                recent_condition,
485                thresholds.repeated_pattern_min_count,
486                thresholds.repeated_pattern_min_success_rate
487            ),
488            [],
489            |row| row.get(0),
490        )
491        .unwrap_or(0);
492
493    let mut scope = EvolutionScope {
494        decision_ids: ids.clone(),
495        ..Default::default()
496    };
497
498    if force && !ids.is_empty() {
499        // Manual trigger: bypass thresholds, enable all enabled modes
500        if mode.skills_enabled() {
501            scope.skills = true;
502            scope.skill_action = if repeated_patterns > 0 {
503                SkillAction::Generate
504            } else {
505                SkillAction::Refine
506            };
507        }
508        if mode.memory_enabled() {
509            scope.memory = true;
510        }
511        if mode.prompts_enabled() {
512            scope.prompts = true;
513        }
514    } else {
515        if mode.skills_enabled()
516            && meaningful >= thresholds.meaningful_threshold_skills
517            && (failures > 0 || repeated_patterns > 0)
518        {
519            scope.skills = true;
520            scope.skill_action = if repeated_patterns > 0 {
521                SkillAction::Generate
522            } else {
523                SkillAction::Refine
524            };
525        }
526        if mode.memory_enabled() && meaningful >= thresholds.meaningful_threshold_memory {
527            scope.memory = true;
528        }
529        if mode.prompts_enabled()
530            && meaningful >= thresholds.meaningful_threshold_prompts
531            && (failures >= thresholds.failures_min_prompts
532                || replans >= thresholds.replans_min_prompts)
533        {
534            scope.prompts = true;
535        }
536    }
537
538    Ok(scope)
539}
540
541// ─── Gatekeeper (L1-L3) ───────────────────────────────────────────────────────
542
543const ALLOWED_EVOLUTION_PATHS: &[&str] = &["prompts", "memory", "skills/_evolved"];
544
545/// L1 path gatekeeper. When skills_root is Some, also allows target under skills_root/_evolved
546/// (project-level skill evolution).
547pub fn gatekeeper_l1_path(chat_root: &Path, target: &Path, skills_root: Option<&Path>) -> bool {
548    for allowed in ALLOWED_EVOLUTION_PATHS {
549        let allowed_dir = chat_root.join(allowed);
550        if target.starts_with(&allowed_dir) {
551            return true;
552        }
553    }
554    if let Some(sr) = skills_root {
555        let evolved = sr.join("_evolved");
556        if target.starts_with(&evolved) {
557            return true;
558        }
559    }
560    false
561}
562
563pub fn gatekeeper_l1_template_integrity(filename: &str, new_content: &str) -> Result<()> {
564    let missing = seed::validate_template(filename, new_content);
565    if !missing.is_empty() {
566        anyhow::bail!(
567            "Gatekeeper L1b: evolved template '{}' is missing required placeholders {:?}",
568            filename,
569            missing
570        );
571    }
572    Ok(())
573}
574
575pub fn gatekeeper_l2_size(new_rules: usize, new_examples: usize, new_skills: usize) -> bool {
576    new_rules <= 5 && new_examples <= 3 && new_skills <= 1
577}
578
579const SENSITIVE_PATTERNS: &[&str] = &[
580    "api_key",
581    "api-key",
582    "apikey",
583    "secret",
584    "password",
585    "passwd",
586    "token",
587    "bearer",
588    "private_key",
589    "private-key",
590    "-----BEGIN",
591    "-----END",
592    "skip scan",
593    "bypass",
594    "disable security",
595    "eval(",
596    "exec(",
597    "__import__",
598];
599
600pub fn gatekeeper_l3_content(content: &str) -> Result<()> {
601    let lower = content.to_lowercase();
602    for pattern in SENSITIVE_PATTERNS {
603        if lower.contains(pattern) {
604            anyhow::bail!(
605                "Gatekeeper L3: evolution product contains sensitive pattern: '{}'",
606                pattern
607            );
608        }
609    }
610    Ok(())
611}
612
613// ─── Snapshots ────────────────────────────────────────────────────────────────
614
615fn versions_dir(chat_root: &Path) -> std::path::PathBuf {
616    chat_root.join("prompts").join("_versions")
617}
618
619pub fn create_snapshot(chat_root: &Path, txn_id: &str, files: &[&str]) -> Result<Vec<String>> {
620    let snap_dir = versions_dir(chat_root).join(txn_id);
621    std::fs::create_dir_all(&snap_dir)?;
622    let prompts = chat_root.join("prompts");
623    let mut backed_up = Vec::new();
624    for name in files {
625        let src = prompts.join(name);
626        if src.exists() {
627            let dst = snap_dir.join(name);
628            std::fs::copy(&src, &dst)?;
629            backed_up.push(name.to_string());
630        }
631    }
632    prune_snapshots(chat_root, 10);
633    Ok(backed_up)
634}
635
636pub fn restore_snapshot(chat_root: &Path, txn_id: &str) -> Result<()> {
637    let snap_dir = versions_dir(chat_root).join(txn_id);
638    if !snap_dir.exists() {
639        anyhow::bail!("Snapshot not found: {}", txn_id);
640    }
641    let prompts = chat_root.join("prompts");
642    for entry in std::fs::read_dir(&snap_dir)? {
643        let entry = entry?;
644        let dst = prompts.join(entry.file_name());
645        std::fs::copy(entry.path(), &dst)?;
646    }
647    tracing::info!("Restored snapshot {}", txn_id);
648    Ok(())
649}
650
651fn prune_snapshots(chat_root: &Path, keep: usize) {
652    let vdir = versions_dir(chat_root);
653    if !vdir.exists() {
654        return;
655    }
656    let mut dirs: Vec<_> = std::fs::read_dir(&vdir)
657        .ok()
658        .into_iter()
659        .flatten()
660        .filter_map(|e| e.ok())
661        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
662        .collect();
663    if dirs.len() <= keep {
664        return;
665    }
666    dirs.sort_by_key(|e| e.file_name());
667    let to_remove = dirs.len() - keep;
668    for entry in dirs.into_iter().take(to_remove) {
669        let _ = std::fs::remove_dir_all(entry.path());
670    }
671}
672
673// ─── Changelog ───────────────────────────────────────────────────────────────
674
675#[derive(serde::Serialize)]
676struct ChangelogEntry {
677    txn_id: String,
678    ts: String,
679    files: Vec<String>,
680    changes: Vec<ChangeDetail>,
681    reason: String,
682}
683
684#[derive(serde::Serialize)]
685struct ChangeDetail {
686    #[serde(rename = "type")]
687    change_type: String,
688    id: String,
689}
690
691pub fn append_changelog(
692    chat_root: &Path,
693    txn_id: &str,
694    files: &[String],
695    changes: &[(String, String)],
696    reason: &str,
697) -> Result<()> {
698    let vdir = versions_dir(chat_root);
699    std::fs::create_dir_all(&vdir)?;
700    let path = vdir.join("changelog.jsonl");
701
702    let entry = ChangelogEntry {
703        txn_id: txn_id.to_string(),
704        ts: chrono::Utc::now().to_rfc3339(),
705        files: files.to_vec(),
706        changes: changes
707            .iter()
708            .map(|(t, id)| ChangeDetail {
709                change_type: t.clone(),
710                id: id.clone(),
711            })
712            .collect(),
713        reason: reason.to_string(),
714    };
715
716    let mut line = serde_json::to_string(&entry)?;
717    line.push('\n');
718
719    use std::io::Write;
720    let mut file = std::fs::OpenOptions::new()
721        .create(true)
722        .append(true)
723        .open(&path)?;
724    file.write_all(line.as_bytes())?;
725    Ok(())
726}
727
728// ─── Audit log ───────────────────────────────────────────────────────────────
729
730pub fn log_evolution_event(
731    conn: &Connection,
732    chat_root: &Path,
733    event_type: &str,
734    target_id: &str,
735    reason: &str,
736    txn_id: &str,
737) -> Result<()> {
738    let ts = chrono::Utc::now().to_rfc3339();
739
740    conn.execute(
741        "INSERT INTO evolution_log (ts, type, target_id, reason, version) VALUES (?1, ?2, ?3, ?4, ?5)",
742        params![ts, event_type, target_id, reason, txn_id],
743    )?;
744
745    let log_path = chat_root.join("evolution.log");
746    let entry = serde_json::json!({
747        "ts": ts,
748        "type": event_type,
749        "id": target_id,
750        "reason": reason,
751        "txn_id": txn_id,
752    });
753    let mut line = serde_json::to_string(&entry)?;
754    line.push('\n');
755    use std::io::Write;
756    let mut file = std::fs::OpenOptions::new()
757        .create(true)
758        .append(true)
759        .open(&log_path)?;
760    file.write_all(line.as_bytes())?;
761
762    skilllite_core::observability::audit_evolution_event(event_type, target_id, reason, txn_id);
763
764    Ok(())
765}
766
767// ─── Mark decisions evolved ───────────────────────────────────────────────────
768
769pub fn mark_decisions_evolved(conn: &Connection, ids: &[i64]) -> Result<()> {
770    if ids.is_empty() {
771        return Ok(());
772    }
773    let placeholders: Vec<String> = ids.iter().map(|_| "?".to_string()).collect();
774    let sql = format!(
775        "UPDATE decisions SET evolved = 1 WHERE id IN ({})",
776        placeholders.join(",")
777    );
778    let mut stmt = conn.prepare(&sql)?;
779    let params: Vec<Box<dyn rusqlite::types::ToSql>> = ids
780        .iter()
781        .map(|id| Box::new(*id) as Box<dyn rusqlite::types::ToSql>)
782        .collect();
783    let param_refs: Vec<&dyn rusqlite::types::ToSql> = params.iter().map(|p| p.as_ref()).collect();
784    stmt.execute(param_refs.as_slice())?;
785    Ok(())
786}
787
788// ─── Run evolution (main entry point) ──────────────────────────────────────────
789
790/// Run a full evolution cycle.
791///
792/// Returns [EvolutionRunResult]: SkippedBusy if another run in progress, NoScope if nothing to evolve, Completed(txn_id) otherwise.
793/// When force=true (manual trigger), bypass decision thresholds.
794/// skills_root: project-level dir (workspace/.skills). When None, skips skill evolution.
795pub async fn run_evolution<L: EvolutionLlm>(
796    chat_root: &Path,
797    skills_root: Option<&Path>,
798    llm: &L,
799    api_base: &str,
800    api_key: &str,
801    model: &str,
802    force: bool,
803) -> Result<EvolutionRunResult> {
804    if !try_start_evolution() {
805        return Ok(EvolutionRunResult::SkippedBusy);
806    }
807
808    let result =
809        run_evolution_inner(chat_root, skills_root, llm, api_base, api_key, model, force).await;
810
811    finish_evolution();
812    result
813}
814
815async fn run_evolution_inner<L: EvolutionLlm>(
816    chat_root: &Path,
817    skills_root: Option<&Path>,
818    llm: &L,
819    _api_base: &str,
820    _api_key: &str,
821    model: &str,
822    force: bool,
823) -> Result<EvolutionRunResult> {
824    let conn = feedback::open_evolution_db(chat_root)?;
825    let scope = should_evolve_impl(&conn, EvolutionMode::from_env(), force)?;
826    if !scope.prompts && !scope.memory && !scope.skills {
827        return Ok(EvolutionRunResult::NoScope);
828    }
829    let txn_id = format!("evo_{}", chrono::Utc::now().format("%Y%m%d_%H%M%S"));
830    tracing::info!(
831        "Starting evolution txn={} (prompts={}, memory={}, skills={})",
832        txn_id,
833        scope.prompts,
834        scope.memory,
835        scope.skills
836    );
837    let snapshot_files = if scope.prompts {
838        create_snapshot(
839            chat_root,
840            &txn_id,
841            &[
842                "rules.json",
843                "examples.json",
844                "planning.md",
845                "execution.md",
846                "system.md",
847            ],
848        )?
849    } else {
850        Vec::new()
851    };
852
853    // Drop conn before async work (Connection is !Send, cannot hold across .await).
854    drop(conn);
855
856    let mut all_changes: Vec<(String, String)> = Vec::new();
857    let mut reason_parts: Vec<String> = Vec::new();
858
859    // Run prompts / skills / memory evolution in parallel. Each module uses block_in_place
860    // to batch its DB operations (one open per module), so we get both parallelism and fewer opens.
861    let (prompt_res, skills_res, memory_res) = tokio::join!(
862        async {
863            if scope.prompts {
864                prompt_learner::evolve_prompts(chat_root, llm, model, &txn_id).await
865            } else {
866                Ok(Vec::new())
867            }
868        },
869        async {
870            if scope.skills {
871                let generate = true;
872                skill_synth::evolve_skills(
873                    chat_root,
874                    skills_root,
875                    llm,
876                    model,
877                    &txn_id,
878                    generate,
879                    force,
880                )
881                .await
882            } else {
883                Ok(Vec::new())
884            }
885        },
886        async {
887            if scope.memory {
888                memory_learner::evolve_memory(chat_root, llm, model, &txn_id).await
889            } else {
890                Ok(Vec::new())
891            }
892        },
893    );
894
895    if scope.prompts {
896        match prompt_res {
897            Ok(changes) => {
898                if !changes.is_empty() {
899                    reason_parts.push(format!("{} prompt changes", changes.len()));
900                }
901                all_changes.extend(changes);
902            }
903            Err(e) => tracing::warn!("Prompt evolution failed: {}", e),
904        }
905    }
906    if scope.skills {
907        match skills_res {
908            Ok(changes) => {
909                if !changes.is_empty() {
910                    reason_parts.push(format!("{} skill changes", changes.len()));
911                }
912                all_changes.extend(changes);
913            }
914            Err(e) => tracing::warn!("Skill evolution failed: {}", e),
915        }
916    }
917    if scope.memory {
918        match memory_res {
919            Ok(changes) => {
920                if !changes.is_empty() {
921                    reason_parts.push(format!("{} memory knowledge update(s)", changes.len()));
922                }
923                all_changes.extend(changes);
924            }
925            Err(e) => tracing::warn!("Memory evolution failed: {}", e),
926        }
927    }
928
929    // Run external learning before changelog so its changes and modified files are in the same txn entry.
930    match external_learner::run_external_learning(chat_root, llm, model, &txn_id).await {
931        Ok(ext_changes) => {
932            if !ext_changes.is_empty() {
933                tracing::info!("EVO-6: {} external changes applied", ext_changes.len());
934                reason_parts.push(format!("{} external change(s)", ext_changes.len()));
935                all_changes.extend(ext_changes);
936            }
937        }
938        Err(e) => tracing::warn!("EVO-6 external learning failed (non-fatal): {}", e),
939    }
940
941    {
942        let conn = feedback::open_evolution_db(chat_root)?;
943
944        for (ctype, cid) in &all_changes {
945            log_evolution_event(&conn, chat_root, ctype, cid, "prompt evolution", &txn_id)?;
946        }
947
948        if scope.prompts {
949            if let Err(e) = prompt_learner::update_reusable_status(&conn, chat_root) {
950                tracing::warn!("Failed to update reusable status: {}", e);
951            }
952        }
953
954        mark_decisions_evolved(&conn, &scope.decision_ids)?;
955        let _ = feedback::update_daily_metrics(&conn);
956        let auto_rolled_back = check_auto_rollback(&conn, chat_root)?;
957        if auto_rolled_back {
958            tracing::info!("EVO: auto-rollback triggered for txn={}", txn_id);
959            let _ = log_evolution_event(
960                &conn,
961                chat_root,
962                "evolution_judgement",
963                "rollback",
964                "Auto-rollback triggered due to performance degradation",
965                &txn_id,
966            );
967        } else {
968            let _ = log_evolution_event(
969                &conn,
970                chat_root,
971                "evolution_judgement",
972                "no_rollback",
973                "No auto-rollback triggered",
974                &txn_id,
975            );
976        }
977        // let _ = feedback::export_judgement(&conn, &chat_root.join("JUDGEMENT.md")); // Removed for refactor
978        if let Ok(Some(summary)) = feedback::build_latest_judgement(&conn) {
979            let _ = log_evolution_event(
980                &conn,
981                chat_root,
982                "evolution_judgement",
983                summary.judgement.as_str(),
984                &summary.reason,
985                &txn_id,
986            );
987            // Insert new judgement output to file here
988            let judgement_output = format!(
989                "## Evolution Judgement\n\n**Judgement:** {}\n\n**Reason:** {}\n",
990                summary.judgement.as_str(),
991                summary.reason
992            );
993            let judgement_path = chat_root.join("JUDGEMENT.md");
994            if let Err(e) = skilllite_fs::atomic_write(&judgement_path, &judgement_output) {
995                tracing::warn!("Failed to write JUDGEMENT.md: {}", e);
996            }
997        }
998
999        if all_changes.is_empty() {
1000            // 即使无变更也记录一次,便于前端时间线展示进化运行记录(含本轮选择的进化方向)
1001            let dir = scope.direction_label();
1002            let reason = if dir.is_empty() {
1003                "进化运行完成,无新规则/技能产出".to_string()
1004            } else {
1005                format!("方向: {};进化运行完成,无新规则/技能产出", dir)
1006            };
1007            let _ = log_evolution_event(&conn, chat_root, "evolution_run", "run", &reason, &txn_id);
1008            return Ok(EvolutionRunResult::Completed(None));
1009        }
1010
1011        let dir = scope.direction_label();
1012        let reason = if dir.is_empty() {
1013            reason_parts.join("; ")
1014        } else {
1015            format!("方向: {};{}", dir, reason_parts.join("; "))
1016        };
1017        // 记录本轮进化运行(含方向),便于前端时间线统一展示
1018        let _ = log_evolution_event(&conn, chat_root, "evolution_run", "run", &reason, &txn_id);
1019
1020        // 只记录内容真正发生变化的文件:用快照与当前版本逐一对比。
1021        // snapshot_files 是进化前备份的全量清单,但实际修改的往往只是其中一部分
1022        // (如 rules.json / examples.json),planning.md 等通常未被触碰。
1023        let snap_dir = versions_dir(chat_root).join(&txn_id);
1024        let prompts_dir = chat_root.join("prompts");
1025        let mut modified_files: Vec<String> = snapshot_files
1026            .iter()
1027            .filter(|fname| {
1028                let snap_path = snap_dir.join(fname);
1029                let curr_path = prompts_dir.join(fname);
1030                match (std::fs::read(&snap_path), std::fs::read(&curr_path)) {
1031                    (Ok(old), Ok(new)) => old != new,
1032                    _ => false,
1033                }
1034            })
1035            .cloned()
1036            .collect();
1037
1038        // External learner writes to prompts/rules.json; include it when external merged/promoted rules but snapshot didn't cover it (e.g. no scope.prompts).
1039        if all_changes
1040            .iter()
1041            .any(|(t, _)| t == "external_rule_added" || t == "external_rule_promoted")
1042        {
1043            const EXTERNAL_RULES_FILE: &str = "rules.json";
1044            if !modified_files.iter().any(|f| f == EXTERNAL_RULES_FILE) {
1045                let rules_path = prompts_dir.join(EXTERNAL_RULES_FILE);
1046                if rules_path.exists() {
1047                    modified_files.push(EXTERNAL_RULES_FILE.to_string());
1048                }
1049            }
1050        }
1051
1052        append_changelog(chat_root, &txn_id, &modified_files, &all_changes, &reason)?;
1053
1054        let _decisions_path = chat_root.join("DECISIONS.md");
1055        // let _ = feedback::export_decisions_md(&conn, &decisions_path); // Removed for refactor
1056
1057        tracing::info!("Evolution txn={} complete: {}", txn_id, reason);
1058    }
1059
1060    Ok(EvolutionRunResult::Completed(Some(txn_id)))
1061}
1062
1063pub fn query_changes_by_txn(conn: &Connection, txn_id: &str) -> Vec<(String, String)> {
1064    let mut stmt =
1065        match conn.prepare("SELECT type, target_id FROM evolution_log WHERE version = ?1") {
1066            Ok(s) => s,
1067            Err(_) => return Vec::new(),
1068        };
1069    stmt.query_map(params![txn_id], |row| {
1070        Ok((
1071            row.get::<_, String>(0)?,
1072            row.get::<_, Option<String>>(1)?.unwrap_or_default(),
1073        ))
1074    })
1075    .ok()
1076    .into_iter()
1077    .flatten()
1078    .filter_map(|r| r.ok())
1079    .collect()
1080}
1081
1082pub fn format_evolution_changes(changes: &[(String, String)]) -> Vec<String> {
1083    changes
1084        .iter()
1085        .filter_map(|(change_type, id)| {
1086            let msg = match change_type.as_str() {
1087                "rule_added" => format!("\u{1f4a1} 已学习新规则: {}", id),
1088                "rule_updated" => format!("\u{1f504} 已优化规则: {}", id),
1089                "rule_retired" => format!("\u{1f5d1}\u{fe0f} 已退役低效规则: {}", id),
1090                "example_added" => format!("\u{1f4d6} 已新增示例: {}", id),
1091                "skill_generated" => format!("\u{2728} 已自动生成 Skill: {}", id),
1092                "skill_pending" => format!(
1093                    "\u{1f4a1} 新 Skill {} 待确认(运行 `skilllite evolution confirm {}` 加入)",
1094                    id, id
1095                ),
1096                "skill_refined" => format!("\u{1f527} 已优化 Skill: {}", id),
1097                "skill_retired" => format!("\u{1f4e6} 已归档 Skill: {}", id),
1098                "evolution_judgement" => {
1099                    let label = match id.as_str() {
1100                        "promote" => "保留",
1101                        "keep_observing" => "继续观察",
1102                        "rollback" => "回滚",
1103                        _ => id,
1104                    };
1105                    format!("\u{1f9ed} 本轮判断: {}", label)
1106                }
1107                "auto_rollback" => format!("\u{26a0}\u{fe0f} 检测到质量下降,已自动回滚: {}", id),
1108                "reusable_promoted" => format!("\u{2b06}\u{fe0f} 规则晋升为通用: {}", id),
1109                "reusable_demoted" => format!("\u{2b07}\u{fe0f} 规则降级为低效: {}", id),
1110                "external_rule_added" => format!("\u{1f310} 已从外部来源学习规则: {}", id),
1111                "external_rule_promoted" => format!("\u{2b06}\u{fe0f} 外部规则晋升为优质: {}", id),
1112                "source_paused" => format!("\u{23f8}\u{fe0f} 信源可达性过低,已暂停: {}", id),
1113                "source_retired" => format!("\u{1f5d1}\u{fe0f} 已退役低质量信源: {}", id),
1114                "source_discovered" => format!("\u{1f50d} 发现新信源: {}", id),
1115                "memory_knowledge_added" => format!("\u{1f4da} 已沉淀知识库(实体与关系): {}", id),
1116                _ => return None,
1117            };
1118            Some(msg)
1119        })
1120        .collect()
1121}
1122
1123// ─── Shutdown hook ────────────────────────────────────────────────────────────
1124
1125pub fn on_shutdown(chat_root: &Path) {
1126    if !try_start_evolution() {
1127        return;
1128    }
1129    if let Ok(conn) = feedback::open_evolution_db(chat_root) {
1130        let _ = feedback::update_daily_metrics(&conn);
1131        // let _ = feedback::export_decisions_md(&conn, &chat_root.join("DECISIONS.md")); // Removed for refactor
1132    }
1133    finish_evolution();
1134}
1135
1136// ─── Auto-rollback ───────────────────────────────────────────────────────────
1137
1138/// Executes the rollback actions (restoring snapshot, logging).
1139fn execute_evolution_rollback(
1140    conn: &Connection,
1141    chat_root: &Path,
1142    txn_id: &str,
1143    reason: &str,
1144) -> Result<()> {
1145    tracing::warn!("Evolution rollback executed: {} (txn={})", reason, txn_id);
1146    restore_snapshot(chat_root, txn_id)?;
1147
1148    conn.execute(
1149        "UPDATE evolution_log SET type = type || '_rolled_back' WHERE version = ?1",
1150        params![txn_id],
1151    )?;
1152
1153    log_evolution_event(
1154        conn,
1155        chat_root,
1156        "auto_rollback",
1157        txn_id,
1158        reason,
1159        &format!("rollback_{}", txn_id),
1160    )?;
1161    Ok(())
1162}
1163pub fn check_auto_rollback(conn: &Connection, chat_root: &Path) -> Result<bool> {
1164    let mut stmt = conn.prepare(
1165        "SELECT date, first_success_rate, user_correction_rate
1166         FROM evolution_metrics
1167         WHERE date > date('now', '-5 days')
1168         ORDER BY date DESC LIMIT 4",
1169    )?;
1170    let metrics: Vec<(String, f64, f64)> = stmt
1171        .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))?
1172        .filter_map(|r| r.ok())
1173        .collect();
1174
1175    if metrics.len() < 3 {
1176        return Ok(false);
1177    }
1178
1179    let fsr_declining = metrics.windows(2).take(3).all(|w| w[0].1 < w[1].1 - 0.10);
1180    let ucr_rising = metrics.windows(2).take(3).all(|w| w[0].2 > w[1].2 + 0.20);
1181
1182    if fsr_declining || ucr_rising {
1183        let reason = if fsr_declining {
1184            "first_success_rate declined >10% for 3 consecutive days"
1185        } else {
1186            "user_correction_rate rose >20% for 3 consecutive days"
1187        };
1188
1189        let last_txn: Option<String> = conn
1190            .query_row(
1191                "SELECT DISTINCT version FROM evolution_log
1192                 WHERE type NOT LIKE '%_rolled_back'
1193                 ORDER BY ts DESC LIMIT 1",
1194                [],
1195                |row| row.get(0),
1196            )
1197            .ok();
1198
1199        if let Some(txn_id) = last_txn {
1200            execute_evolution_rollback(conn, chat_root, &txn_id, reason)?;
1201            return Ok(true);
1202        }
1203    }
1204
1205    Ok(false)
1206}
1207
1208#[cfg(test)]
1209mod lib_tests {
1210    use super::*;
1211    use std::path::Path;
1212    use std::sync::Mutex;
1213
1214    static EVO_LOCK: Mutex<()> = Mutex::new(());
1215
1216    #[test]
1217    fn strip_think_blocks_after_closing_tag() {
1218        let s = "<think>\nhidden\n</think>\nvisible reply";
1219        assert_eq!(strip_think_blocks(s), "visible reply");
1220    }
1221
1222    #[test]
1223    fn strip_think_blocks_plain_text_unchanged() {
1224        let s = "no think tags here";
1225        assert_eq!(strip_think_blocks(s), s);
1226    }
1227
1228    #[test]
1229    fn strip_think_blocks_reasoning_tag() {
1230        let s = "<reasoning>x</reasoning>\nhello";
1231        assert_eq!(strip_think_blocks(s), "hello");
1232    }
1233
1234    #[test]
1235    fn evolution_message_constructors() {
1236        let u = EvolutionMessage::user("u");
1237        assert_eq!(u.role, "user");
1238        assert_eq!(u.content.as_deref(), Some("u"));
1239        let sy = EvolutionMessage::system("s");
1240        assert_eq!(sy.role, "system");
1241    }
1242
1243    #[test]
1244    fn evolution_mode_capability_flags() {
1245        assert!(EvolutionMode::All.prompts_enabled());
1246        assert!(EvolutionMode::All.memory_enabled());
1247        assert!(EvolutionMode::All.skills_enabled());
1248        assert!(EvolutionMode::PromptsOnly.prompts_enabled());
1249        assert!(!EvolutionMode::PromptsOnly.memory_enabled());
1250        assert!(!EvolutionMode::MemoryOnly.prompts_enabled());
1251        assert!(EvolutionMode::MemoryOnly.memory_enabled());
1252        assert!(EvolutionMode::Disabled.is_disabled());
1253    }
1254
1255    #[test]
1256    fn evolution_run_result_txn_id() {
1257        assert_eq!(
1258            EvolutionRunResult::Completed(Some("t1".into())).txn_id(),
1259            Some("t1")
1260        );
1261        assert_eq!(EvolutionRunResult::SkippedBusy.txn_id(), None);
1262    }
1263
1264    #[test]
1265    fn gatekeeper_l2_size_bounds() {
1266        assert!(gatekeeper_l2_size(5, 3, 1));
1267        assert!(!gatekeeper_l2_size(6, 0, 0));
1268        assert!(!gatekeeper_l2_size(0, 4, 0));
1269        assert!(!gatekeeper_l2_size(0, 0, 2));
1270    }
1271
1272    #[test]
1273    fn gatekeeper_l3_rejects_secret_pattern() {
1274        assert!(gatekeeper_l3_content("safe text").is_ok());
1275        assert!(gatekeeper_l3_content("has api_key in body").is_err());
1276    }
1277
1278    #[test]
1279    fn gatekeeper_l1_path_allows_prompts_under_chat_root() {
1280        let root = Path::new("/home/u/.skilllite/chat");
1281        let target = root.join("prompts/rules.json");
1282        assert!(gatekeeper_l1_path(root, &target, None));
1283        let bad = Path::new("/etc/passwd");
1284        assert!(!gatekeeper_l1_path(root, bad, None));
1285    }
1286
1287    #[test]
1288    fn try_start_evolution_is_exclusive() {
1289        let _g = EVO_LOCK.lock().expect("evo lock");
1290        finish_evolution();
1291        assert!(try_start_evolution());
1292        assert!(!try_start_evolution());
1293        finish_evolution();
1294    }
1295
1296    #[test]
1297    fn evolution_thresholds_default_nonzero_cooldown() {
1298        let t = EvolutionThresholds::default();
1299        assert!(t.cooldown_hours > 0.0);
1300        assert!(t.recent_days > 0);
1301    }
1302}