1use 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
43pub(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
52pub(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 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
216pub 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 let _lock = crate::core::data_dir::test_env_lock();
495 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 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}