Skip to main content

formal_ai/memory/
bundle.rs

1//! Full-memory bundle export / import (`formal_ai_bundle` Links Notation).
2//!
3//! A bundle is the self-contained `.lino` document the browser's
4//! **Export memory** topbar button now writes by default — seed files,
5//! UI preferences, environment metadata, and the entire append-only
6//! `demo_memory` event log in a single file. The CLI mirrors the same
7//! defaults (`formal-ai memory export` writes a bundle; `--events-only`
8//! opts back into the legacy `demo_memory` shape).
9
10use std::collections::BTreeMap;
11
12use super::{
13    escape_value, format_event_into, isoformat_now, parse_links_notation, parse_quoted,
14    split_first_token, MemoryEvent, BUNDLE_HEADER, ROOT_HEADER,
15};
16
17/// Build a single Links Notation bundle document.
18///
19/// Contains the static seed plus the dynamic memory log plus arbitrary
20/// environment metadata. Mirrors the browser's `FormalAiMemory.exportBundle`
21/// so a user can drop the same file into any interface.
22#[must_use]
23pub fn export_bundle(seed_files: &[(&str, &str)], events: &[MemoryEvent]) -> String {
24    let mut out = String::from(BUNDLE_HEADER);
25    out.push('\n');
26    out.push_str("  exported_at \"");
27    out.push_str(&escape_value(&isoformat_now()));
28    out.push_str("\"\n");
29    if !seed_files.is_empty() {
30        out.push_str("  seed_files\n");
31        for (name, contents) in seed_files {
32            out.push_str("    file \"");
33            out.push_str(&escape_value(name));
34            out.push_str("\"\n");
35            for line in contents.lines() {
36                if line.is_empty() {
37                    continue;
38                }
39                out.push_str("      ");
40                out.push_str(line);
41                out.push('\n');
42            }
43        }
44    }
45    out.push_str("  ");
46    out.push_str(ROOT_HEADER);
47    out.push('\n');
48    for event in events {
49        // Indent each event one level deeper than the standalone memory
50        // export so it nests inside the bundle.
51        let mut block = String::new();
52        format_event_into(event, &mut block);
53        for line in block.lines() {
54            if line.is_empty() {
55                continue;
56            }
57            out.push_str("  ");
58            out.push_str(line);
59            out.push('\n');
60        }
61    }
62    out
63}
64
65/// Recover the memory log section from a `formal_ai_bundle` document.
66///
67/// Returns `None` if the input is not a recognised bundle. Used by
68/// `formal-ai bundle import` so a user can drag the web demo's
69/// `formal-ai-bundle.lino` into the CLI and get back just the events.
70#[must_use]
71pub fn extract_memory_from_bundle(text: &str) -> Option<Vec<MemoryEvent>> {
72    if !text.trim_start().starts_with(BUNDLE_HEADER) {
73        return None;
74    }
75    let mut inner = String::new();
76    let mut inside = false;
77    for line in text.lines() {
78        let indent = line.chars().take_while(|c| *c == ' ').count();
79        let content = &line[indent..];
80        if !inside {
81            if indent == 2 && content == ROOT_HEADER {
82                inside = true;
83                inner.push_str(content);
84                inner.push('\n');
85            }
86            continue;
87        }
88        if indent <= 2 && !content.starts_with("event ") && !content.is_empty() {
89            // A sibling section at the same depth as the memory header ends
90            // the memory block.
91            if indent == 2 {
92                break;
93            }
94        }
95        if indent < 2 {
96            break;
97        }
98        // Strip two spaces of bundle indentation so the inner doc looks like
99        // a standalone `demo_memory` file the existing parser understands.
100        let stripped = line.strip_prefix("  ").unwrap_or(line);
101        inner.push_str(stripped);
102        inner.push('\n');
103    }
104    if !inside {
105        return None;
106    }
107    Some(parse_links_notation(&inner))
108}
109
110/// Environment metadata embedded at the top of a `formal_ai_bundle` document.
111///
112/// Mirrors the `info` object passed by the browser to
113/// `FormalAiMemory.exportBundle({ info })`: a small free-form record that lets
114/// the maintainer reconstruct the runtime context in which the export was
115/// produced (app version, URL, user agent, worker state, demo/manual mode).
116#[derive(Debug, Default, Clone, PartialEq, Eq)]
117pub struct BundleInfo {
118    pub exported_at: Option<String>,
119    pub version: Option<String>,
120    pub url: Option<String>,
121    pub user_agent: Option<String>,
122    pub worker_state: Option<String>,
123    pub mode: Option<String>,
124}
125
126/// Structured view of a parsed `formal_ai_bundle` document.
127///
128/// Returned by [`import_full_memory`]. `seed_files` and `preferences` keep
129/// insertion order; `agent_info` is the parsed key/value map recovered from
130/// the embedded `data/seed/agent-info.lino` (or `seed/agent-info.lino`) file,
131/// when present — it powers [`suggest_migrations`].
132#[derive(Debug, Default, Clone, PartialEq, Eq)]
133pub struct ParsedBundle {
134    pub events: Vec<MemoryEvent>,
135    pub seed_files: Vec<(String, String)>,
136    pub preferences: Vec<(String, String)>,
137    pub info: BundleInfo,
138    pub agent_info: BTreeMap<String, String>,
139}
140
141/// Materialize imported seed files as recomputable `seed_cache` events.
142///
143/// This is the production producer for the `seed_cache` kind (issue #494 via
144/// issue #540 §4): a full-memory import copies the bundle's seed files into
145/// the event log, so seed data participates in usage counting and — being
146/// classified as a recomputable cache by the dreaming lexicon — is among the
147/// first data reclaimed under storage pressure. Ids are stable over the file
148/// name, so re-importing the same bundle never duplicates the cache.
149#[must_use]
150pub fn seed_cache_events(seed_files: &[(String, String)]) -> Vec<MemoryEvent> {
151    seed_files
152        .iter()
153        .map(|(name, contents)| MemoryEvent {
154            id: crate::engine::stable_id("seed_cache", name),
155            kind: Some(String::from("seed_cache")),
156            intent: Some(String::from("seed")),
157            tool: Some(name.clone()),
158            content: Some(contents.clone()),
159            ..MemoryEvent::default()
160        })
161        .collect()
162}
163
164/// Build the canonical full-memory `.lino` document — the same shape the
165/// browser's "Export memory" button now produces.
166///
167/// Contains, in order: bundle metadata (`exported_at`, `version`, `url`,
168/// `user_agent`, `worker_state`, `mode`), every seed file, optional UI
169/// preferences, then the entire `demo_memory` event log. A single file is
170/// enough to replay the agent's state on any interface.
171#[must_use]
172pub fn export_full_memory(
173    seed_files: &[(&str, &str)],
174    events: &[MemoryEvent],
175    preferences: &[(&str, &str)],
176    info: &BundleInfo,
177) -> String {
178    let mut out = String::from(BUNDLE_HEADER);
179    out.push('\n');
180    let exported_at = info.exported_at.clone().unwrap_or_else(isoformat_now);
181    out.push_str("  exported_at \"");
182    out.push_str(&escape_value(&exported_at));
183    out.push_str("\"\n");
184    push_optional_info(&mut out, "version", info.version.as_deref());
185    push_optional_info(&mut out, "url", info.url.as_deref());
186    push_optional_info(&mut out, "user_agent", info.user_agent.as_deref());
187    push_optional_info(&mut out, "worker_state", info.worker_state.as_deref());
188    push_optional_info(&mut out, "mode", info.mode.as_deref());
189    if !seed_files.is_empty() {
190        out.push_str("  seed_files\n");
191        for (name, contents) in seed_files {
192            out.push_str("    file \"");
193            out.push_str(&escape_value(name));
194            out.push_str("\"\n");
195            for line in contents.lines() {
196                if line.is_empty() {
197                    continue;
198                }
199                out.push_str("      ");
200                out.push_str(line);
201                out.push('\n');
202            }
203        }
204    }
205    if !preferences.is_empty() {
206        out.push_str("  preferences\n");
207        for (key, value) in preferences {
208            if key.is_empty() {
209                continue;
210            }
211            out.push_str("    ");
212            out.push_str(key);
213            out.push_str(" \"");
214            out.push_str(&escape_value(value));
215            out.push_str("\"\n");
216        }
217    }
218    out.push_str("  ");
219    out.push_str(ROOT_HEADER);
220    out.push('\n');
221    for event in events {
222        let mut block = String::new();
223        format_event_into(event, &mut block);
224        for line in block.lines() {
225            if line.is_empty() {
226                continue;
227            }
228            out.push_str("  ");
229            out.push_str(line);
230            out.push('\n');
231        }
232    }
233    out
234}
235
236fn push_optional_info(out: &mut String, key: &str, value: Option<&str>) {
237    let Some(value) = value else { return };
238    if value.is_empty() {
239        return;
240    }
241    out.push_str("  ");
242    out.push_str(key);
243    out.push_str(" \"");
244    out.push_str(&escape_value(value));
245    out.push_str("\"\n");
246}
247
248/// Parse a bundle into its structured pieces.
249///
250/// The function is forgiving: unknown subsections are ignored, and a legacy
251/// `demo_memory` document returns a `ParsedBundle` whose `events` are
252/// populated and every other field empty (matching
253/// `FormalAiMemory.importFullMemory` in the browser).
254#[must_use]
255pub fn import_full_memory(text: &str) -> ParsedBundle {
256    let trimmed = text.trim_start();
257    if !trimmed.starts_with(BUNDLE_HEADER) {
258        return ParsedBundle {
259            events: parse_links_notation(text),
260            ..ParsedBundle::default()
261        };
262    }
263    parse_bundle_document(text)
264}
265
266#[allow(clippy::too_many_lines)]
267fn parse_bundle_document(text: &str) -> ParsedBundle {
268    let mut bundle = ParsedBundle::default();
269    let mut section: Option<&'static str> = None;
270    let mut current_seed_file: Option<String> = None;
271    let mut current_seed_body = String::new();
272    let mut memory_lines: Vec<String> = Vec::new();
273    for line in text.lines() {
274        if line.is_empty() {
275            if section == Some("seed_files") && current_seed_file.is_some() {
276                current_seed_body.push('\n');
277            }
278            continue;
279        }
280        let indent = line.chars().take_while(|c| *c == ' ').count();
281        let content = &line[indent..];
282        if indent == 0 {
283            // Top-level header; reset any open seed file before continuing.
284            if let Some(name) = current_seed_file.take() {
285                bundle
286                    .seed_files
287                    .push((name, std::mem::take(&mut current_seed_body)));
288            }
289            section = None;
290            continue;
291        }
292        if indent == 2 {
293            if let Some(name) = current_seed_file.take() {
294                bundle
295                    .seed_files
296                    .push((name, std::mem::take(&mut current_seed_body)));
297            }
298            if content == "seed_files" {
299                section = Some("seed_files");
300                continue;
301            }
302            if content == "preferences" {
303                section = Some("preferences");
304                continue;
305            }
306            if content == ROOT_HEADER {
307                section = Some("memory");
308                memory_lines.push(String::from(ROOT_HEADER));
309                continue;
310            }
311            // Free-form info field, e.g. `version "0.22.0"`.
312            if let Some((key, rest)) = split_first_token(content) {
313                if let Some(value) = parse_quoted(rest) {
314                    match key {
315                        "exported_at" => bundle.info.exported_at = Some(value),
316                        "version" => bundle.info.version = Some(value),
317                        "url" => bundle.info.url = Some(value),
318                        "user_agent" => bundle.info.user_agent = Some(value),
319                        "worker_state" => bundle.info.worker_state = Some(value),
320                        "mode" => bundle.info.mode = Some(value),
321                        _ => {}
322                    }
323                }
324            }
325            section = None;
326            continue;
327        }
328        match section {
329            Some("seed_files") => {
330                if indent == 4 {
331                    if let Some(name) = current_seed_file.take() {
332                        bundle
333                            .seed_files
334                            .push((name, std::mem::take(&mut current_seed_body)));
335                    }
336                    if let Some(rest) = content.strip_prefix("file ") {
337                        if let Some(value) = parse_quoted(rest) {
338                            current_seed_file = Some(value);
339                            current_seed_body = String::new();
340                        }
341                    }
342                } else if current_seed_file.is_some() && indent >= 6 {
343                    let body = if line.len() >= 6 { &line[6..] } else { "" };
344                    if !current_seed_body.is_empty() {
345                        current_seed_body.push('\n');
346                    }
347                    current_seed_body.push_str(body);
348                }
349            }
350            Some("preferences") if indent == 4 => {
351                if let Some((key, rest)) = split_first_token(content) {
352                    if let Some(value) = parse_quoted(rest) {
353                        bundle.preferences.push((key.to_string(), value));
354                    }
355                }
356            }
357            Some("memory") => {
358                // Strip the 2 spaces of bundle indentation so the captured
359                // block matches the standalone `demo_memory` shape parsed by
360                // `parse_links_notation`.
361                let stripped = if line.len() >= 2 { &line[2..] } else { line };
362                memory_lines.push(stripped.to_string());
363            }
364            _ => {}
365        }
366    }
367    if let Some(name) = current_seed_file.take() {
368        bundle.seed_files.push((name, current_seed_body));
369    }
370    if !memory_lines.is_empty() {
371        bundle.events = parse_links_notation(&memory_lines.join("\n"));
372    }
373    bundle.agent_info = extract_agent_info(&bundle.seed_files);
374    bundle
375}
376
377fn extract_agent_info(seed_files: &[(String, String)]) -> BTreeMap<String, String> {
378    for (name, contents) in seed_files {
379        if name == "data/seed/agent-info.lino" || name == "seed/agent-info.lino" {
380            return parse_agent_info(contents);
381        }
382    }
383    BTreeMap::new()
384}
385
386fn parse_agent_info(text: &str) -> BTreeMap<String, String> {
387    let mut out = BTreeMap::new();
388    let mut current_field: Option<String> = None;
389    for line in text.lines() {
390        let indent = line.chars().take_while(|c| *c == ' ').count();
391        let content = &line[indent..];
392        if indent == 2 {
393            if let Some(rest) = content.strip_prefix("field ") {
394                if let Some(value) = parse_quoted(rest) {
395                    current_field = Some(value);
396                }
397            }
398        } else if indent == 4 {
399            if let Some(rest) = content.strip_prefix("value ") {
400                if let Some(value) = parse_quoted(rest) {
401                    if let Some(key) = current_field.take() {
402                        out.insert(key, value);
403                    }
404                }
405            }
406        }
407    }
408    out
409}
410
411/// Suggest data migrations between an imported bundle and the running app.
412///
413/// The first migration check covers the seed version baked into
414/// `data/seed/agent-info.lino` (`field "version"`). When the version moved
415/// forward, the returned suggestion tells the user where to look. A legacy
416/// `demo_memory`-only import also yields a suggestion explaining that the
417/// seed-of-origin is unknown. Returns an empty vector when no migration is
418/// needed so the caller can branch on emptiness.
419#[must_use]
420pub fn suggest_migrations(
421    imported: &ParsedBundle,
422    current_agent_info: &BTreeMap<String, String>,
423) -> Vec<String> {
424    let mut out = Vec::new();
425    let imported_version = imported
426        .agent_info
427        .get("version")
428        .cloned()
429        .or_else(|| imported.info.version.clone());
430    let current_version = current_agent_info.get("version").cloned();
431    match (imported_version.as_deref(), current_version.as_deref()) {
432        (Some(imported_v), Some(current_v)) if imported_v != current_v => {
433            out.push(format!(
434                "Seed version {imported_v} → {current_v}: review the new entries in data/seed/ \
435                 (multilingual responses, concepts, tools) — your imported memory was \
436                 authored against an older seed.",
437            ));
438        }
439        (Some(imported_v), None) => {
440            out.push(format!(
441                "Imported bundle was authored against seed version {imported_v} but the \
442                 running app does not expose a seed version. Update the app to compare.",
443            ));
444        }
445        _ => {}
446    }
447    if imported.seed_files.is_empty() && !imported.events.is_empty() {
448        out.push(String::from(
449            "Imported file is a legacy demo_memory log (no seed). The events were \
450             imported, but the seed at the time of capture is unknown — export from \
451             this session to upgrade to a full bundle.",
452        ));
453    }
454    out
455}