Skip to main content

navi_core/
operational_memory.rs

1//! Structured operational memory entries and retrieval.
2
3use 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}