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        if let Err(error) = 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            tracing::warn!(%error, "procedure capacity reclaim failed");
144        }
145    }
146
147    pub fn detect_patterns(&mut self, episodes: &[Episode], policy: &ProceduralPolicy) {
148        let sequences = extract_tool_sequences(episodes);
149        let patterns = find_repeated_sequences(&sequences, policy);
150
151        for (steps, count, keywords) in patterns {
152            if count < policy.min_repetitions || steps.len() < policy.min_sequence_len {
153                continue;
154            }
155
156            let name = generate_procedure_name(&steps);
157            let already_exists = self.procedures.iter().any(|p| p.name == name);
158            if already_exists {
159                continue;
160            }
161
162            let success_count = episodes
163                .iter()
164                .filter(|ep| matches!(ep.outcome, Outcome::Success { .. }))
165                .count();
166            let confidence = success_count as f32 / episodes.len().max(1) as f32;
167
168            self.add_procedure(
169                Procedure {
170                    id: format!("proc-{}", md5_short(&name)),
171                    name,
172                    description: format!("Detected workflow ({count} repetitions)"),
173                    steps,
174                    activation_keywords: keywords,
175                    confidence,
176                    times_used: count as u32,
177                    times_succeeded: success_count as u32,
178                    last_used: Utc::now(),
179                    project_specific: true,
180                    created_at: Utc::now(),
181                },
182                policy,
183            );
184        }
185    }
186
187    fn store_path(project_hash: &str) -> Option<PathBuf> {
188        let dir = crate::core::data_dir::lean_ctx_data_dir()
189            .ok()?
190            .join("memory")
191            .join("procedures");
192        Some(dir.join(format!("{project_hash}.json")))
193    }
194
195    pub fn load(project_hash: &str) -> Option<Self> {
196        let path = Self::store_path(project_hash)?;
197        let data = std::fs::read_to_string(path).ok()?;
198        serde_json::from_str(&data).ok()
199    }
200
201    pub fn load_or_create(project_hash: &str) -> Self {
202        Self::load(project_hash).unwrap_or_else(|| Self::new(project_hash))
203    }
204
205    pub fn save(&self) -> Result<(), String> {
206        let path = Self::store_path(&self.project_hash)
207            .ok_or_else(|| "Cannot determine data directory".to_string())?;
208        if let Some(dir) = path.parent() {
209            std::fs::create_dir_all(dir).map_err(|e| format!("{e}"))?;
210        }
211        let json = serde_json::to_string_pretty(self).map_err(|e| format!("{e}"))?;
212        std::fs::write(path, json).map_err(|e| format!("{e}"))
213    }
214}
215
216/// Auto-learning hook (GL #478): run pattern detection over the full episode
217/// log and persist the result. Called after every recorded episode, so
218/// recurring workflows surface on the dashboard without anyone ever invoking
219/// `ctx_session action=procedures value=detect` by hand. Best-effort: returns
220/// the number of stored procedures, or `None` when there is nothing to learn
221/// from yet. Detection is cheap (n-grams over <= `max_episodes` sequences),
222/// so no throttling is needed.
223pub fn auto_detect_from_episodes(project_hash: &str, policy: &ProceduralPolicy) -> Option<usize> {
224    let episodes = super::episodic_memory::EpisodicStore::load(project_hash)?;
225    if episodes.episodes.is_empty() {
226        return None;
227    }
228    let mut procs = ProceduralStore::load_or_create(project_hash);
229    procs.detect_patterns(&episodes.episodes, policy);
230    procs.save().ok()?;
231    Some(procs.procedures.len())
232}
233
234fn extract_tool_sequences(episodes: &[Episode]) -> Vec<Vec<String>> {
235    episodes
236        .iter()
237        .map(|ep| ep.actions.iter().map(|a| a.tool.clone()).collect())
238        .collect()
239}
240
241fn find_repeated_sequences(
242    sequences: &[Vec<String>],
243    policy: &ProceduralPolicy,
244) -> Vec<(Vec<ProcedureStep>, usize, Vec<String>)> {
245    let mut ngram_counts: HashMap<Vec<String>, usize> = HashMap::new();
246
247    for seq in sequences {
248        if seq.len() < policy.min_sequence_len {
249            continue;
250        }
251        let max_win = seq.len().min(policy.max_window_size);
252        for window_size in policy.min_sequence_len..=max_win {
253            for window in seq.windows(window_size) {
254                let key: Vec<String> = window.to_vec();
255                *ngram_counts.entry(key).or_insert(0) += 1;
256            }
257        }
258    }
259
260    let mut results: Vec<(Vec<ProcedureStep>, usize, Vec<String>)> = Vec::new();
261
262    let mut sorted: Vec<_> = ngram_counts.into_iter().collect();
263    sorted.sort_by(|a, b| {
264        let score_a = a.1 * a.0.len();
265        let score_b = b.1 * b.0.len();
266        score_b.cmp(&score_a)
267    });
268
269    let mut seen_prefixes: std::collections::HashSet<String> = std::collections::HashSet::new();
270
271    for (tools, count) in sorted {
272        if count < policy.min_repetitions {
273            continue;
274        }
275
276        let prefix = tools.join("->");
277        let is_substring = seen_prefixes.iter().any(|s| s.contains(&prefix));
278        if is_substring {
279            continue;
280        }
281
282        seen_prefixes.insert(prefix);
283
284        let steps: Vec<ProcedureStep> = tools
285            .iter()
286            .map(|t| ProcedureStep {
287                tool: t.clone(),
288                description: String::new(),
289                optional: false,
290            })
291            .collect();
292
293        let keywords: Vec<String> = tools
294            .iter()
295            .filter(|t| !t.starts_with("ctx_"))
296            .cloned()
297            .collect();
298
299        results.push((steps, count, keywords));
300    }
301
302    results
303}
304
305fn generate_procedure_name(steps: &[ProcedureStep]) -> String {
306    let tools: Vec<&str> = steps.iter().map(|s| s.tool.as_str()).collect();
307    let short: Vec<&str> = tools
308        .iter()
309        .map(|t| t.strip_prefix("ctx_").unwrap_or(t))
310        .collect();
311    format!("workflow-{}", short.join("-"))
312}
313
314fn md5_short(input: &str) -> String {
315    use md5::{Digest, Md5};
316    let result = Md5::digest(input.as_bytes());
317    crate::core::agent_identity::hex_encode(&result)[..8].to_string()
318}
319
320fn usage_recency(proc: &Procedure) -> f32 {
321    let days_old = Utc::now().signed_duration_since(proc.last_used).num_days() as f32;
322    (1.0 - days_old / 30.0).max(0.0)
323}
324
325pub fn format_suggestion(proc: &Procedure) -> String {
326    let mut output = format!(
327        "Suggested workflow: {} (confidence: {:.0}%, used {}x, success rate: {:.0}%)\n",
328        proc.name,
329        proc.confidence * 100.0,
330        proc.times_used,
331        proc.success_rate() * 100.0
332    );
333    for (i, step) in proc.steps.iter().enumerate() {
334        let opt = if step.optional { " (optional)" } else { "" };
335        output.push_str(&format!("  {}. {}{opt}\n", i + 1, step.tool));
336    }
337    output
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use crate::core::episodic_memory::{Action, Episode, Outcome};
344
345    fn make_episode_with_tools(tools: &[&str]) -> Episode {
346        Episode {
347            id: "ep-1".to_string(),
348            session_id: "s-1".to_string(),
349            timestamp: Utc::now(),
350            task_description: "test task".to_string(),
351            actions: tools
352                .iter()
353                .map(|t| Action {
354                    tool: t.to_string(),
355                    description: String::new(),
356                    timestamp: Utc::now(),
357                    duration_ms: 100,
358                    success: true,
359                })
360                .collect(),
361            outcome: Outcome::Success { tests_passed: true },
362            affected_files: vec![],
363            summary: String::new(),
364            duration_secs: 60,
365            tokens_used: 1000,
366        }
367    }
368
369    #[test]
370    fn detect_patterns_from_episodes() {
371        let policy = ProceduralPolicy::default();
372        let episodes: Vec<Episode> = (0..5)
373            .map(|_| make_episode_with_tools(&["ctx_read", "ctx_shell", "ctx_read"]))
374            .collect();
375
376        let mut store = ProceduralStore::new("test");
377        store.detect_patterns(&episodes, &policy);
378
379        assert!(
380            !store.procedures.is_empty(),
381            "Should detect at least one pattern"
382        );
383    }
384
385    #[test]
386    fn suggest_matching_procedure() {
387        let policy = ProceduralPolicy::default();
388        let mut store = ProceduralStore::new("test");
389        store.add_procedure(
390            Procedure {
391                id: "proc-1".to_string(),
392                name: "deploy-workflow".to_string(),
393                description: "Deploy".to_string(),
394                steps: vec![ProcedureStep {
395                    tool: "ctx_shell".to_string(),
396                    description: "cargo build".to_string(),
397                    optional: false,
398                }],
399                activation_keywords: vec!["deploy".to_string(), "release".to_string()],
400                confidence: 0.8,
401                times_used: 5,
402                times_succeeded: 4,
403                last_used: Utc::now(),
404                project_specific: true,
405                created_at: Utc::now(),
406            },
407            &policy,
408        );
409
410        let suggestions = store.suggest("deploy the new version");
411        assert_eq!(suggestions.len(), 1);
412        assert_eq!(suggestions[0].name, "deploy-workflow");
413
414        let none = store.suggest("refactor the database layer");
415        assert!(none.is_empty());
416    }
417
418    #[test]
419    fn record_usage_updates_confidence() {
420        let policy = ProceduralPolicy::default();
421        let mut store = ProceduralStore::new("test");
422        store.add_procedure(
423            Procedure {
424                id: "proc-1".to_string(),
425                name: "test-workflow".to_string(),
426                description: "Test".to_string(),
427                steps: vec![],
428                activation_keywords: vec![],
429                confidence: 0.5,
430                times_used: 0,
431                times_succeeded: 0,
432                last_used: Utc::now(),
433                project_specific: false,
434                created_at: Utc::now(),
435            },
436            &policy,
437        );
438
439        store.record_usage("proc-1", true);
440        let proc = &store.procedures[0];
441        assert_eq!(proc.times_used, 1);
442        assert_eq!(proc.times_succeeded, 1);
443        assert!(proc.confidence > 0.5);
444    }
445
446    #[test]
447    fn success_rate_calculation() {
448        let proc = Procedure {
449            id: "p".to_string(),
450            name: "n".to_string(),
451            description: String::new(),
452            steps: vec![],
453            activation_keywords: vec![],
454            confidence: 0.5,
455            times_used: 10,
456            times_succeeded: 7,
457            last_used: Utc::now(),
458            project_specific: false,
459            created_at: Utc::now(),
460        };
461        assert!((proc.success_rate() - 0.7).abs() < 0.01);
462    }
463
464    #[test]
465    fn max_procedures_enforced() {
466        let policy = ProceduralPolicy::default();
467        let mut store = ProceduralStore::new("test");
468        for i in 0..110 {
469            store.add_procedure(
470                Procedure {
471                    id: format!("p-{i}"),
472                    name: format!("workflow-{i}"),
473                    description: String::new(),
474                    steps: vec![],
475                    activation_keywords: vec![],
476                    confidence: i as f32 / 110.0,
477                    times_used: 0,
478                    times_succeeded: 0,
479                    last_used: Utc::now(),
480                    project_specific: false,
481                    created_at: Utc::now(),
482                },
483                &policy,
484            );
485        }
486        assert!(store.procedures.len() <= policy.max_procedures);
487    }
488
489    #[test]
490    fn auto_detect_learns_from_recorded_episodes() {
491        // Env mutation requires the process-wide lock, or parallel tests that
492        // also touch LEAN_CTX_DATA_DIR race and the store lands elsewhere.
493        let _lock = crate::core::data_dir::test_env_lock();
494        // Isolated data dir so the test never touches the real memory stores.
495        let dir = std::env::temp_dir().join(format!("lctx-procauto-{}", std::process::id()));
496        let _ = std::fs::create_dir_all(&dir);
497        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
498
499        let hash = "auto-detect-test";
500        let policy = crate::core::memory_policy::MemoryPolicy::default();
501        let mut episodes = crate::core::episodic_memory::EpisodicStore::new(hash);
502        for _ in 0..5 {
503            episodes.record_episode(
504                make_episode_with_tools(&["ctx_read", "ctx_shell", "ctx_read"]),
505                &policy.episodic,
506            );
507        }
508        episodes.save().expect("episodic save");
509
510        let learned = auto_detect_from_episodes(hash, &policy.procedural);
511        assert!(
512            learned.is_some_and(|n| n > 0),
513            "auto-detect should learn at least one workflow, got {learned:?}"
514        );
515        // The store must be persisted, not just held in memory.
516        let reloaded = ProceduralStore::load(hash).expect("procedural store persisted");
517        assert!(!reloaded.procedures.is_empty());
518
519        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
520        let _ = std::fs::remove_dir_all(&dir);
521    }
522
523    #[test]
524    fn format_suggestion_output() {
525        let proc = Procedure {
526            id: "p".to_string(),
527            name: "deploy-workflow".to_string(),
528            description: String::new(),
529            steps: vec![
530                ProcedureStep {
531                    tool: "ctx_shell".to_string(),
532                    description: "test".to_string(),
533                    optional: false,
534                },
535                ProcedureStep {
536                    tool: "ctx_shell".to_string(),
537                    description: "build".to_string(),
538                    optional: true,
539                },
540            ],
541            activation_keywords: vec![],
542            confidence: 0.85,
543            times_used: 10,
544            times_succeeded: 8,
545            last_used: Utc::now(),
546            project_specific: false,
547            created_at: Utc::now(),
548        };
549        let output = format_suggestion(&proc);
550        assert!(output.contains("deploy-workflow"));
551        assert!(output.contains("85%"));
552        assert!(output.contains("(optional)"));
553    }
554}