use std::path::{Path, PathBuf};
use crate::memory::{export_links_notation, parse_links_notation, MemoryEvent};
#[must_use]
pub fn events_since(events: &[MemoryEvent], last_seen: Option<&str>) -> Vec<MemoryEvent> {
let Some(last_seen) = last_seen.filter(|id| !id.is_empty()) else {
return events.to_vec();
};
events
.iter()
.position(|event| event.id == last_seen)
.map_or_else(|| events.to_vec(), |index| events[index + 1..].to_vec())
}
#[must_use]
pub fn merge_union_by_id(base: &[MemoryEvent], incoming: &[MemoryEvent]) -> Vec<MemoryEvent> {
let mut merged: Vec<MemoryEvent> = base.to_vec();
for event in incoming {
match merged.iter_mut().find(|existing| existing.id == event.id) {
Some(existing) => *existing = merge_event(existing, event),
None => merged.push(event.clone()),
}
}
merged
}
#[must_use]
pub fn merge_event(base: &MemoryEvent, incoming: &MemoryEvent) -> MemoryEvent {
fn pick(base: Option<&String>, incoming: Option<&String>) -> Option<String> {
match incoming {
Some(value) if !value.is_empty() => Some(value.clone()),
_ => base.cloned(),
}
}
let evidence = if incoming.evidence.is_empty() {
base.evidence.clone()
} else {
incoming.evidence.clone()
};
MemoryEvent {
id: base.id.clone(),
kind: pick(base.kind.as_ref(), incoming.kind.as_ref()),
role: pick(base.role.as_ref(), incoming.role.as_ref()),
intent: pick(base.intent.as_ref(), incoming.intent.as_ref()),
tool: pick(base.tool.as_ref(), incoming.tool.as_ref()),
inputs: pick(base.inputs.as_ref(), incoming.inputs.as_ref()),
outputs: pick(base.outputs.as_ref(), incoming.outputs.as_ref()),
content: pick(base.content.as_ref(), incoming.content.as_ref()),
sent_at: pick(base.sent_at.as_ref(), incoming.sent_at.as_ref()),
demo_label: pick(base.demo_label.as_ref(), incoming.demo_label.as_ref()),
conversation_id: pick(
base.conversation_id.as_ref(),
incoming.conversation_id.as_ref(),
),
conversation_title: pick(
base.conversation_title.as_ref(),
incoming.conversation_title.as_ref(),
),
evidence,
}
}
#[must_use]
pub fn configured_memory_path() -> Option<PathBuf> {
std::env::var("FORMAL_AI_MEMORY_PATH")
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
#[derive(Debug, Clone, Default)]
pub struct SyncStore {
path: Option<PathBuf>,
events: Vec<MemoryEvent>,
}
impl SyncStore {
#[must_use]
pub fn open() -> Self {
configured_memory_path().map_or_else(Self::default, |path| Self::open_at(&path))
}
#[must_use]
pub fn open_at(path: &Path) -> Self {
let events = std::fs::read_to_string(path)
.map(|text| parse_links_notation(&text))
.unwrap_or_default();
Self {
path: Some(path.to_path_buf()),
events,
}
}
#[must_use]
pub fn events(&self) -> &[MemoryEvent] {
&self.events
}
#[must_use]
pub fn to_links_notation(&self) -> String {
export_links_notation(&self.events)
}
#[must_use]
pub fn delta_links_notation(&self, last_seen: Option<&str>) -> String {
export_links_notation(&events_since(&self.events, last_seen))
}
pub fn import_links_notation(&mut self, text: &str) -> std::io::Result<usize> {
let incoming = parse_links_notation(text);
let before = self.events.len();
self.events = merge_union_by_id(&self.events, &incoming);
let added = self.events.len() - before;
self.persist()?;
Ok(added)
}
fn persist(&self) -> std::io::Result<()> {
let Some(path) = self.path.as_ref() else {
return Ok(());
};
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(path, self.to_links_notation())
}
}