1use std::path::{Path, PathBuf};
22
23use crate::memory::{export_links_notation, parse_links_notation, MemoryEvent};
24
25#[must_use]
34pub fn events_since(events: &[MemoryEvent], last_seen: Option<&str>) -> Vec<MemoryEvent> {
35 let Some(last_seen) = last_seen.filter(|id| !id.is_empty()) else {
36 return events.to_vec();
37 };
38 events
39 .iter()
40 .position(|event| event.id == last_seen)
41 .map_or_else(|| events.to_vec(), |index| events[index + 1..].to_vec())
42}
43
44#[must_use]
51pub fn merge_union_by_id(base: &[MemoryEvent], incoming: &[MemoryEvent]) -> Vec<MemoryEvent> {
52 let mut merged: Vec<MemoryEvent> = base.to_vec();
53 for event in incoming {
54 match merged.iter_mut().find(|existing| existing.id == event.id) {
55 Some(existing) => *existing = merge_event(existing, event),
56 None => merged.push(event.clone()),
57 }
58 }
59 merged
60}
61
62#[must_use]
68pub fn merge_event(base: &MemoryEvent, incoming: &MemoryEvent) -> MemoryEvent {
69 fn pick(base: Option<&String>, incoming: Option<&String>) -> Option<String> {
70 match incoming {
71 Some(value) if !value.is_empty() => Some(value.clone()),
72 _ => base.cloned(),
73 }
74 }
75 let evidence = if incoming.evidence.is_empty() {
76 base.evidence.clone()
77 } else {
78 incoming.evidence.clone()
79 };
80 let payload_changed = [
81 (&base.kind, &incoming.kind),
82 (&base.role, &incoming.role),
83 (&base.intent, &incoming.intent),
84 (&base.tool, &incoming.tool),
85 (&base.inputs, &incoming.inputs),
86 (&base.outputs, &incoming.outputs),
87 (&base.content, &incoming.content),
88 (&base.sent_at, &incoming.sent_at),
89 (&base.demo_label, &incoming.demo_label),
90 (&base.conversation_id, &incoming.conversation_id),
91 (&base.conversation_title, &incoming.conversation_title),
92 ]
93 .iter()
94 .any(|(left, right)| right.as_ref().is_some_and(|value| !value.is_empty()) && left != right)
95 || (!incoming.evidence.is_empty() && base.evidence != incoming.evidence);
96 let observed_writes = base.write_count.max(1).max(incoming.write_count.max(1));
97 let write_count = if payload_changed && incoming.write_count <= base.write_count {
98 observed_writes.saturating_add(1)
99 } else {
100 observed_writes
101 };
102 MemoryEvent {
103 id: base.id.clone(),
104 kind: pick(base.kind.as_ref(), incoming.kind.as_ref()),
105 role: pick(base.role.as_ref(), incoming.role.as_ref()),
106 intent: pick(base.intent.as_ref(), incoming.intent.as_ref()),
107 tool: pick(base.tool.as_ref(), incoming.tool.as_ref()),
108 inputs: pick(base.inputs.as_ref(), incoming.inputs.as_ref()),
109 outputs: pick(base.outputs.as_ref(), incoming.outputs.as_ref()),
110 content: pick(base.content.as_ref(), incoming.content.as_ref()),
111 sent_at: pick(base.sent_at.as_ref(), incoming.sent_at.as_ref()),
112 demo_label: pick(base.demo_label.as_ref(), incoming.demo_label.as_ref()),
113 conversation_id: pick(
114 base.conversation_id.as_ref(),
115 incoming.conversation_id.as_ref(),
116 ),
117 conversation_title: pick(
118 base.conversation_title.as_ref(),
119 incoming.conversation_title.as_ref(),
120 ),
121 evidence,
122 access_count: base.access_count.max(incoming.access_count),
125 write_count,
128 }
129}
130
131#[must_use]
136pub fn configured_memory_path() -> Option<PathBuf> {
137 Some(crate::shared_memory::shared_memory_path())
138}
139
140#[must_use]
142pub fn chat_recording_enabled() -> bool {
143 !matches!(
144 std::env::var("FORMAL_AI_RECORD_CHAT").as_deref(),
145 Ok("0" | "false" | "off")
146 )
147}
148
149#[derive(Debug, Clone, Default)]
154pub struct SyncStore {
155 path: Option<PathBuf>,
156 events: Vec<MemoryEvent>,
157}
158
159#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct RecordedToolExecution {
162 pub tool: String,
163 pub inputs: String,
164 pub outputs: String,
165}
166
167impl SyncStore {
168 #[must_use]
170 pub fn open() -> Self {
171 configured_memory_path().map_or_else(Self::default, |path| Self::open_at(&path))
172 }
173
174 #[must_use]
176 pub fn open_at(path: &Path) -> Self {
177 if let Err(error) = crate::shared_memory::ensure_shared_memory_file(path) {
178 if std::env::var("FORMAL_AI_MEMORY_DEBUG").as_deref() == Ok("1") {
179 eprintln!("[memory] could not initialize {}: {error}", path.display());
180 }
181 }
182 let events = std::fs::read_to_string(path)
183 .map(|text| parse_links_notation(&text))
184 .unwrap_or_default();
185 Self {
186 path: Some(path.to_path_buf()),
187 events,
188 }
189 }
190
191 #[must_use]
193 pub fn events(&self) -> &[MemoryEvent] {
194 &self.events
195 }
196
197 #[must_use]
199 pub fn to_links_notation(&self) -> String {
200 export_links_notation(&self.events)
201 }
202
203 #[must_use]
206 pub fn delta_links_notation(&self, last_seen: Option<&str>) -> String {
207 export_links_notation(&events_since(&self.events, last_seen))
208 }
209
210 pub fn import_links_notation(&mut self, text: &str) -> std::io::Result<usize> {
216 let incoming = parse_links_notation(text);
217 let before = self.events.len();
218 self.events = merge_union_by_id(&self.events, &incoming);
219 let added = self.events.len() - before;
220 if let Some(path) = self.path.as_deref() {
221 let mut memory =
222 crate::memory::MemoryStore::from_events(std::mem::take(&mut self.events));
223 let _ = crate::storage_policy::apply_auto_free_space_for_write(
224 &mut memory,
225 path,
226 u64::try_from(text.len()).unwrap_or(u64::MAX),
227 )?;
228 self.events = memory.events().to_vec();
229 }
230 self.persist()?;
231 Ok(added)
232 }
233
234 pub fn record_chat_exchange(&mut self, prompt: &str, answer: &str) -> std::io::Result<usize> {
244 self.record_chat_exchange_with_tools(prompt, answer, &[])
245 }
246
247 pub fn record_chat_exchange_with_tools(
256 &mut self,
257 prompt: &str,
258 answer: &str,
259 tools: &[RecordedToolExecution],
260 ) -> std::io::Result<usize> {
261 if self.path.is_none() || !chat_recording_enabled() {
262 return Ok(0);
263 }
264 let seed = format!("{prompt}\0{answer}");
265 let user_id = crate::engine::stable_id("chat_user", &seed);
266 let mut recorded = vec![MemoryEvent {
267 id: user_id.clone(),
268 kind: Some(String::from("message")),
269 role: Some(String::from("user")),
270 content: Some(prompt.to_owned()),
271 write_count: 1,
272 ..MemoryEvent::default()
273 }];
274 let mut evidence = vec![user_id.clone()];
275 for execution in tools {
276 let tool_seed = format!(
277 "{prompt}\0{}\0{}\0{}",
278 execution.tool, execution.inputs, execution.outputs
279 );
280 let id = crate::engine::stable_id("chat_tool", &tool_seed);
281 evidence.push(id.clone());
282 recorded.push(MemoryEvent {
283 id,
284 kind: Some(String::from("tool_call")),
285 role: Some(String::from("assistant")),
286 intent: Some(String::from("execute_tool")),
287 tool: Some(execution.tool.clone()),
288 inputs: Some(execution.inputs.clone()),
289 outputs: Some(execution.outputs.clone()),
290 content: Some(format!("tool:{}", execution.tool)),
291 evidence: vec![user_id.clone()],
292 write_count: 1,
293 ..MemoryEvent::default()
294 });
295 }
296 recorded.push(MemoryEvent {
297 id: crate::engine::stable_id("chat_task", &seed),
298 kind: Some(String::from("task")),
299 role: Some(String::from("assistant")),
300 intent: Some(String::from("solve")),
301 inputs: Some(prompt.to_owned()),
302 outputs: Some(answer.to_owned()),
303 evidence,
304 write_count: 1,
305 ..MemoryEvent::default()
306 });
307 let before = self.events.len();
308 self.events = merge_union_by_id(&self.events, &recorded);
309 let added = self.events.len() - before;
310 if added > 0 {
311 self.persist()?;
312 }
313 Ok(added)
314 }
315
316 fn persist(&self) -> std::io::Result<()> {
317 let Some(path) = self.path.as_ref() else {
318 return Ok(());
319 };
320 crate::memory::write_locked_atomic(path, &self.to_links_notation())
323 }
324}