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, ChatParams};
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 refresh_memory_snapshot(&mut self) {
32 self.memory_snapshot = self.read_memory();
33 self.bump_cache_epoch();
34 }
35
36 #[must_use]
38 pub fn memory_snapshot(&self) -> &str {
39 &self.memory_snapshot
40 }
41
42 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 self.context_total = None;
53 self.last_cache_rate = None;
54 }
55
56 #[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 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, ¶ms)
108 .await
109 {
110 let ops = parse_memory_ops(&completion.text);
111 let _ = tx.send((space, ops));
112 }
113 });
114 }
115
116 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 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 if self.session.is_none() {
165 self.refresh_memory_snapshot();
166 }
167 }
168}
169
170fn 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
183pub 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
190pub 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}