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