1pub 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#[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#[async_trait::async_trait]
54pub trait EvolutionLlm: Send + Sync {
55 async fn complete(
57 &self,
58 messages: &[EvolutionMessage],
59 model: &str,
60 temperature: f64,
61 ) -> Result<String>;
62}
63
64pub 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 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 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
165pub enum SkillAction {
166 #[default]
167 None,
168 Generate,
169 Refine,
170}
171
172static 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#[derive(Debug, Clone)]
188pub enum EvolutionRunResult {
189 SkippedBusy,
191 NoScope,
193 Completed(Option<String>),
195}
196
197impl EvolutionRunResult {
198 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
207pub use skilllite_fs::atomic_write;
210
211#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
249pub enum EvolutionProfile {
250 Default,
252 Demo,
254 Conservative,
256}
257
258impl EvolutionThresholds {
259 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 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#[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 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
400fn 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 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 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
541const ALLOWED_EVOLUTION_PATHS: &[&str] = &["prompts", "memory", "skills/_evolved"];
544
545pub 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
613fn 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#[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
728pub 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
767pub 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
788pub 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);
855
856 let mut all_changes: Vec<(String, String)> = Vec::new();
857 let mut reason_parts: Vec<String> = Vec::new();
858
859 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 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 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 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 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 let _ = log_evolution_event(&conn, chat_root, "evolution_run", "run", &reason, &txn_id);
1019
1020 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 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 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
1123pub 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 }
1133 finish_evolution();
1134}
1135
1136fn 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}