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            agent_id: None,
367        }
368    }
369
370    #[test]
371    fn detect_patterns_from_episodes() {
372        let policy = ProceduralPolicy::default();
373        let episodes: Vec<Episode> = (0..5)
374            .map(|_| make_episode_with_tools(&["ctx_read", "ctx_shell", "ctx_read"]))
375            .collect();
376
377        let mut store = ProceduralStore::new("test");
378        store.detect_patterns(&episodes, &policy);
379
380        assert!(
381            !store.procedures.is_empty(),
382            "Should detect at least one pattern"
383        );
384    }
385
386    #[test]
387    fn suggest_matching_procedure() {
388        let policy = ProceduralPolicy::default();
389        let mut store = ProceduralStore::new("test");
390        store.add_procedure(
391            Procedure {
392                id: "proc-1".to_string(),
393                name: "deploy-workflow".to_string(),
394                description: "Deploy".to_string(),
395                steps: vec![ProcedureStep {
396                    tool: "ctx_shell".to_string(),
397                    description: "cargo build".to_string(),
398                    optional: false,
399                }],
400                activation_keywords: vec!["deploy".to_string(), "release".to_string()],
401                confidence: 0.8,
402                times_used: 5,
403                times_succeeded: 4,
404                last_used: Utc::now(),
405                project_specific: true,
406                created_at: Utc::now(),
407            },
408            &policy,
409        );
410
411        let suggestions = store.suggest("deploy the new version");
412        assert_eq!(suggestions.len(), 1);
413        assert_eq!(suggestions[0].name, "deploy-workflow");
414
415        let none = store.suggest("refactor the database layer");
416        assert!(none.is_empty());
417    }
418
419    #[test]
420    fn record_usage_updates_confidence() {
421        let policy = ProceduralPolicy::default();
422        let mut store = ProceduralStore::new("test");
423        store.add_procedure(
424            Procedure {
425                id: "proc-1".to_string(),
426                name: "test-workflow".to_string(),
427                description: "Test".to_string(),
428                steps: vec![],
429                activation_keywords: vec![],
430                confidence: 0.5,
431                times_used: 0,
432                times_succeeded: 0,
433                last_used: Utc::now(),
434                project_specific: false,
435                created_at: Utc::now(),
436            },
437            &policy,
438        );
439
440        store.record_usage("proc-1", true);
441        let proc = &store.procedures[0];
442        assert_eq!(proc.times_used, 1);
443        assert_eq!(proc.times_succeeded, 1);
444        assert!(proc.confidence > 0.5);
445    }
446
447    #[test]
448    fn success_rate_calculation() {
449        let proc = Procedure {
450            id: "p".to_string(),
451            name: "n".to_string(),
452            description: String::new(),
453            steps: vec![],
454            activation_keywords: vec![],
455            confidence: 0.5,
456            times_used: 10,
457            times_succeeded: 7,
458            last_used: Utc::now(),
459            project_specific: false,
460            created_at: Utc::now(),
461        };
462        assert!((proc.success_rate() - 0.7).abs() < 0.01);
463    }
464
465    #[test]
466    fn max_procedures_enforced() {
467        let policy = ProceduralPolicy::default();
468        let mut store = ProceduralStore::new("test");
469        for i in 0..110 {
470            store.add_procedure(
471                Procedure {
472                    id: format!("p-{i}"),
473                    name: format!("workflow-{i}"),
474                    description: String::new(),
475                    steps: vec![],
476                    activation_keywords: vec![],
477                    confidence: i as f32 / 110.0,
478                    times_used: 0,
479                    times_succeeded: 0,
480                    last_used: Utc::now(),
481                    project_specific: false,
482                    created_at: Utc::now(),
483                },
484                &policy,
485            );
486        }
487        assert!(store.procedures.len() <= policy.max_procedures);
488    }
489
490    #[test]
491    fn auto_detect_learns_from_recorded_episodes() {
492        // Env mutation requires the process-wide lock, or parallel tests that
493        // also touch LEAN_CTX_DATA_DIR race and the store lands elsewhere.
494        let _lock = crate::core::data_dir::test_env_lock();
495        // Isolated data dir so the test never touches the real memory stores.
496        let dir = std::env::temp_dir().join(format!("lctx-procauto-{}", std::process::id()));
497        let _ = std::fs::create_dir_all(&dir);
498        crate::test_env::set_var("LEAN_CTX_DATA_DIR", dir.to_str().unwrap());
499
500        let hash = "auto-detect-test";
501        let policy = crate::core::memory_policy::MemoryPolicy::default();
502        let mut episodes = crate::core::episodic_memory::EpisodicStore::new(hash);
503        for _ in 0..5 {
504            episodes.record_episode(
505                make_episode_with_tools(&["ctx_read", "ctx_shell", "ctx_read"]),
506                &policy.episodic,
507            );
508        }
509        episodes.save().expect("episodic save");
510
511        let learned = auto_detect_from_episodes(hash, &policy.procedural);
512        assert!(
513            learned.is_some_and(|n| n > 0),
514            "auto-detect should learn at least one workflow, got {learned:?}"
515        );
516        // The store must be persisted, not just held in memory.
517        let reloaded = ProceduralStore::load(hash).expect("procedural store persisted");
518        assert!(!reloaded.procedures.is_empty());
519
520        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
521        let _ = std::fs::remove_dir_all(&dir);
522    }
523
524    #[test]
525    fn format_suggestion_output() {
526        let proc = Procedure {
527            id: "p".to_string(),
528            name: "deploy-workflow".to_string(),
529            description: String::new(),
530            steps: vec![
531                ProcedureStep {
532                    tool: "ctx_shell".to_string(),
533                    description: "test".to_string(),
534                    optional: false,
535                },
536                ProcedureStep {
537                    tool: "ctx_shell".to_string(),
538                    description: "build".to_string(),
539                    optional: true,
540                },
541            ],
542            activation_keywords: vec![],
543            confidence: 0.85,
544            times_used: 10,
545            times_succeeded: 8,
546            last_used: Utc::now(),
547            project_specific: false,
548            created_at: Utc::now(),
549        };
550        let output = format_suggestion(&proc);
551        assert!(output.contains("deploy-workflow"));
552        assert!(output.contains("85%"));
553        assert!(output.contains("(optional)"));
554    }
555}