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 }
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 let _lock = crate::core::data_dir::test_env_lock();
494 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 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}