Skip to main content

formal_ai/
memory.rs

1//! Portable Links Notation memory log shared across every interface.
2//!
3//! The browser demo persists its conversation memory under `IndexedDB` as a
4//! `demo_memory` Links Notation document (see `src/web/memory.js`). The CLI
5//! and the HTTP server reuse the **exact same wire format** so a user can
6//! migrate their agent's memory between surfaces with a single `.lino`
7//! file:
8//!
9//! ```text
10//! demo_memory
11//!   event "id1"
12//!     role "user"
13//!     content "Hi"
14//!     sentAt "2026-05-15T12:00:00.000Z"
15//!   event "id2"
16//!     role "assistant"
17//!     intent "greeting"
18//!     content "Hi, how may I help you?"
19//!     sentAt "2026-05-15T12:00:01.000Z"
20//! ```
21//!
22//! The store is append-only for normal writes. Destructive paths are explicit
23//! user-initiated maintenance operations: purge already-deleted conversations
24//! or reset the dynamic event log after the caller has handled confirmation /
25//! backup. Older logs without the optional `kind`/`tool`/`inputs`/`outputs`
26//! fields still parse as plain user/assistant turns, so the format is
27//! forward-compatible.
28//!
29//! Full-memory bundles (`formal_ai_bundle`) — seed files + UI preferences +
30//! environment metadata + the entire event log in a single document — live in
31//! the [`bundle`] submodule. They are the default shape every "export memory"
32//! surface now writes (see issue #18 / R109).
33//!
34//! See [`super::seed`] for the static knowledge surface that pairs with this
35//! dynamic memory log, and `VISION.md` (Single-File Reproducibility) for the
36//! reasoning behind the unified format.
37
38use 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/// One recorded turn / step / tool invocation.
54///
55/// All fields are optional so the same record shape covers user/assistant
56/// messages, internal reasoning steps, and tool invocations without
57/// branching the schema.
58#[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    /// How many times this event has been read back (recalled) so far.
74    ///
75    /// Issue #494 asks that usage be *counted on access*, not inferred from
76    /// citations: recall paths call [`MemoryStore::record_access`], and the
77    /// dreaming planner adds this count to the citation count so
78    /// frequently-read data is not evicted as "unused".
79    pub access_count: u64,
80    /// How many durable writes have created or changed this event.
81    ///
82    /// The first persistence is write one. Explicit substitutions and repeated
83    /// stable-id assertions increment the count, allowing the dreaming retention
84    /// policy to protect frequently changed knowledge as well as frequently read
85    /// knowledge (issue #686).
86    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/// Memory log for dynamic events. Normal writes append records; explicit purge
110/// and reset methods exist for irreversible user-requested cleanup.
111#[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    /// Build a store from an existing list. Useful for tests and for the
123    /// `from_links_notation` factory below.
124    #[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    /// Append every event from `other` to this store. Returns the number of
138    /// events appended.
139    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    /// Permanently remove all events that belong to conversations already
149    /// marked with a `conversation_deleted` event.
150    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    /// Permanently remove all events attributed to a single conversation id.
172    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    /// Clear every dynamic memory event while keeping the static seed intact.
183    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    /// Count one read access on each event at `indices` (issue #494: usage is
195    /// counted on access, not inferred from citations alone). Out-of-range
196    /// indices are ignored. Returns how many events were actually counted.
197    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    /// Rewrite every textual field of every stored event, replacing each
209    /// occurrence of `old` with `new`. This is the *write* half of the
210    /// natural-language substitution primitive (issue #529): a recall reads the
211    /// associative memory, a substitution transforms it in place — together they
212    /// give the user full, link-cli-style read+write control over memory through
213    /// ordinary language.
214    ///
215    /// Returns the total number of textual occurrences replaced across the
216    /// store. Structural keys are never touched: only the value-bearing fields
217    /// (`content`, `inputs`, `outputs`, `conversation_title`, `demo_label`) and
218    /// free-form `evidence` entries are rewritten, so a substitution can never
219    /// corrupt the document shape.
220    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    /// Render the entire memory log as a portable `demo_memory` Links
261    /// Notation document.
262    #[must_use]
263    pub fn export_links_notation(&self) -> String {
264        export_links_notation(&self.events)
265    }
266
267    /// Parse a `demo_memory` document and replace the store's contents.
268    pub fn replace_from_links_notation(&mut self, text: &str) {
269        self.events = parse_links_notation(text);
270    }
271
272    /// Append every event parsed from a `demo_memory` document. Returns the
273    /// number of events appended.
274    pub fn import_links_notation(&mut self, text: &str) -> usize {
275        let parsed = parse_links_notation(text);
276        self.import(&parsed)
277    }
278
279    /// Load events from a file on disk. Missing file yields an empty store.
280    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    /// Persist the full store back to a file on disk. Creates parent
290    /// directories as needed. The write is atomic (temp file + rename) and
291    /// serialized against other writers via an advisory lock — see
292    /// [`write_locked_atomic`].
293    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
298/// Write `contents` to `path` atomically while holding an exclusive advisory
299/// lock on `{path}.lock`.
300///
301/// The foreground server and the background dreaming thread share one memory
302/// log (issue #540 §6): the lock keeps their read-modify-write cycles from
303/// interleaving, and the temp-file + rename step guarantees a reader never
304/// observes a torn, half-written document even if the process dies mid-write.
305///
306/// # Errors
307/// Returns an [`io::Error`] when the lock file, temp file, or rename fails.
308pub 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
335/// Replace every occurrence of `old` with `new` inside `value`, rewriting it in
336/// place and returning the number of replacements made.
337fn 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/// Serialize a slice of events as a `demo_memory` Links Notation document.
353#[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/// Parse a `demo_memory` Links Notation document into events.
408///
409/// The parser is lenient: a missing or differently-named header yields an
410/// empty list (no panic), and unknown field names are ignored so newer
411/// browser logs can be imported into older CLI builds without breaking.
412#[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// A tiny deterministic ISO-8601 stamp that does not pull in `chrono`. The
547// browser side records `new Date().toISOString()`; for the CLI we emit a
548// fixed-precision UTC string built from the system clock.
549#[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    // Convert seconds since epoch to UTC components without external deps.
563    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    // Algorithm adapted from civil-from-days (Howard Hinnant, public domain).
579    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; // [0, 146096]
582    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
583    let mut y = yoe as i64 + era * 400;
584    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
585    let mp = (5 * doy + 2) / 153; // [0, 11]
586    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
587    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
588    if m <= 2 {
589        y += 1;
590    }
591    (y as i32, m as u32, d as u32)
592}