navi_core/
operational_memory.rs1use crate::security::redact_secrets;
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6
7#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
8pub struct OperationalMemoryEntry {
9 pub id: String,
10 pub scope: MemoryScope,
11 pub text: String,
12 pub source_trace: Option<String>,
13 pub confidence: f64,
14 pub expires_at_ms: Option<u64>,
15 pub verifier_evidence: Vec<String>,
16 pub owner: Option<String>,
17 pub files: Vec<PathBuf>,
18 pub tags: Vec<String>,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum MemoryScope {
24 Session,
25 Project,
26 UserTeam,
27 Procedural,
28}
29
30#[derive(Debug, Default, Clone, Serialize, Deserialize)]
31pub struct OperationalMemoryStore {
32 entries: Vec<OperationalMemoryEntry>,
33}
34
35impl OperationalMemoryStore {
36 pub fn insert(&mut self, mut entry: OperationalMemoryEntry) {
37 entry.text = redact_secrets(&entry.text);
38 self.entries.retain(|existing| existing.id != entry.id);
39 self.entries.push(entry);
40 }
41
42 pub fn retrieve(
43 &self,
44 task: &str,
45 files: &[PathBuf],
46 now_ms: u64,
47 budget_bytes: usize,
48 ) -> Vec<OperationalMemoryEntry> {
49 let task = task.to_lowercase();
50 let mut scored = self
51 .entries
52 .iter()
53 .filter(|entry| {
54 entry
55 .expires_at_ms
56 .map(|expires| now_ms <= expires)
57 .unwrap_or(true)
58 })
59 .map(|entry| (memory_score(entry, &task, files), entry))
60 .filter(|(score, _)| *score > 0.0)
61 .collect::<Vec<_>>();
62 scored.sort_by(|(left_score, left), (right_score, right)| {
63 right_score
64 .partial_cmp(left_score)
65 .unwrap_or(std::cmp::Ordering::Equal)
66 .then_with(|| left.id.cmp(&right.id))
67 });
68
69 let mut used = 0usize;
70 let mut out = Vec::new();
71 for (_, entry) in scored {
72 let bytes = entry.text.len();
73 if used.saturating_add(bytes) > budget_bytes {
74 continue;
75 }
76 used += bytes;
77 out.push(entry.clone());
78 }
79 out
80 }
81
82 pub fn render_prompt_context(
83 &self,
84 task: &str,
85 files: &[PathBuf],
86 now_ms: u64,
87 budget_bytes: usize,
88 ) -> String {
89 self.retrieve(task, files, now_ms, budget_bytes)
90 .into_iter()
91 .map(|entry| format!("- [{}] {}", scope_label(&entry.scope), entry.text))
92 .collect::<Vec<_>>()
93 .join("\n")
94 }
95
96 pub fn entries(&self) -> &[OperationalMemoryEntry] {
97 &self.entries
98 }
99}
100
101fn memory_score(entry: &OperationalMemoryEntry, task: &str, files: &[PathBuf]) -> f64 {
102 let mut score = entry.confidence.max(0.0);
103 let text = entry.text.to_lowercase();
104 for term in task.split_whitespace() {
105 if text.contains(term) || entry.tags.iter().any(|tag| tag.contains(term)) {
106 score += 1.0;
107 }
108 }
109 for file in files {
110 if entry.files.iter().any(|candidate| candidate == file) {
111 score += 2.0;
112 }
113 }
114 if !entry.verifier_evidence.is_empty() {
115 score += 1.0;
116 }
117 score
118}
119
120fn scope_label(scope: &MemoryScope) -> &'static str {
121 match scope {
122 MemoryScope::Session => "session",
123 MemoryScope::Project => "project",
124 MemoryScope::UserTeam => "team",
125 MemoryScope::Procedural => "procedure",
126 }
127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 fn entry(id: &str, text: &str) -> OperationalMemoryEntry {
134 OperationalMemoryEntry {
135 id: id.to_string(),
136 scope: MemoryScope::Procedural,
137 text: text.to_string(),
138 source_trace: None,
139 confidence: 1.0,
140 expires_at_ms: None,
141 verifier_evidence: vec!["just test".to_string()],
142 owner: None,
143 files: vec![PathBuf::from("src/lib.rs")],
144 tags: vec!["rust".to_string()],
145 }
146 }
147
148 #[test]
149 fn retrieves_by_task_file_and_budget() {
150 let mut store = OperationalMemoryStore::default();
151 store.insert(entry(
152 "a",
153 "Run just test-crate navi-core for Rust core changes",
154 ));
155 store.insert(entry("b", "Unrelated note"));
156
157 let found = store.retrieve("rust core", &[PathBuf::from("src/lib.rs")], 0, 200);
158
159 assert_eq!(found[0].id, "a");
160 }
161
162 #[test]
163 fn redacts_secret_like_memory_text() {
164 let mut store = OperationalMemoryStore::default();
165 store.insert(entry("secret", "token = sk-12345678901234567890"));
166
167 assert!(!store.entries()[0].text.contains("sk-123"));
168 }
169
170 #[test]
171 fn injection_budget_skips_large_entries_instead_of_stopping() {
172 let mut store = OperationalMemoryStore::default();
173 store.insert(entry("large", "rust ".repeat(200).as_str()));
174 store.insert(entry(
175 "small",
176 "rust workflow: run just test-crate navi-core",
177 ));
178
179 let rendered = store.render_prompt_context("rust", &[], 0, 80);
180
181 assert!(rendered.contains("rust workflow"));
182 assert!(!rendered.contains("rust rust rust rust rust rust"));
183 }
184}