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
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
186pub 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 crate::core::agent_identity::hex_encode(&result)[..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 let _lock = crate::core::data_dir::test_env_lock();
464 let dir = std::env::temp_dir().join(format!("lctx-procauto-{}", std::process::id()));
466 let _ = std::fs::create_dir_all(&dir);
467 crate::test_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 let reloaded = ProceduralStore::load(hash).expect("procedural store persisted");
487 assert!(!reloaded.procedures.is_empty());
488
489 crate::test_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}