Skip to main content

nexus_core/app/
memory.rs

1// Casts here are on bounded values: token counts, byte sizes, and
2// selection indices — never on unbounded input. JSON-derived indices in
3// provider/tools go through try_from instead.
4#![allow(
5    clippy::cast_possible_truncation,
6    clippy::cast_possible_wrap,
7    clippy::cast_precision_loss,
8    clippy::cast_sign_loss
9)]
10use super::{App, MemoryOp};
11use crate::db::Message;
12use crate::provider::{ChatMessage, ChatParams};
13use std::fmt::Write as _;
14use tokio::sync::mpsc;
15
16impl App {
17    // --- memory (per-space, extracted after every assistant reply) ---
18
19    /// Raw contents of the active space's memory file, capped to ~120k chars
20    /// (~30k tokens — headroom is cheap on 1M-context models; this just stops
21    /// a runaway file from eating the whole budget).
22    pub fn read_memory(&self) -> String {
23        let text = std::fs::read_to_string(self.space.memory_path(&self.active_space.name))
24            .unwrap_or_default();
25        text.chars().take(120_000).collect()
26    }
27
28    /// Reload the active session's memory snapshot and begin a new prompt-cache
29    /// epoch. New inferred facts normally do not call this while a session is
30    /// active; switching sessions or an explicit refresh does.
31    pub fn refresh_memory_snapshot(&mut self) {
32        self.memory_snapshot = self.read_memory();
33        self.bump_cache_epoch();
34    }
35
36    /// Return the memory text that is stable for the active cache epoch.
37    #[must_use]
38    pub fn memory_snapshot(&self) -> &str {
39        &self.memory_snapshot
40    }
41
42    /// Advance the app-local prompt-cache epoch after a serialized-prefix
43    /// boundary such as a memory or prompt refresh.
44    pub(crate) fn bump_cache_epoch(&mut self) {
45        self.cache_epoch = self.cache_epoch.wrapping_add(1);
46        self.prompt_datetime = chrono::Utc::now()
47            .format("%Y-%m-%d %H:%M UTC, %A")
48            .to_string();
49        // The exact total belongs to the previous serialized prompt/model
50        // lane. Let the next request establish a fresh value rather than
51        // pinning the context bar to an unrelated boundary.
52        self.context_total = None;
53        self.last_cache_rate = None;
54    }
55
56    /// Build a non-sensitive cache-lane key for one session and epoch.
57    #[must_use]
58    pub(crate) fn prompt_cache_key_for(&self, session_id: &str) -> String {
59        format!("{session_id}:{}", self.cache_epoch)
60    }
61
62    /// After an assistant reply, ask the memory model for ADD/UPDATE/DELETE ops
63    /// against the space's fact file. No-op if extraction is disabled or the
64    /// last exchange is unavailable.
65    pub fn maybe_extract_memory(&mut self) {
66        if self.memory_model.trim().is_empty() {
67            return;
68        }
69        let Some((provider, raw_model)) = self.resolve_utility_model_backend(&self.memory_model)
70        else {
71            return;
72        };
73        let Some((user_msg, assistant_msg)) = latest_memory_exchange(&self.messages) else {
74            return;
75        };
76        let facts = self.read_memory();
77        let space = self.active_space.name.clone();
78        let prompt_cache_key = self
79            .session
80            .as_ref()
81            .map(|session| format!("memory:{}", self.prompt_cache_key_for(&session.id)));
82        let (tx, rx) = mpsc::unbounded_channel();
83        self.memory_rx = Some(rx);
84        tokio::spawn(async move {
85            let truncate = |s: &str| s.chars().take(2000).collect::<String>();
86            let prompt = format!(
87                "Stored facts (numbered, may be empty):\n{facts}\n\n\
88                 Latest exchange:\nuser: {}\nassistant: {}\n\n\
89                 Reply with ONLY a JSON array of memory ops, no markdown, no prose. \
90                 Each op is one of:\n\
91                 {{\"op\":\"add\",\"text\":\"<durable single-line fact>\"}}\n\
92                 {{\"op\":\"update\",\"id\":<N>,\"text\":\"<replacement>\"}}\n\
93                 {{\"op\":\"delete\",\"id\":<N>}}\n\
94                 Empty array [] if nothing memory-worthy. Facts must be durable and \
95                 user/project-relevant (preferences, identity, ongoing goals) — never a \
96                 summary of what was just said. Merge/update instead of duplicating. \
97                 Keep the total under 500 facts.",
98                truncate(&user_msg.content),
99                truncate(&assistant_msg.content),
100            );
101            let msgs = vec![ChatMessage::text("user", prompt)];
102            let params = ChatParams {
103                prompt_cache_key,
104                ..ChatParams::default()
105            };
106            if let Ok(completion) = provider
107                .complete_with_params(&raw_model, msgs, &params)
108                .await
109            {
110                let ops = parse_memory_ops(&completion.text);
111                let _ = tx.send((space, ops));
112            }
113        });
114    }
115
116    /// Apply extracted ops to the active space's memory file, if it's still the
117    /// active one (a meanwhile space-switch discards stale results).
118    pub fn on_memory_result(&mut self, result: Option<(String, Vec<MemoryOp>)>) {
119        self.memory_rx = None;
120        let Some((space, ops)) = result else { return };
121        if space != self.active_space.name || ops.is_empty() {
122            return;
123        }
124        // Ids in `ops` refer to the *original* numbering, so resolve updates/
125        // deletes against that fixed list before appending adds — mutating the
126        // vector in place as ops are applied would shift later ids underfoot.
127        let mut updates: std::collections::HashMap<usize, String> =
128            std::collections::HashMap::new();
129        let mut deletes: std::collections::HashSet<usize> = std::collections::HashSet::new();
130        let mut adds: Vec<String> = Vec::new();
131        for op in ops {
132            match op {
133                MemoryOp::Add(text) => adds.push(text),
134                MemoryOp::Update(id, text) => {
135                    updates.insert(id, text);
136                }
137                MemoryOp::Delete(id) => {
138                    deletes.insert(id);
139                }
140            }
141        }
142        let mut facts: Vec<String> = self
143            .read_memory()
144            .lines()
145            .filter_map(parse_fact_line)
146            .map(|(_, text)| text)
147            .enumerate()
148            .filter(|(i, _)| !deletes.contains(&(i + 1)))
149            .map(|(i, text)| updates.remove(&(i + 1)).unwrap_or(text))
150            .collect();
151        facts.extend(adds);
152        let body: String = facts
153            .iter()
154            .enumerate()
155            .fold(String::new(), |mut b, (i, f)| {
156                let _ = writeln!(b, "{}. {f}", i + 1);
157                b
158            });
159        let _ = self.space.ensure_space_dir(&self.active_space.name);
160        let _ = std::fs::write(self.space.memory_path(&self.active_space.name), body);
161        // A blank/new chat has no active run to protect, so make a completed
162        // background write visible to the next request. Active sessions keep
163        // their frozen snapshot until the user switches/restarts them.
164        if self.session.is_none() {
165            self.refresh_memory_snapshot();
166        }
167    }
168}
169
170/// Latest user→assistant exchange worth memory extraction. Tool results are
171/// stored as transcript messages between the user and final assistant answer,
172/// so don't require the final two visible rows to be exactly user/assistant.
173fn latest_memory_exchange(messages: &[Message]) -> Option<(Message, Message)> {
174    let assistant_idx = messages
175        .iter()
176        .rposition(|m| m.role == "assistant" && m.persona.is_none())?;
177    let user_idx = messages[..assistant_idx]
178        .iter()
179        .rposition(|m| m.role == "user")?;
180    Some((messages[user_idx].clone(), messages[assistant_idx].clone()))
181}
182
183/// Parse one numbered fact line (`"3. some fact"`) into `(id, text)`.
184pub fn parse_fact_line(line: &str) -> Option<(usize, String)> {
185    let (num, rest) = line.split_once(". ")?;
186    let id: usize = num.trim().parse().ok()?;
187    Some((id, rest.trim().to_string()))
188}
189
190/// Parse the memory model's reply into a list of ops. Tolerates surrounding
191/// prose/fences by extracting the first `[...]`; malformed or unrecognized
192/// entries are silently skipped rather than failing the whole batch.
193pub fn parse_memory_ops(text: &str) -> Vec<MemoryOp> {
194    let Some(start) = text.find('[') else {
195        return Vec::new();
196    };
197    let Some(end) = text.rfind(']') else {
198        return Vec::new();
199    };
200    let Some(json) = text.get(start..=end) else {
201        return Vec::new();
202    };
203    let Ok(arr) = serde_json::from_str::<serde_json::Value>(json) else {
204        return Vec::new();
205    };
206    let Some(arr) = arr.as_array() else {
207        return Vec::new();
208    };
209    arr.iter()
210        .filter_map(|v| {
211            let op = v.get("op")?.as_str()?;
212            match op {
213                "add" => Some(MemoryOp::Add(v.get("text")?.as_str()?.trim().to_string())),
214                "update" => Some(MemoryOp::Update(
215                    v.get("id")?.as_u64()? as usize,
216                    v.get("text")?.as_str()?.trim().to_string(),
217                )),
218                "delete" => Some(MemoryOp::Delete(v.get("id")?.as_u64()? as usize)),
219                _ => None,
220            }
221        })
222        .collect()
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn msg(role: &str, content: &str) -> Message {
230        Message {
231            role: role.to_string(),
232            content: content.to_string(),
233            model: None,
234            reasoning: None,
235            tokens: None,
236            secs: None,
237            cost: None,
238            phrase: None,
239            persona: None,
240            created_at: None,
241        }
242    }
243
244    #[test]
245    fn latest_memory_exchange_skips_tool_rows_between_user_and_assistant() {
246        let messages = vec![
247            msg("user", "remember I prefer terse answers"),
248            msg("tool_call", "search result"),
249            msg("assistant", "Noted."),
250        ];
251
252        let (user, assistant) = latest_memory_exchange(&messages).unwrap();
253        assert_eq!(user.content, "remember I prefer terse answers");
254        assert_eq!(assistant.content, "Noted.");
255    }
256
257    #[test]
258    fn latest_memory_exchange_ignores_persona_round_replies() {
259        let mut persona = msg("assistant", "persona chatter");
260        persona.persona = Some("Skeptic".to_string());
261        let messages = vec![
262            msg("user", "remember x"),
263            persona,
264            msg("assistant", "final"),
265        ];
266
267        let (_, assistant) = latest_memory_exchange(&messages).unwrap();
268        assert_eq!(assistant.content, "final");
269    }
270}