Skip to main content

pawan/
memory.rs

1//! Autonomous memory: extract durable learnings and inject at startup.
2
3use crate::agent::Message;
4use crate::{PawanError, Result};
5use chrono::{DateTime, Duration, Utc};
6use serde::{Deserialize, Serialize};
7use std::cmp::Ordering;
8use std::collections::hash_map::DefaultHasher;
9use std::collections::{HashMap, HashSet};
10use std::fs;
11use std::hash::{Hash, Hasher};
12use std::path::{Path, PathBuf};
13use uuid::Uuid;
14
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct Memory {
17    /// Storage key (unique)
18    pub key: String,
19    /// Markdown content
20    pub content: String,
21    pub source_session: String,
22    pub created_at: String,
23    pub updated_at: String,
24    pub relevance_score: f64,
25}
26
27impl Memory {
28    /// True when this memory is intended for reuse across sessions (architecture, tools, durable patterns).
29    /// Session-tuned notes and one-off debug tips remain session-scoped.
30    pub fn is_shared(&self) -> bool {
31        if self
32            .key
33            .strip_prefix("shared.")
34            .is_some_and(|rest| !rest.is_empty())
35        {
36            return true;
37        }
38        let c = self.content.to_lowercase();
39        c.contains("architecture decision")
40            || c.contains("architecture decisions")
41            || c.contains("tool definition")
42            || c.contains("tool definitions")
43            || c.contains("reusable knowledge")
44    }
45}
46
47#[derive(Debug, Clone)]
48pub struct MemoryStore {
49    /// Base path (default: ~/.pawan/memories/)
50    pub base_path: PathBuf,
51}
52
53impl MemoryStore {
54    pub fn new(base_path: PathBuf) -> Self {
55        Self { base_path }
56    }
57
58    pub fn default_path() -> Result<PathBuf> {
59        let home = dirs::home_dir().ok_or_else(|| {
60            PawanError::Config("Failed to resolve home directory for memory store".to_string())
61        })?;
62        Ok(home.join(".pawan").join("memories"))
63    }
64
65    pub fn new_default() -> Result<Self> {
66        Ok(Self::new(Self::default_path()?))
67    }
68
69    fn ensure_dirs(&self) -> Result<()> {
70        fs::create_dir_all(&self.base_path)?;
71        Ok(())
72    }
73
74    pub fn key_to_filename(key: &str) -> String {
75        // Keep it filesystem-safe and stable.
76        let mut out = String::with_capacity(key.len());
77        for ch in key.chars() {
78            let safe = match ch {
79                'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' | '.' => ch,
80                _ => '_',
81            };
82            out.push(safe);
83        }
84        if out.is_empty() {
85            "_".to_string()
86        } else {
87            out
88        }
89    }
90
91    fn memory_path(&self, key: &str) -> PathBuf {
92        self.base_path
93            .join(format!("{}.json", Self::key_to_filename(key)))
94    }
95
96    pub fn save(&self, memory: &Memory) -> Result<()> {
97        self.ensure_dirs()?;
98
99        let path = self.memory_path(&memory.key);
100        let tmp = path.with_extension("json.tmp");
101        let payload = serde_json::to_vec_pretty(memory)
102            .map_err(|e| PawanError::Parse(format!("Failed to serialize memory: {e}")))?;
103
104        fs::write(&tmp, payload)?;
105        fs::rename(&tmp, &path)?;
106
107        self.evict_fifo(100)?;
108        Ok(())
109    }
110
111    pub fn load(&self, key: &str) -> Result<Memory> {
112        let path = self.memory_path(key);
113        let bytes = fs::read(&path).map_err(|e| {
114            if e.kind() == std::io::ErrorKind::NotFound {
115                PawanError::NotFound(format!("Memory not found: {key}"))
116            } else {
117                PawanError::Io(e)
118            }
119        })?;
120        serde_json::from_slice::<Memory>(&bytes)
121            .map_err(|e| PawanError::Parse(format!("Failed to parse memory JSON: {e}")))
122    }
123
124    pub fn list(&self) -> Result<Vec<String>> {
125        if !self.base_path.exists() {
126            return Ok(vec![]);
127        }
128        let mut keys = vec![];
129        for entry in fs::read_dir(&self.base_path)? {
130            let entry = entry?;
131            let path = entry.path();
132            if path.extension().and_then(|s| s.to_str()) != Some("json") {
133                continue;
134            }
135            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
136                keys.push(stem.to_string());
137            }
138        }
139        keys.sort();
140        Ok(keys)
141    }
142
143    pub fn delete(&self, key: &str) -> Result<()> {
144        let path = self.memory_path(key);
145        match fs::remove_file(&path) {
146            Ok(()) => Ok(()),
147            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
148            Err(e) => Err(PawanError::Io(e)),
149        }
150    }
151
152    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<Memory>> {
153        if limit == 0 {
154            return Ok(vec![]);
155        }
156        let q = query.to_lowercase();
157        let terms: Vec<&str> = q.split_whitespace().filter(|t| !t.is_empty()).collect();
158        if terms.is_empty() {
159            return Ok(vec![]);
160        }
161
162        let mut hits: Vec<Memory> = vec![];
163        if !self.base_path.exists() {
164            return Ok(vec![]);
165        }
166        for entry in fs::read_dir(&self.base_path)? {
167            let entry = entry?;
168            let path = entry.path();
169            if path.extension().and_then(|s| s.to_str()) != Some("json") {
170                continue;
171            }
172            let bytes = match fs::read(&path) {
173                Ok(b) => b,
174                Err(_) => continue,
175            };
176            let mut mem: Memory = match serde_json::from_slice(&bytes) {
177                Ok(m) => m,
178                Err(_) => continue,
179            };
180            let hay = format!(
181                "{}
182{}",
183                mem.key, mem.content
184            )
185            .to_lowercase();
186            let mut score = 0.0f64;
187            for t in &terms {
188                if t.len() < 2 {
189                    continue;
190                }
191                let mut idx = 0;
192                while let Some(pos) = hay[idx..].find(t) {
193                    score += 1.0;
194                    idx += pos + t.len();
195                    if idx >= hay.len() {
196                        break;
197                    }
198                }
199            }
200            if score > 0.0 {
201                mem.relevance_score = score;
202                hits.push(mem);
203            }
204        }
205
206        hits.sort_by(|a, b| {
207            let s = b
208                .relevance_score
209                .partial_cmp(&a.relevance_score)
210                .unwrap_or(Ordering::Equal);
211            if s != Ordering::Equal {
212                return s;
213            }
214            b.updated_at.cmp(&a.updated_at)
215        });
216
217        hits.truncate(limit);
218        Ok(hits)
219    }
220
221    fn evict_fifo(&self, keep_last: usize) -> Result<()> {
222        if !self.base_path.exists() {
223            return Ok(());
224        }
225
226        let mut entries: Vec<(std::time::SystemTime, PathBuf)> = vec![];
227        for entry in fs::read_dir(&self.base_path)? {
228            let entry = entry?;
229            let path = entry.path();
230            if path.extension().and_then(|s| s.to_str()) != Some("json") {
231                continue;
232            }
233            let meta = match entry.metadata() {
234                Ok(m) => m,
235                Err(_) => continue,
236            };
237            let mtime = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
238            entries.push((mtime, path));
239        }
240
241        if entries.len() <= keep_last {
242            return Ok(());
243        }
244
245        entries.sort_by_key(|(mtime, _path)| *mtime);
246        let to_delete = entries.len().saturating_sub(keep_last);
247        for (_, path) in entries.iter().take(to_delete) {
248            let _ = fs::remove_file(path);
249        }
250        Ok(())
251    }
252
253    pub fn write_memory_summary_markdown(&self, top: &[Memory]) -> Result<PathBuf> {
254        self.ensure_dirs()?;
255        let summary_path = self.base_path.join("memory_summary.md");
256
257        if top.is_empty() {
258            let _ = fs::remove_file(&summary_path);
259            return Ok(summary_path);
260        }
261
262        let mut out = String::new();
263        out.push_str(
264            "# Memory Summary
265
266",
267        );
268        out.push_str(
269            "This file is auto-generated. Treat as heuristic context.
270
271",
272        );
273        for m in top {
274            out.push_str("## ");
275            out.push_str(&m.key);
276            out.push('\n');
277            out.push_str("- source_session: ");
278            out.push_str(&m.source_session);
279            out.push('\n');
280            out.push_str("- updated_at: ");
281            out.push_str(&m.updated_at);
282            out.push('\n');
283            out.push_str("- relevance_score: ");
284            out.push_str(&format!("{:.2}", m.relevance_score));
285            out.push('\n');
286            out.push('\n');
287            out.push_str(m.content.trim());
288            out.push_str(
289                "
290
291---
292
293",
294            );
295        }
296
297        fs::write(&summary_path, out)?;
298        Ok(summary_path)
299    }
300    /// Extract durable learnings from a conversation and save them.
301    /// Call this at session end or periodically during long sessions.
302    pub fn extract_from_conversation(&self, messages: &[Message]) -> Result<Vec<Memory>> {
303        if messages.is_empty() {
304            return Ok(vec![]);
305        }
306
307        self.ensure_dirs()?;
308        let session_id = Uuid::new_v4().to_string();
309        let now = Utc::now().to_rfc3339();
310
311        let lines = collect_conversation_lines(messages);
312        let full_text = conversation_text(messages);
313
314        let mut by_key: HashMap<String, Memory> = HashMap::new();
315
316        let mut line_counts: HashMap<String, usize> = HashMap::new();
317        for line in &lines {
318            let n = normalize_line(line);
319            if n.len() >= 16 {
320                *line_counts.entry(n).or_insert(0) += 1;
321            }
322        }
323        for (line, count) in line_counts {
324            if count < 3 {
325                continue;
326            }
327            let key = format!("heuristic.repeated.{:016x}", hash64(&line));
328            let rel = ((count as f64) / 10.0).min(1.0) + 0.35;
329            let content = format!("**Repeated pattern** ({count}× in session)\n\n{line}");
330            merge_into_map(&mut by_key, key, content, rel, &session_id, &now);
331        }
332
333        let words: Vec<String> = ordered_word_tokens(&full_text);
334        if words.len() >= 4 {
335            let mut grams: HashMap<String, usize> = HashMap::new();
336            for w in words.windows(4) {
337                let g = w.join(" ");
338                if g.len() < 12 {
339                    continue;
340                }
341                *grams.entry(g).or_insert(0) += 1;
342            }
343            for (g, count) in grams {
344                if count < 3 {
345                    continue;
346                }
347                let key = format!("heuristic.ngram.{:016x}", hash64(&g));
348                let rel = ((count as f64) / 8.0).min(1.0) + 0.25;
349                let content = format!("**Repeated phrase** ({count}×)\n\n`{g}`");
350                merge_into_map(&mut by_key, key, content, rel, &session_id, &now);
351            }
352        }
353
354        for span in extract_error_fix_spans(&lines) {
355            let key = format!("heuristic.errorfix.{:016x}", hash64(&span));
356            let content = format!("**Error / recovery pattern**\n\n{span}");
357            merge_into_map(&mut by_key, key, content, 0.75, &session_id, &now);
358        }
359
360        for line in &lines {
361            if !looks_like_command(line) {
362                continue;
363            }
364            let key = format!("heuristic.command.{:016x}", hash64(line));
365            let content = format!("**Command pattern**\n\n`{}`", line.trim());
366            merge_into_map(&mut by_key, key, content, 0.6, &session_id, &now);
367        }
368
369        for line in &lines {
370            if !looks_like_config(line) {
371                continue;
372            }
373            let key = format!("heuristic.config.{:016x}", hash64(line));
374            let content = format!("**Configuration / setup hint**\n\n{}", line.trim());
375            merge_into_map(&mut by_key, key, content, 0.55, &session_id, &now);
376        }
377
378        for line in &lines {
379            let l = line.to_lowercase();
380            if !(l.contains("we should")
381                || l.contains("we will")
382                || l.contains("design decision")
383                || l.contains("architecture")
384                || l.contains("use a ")
385                || l.contains("prefer "))
386            {
387                continue;
388            }
389            if line.trim().len() < 12 {
390                continue;
391            }
392            let key = format!("heuristic.design.{:016x}", hash64(line));
393            let content = format!("**Design / architecture note**\n\n{}", line.trim());
394            merge_into_map(&mut by_key, key, content, 0.5, &session_id, &now);
395        }
396
397        let mut saved: Vec<Memory> = by_key.into_values().collect();
398        saved.sort_by(|a, b| {
399            b.relevance_score
400                .partial_cmp(&a.relevance_score)
401                .unwrap_or(Ordering::Equal)
402                .then(b.updated_at.cmp(&a.updated_at))
403        });
404
405        for mem in &saved {
406            self.save_merged_memory(mem)?;
407        }
408
409        Ok(saved)
410    }
411
412    pub fn consolidate(&self) -> Result<()> {
413        self.ensure_dirs()?;
414        if !self.base_path.exists() {
415            return Ok(());
416        }
417
418        let now = Utc::now();
419        let now_str = now.to_rfc3339();
420        let memories = self.load_all_memories()?;
421
422        for m in &memories {
423            if m.relevance_score >= 0.1 {
424                continue;
425            }
426            if let Some(updated) = parse_rfc3339_utc(&m.updated_at) {
427                if now.signed_duration_since(updated) > Duration::days(90) {
428                    self.delete(&m.key)?;
429                }
430            }
431        }
432
433        let memories = self.load_all_memories()?;
434        if memories.is_empty() {
435            return Ok(());
436        }
437
438        let mut groups: HashMap<String, Vec<Memory>> = HashMap::new();
439        for m in memories {
440            groups.entry(merge_bucket_key(&m)).or_default().push(m);
441        }
442
443        for (_bucket, group) in groups {
444            if group.len() <= 1 {
445                continue;
446            }
447            let mut group = group;
448            group.sort_by(|a, b| {
449                b.relevance_score
450                    .partial_cmp(&a.relevance_score)
451                    .unwrap_or(Ordering::Equal)
452                    .then(b.updated_at.cmp(&a.updated_at))
453            });
454
455            let winner = &group[0];
456            let mut content = winner.content.clone();
457            for other in group.iter().skip(1) {
458                content.push_str("\n\n---\n\n");
459                content.push_str(other.content.trim());
460            }
461
462            let merged = Memory {
463                key: winner.key.clone(),
464                content,
465                source_session: winner.source_session.clone(),
466                created_at: winner.created_at.clone(),
467                updated_at: now_str.clone(),
468                relevance_score: winner.relevance_score,
469            };
470
471            self.save(&merged)?;
472            for other in group.iter().skip(1) {
473                if other.key != merged.key {
474                    self.delete(&other.key)?;
475                }
476            }
477        }
478        Ok(())
479    }
480
481    pub fn get_relevant(&self, query: &str, limit: usize) -> Result<Vec<Memory>> {
482        if limit == 0 {
483            return Ok(vec![]);
484        }
485        let q_words: HashSet<String> = stopword_filtered_tokens(query);
486        if q_words.is_empty() {
487            return Ok(vec![]);
488        }
489        let memories = self.load_all_memories()?;
490        if memories.is_empty() {
491            return Ok(vec![]);
492        }
493        let query_lc = query.to_lowercase();
494        let mut scored: Vec<(f64, Memory)> = Vec::new();
495        for m in memories {
496            let hay = format!("{} {}", m.key, m.content);
497            let h_words = stopword_filtered_tokens(&hay);
498            let mut combined = jaccard(&q_words, &h_words) * 2.0 + m.relevance_score * 0.2;
499            if combined > 0.0 && m.content.to_lowercase().contains(&query_lc) {
500                combined += 0.15;
501            }
502            if combined > 0.0 {
503                scored.push((combined, m));
504            }
505        }
506        scored.sort_by(|a, b| {
507            b.0.partial_cmp(&a.0)
508                .unwrap_or(Ordering::Equal)
509                .then(b.1.updated_at.cmp(&a.1.updated_at))
510                .then(
511                    b.1.relevance_score
512                        .partial_cmp(&a.1.relevance_score)
513                        .unwrap_or(Ordering::Equal),
514                )
515        });
516        Ok(scored.into_iter().map(|(_, m)| m).take(limit).collect())
517    }
518
519    pub fn inject_as_context(&self, query: &str) -> Result<String> {
520        const LIMIT: usize = 12;
521        let mems = self.get_relevant(query, LIMIT)?;
522        if mems.is_empty() {
523            return Ok(String::new());
524        }
525        let mut out = String::new();
526        out.push_str("## Relevant memory context\n\n");
527        for m in mems {
528            out.push_str("### ");
529            out.push_str(&m.key);
530            out.push_str("\n\n");
531            out.push_str(m.content.trim());
532            out.push_str("\n\n---\n\n");
533        }
534        Ok(out)
535    }
536
537    fn load_all_memories(&self) -> Result<Vec<Memory>> {
538        if !self.base_path.exists() {
539            return Ok(vec![]);
540        }
541        let mut out = vec![];
542        for entry in fs::read_dir(&self.base_path)? {
543            let entry = entry?;
544            let path = entry.path();
545            if path.extension().and_then(|s| s.to_str()) != Some("json") {
546                continue;
547            }
548            let bytes = match fs::read(&path) {
549                Ok(b) => b,
550                Err(_) => continue,
551            };
552            if let Ok(mem) = serde_json::from_slice::<Memory>(&bytes) {
553                out.push(mem);
554            }
555        }
556        Ok(out)
557    }
558
559    fn save_merged_memory(&self, mem: &Memory) -> Result<()> {
560        if !self.memory_path(&mem.key).exists() {
561            return self.save(mem);
562        }
563        let mut cur = self.load(&mem.key)?;
564        if mem.relevance_score > cur.relevance_score {
565            cur.relevance_score = mem.relevance_score;
566        }
567        if !cur.content.contains(&mem.content) {
568            cur.content.push_str("\n\n---\n\n");
569            cur.content.push_str(mem.content.trim());
570        }
571        cur.updated_at = mem.updated_at.clone();
572        self.save(&cur)
573    }
574}
575
576/// Map a memory key to a stable, filesystem-safe filename (`unsafe` → `'_'`).
577pub fn sanitize_key(key: &str) -> String {
578    MemoryStore::key_to_filename(key)
579}
580
581fn ordered_word_tokens(text: &str) -> Vec<String> {
582    text.to_lowercase()
583        .split(|c: char| !c.is_alphanumeric() && c != '_')
584        .filter(|w| w.len() >= 2)
585        .map(|w| w.to_string())
586        .collect()
587}
588
589fn merge_into_map(
590    map: &mut HashMap<String, Memory>,
591    key: String,
592    content: String,
593    relevance: f64,
594    session_id: &str,
595    now: &str,
596) {
597    let relevance = relevance.clamp(0.0, 5.0);
598    map.entry(key.clone())
599        .and_modify(|m| {
600            m.relevance_score = m.relevance_score.max(relevance);
601            m.updated_at = now.to_string();
602            if !m.content.contains(&content) {
603                m.content.push_str("\n\n---\n\n");
604                m.content.push_str(&content);
605            }
606        })
607        .or_insert_with(|| Memory {
608            key,
609            content,
610            source_session: session_id.to_string(),
611            created_at: now.to_string(),
612            updated_at: now.to_string(),
613            relevance_score: relevance,
614        });
615}
616
617fn collect_conversation_lines(messages: &[Message]) -> Vec<String> {
618    let mut out = vec![];
619    for m in messages {
620        for line in m.content.lines() {
621            out.push(line.to_string());
622        }
623        if let Some(tr) = &m.tool_result {
624            let s = serde_json::to_string(&tr.content).unwrap_or_default();
625            for line in s.lines() {
626                out.push(line.to_string());
627            }
628        }
629    }
630    out
631}
632
633fn conversation_text(messages: &[Message]) -> String {
634    let mut s = String::new();
635    for m in messages {
636        if !m.content.is_empty() {
637            s.push_str(&m.content);
638            s.push('\n');
639        }
640        if let Some(tr) = &m.tool_result {
641            if let Ok(j) = serde_json::to_string(&tr.content) {
642                s.push_str(&j);
643                s.push('\n');
644            }
645        }
646    }
647    s
648}
649
650fn normalize_line(line: &str) -> String {
651    let t = line.trim();
652    t.chars().filter(|c| !c.is_control()).collect::<String>()
653}
654
655fn tokenize_words(text: &str) -> HashSet<String> {
656    let mut out = HashSet::new();
657    for w in text
658        .to_lowercase()
659        .split(|c: char| !c.is_alphanumeric() && c != '_')
660    {
661        if w.len() < 2 {
662            continue;
663        }
664        out.insert(w.to_string());
665    }
666    out
667}
668
669fn stopword_filtered_tokens(text: &str) -> HashSet<String> {
670    const STOP: &[&str] = &[
671        "the", "a", "an", "is", "are", "and", "or", "of", "to", "in", "for", "on", "with", "as",
672        "at", "it", "be", "this", "that", "we", "you",
673    ];
674    let mut t = tokenize_words(text);
675    t.retain(|w| !STOP.contains(&w.as_str()));
676    t
677}
678
679fn jaccard(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
680    if a.is_empty() || b.is_empty() {
681        return 0.0;
682    }
683    let inter = a.intersection(b).count();
684    let uni = a.union(b).count();
685    if uni == 0 {
686        0.0
687    } else {
688        inter as f64 / uni as f64
689    }
690}
691
692fn extract_error_fix_spans(lines: &[String]) -> Vec<String> {
693    const ERR: &[&str] = &[
694        "error",
695        "failed",
696        "panic",
697        "exception",
698        "traceback",
699        "e031",
700        "e0",
701    ];
702    const OK: &[&str] = &[
703        "fixed", "success", "works", "resolved", "passing", "ok", "done",
704    ];
705    let mut spans = vec![];
706    for i in 0..lines.len() {
707        let li = lines[i].to_lowercase();
708        if !ERR.iter().any(|e| li.contains(e)) {
709            continue;
710        }
711        for j in (i + 1)..lines.len().min(i + 40) {
712            let lj = lines[j].to_lowercase();
713            if OK.iter().any(|e| lj.contains(e)) {
714                let span = lines[i..=j].join("\n");
715                if (20..=4000).contains(&span.chars().count()) {
716                    spans.push(span);
717                }
718                break;
719            }
720        }
721    }
722    spans
723}
724
725fn looks_like_command(line: &str) -> bool {
726    let t = line.trim();
727    if t.starts_with('$') {
728        return true;
729    }
730    if t.starts_with("cargo ")
731        || t.starts_with("rustc ")
732        || t.starts_with("git ")
733        || t.starts_with("rg ")
734        || t.starts_with("fd ")
735    {
736        return true;
737    }
738    if t.starts_with("bun ") || t.starts_with("npm ") || t.starts_with("pnpm ") {
739        return true;
740    }
741    if t.starts_with("make ") || t.starts_with("just ") {
742        return true;
743    }
744    t.contains(" 2>&1") || t.contains("| ")
745}
746
747fn looks_like_config(line: &str) -> bool {
748    let t = line.to_lowercase();
749    t.contains(".toml")
750        || t.contains(".env")
751        || t.contains("pawan.toml")
752        || t.contains("config.toml")
753        || t.contains("export ")
754        || t.contains("feature flag")
755        || t.contains("timeout clamped")
756}
757
758fn hash64(s: &str) -> u64 {
759    let mut h = DefaultHasher::new();
760    s.hash(&mut h);
761    h.finish()
762}
763
764fn parse_rfc3339_utc(s: &str) -> Option<DateTime<Utc>> {
765    DateTime::parse_from_rfc3339(s)
766        .ok()
767        .map(|dt| dt.with_timezone(&Utc))
768}
769
770fn merge_bucket_key(m: &Memory) -> String {
771    m.key
772        .to_lowercase()
773        .chars()
774        .filter(|c| c.is_alphanumeric() || *c == '_' || *c == '.')
775        .collect()
776}
777/// Build the memory guidance block to inject into the system prompt.
778///
779/// If the memory summary does not exist (or is empty), returns None.
780pub fn load_memory_guidance_block(store: &MemoryStore) -> Option<String> {
781    let path = store.base_path.join("memory_summary.md");
782    let text = fs::read_to_string(&path).ok()?;
783    if text.trim().is_empty() {
784        return None;
785    }
786
787    Some(format!(
788        "## Memory Guidance
789
790Preloaded memory resource: /root/.omp/agent/memories/--tmp--/memory_summary.md
791
792{}",
793        text.trim()
794    ))
795}
796
797/// Inject memory guidance into an existing system prompt.
798///
799/// No-op when memory summary is missing/empty.
800pub fn inject_memory_guidance_into_prompt(prompt: String, store: &MemoryStore) -> String {
801    let Some(block) = load_memory_guidance_block(store) else {
802        return prompt;
803    };
804    format!(
805        "{}
806
807{}",
808        prompt, block
809    )
810}
811
812#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
813pub struct ExtractedMemoryItem {
814    pub title: String,
815    pub markdown: String,
816    #[serde(default)]
817    pub relevance_score: Option<f64>,
818}
819
820#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
821pub struct MemoryExtraction {
822    pub items: Vec<ExtractedMemoryItem>,
823}
824
825pub fn parse_memory_extraction_json(s: &str) -> Result<MemoryExtraction> {
826    serde_json::from_str::<MemoryExtraction>(s)
827        .map_err(|e| PawanError::Parse(format!("Failed to parse memory extraction JSON: {e}")))
828}
829
830pub fn memories_from_extraction(session_id: &str, extraction: MemoryExtraction) -> Vec<Memory> {
831    let now = Utc::now().to_rfc3339();
832    extraction
833        .items
834        .into_iter()
835        .enumerate()
836        .map(|(i, item)| Memory {
837            key: format!("session_{}_extract_{}", session_id, i),
838            content: item.markdown,
839            source_session: session_id.to_string(),
840            created_at: now.clone(),
841            updated_at: now.clone(),
842            relevance_score: item.relevance_score.unwrap_or(1.0),
843        })
844        .collect()
845}
846
847pub fn session_extract_key(session_id: &str) -> String {
848    format!("session_{}_extract", session_id)
849}
850
851pub fn make_session_extract_memory(session_id: &str, markdown: String) -> Memory {
852    let now = Utc::now().to_rfc3339();
853    Memory {
854        key: session_extract_key(session_id),
855        content: markdown,
856        source_session: session_id.to_string(),
857        created_at: now.clone(),
858        updated_at: now,
859        relevance_score: 1.0,
860    }
861}
862
863pub fn now_rfc3339() -> String {
864    Utc::now().to_rfc3339()
865}
866
867pub fn is_empty_or_ws(s: &str) -> bool {
868    s.trim().is_empty()
869}
870
871pub fn ensure_parent_dir(path: &Path) -> Result<()> {
872    if let Some(parent) = path.parent() {
873        fs::create_dir_all(parent)?;
874    }
875    Ok(())
876}
877
878#[cfg(test)]
879mod tests {
880    use super::*;
881    use tempfile::TempDir;
882
883    #[test]
884    fn test_extract_memories_from_synthetic_session_json() {
885        let json = r#"{
886  "items": [
887    { "title": "Decision", "markdown": "- Use flat files for MVP", "relevance_score": 3.0 },
888    { "title": "Pitfall", "markdown": "- Avoid injecting stale memory without repo checks" }
889  ]
890}"#;
891
892        let extraction = parse_memory_extraction_json(json).unwrap();
893        assert_eq!(extraction.items.len(), 2);
894
895        let mems = memories_from_extraction("abc123", extraction);
896        assert_eq!(mems.len(), 2);
897        assert!(mems[0].key.contains("session_abc123_extract_0"));
898        assert_eq!(mems[0].relevance_score, 3.0);
899        assert_eq!(mems[1].relevance_score, 1.0);
900    }
901
902    #[test]
903    fn test_inject_memories_into_new_session_prompt() {
904        let td = TempDir::new().unwrap();
905        let store = MemoryStore::new(td.path().join("memories"));
906
907        let m = Memory {
908            key: "k1".to_string(),
909            content: "- Always run cargo check".to_string(),
910            source_session: "s1".to_string(),
911            created_at: now_rfc3339(),
912            updated_at: now_rfc3339(),
913            relevance_score: 5.0,
914        };
915        store.save(&m).unwrap();
916
917        let top = store.search("cargo", 5).unwrap();
918        store.write_memory_summary_markdown(&top).unwrap();
919
920        let base = "You are pawan.".to_string();
921        let injected = inject_memory_guidance_into_prompt(base, &store);
922        assert!(injected.contains("## Memory Guidance"));
923        assert!(injected.contains("/root/.omp/agent/memories/--tmp--/memory_summary.md"));
924        assert!(injected.contains("Always run cargo check"));
925    }
926
927    #[test]
928    fn test_extract_from_conversation_empty() {
929        let td = TempDir::new().unwrap();
930        let store = MemoryStore::new(td.path().join("memories"));
931        let got = store.extract_from_conversation(&[]).unwrap();
932        assert!(got.is_empty());
933    }
934
935    #[test]
936    fn test_extract_from_conversation_repetition() {
937        let td = TempDir::new().unwrap();
938        let store = MemoryStore::new(td.path().join("memories"));
939        let line = "Always run cargo check before committing changes to the branch";
940        let rep = (0..3u8)
941            .map(|_| Message {
942                role: crate::agent::Role::User,
943                content: line.to_string(),
944                tool_calls: vec![],
945                tool_result: None,
946            })
947            .collect::<Vec<_>>();
948        let mems = store.extract_from_conversation(&rep).unwrap();
949        assert!(!mems.is_empty());
950    }
951
952    #[test]
953    fn test_consolidate_merges_similar_keys() {
954        let td = TempDir::new().unwrap();
955        let store = MemoryStore::new(td.path().join("memories"));
956        let now = now_rfc3339();
957        let a = Memory {
958            key: "my.Feature-A".to_string(),
959            content: "note a".to_string(),
960            source_session: "s".to_string(),
961            created_at: now.clone(),
962            updated_at: now.clone(),
963            relevance_score: 0.5,
964        };
965        let b = Memory {
966            key: "myFeatureA".to_string(),
967            content: "note b".to_string(),
968            source_session: "s".to_string(),
969            created_at: now.clone(),
970            updated_at: now.clone(),
971            relevance_score: 0.9,
972        };
973        store.save(&a).unwrap();
974        store.save(&b).unwrap();
975        assert_eq!(store.list().unwrap().len(), 2);
976        store.consolidate().unwrap();
977        let mems = store.get_relevant("note", 5).unwrap();
978        assert!(!mems.is_empty());
979    }
980
981    #[test]
982    fn test_get_relevant_ordering() {
983        let td = TempDir::new().unwrap();
984        let store = MemoryStore::new(td.path().join("memories"));
985        let now = now_rfc3339();
986        store
987            .save(&Memory {
988                key: "k_alpha".to_string(),
989                content: "alpha beta gamma".to_string(),
990                source_session: "s".to_string(),
991                created_at: now.clone(),
992                updated_at: now.clone(),
993                relevance_score: 0.2,
994            })
995            .unwrap();
996        store
997            .save(&Memory {
998                key: "k_best".to_string(),
999                content: "alpha beta zeta".to_string(),
1000                source_session: "s".to_string(),
1001                created_at: now.clone(),
1002                updated_at: now.clone(),
1003                relevance_score: 1.0,
1004            })
1005            .unwrap();
1006        let out = store.get_relevant("alpha beta", 2).unwrap();
1007        assert_eq!(out.len(), 2);
1008    }
1009
1010    #[test]
1011    fn test_inject_as_context_empty() {
1012        let td = TempDir::new().unwrap();
1013        let store = MemoryStore::new(td.path().join("memories"));
1014        let s = store.inject_as_context("does-not-exist-xyz").unwrap();
1015        assert!(s.is_empty());
1016    }
1017
1018    #[test]
1019    fn test_sanitize_key_removes_unsafe_chars() {
1020        assert_eq!(sanitize_key("hello/world"), "hello_world");
1021        assert_eq!(sanitize_key("test-file.v2"), "test-file.v2");
1022    }
1023
1024    #[test]
1025    fn test_key_to_filename() {
1026        let result = MemoryStore::key_to_filename("Test Key!@#");
1027        assert!(result.contains("Test"));
1028    }
1029
1030    #[test]
1031    fn test_sanitize_key_empty_and_large_inputs() {
1032        assert_eq!(sanitize_key(""), "_");
1033        let big = "a".repeat(50_000);
1034        let s = sanitize_key(&big);
1035        assert_eq!(s.len(), 50_000);
1036    }
1037}