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;
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    /// After an assistant reply, ask the memory model for ADD/UPDATE/DELETE ops
29    /// against the space's fact file. No-op if extraction is disabled or the
30    /// last exchange is unavailable.
31    pub fn maybe_extract_memory(&mut self) {
32        if self.memory_model.trim().is_empty() {
33            return;
34        }
35        let Some((provider, raw_model)) = self.resolve_utility_model_backend(&self.memory_model)
36        else {
37            return;
38        };
39        let Some((user_msg, assistant_msg)) = latest_memory_exchange(&self.messages) else {
40            return;
41        };
42        let facts = self.read_memory();
43        let space = self.active_space.name.clone();
44        let (tx, rx) = mpsc::unbounded_channel();
45        self.memory_rx = Some(rx);
46        tokio::spawn(async move {
47            let truncate = |s: &str| s.chars().take(2000).collect::<String>();
48            let prompt = format!(
49                "Stored facts (numbered, may be empty):\n{facts}\n\n\
50                 Latest exchange:\nuser: {}\nassistant: {}\n\n\
51                 Reply with ONLY a JSON array of memory ops, no markdown, no prose. \
52                 Each op is one of:\n\
53                 {{\"op\":\"add\",\"text\":\"<durable single-line fact>\"}}\n\
54                 {{\"op\":\"update\",\"id\":<N>,\"text\":\"<replacement>\"}}\n\
55                 {{\"op\":\"delete\",\"id\":<N>}}\n\
56                 Empty array [] if nothing memory-worthy. Facts must be durable and \
57                 user/project-relevant (preferences, identity, ongoing goals) — never a \
58                 summary of what was just said. Merge/update instead of duplicating. \
59                 Keep the total under 500 facts.",
60                truncate(&user_msg.content),
61                truncate(&assistant_msg.content),
62            );
63            let msgs = vec![ChatMessage::text("user", prompt)];
64            if let Ok(text) = provider.complete(&raw_model, msgs).await {
65                let ops = parse_memory_ops(&text);
66                let _ = tx.send((space, ops));
67            }
68        });
69    }
70
71    /// Apply extracted ops to the active space's memory file, if it's still the
72    /// active one (a meanwhile space-switch discards stale results).
73    pub fn on_memory_result(&mut self, result: Option<(String, Vec<MemoryOp>)>) {
74        self.memory_rx = None;
75        let Some((space, ops)) = result else { return };
76        if space != self.active_space.name || ops.is_empty() {
77            return;
78        }
79        // Ids in `ops` refer to the *original* numbering, so resolve updates/
80        // deletes against that fixed list before appending adds — mutating the
81        // vector in place as ops are applied would shift later ids underfoot.
82        let mut updates: std::collections::HashMap<usize, String> =
83            std::collections::HashMap::new();
84        let mut deletes: std::collections::HashSet<usize> = std::collections::HashSet::new();
85        let mut adds: Vec<String> = Vec::new();
86        for op in ops {
87            match op {
88                MemoryOp::Add(text) => adds.push(text),
89                MemoryOp::Update(id, text) => {
90                    updates.insert(id, text);
91                }
92                MemoryOp::Delete(id) => {
93                    deletes.insert(id);
94                }
95            }
96        }
97        let mut facts: Vec<String> = self
98            .read_memory()
99            .lines()
100            .filter_map(parse_fact_line)
101            .map(|(_, text)| text)
102            .enumerate()
103            .filter(|(i, _)| !deletes.contains(&(i + 1)))
104            .map(|(i, text)| updates.remove(&(i + 1)).unwrap_or(text))
105            .collect();
106        facts.extend(adds);
107        let body: String = facts
108            .iter()
109            .enumerate()
110            .fold(String::new(), |mut b, (i, f)| {
111                let _ = writeln!(b, "{}. {f}", i + 1);
112                b
113            });
114        let _ = self.space.ensure_space_dir(&self.active_space.name);
115        let _ = std::fs::write(self.space.memory_path(&self.active_space.name), body);
116    }
117}
118
119/// Latest user→assistant exchange worth memory extraction. Tool results are
120/// stored as transcript messages between the user and final assistant answer,
121/// so don't require the final two visible rows to be exactly user/assistant.
122fn latest_memory_exchange(messages: &[Message]) -> Option<(Message, Message)> {
123    let assistant_idx = messages
124        .iter()
125        .rposition(|m| m.role == "assistant" && m.persona.is_none())?;
126    let user_idx = messages[..assistant_idx]
127        .iter()
128        .rposition(|m| m.role == "user")?;
129    Some((messages[user_idx].clone(), messages[assistant_idx].clone()))
130}
131
132/// Parse one numbered fact line (`"3. some fact"`) into `(id, text)`.
133pub fn parse_fact_line(line: &str) -> Option<(usize, String)> {
134    let (num, rest) = line.split_once(". ")?;
135    let id: usize = num.trim().parse().ok()?;
136    Some((id, rest.trim().to_string()))
137}
138
139/// Parse the memory model's reply into a list of ops. Tolerates surrounding
140/// prose/fences by extracting the first `[...]`; malformed or unrecognized
141/// entries are silently skipped rather than failing the whole batch.
142pub fn parse_memory_ops(text: &str) -> Vec<MemoryOp> {
143    let Some(start) = text.find('[') else {
144        return Vec::new();
145    };
146    let Some(end) = text.rfind(']') else {
147        return Vec::new();
148    };
149    let Some(json) = text.get(start..=end) else {
150        return Vec::new();
151    };
152    let Ok(arr) = serde_json::from_str::<serde_json::Value>(json) else {
153        return Vec::new();
154    };
155    let Some(arr) = arr.as_array() else {
156        return Vec::new();
157    };
158    arr.iter()
159        .filter_map(|v| {
160            let op = v.get("op")?.as_str()?;
161            match op {
162                "add" => Some(MemoryOp::Add(v.get("text")?.as_str()?.trim().to_string())),
163                "update" => Some(MemoryOp::Update(
164                    v.get("id")?.as_u64()? as usize,
165                    v.get("text")?.as_str()?.trim().to_string(),
166                )),
167                "delete" => Some(MemoryOp::Delete(v.get("id")?.as_u64()? as usize)),
168                _ => None,
169            }
170        })
171        .collect()
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn msg(role: &str, content: &str) -> Message {
179        Message {
180            role: role.to_string(),
181            content: content.to_string(),
182            model: None,
183            reasoning: None,
184            tokens: None,
185            secs: None,
186            cost: None,
187            phrase: None,
188            persona: None,
189            created_at: None,
190        }
191    }
192
193    #[test]
194    fn latest_memory_exchange_skips_tool_rows_between_user_and_assistant() {
195        let messages = vec![
196            msg("user", "remember I prefer terse answers"),
197            msg("tool_call", "search result"),
198            msg("assistant", "Noted."),
199        ];
200
201        let (user, assistant) = latest_memory_exchange(&messages).unwrap();
202        assert_eq!(user.content, "remember I prefer terse answers");
203        assert_eq!(assistant.content, "Noted.");
204    }
205
206    #[test]
207    fn latest_memory_exchange_ignores_persona_round_replies() {
208        let mut persona = msg("assistant", "persona chatter");
209        persona.persona = Some("Skeptic".to_string());
210        let messages = vec![
211            msg("user", "remember x"),
212            persona,
213            msg("assistant", "final"),
214        ];
215
216        let (_, assistant) = latest_memory_exchange(&messages).unwrap();
217        assert_eq!(assistant.content, "final");
218    }
219}