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