1#![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 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 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 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 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
119fn 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
132pub 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
139pub 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}