Skip to main content

lean_ctx/core/
procedural_memory.rs

1//! Procedural Memory — recurring workflow detection and template storage.
2//!
3//! Detects repeated tool-call sequences in Episodic Memory and stores them
4//! as reusable Procedures with activation/termination conditions.
5
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::PathBuf;
10
11use super::episodic_memory::{Episode, Outcome};
12
13use crate::core::memory_policy::ProceduralPolicy;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ProceduralStore {
17    pub project_hash: String,
18    pub procedures: Vec<Procedure>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Procedure {
23    pub id: String,
24    pub name: String,
25    pub description: String,
26    pub steps: Vec<ProcedureStep>,
27    pub activation_keywords: Vec<String>,
28    pub confidence: f32,
29    pub times_used: u32,
30    pub times_succeeded: u32,
31    pub last_used: DateTime<Utc>,
32    pub project_specific: bool,
33    pub created_at: DateTime<Utc>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
37pub struct ProcedureStep {
38    pub tool: String,
39    pub description: String,
40    pub optional: bool,
41}
42
43/// Retention value of a procedure (higher = keep): confidence-weighted, with a
44/// success-rate and usage component. The single ranking used by both the
45/// per-write reclaim ([`ProceduralStore::add_procedure`]) and the consolidation
46/// capacity pass, so procedure eviction is consistent everywhere (#995).
47pub(crate) fn retention_score(p: &Procedure) -> f32 {
48    let use_score = (p.times_used.min(20) as f32) / 20.0;
49    p.confidence * 0.5 + p.success_rate() * 0.3 + use_score * 0.2
50}
51
52/// Order procedures best-kept first (descending retention), deterministically.
53/// Pass to [`crate::core::memory_capacity::reclaim_store`], which archives the
54/// lowest-ranked tail.
55pub(crate) fn retention_cmp(a: &Procedure, b: &Procedure) -> std::cmp::Ordering {
56    retention_score(b)
57        .total_cmp(&retention_score(a))
58        .then_with(|| b.last_used.cmp(&a.last_used))
59        .then_with(|| b.created_at.cmp(&a.created_at))
60        .then_with(|| a.name.cmp(&b.name))
61        .then_with(|| a.id.cmp(&b.id))
62}
63
64impl Procedure {
65    pub fn success_rate(&self) -> f32 {
66        if self.times_used == 0 {
67            return 0.0;
68        }
69        self.times_succeeded as f32 / self.times_used as f32
70    }
71
72    pub fn matches_context(&self, task: &str) -> bool {
73        let task_lower = task.to_lowercase();
74        self.activation_keywords
75            .iter()
76            .any(|kw| task_lower.contains(&kw.to_lowercase()))
77    }
78}
79
80impl ProceduralStore {
81    pub fn new(project_hash: &str) -> Self {
82        Self {
83            project_hash: project_hash.to_string(),
84            procedures: Vec::new(),
85        }
86    }
87
88    pub fn suggest(&self, task: &str) -> Vec<&Procedure> {
89        let mut matches: Vec<(&Procedure, f32)> = self
90            .procedures
91            .iter()
92            .filter(|p| p.matches_context(task) && p.confidence >= 0.3)
93            .map(|p| {
94                let score = p.confidence * 0.5 + p.success_rate() * 0.3 + usage_recency(p) * 0.2;
95                (p, score)
96            })
97            .collect();
98
99        matches.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
100        matches.into_iter().map(|(p, _)| p).collect()
101    }
102
103    pub fn record_usage(&mut self, procedure_id: &str, success: bool) {
104        if let Some(proc) = self.procedures.iter_mut().find(|p| p.id == procedure_id) {
105            proc.times_used += 1;
106            if success {
107                proc.times_succeeded += 1;
108            }
109            proc.last_used = Utc::now();
110            proc.confidence =
111                (proc.confidence * 0.8 + if success { 0.2 } else { -0.1 }).clamp(0.0, 1.0);
112        }
113    }
114
115    pub fn add_procedure(&mut self, procedure: Procedure, policy: &ProceduralPolicy) {
116        if let Some(existing) = self
117            .procedures
118            .iter_mut()
119            .find(|p| p.name == procedure.name)
120        {
121            existing.confidence = existing.confidence.midpoint(procedure.confidence);
122            existing.steps = procedure.steps;
123            existing.activation_keywords = procedure.activation_keywords;
124        } else {
125            self.procedures.push(procedure);
126        }
127
128        // Lossless capacity reclaim (#995): keep the highest-value procedures and
129        // archive the rest instead of truncating them away. `add_procedure` only
130        // sees `ProceduralPolicy`, so it uses the standard headroom default and is
131        // always lossless (procedure eviction was already unconditional — it just
132        // used to lose the dropped procedures). Same `retention_cmp` as the
133        // consolidation capacity pass, so ranking is identical everywhere.
134        crate::core::memory_capacity::reclaim_store(
135            crate::core::memory_archive::MemoryStore::Procedures,
136            Some(&self.project_hash),
137            &mut self.procedures,
138            policy.max_procedures,
139            crate::core::memory_lifecycle::DEFAULT_RECLAIM_HEADROOM_PCT,
140            true,
141            retention_cmp,
142        );
143    }
144
145    pub fn detect_patterns(&mut self, episodes: &[Episode], policy: &ProceduralPolicy) {
146        let sequences = extract_tool_sequences(episodes);
147        let patterns = find_repeated_sequences(&sequences, policy);
148
149        for (steps, count, keywords) in patterns {
150            if count < policy.min_repetitions || steps.len() < policy.min_sequence_len {
151                continue;
152            }
153
154            let name = generate_procedure_name(&steps);
155            let already_exists = self.procedures.iter().any(|p| p.name == name);
156            if already_exists {
157                continue;
158            }
159
160            let success_count = episodes
161                .iter()
162                .filter(|ep| matches!(ep.outcome, Outcome::Success { .. }))
163                .count();
164            let confidence = success_count as f32 / episodes.len().max(1) as f32;
165
166            self.add_procedure(
167                Procedure {
168                    id: format!("proc-{}", md5_short(&name)),
169                    name,
170                    description: format!("Detected workflow ({count} repetitions)"),
171                    steps,
172                    activation_keywords: keywords,
173                    confidence,
174                    times_used: count as u32,
175                    times_succeeded: success_count as u32,
176                    last_used: Utc::now(),
177                    project_specific: true,
178                    created_at: Utc::now(),
179                },
180                policy,
181            );
182        }
183    }
184
185    fn store_path(project_hash: &str) -> Option<PathBuf> {
186        let dir = crate::core::data_dir::lean_ctx_data_dir()
187            .ok()?
188            .join("memory")
189            .join("procedures");
190        Some(dir.join(format!("{project_hash}.json")))
191    }
192
193    pub fn load(project_hash: &str) -> Option<Self> {
194        let path = Self::store_path(project_hash)?;
195        let data = std::fs::read_to_string(path).ok()?;
196        serde_json::from_str(&data).ok()
197    }
198
199    pub fn load_or_create(project_hash: &str) -> Self {
200        Self::load(project_hash).unwrap_or_else(|| Self::new(project_hash))
201    }
202
203    pub fn save(&self) -> Result<(), String> {
204        let path = Self::store_path(&self.project_hash)
205            .ok_or_else(|| "Cannot determine data directory".to_string())?;
206        if let Some(dir) = path.parent() {
207            std::fs::create_dir_all(dir).map_err(|e| format!("{e}"))?;
208        }
209        let json = serde_json::to_string_pretty(self).map_err(|e| format!("{e}"))?;
210        std::fs::write(path, json).map_err(|e| format!("{e}"))
211    }
212}
213
214/// Auto-learning hook (GL #478): run pattern detection over the full episode
215/// log and persist the result. Called after every recorded episode, so
216/// recurring workflows surface on the dashboard without anyone ever invoking
217/// `ctx_session action=procedures value=detect` by hand. Best-effort: returns
218/// the number of stored procedures, or `None` when there is nothing to learn
219/// from yet. Detection is cheap (n-grams over <= `max_episodes` sequences),
220/// so no throttling is needed.
221pub fn auto_detect_from_episodes(project_hash: &str, policy: &ProceduralPolicy) -> Option<usize> {
222    let episodes = super::episodic_memory::EpisodicStore::load(project_hash)?;
223    if episodes.episodes.is_empty() {
224        return None;
225    }
226    let mut procs = ProceduralStore::load_or_create(project_hash);
227    procs.detect_patterns(&episodes.episodes, policy);
228    procs.save().ok()?;
229    Some(procs.procedures.len())
230}
231
232fn extract_tool_sequences(episodes: &[Episode]) -> Vec<Vec<String>> {
233    episodes
234        .iter()
235        .map(|ep| ep.actions.iter().map(|a| a.tool.clone()).collect())
236        .collect()
237}
238
239fn find_repeated_sequences(
240    sequences: &[Vec<String>],
241    policy: &ProceduralPolicy,
242) -> Vec<(Vec<ProcedureStep>, usize, Vec<String>)> {
243    let mut ngram_counts: HashMap<Vec<String>, usize> = HashMap::new();
244
245    for seq in sequences {
246        if seq.len() < policy.min_sequence_len {
247            continue;
248        }
249        let max_win = seq.len().min(policy.max_window_size);
250        for window_size in policy.min_sequence_len..=max_win {
251            for window in seq.windows(window_size) {
252                let key: Vec<String> = window.to_vec();
253                *ngram_counts.entry(key).or_insert(0) += 1;
254            }
255        }
256    }
257
258    let mut results: Vec<(Vec<ProcedureStep>, usize, Vec<String>)> = Vec::new();
259
260    let mut sorted: Vec<_> = ngram_counts.into_iter().collect();
261    sorted.sort_by(|a, b| {
262        let score_a = a.1 * a.0.len();
263        let score_b = b.1 * b.0.len();
264        score_b.cmp(&score_a)
265    });
266
267    let mut seen_prefixes: std::collections::HashSet<String> = std::collections::HashSet::new();
268
269    for (tools, count) in sorted {
270        if count < policy.min_repetitions {
271            continue;
272        }
273
274        let prefix = tools.join("->");
275        let is_substring = seen_prefixes.iter().any(|s| s.contains(&prefix));
276        if is_substring {
277            continue;
278        }
279
280        seen_prefixes.insert(prefix);
281
282        let steps: Vec<ProcedureStep> = tools
283            .iter()
284            .map(|t| ProcedureStep {
285                tool: t.clone(),
286                description: String::new(),
287                optional: false,
288            })
289            .collect();
290
291        let keywords: Vec<String> = tools
292            .iter()
293            .filter(|t| !t.starts_with("ctx_"))
294            .cloned()
295            .collect();
296
297        results.push((steps, count, keywords));
298    }
299
300    results
301}
302
303fn generate_procedure_name(steps: &[ProcedureStep]) -> String {
304    let tools: Vec<&str> = steps.iter().map(|s| s.tool.as_str()).collect();
305    let short: Vec<&str> = tools
306        .iter()
307        .map(|t| t.strip_prefix("ctx_").unwrap_or(t))
308        .collect();
309    format!("workflow-{}", short.join("-"))
310}
311
312fn md5_short(input: &str) -> String {
313    use md5::{Digest, Md5};
314    let result = Md5::digest(input.as_bytes());
315    crate::core::agent_identity::hex_encode(&result)[..8].to_string()
316}
317
318fn usage_recency(proc: &Procedure) -> f32 {
319    let days_old = Utc::now().signed_duration_since(proc.last_used).num_days() as f32;
320    (1.0 - days_old / 30.0).max(0.0)
321}
322
323pub fn format_suggestion(proc: &Procedure) -> String {
324    let mut output = format!(
325        "Suggested workflow: {} (confidence: {:.0}%, used {}x, success rate: {:.0}%)\n",
326        proc.name,
327        proc.confidence * 100.0,
328        proc.times_used,
329        proc.success_rate() * 100.0
330    );
331    for (i, step) in proc.steps.iter().enumerate() {
332        let opt = if step.optional { " (optional)" } else { "" };
333        output.push_str(&format!("  {}. {}{opt}\n", i + 1, step.tool));
334    }
335    output
336}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::core::episodic_memory::{Action, Episode, Outcome};
342
343    fn make_episode_with_tools(tools: &[&str]) -> Episode {
344        Episode {
345            id: "ep-1".to_string(),
346            session_id: "s-1".to_string(),
347            timestamp: Utc::now(),
348            task_description: "test task".to_string(),
349            actions: tools
350                .iter()
351                .map(|t| Action {
352                    tool: t.to_string(),
353                    description: String::new(),
354                    timestamp: Utc::now(),
355                    duration_ms: 100,
356                    success: true,
357                })
358                .collect(),
359            outcome: Outcome::Success { tests_passed: true },
360            affected_files: vec![],
361            summary: String::new(),
362            duration_secs: 60,
363            tokens_used: 1000,
364        }
365    }
366
367    #[test]
368    fn detect_patterns_from_episodes() {
369        let policy = ProceduralPolicy::default();
370        let episodes: Vec<Episode> = (0..5)
371            .map(|_| make_episode_with_tools(&["ctx_read", "ctx_shell", "ctx_read"]))
372            .collect();
373
374        let mut store = ProceduralStore::new("test");
375        store.detect_patterns(&episodes, &policy);
376
377        assert!(
378            !store.procedures.is_empty(),
379            "Should detect at least one pattern"
380        );
381    }
382
383    #[test]
384    fn suggest_matching_procedure() {
385        let policy = ProceduralPolicy::default();
386        let mut store = ProceduralStore::new("test");
387        store.add_procedure(
388            Procedure {
389                id: "proc-1".to_string(),
390                name: "deploy-workflow".to_string(),
391                description: "Deploy".to_string(),
392                steps: vec![ProcedureStep {
393                    tool: "ctx_shell".to_string(),
394                    description: "cargo build".to_string(),
395                    optional: false,
396                }],
397                activation_keywords: vec!["deploy".to_string(), "release".to_string()],
398                confidence: 0.8,
399                times_used: 5,
400                times_succeeded: 4,
401                last_used: Utc::now(),
402                project_specific: true,
403                created_at: Utc::now(),
404            },
405            &policy,
406        );
407
408        let suggestions = store.suggest("deploy the new version");
409        assert_eq!(suggestions.len(), 1);
410        assert_eq!(suggestions[0].name, "deploy-workflow");
411
412        let none = store.suggest("refactor the database layer");
413        assert!(none.is_empty());
414    }
415
416    #[test]
417    fn record_usage_updates_confidence() {
418        let policy = ProceduralPolicy::default();
419        let mut store = ProceduralStore::new("test");
420        store.add_procedure(
421            Procedure {
422                id: "proc-1".to_string(),
423                name: "test-workflow".to_string(),
424                description: "Test".to_string(),
425                steps: vec![],
426                activation_keywords: vec![],
427                confidence: 0.5,
428                times_used: 0,
429                times_succeeded: 0,
430                last_used: Utc::now(),
431                project_specific: false,
432                created_at: Utc::now(),
433            },
434            &policy,
435        );
436
437        store.record_usage("proc-1", true);
438        let proc = &store.procedures[0];
439        assert_eq!(proc.times_used, 1);
440        assert_eq!(proc.times_succeeded, 1);
441        assert!(proc.confidence > 0.5);
442    }
443
444    #[test]
445    fn success_rate_calculation() {
446        let proc = Procedure {
447            id: "p".to_string(),
448            name: "n".to_string(),
449            description: String::new(),
450            steps: vec![],
451            activation_keywords: vec![],
452            confidence: 0.5,
453            times_used: 10,
454            times_succeeded: 7,
455            last_used: Utc::now(),
456            project_specific: false,
457            created_at: Utc::now(),
458        };
459        assert!((proc.success_rate() - 0.7).abs() < 0.01);
460    }
461
462    #[test]
463    fn max_procedures_enforced() {
464        let policy = ProceduralPolicy::default();
465        let mut store = ProceduralStore::new("test");
466        for i in 0..110 {
467            store.add_procedure(
468                Procedure {
469                    id: format!("p-{i}"),
470                    name: format!("workflow-{i}"),
471                    description: String::new(),
472                    steps: vec![],
473                    activation_keywords: vec![],
474                    confidence: i as f32 / 110.0,
475                    times_used: 0,
476                    times_succeeded: 0,
477                    last_used: Utc::now(),
478                    project_specific: false,
479                    created_at: Utc::now(),
480                },
481                &policy,
482            );
483        }
484        assert!(store.procedures.len() <= policy.max_procedures);
485    }
486
487    #[test]
488    fn auto_detect_learns_from_recorded_episodes() {
489        // Env mutation requires the process-wide lock, or parallel tests that
490        // also touch LEAN_CTX_DATA_DIR race and the store lands elsewhere.
491        let _lock = crate::core::data_dir::test_env_lock();
492        // Isolated data dir so the test never touches the real memory stores.
493        let dir = std::env::temp_dir().join(format!("lctx-procauto-{}", std::process::id()));
494        let _ = std::fs::create_dir_all(&dir);
495        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
496
497        let hash = "auto-detect-test";
498        let policy = crate::core::memory_policy::MemoryPolicy::default();
499        let mut episodes = crate::core::episodic_memory::EpisodicStore::new(hash);
500        for _ in 0..5 {
501            episodes.record_episode(
502                make_episode_with_tools(&["ctx_read", "ctx_shell", "ctx_read"]),
503                &policy.episodic,
504            );
505        }
506        episodes.save().expect("episodic save");
507
508        let learned = auto_detect_from_episodes(hash, &policy.procedural);
509        assert!(
510            learned.is_some_and(|n| n > 0),
511            "auto-detect should learn at least one workflow, got {learned:?}"
512        );
513        // The store must be persisted, not just held in memory.
514        let reloaded = ProceduralStore::load(hash).expect("procedural store persisted");
515        assert!(!reloaded.procedures.is_empty());
516
517        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
518        let _ = std::fs::remove_dir_all(&dir);
519    }
520
521    #[test]
522    fn format_suggestion_output() {
523        let proc = Procedure {
524            id: "p".to_string(),
525            name: "deploy-workflow".to_string(),
526            description: String::new(),
527            steps: vec![
528                ProcedureStep {
529                    tool: "ctx_shell".to_string(),
530                    description: "test".to_string(),
531                    optional: false,
532                },
533                ProcedureStep {
534                    tool: "ctx_shell".to_string(),
535                    description: "build".to_string(),
536                    optional: true,
537                },
538            ],
539            activation_keywords: vec![],
540            confidence: 0.85,
541            times_used: 10,
542            times_succeeded: 8,
543            last_used: Utc::now(),
544            project_specific: false,
545            created_at: Utc::now(),
546        };
547        let output = format_suggestion(&proc);
548        assert!(output.contains("deploy-workflow"));
549        assert!(output.contains("85%"));
550        assert!(output.contains("(optional)"));
551    }
552}