1use std::collections::BTreeSet;
39use std::fs;
40use std::io;
41use std::path::Path;
42
43pub mod bundle;
44
45pub use bundle::{
46 export_bundle, export_full_memory, extract_memory_from_bundle, import_full_memory,
47 seed_cache_events, suggest_migrations, BundleInfo, ParsedBundle,
48};
49
50pub(crate) const ROOT_HEADER: &str = "demo_memory";
51pub(crate) const BUNDLE_HEADER: &str = "formal_ai_bundle";
52
53#[derive(Debug, Default, Clone, PartialEq, Eq)]
59pub struct MemoryEvent {
60 pub id: String,
61 pub kind: Option<String>,
62 pub role: Option<String>,
63 pub intent: Option<String>,
64 pub tool: Option<String>,
65 pub inputs: Option<String>,
66 pub outputs: Option<String>,
67 pub content: Option<String>,
68 pub sent_at: Option<String>,
69 pub demo_label: Option<String>,
70 pub conversation_id: Option<String>,
71 pub conversation_title: Option<String>,
72 pub evidence: Vec<String>,
73 pub access_count: u64,
80 pub write_count: u64,
87}
88
89impl MemoryEvent {
90 #[must_use]
91 pub fn user(content: impl Into<String>) -> Self {
92 Self {
93 role: Some(String::from("user")),
94 content: Some(content.into()),
95 ..Self::default()
96 }
97 }
98
99 #[must_use]
100 pub fn assistant(content: impl Into<String>) -> Self {
101 Self {
102 role: Some(String::from("assistant")),
103 content: Some(content.into()),
104 ..Self::default()
105 }
106 }
107}
108
109#[derive(Debug, Default, Clone)]
112pub struct MemoryStore {
113 events: Vec<MemoryEvent>,
114}
115
116impl MemoryStore {
117 #[must_use]
118 pub const fn new() -> Self {
119 Self { events: Vec::new() }
120 }
121
122 #[must_use]
125 pub fn from_events(mut events: Vec<MemoryEvent>) -> Self {
126 for event in &mut events {
127 initialize_write_count(event);
128 }
129 Self { events }
130 }
131
132 pub fn append(&mut self, mut event: MemoryEvent) {
133 initialize_write_count(&mut event);
134 self.events.push(event);
135 }
136
137 pub fn import(&mut self, other: &[MemoryEvent]) -> usize {
140 let initial = self.events.len();
141 self.events.extend(other.iter().cloned().map(|mut event| {
142 initialize_write_count(&mut event);
143 event
144 }));
145 self.events.len() - initial
146 }
147
148 pub fn purge_deleted_conversations(&mut self) -> usize {
151 let deleted_ids: BTreeSet<String> = self
152 .events
153 .iter()
154 .filter(|event| event.kind.as_deref() == Some("conversation_deleted"))
155 .filter_map(|event| event.conversation_id.as_deref())
156 .map(ToOwned::to_owned)
157 .collect();
158 if deleted_ids.is_empty() {
159 return 0;
160 }
161 let initial = self.events.len();
162 self.events.retain(|event| {
163 event
164 .conversation_id
165 .as_deref()
166 .is_none_or(|id| !deleted_ids.contains(id))
167 });
168 initial - self.events.len()
169 }
170
171 pub fn purge_conversation(&mut self, conversation_id: &str) -> usize {
173 if conversation_id.is_empty() {
174 return 0;
175 }
176 let initial = self.events.len();
177 self.events
178 .retain(|event| event.conversation_id.as_deref() != Some(conversation_id));
179 initial - self.events.len()
180 }
181
182 pub fn reset(&mut self) -> usize {
184 let initial = self.events.len();
185 self.events.clear();
186 initial
187 }
188
189 #[must_use]
190 pub fn events(&self) -> &[MemoryEvent] {
191 &self.events
192 }
193
194 pub fn record_access(&mut self, indices: &[usize]) -> usize {
198 let mut counted = 0;
199 for &index in indices {
200 if let Some(event) = self.events.get_mut(index) {
201 event.access_count = event.access_count.saturating_add(1);
202 counted += 1;
203 }
204 }
205 counted
206 }
207
208 pub fn apply_substitution(&mut self, old: &str, new: &str) -> usize {
221 if old.is_empty() {
222 return 0;
223 }
224 let mut replacements = 0;
225 for event in &mut self.events {
226 let before = replacements;
227 for value in [
228 &mut event.content,
229 &mut event.inputs,
230 &mut event.outputs,
231 &mut event.conversation_title,
232 &mut event.demo_label,
233 ]
234 .into_iter()
235 .flatten()
236 {
237 replacements += replace_counting(value, old, new);
238 }
239 for entry in &mut event.evidence {
240 replacements += replace_counting(entry, old, new);
241 }
242 if replacements > before {
243 initialize_write_count(event);
244 event.write_count = event.write_count.saturating_add(1);
245 }
246 }
247 replacements
248 }
249
250 #[must_use]
251 pub const fn len(&self) -> usize {
252 self.events.len()
253 }
254
255 #[must_use]
256 pub const fn is_empty(&self) -> bool {
257 self.events.is_empty()
258 }
259
260 #[must_use]
263 pub fn export_links_notation(&self) -> String {
264 export_links_notation(&self.events)
265 }
266
267 pub fn replace_from_links_notation(&mut self, text: &str) {
269 self.events = parse_links_notation(text);
270 }
271
272 pub fn import_links_notation(&mut self, text: &str) -> usize {
275 let parsed = parse_links_notation(text);
276 self.import(&parsed)
277 }
278
279 pub fn load_from_file<P: AsRef<Path>>(path: P) -> io::Result<Self> {
281 let path = path.as_ref();
282 if !path.exists() {
283 return Ok(Self::new());
284 }
285 let text = fs::read_to_string(path)?;
286 Ok(Self::from_events(import_full_memory(&text).events))
287 }
288
289 pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
294 write_locked_atomic(path.as_ref(), &self.export_links_notation())
295 }
296}
297
298pub fn write_locked_atomic(path: &Path, contents: &str) -> io::Result<()> {
309 use fs2::FileExt as _;
310 if let Some(parent) = path.parent() {
311 if !parent.as_os_str().is_empty() {
312 fs::create_dir_all(parent)?;
313 }
314 }
315 let file_name = path
316 .file_name()
317 .and_then(|name| name.to_str())
318 .unwrap_or("memory.lino");
319 let lock_path = path.with_file_name(format!("{file_name}.lock"));
320 let lock_file = fs::OpenOptions::new()
321 .create(true)
322 .truncate(false)
323 .write(true)
324 .open(&lock_path)?;
325 lock_file.lock_exclusive()?;
326 let temp_path = path.with_file_name(format!("{file_name}.tmp.{}", std::process::id()));
327 let result = fs::write(&temp_path, contents).and_then(|()| fs::rename(&temp_path, path));
328 if result.is_err() {
329 let _ = fs::remove_file(&temp_path);
330 }
331 let _ = fs2::FileExt::unlock(&lock_file);
332 result
333}
334
335fn replace_counting(value: &mut String, old: &str, new: &str) -> usize {
338 if old.is_empty() || !value.contains(old) {
339 return 0;
340 }
341 let count = value.matches(old).count();
342 *value = value.replace(old, new);
343 count
344}
345
346const fn initialize_write_count(event: &mut MemoryEvent) {
347 if event.write_count == 0 {
348 event.write_count = 1;
349 }
350}
351
352#[must_use]
354pub fn export_links_notation(events: &[MemoryEvent]) -> String {
355 let mut out = String::from(ROOT_HEADER);
356 out.push('\n');
357 for event in events {
358 format_event_into(event, &mut out);
359 }
360 out
361}
362
363pub(crate) fn format_event_into(event: &MemoryEvent, out: &mut String) {
364 out.push_str(" event \"");
365 out.push_str(&escape_value(&event.id));
366 out.push_str("\"\n");
367 let pairs: [(&str, Option<&str>); 11] = [
368 ("kind", event.kind.as_deref()),
369 ("role", event.role.as_deref()),
370 ("intent", event.intent.as_deref()),
371 ("tool", event.tool.as_deref()),
372 ("inputs", event.inputs.as_deref()),
373 ("outputs", event.outputs.as_deref()),
374 ("content", event.content.as_deref()),
375 ("sentAt", event.sent_at.as_deref()),
376 ("demoLabel", event.demo_label.as_deref()),
377 ("conversationId", event.conversation_id.as_deref()),
378 ("conversationTitle", event.conversation_title.as_deref()),
379 ];
380 for (key, value) in pairs {
381 let Some(value) = value else { continue };
382 if value.is_empty() {
383 continue;
384 }
385 out.push_str(" ");
386 out.push_str(key);
387 out.push_str(" \"");
388 out.push_str(&escape_value(value));
389 out.push_str("\"\n");
390 }
391 if !event.evidence.is_empty() {
392 let joined = event.evidence.join("|");
393 out.push_str(" evidence \"");
394 out.push_str(&escape_value(&joined));
395 out.push_str("\"\n");
396 }
397 if event.access_count > 0 {
398 out.push_str(" accessCount \"");
399 out.push_str(&event.access_count.to_string());
400 out.push_str("\"\n");
401 }
402 out.push_str(" writeCount \"");
403 out.push_str(&event.write_count.max(1).to_string());
404 out.push_str("\"\n");
405}
406
407#[must_use]
413pub fn parse_links_notation(text: &str) -> Vec<MemoryEvent> {
414 let mut events = Vec::new();
415 let mut current: Option<MemoryEvent> = None;
416 let mut saw_header = false;
417 for line in text.lines() {
418 let trimmed = line.trim_end();
419 if trimmed.is_empty() {
420 continue;
421 }
422 let indent = line.chars().take_while(|c| *c == ' ').count();
423 let content = &line[indent..];
424 if indent == 0 {
425 if content == ROOT_HEADER {
426 saw_header = true;
427 }
428 continue;
429 }
430 if !saw_header {
431 continue;
432 }
433 if indent == 2 {
434 if let Some(name) = content.strip_prefix("event ") {
435 if let Some(existing) = current.take() {
436 events.push(existing);
437 }
438 let id = parse_quoted(name).unwrap_or_default();
439 current = Some(MemoryEvent {
440 id,
441 ..MemoryEvent::default()
442 });
443 }
444 continue;
445 }
446 if indent == 4 {
447 let Some(current) = current.as_mut() else {
448 continue;
449 };
450 let Some((key, rest)) = split_first_token(content) else {
451 continue;
452 };
453 let Some(value) = parse_quoted(rest) else {
454 continue;
455 };
456 match key {
457 "kind" => current.kind = Some(value),
458 "role" => current.role = Some(value),
459 "intent" => current.intent = Some(value),
460 "tool" => current.tool = Some(value),
461 "inputs" => current.inputs = Some(value),
462 "outputs" => current.outputs = Some(value),
463 "content" => current.content = Some(value),
464 "sentAt" => current.sent_at = Some(value),
465 "demoLabel" => current.demo_label = Some(value),
466 "conversationId" => current.conversation_id = Some(value),
467 "conversationTitle" => current.conversation_title = Some(value),
468 "accessCount" => current.access_count = value.parse().unwrap_or(0),
469 "writeCount" => current.write_count = value.parse().unwrap_or(1).max(1),
470 "evidence" => {
471 current.evidence = value
472 .split('|')
473 .filter(|s| !s.is_empty())
474 .map(ToOwned::to_owned)
475 .collect();
476 }
477 _ => {}
478 }
479 }
480 }
481 if let Some(existing) = current.take() {
482 events.push(existing);
483 }
484 for event in &mut events {
485 initialize_write_count(event);
486 }
487 events
488}
489
490pub(crate) fn escape_value(value: &str) -> String {
491 value
492 .replace('\\', "\\\\")
493 .replace('"', "\\\"")
494 .replace('\n', "\\n")
495 .replace('\r', "\\r")
496 .replace('\t', "\\t")
497}
498
499fn unescape_value(value: &str) -> String {
500 let mut out = String::with_capacity(value.len());
501 let mut chars = value.chars();
502 while let Some(ch) = chars.next() {
503 if ch == '\\' {
504 if let Some(next) = chars.next() {
505 match next {
506 'n' => out.push('\n'),
507 'r' => out.push('\r'),
508 't' => out.push('\t'),
509 '\\' => out.push('\\'),
510 '"' => out.push('"'),
511 other => out.push(other),
512 }
513 }
514 } else {
515 out.push(ch);
516 }
517 }
518 out
519}
520
521pub(crate) fn parse_quoted(rest: &str) -> Option<String> {
522 let trimmed = rest.trim_start();
523 let bytes = trimmed.as_bytes();
524 if bytes.first() != Some(&b'"') {
525 return None;
526 }
527 let mut i = 1;
528 while i < bytes.len() {
529 match bytes[i] {
530 b'\\' => i += 2,
531 b'"' => return Some(unescape_value(&trimmed[1..i])),
532 _ => i += 1,
533 }
534 }
535 None
536}
537
538pub(crate) fn split_first_token(content: &str) -> Option<(&str, &str)> {
539 let trimmed = content.trim_start();
540 let mut split = trimmed.splitn(2, ' ');
541 let head = split.next()?;
542 let tail = split.next().unwrap_or("");
543 Some((head, tail))
544}
545
546#[allow(clippy::cast_possible_wrap)]
550pub(crate) fn isoformat_now() -> String {
551 use std::time::{SystemTime, UNIX_EPOCH};
552 let now = SystemTime::now()
553 .duration_since(UNIX_EPOCH)
554 .unwrap_or_default();
555 let secs = now.as_secs() as i64;
556 let millis = now.subsec_millis();
557 format_iso8601(secs, millis)
558}
559
560#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
561fn format_iso8601(secs_since_epoch: i64, millis: u32) -> String {
562 let days = secs_since_epoch.div_euclid(86_400);
564 let time = secs_since_epoch.rem_euclid(86_400);
565 let hours = (time / 3_600) as u32;
566 let minutes = ((time % 3_600) / 60) as u32;
567 let seconds = (time % 60) as u32;
568 let (year, month, day) = days_to_date(days);
569 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}.{millis:03}Z")
570}
571
572#[allow(
573 clippy::cast_possible_truncation,
574 clippy::cast_possible_wrap,
575 clippy::cast_sign_loss
576)]
577const fn days_to_date(days: i64) -> (i32, u32, u32) {
578 let z = days + 719_468;
580 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
581 let doe = (z - era * 146_097) as u64; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let mut y = yoe as i64 + era * 400;
584 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; if m <= 2 {
589 y += 1;
590 }
591 (y as i32, m as u32, d as u32)
592}