1use 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 pub key: String,
19 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 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 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 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 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
576pub 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}
777pub 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
797pub 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}