Skip to main content

dejadb_store/
migrate.rs

1//! Migration importers — read another memory system's export and write it
2//! into a DejaDB file with original timestamps, provenance, and (where the
3//! source has one) the full edit history preserved as supersession chains.
4//!
5//! File-based by design: no network calls, no source-SDK dependencies. Each
6//! function takes the export payload the caller read from disk (JSON value or
7//! JSONL text) — `docs/migrate.md` documents the one-liner that produces each
8//! payload from its source system. The CLI surface is `deja migrate --from
9//! <source> --file <path>`; bindings expose the same via `migrate()`.
10//!
11//! Import conventions, shared by every source:
12//! - `created_at` = the source's original timestamp (epoch ms); the op-log
13//!   HLC records when *this store* learned it — both truths are kept.
14//! - `source_type = "import"`, and `context.import` carries the source name
15//!   plus original ids, so every imported grain is auditable back to where
16//!   it came from.
17//! - Prose goes in `context.content` (never the term dictionary) and, capped,
18//!   in `embedding_text` so the FTS and vector legs index the original text.
19//! - Sources with edit history (mem0) replay it: ADD → add, UPDATE →
20//!   supersede, DELETE → forget — arriving with their original timestamps,
21//!   so `history()` shows the pre-import evolution.
22//! - Note-shaped sources (Basic Memory, Letta core-memory blocks) import as
23//!   `memory_file` chains under `/memories/...` — the same shape the
24//!   Anthropic memory-tool backend edits, so imported notes are immediately
25//!   live for `view`/`str_replace`/`insert` (see `memory_tool.rs`).
26
27use std::collections::HashMap;
28
29use dejadb_core::error::{DejaDbError, Result};
30use dejadb_core::format::serialize::serialize_grain;
31use dejadb_core::types::{Event, Fact, Grain, Role};
32use serde_json::{json, Value};
33
34use crate::memory_tool::MEMORY_FILE_RELATION;
35use crate::DejaDB;
36
37/// Cap on `embedding_text` (documented grain-level limit: 8 KiB).
38const ET_MAX_BYTES: usize = 8192;
39/// Cap on collected anomaly notes so a messy export can't balloon the report.
40const NOTES_MAX: usize = 50;
41
42/// What an import did. `notes` collects per-record anomalies (skipped rows,
43/// unparseable timestamps) up to a cap — an import never fails because one
44/// record is malformed.
45#[derive(Debug, Default)]
46pub struct MigrateReport {
47    pub added: usize,
48    pub superseded: usize,
49    pub forgotten: usize,
50    pub skipped: usize,
51    pub notes: Vec<String>,
52}
53
54impl MigrateReport {
55    fn note(&mut self, s: String) {
56        if self.notes.len() < NOTES_MAX {
57            self.notes.push(s);
58        } else if self.notes.len() == NOTES_MAX {
59            self.notes.push("… further notes suppressed".to_string());
60        }
61    }
62
63    pub fn to_json(&self) -> Value {
64        json!({
65            "added": self.added,
66            "superseded": self.superseded,
67            "forgotten": self.forgotten,
68            "skipped": self.skipped,
69            "notes": self.notes,
70        })
71    }
72}
73
74// ---- shared helpers -------------------------------------------------------
75
76/// Add a grain unless the store already holds its content address —
77/// re-running the same import is a no-op, not an error (the UNIQUE(hash)
78/// index rejects duplicates; we probe first to report them as `skipped`).
79///
80/// Caveat: a record with no source timestamp gets the import time as
81/// `created_at`, which lands in the blob — so only records that carry their
82/// own timestamp (all real exports do) are exactly re-run-dedupable.
83fn add_dedup<G: Grain + 'static>(
84    m: &mut DejaDB,
85    grain: &G,
86    rep: &mut MigrateReport,
87) -> Result<()> {
88    let (_, hash) = serialize_grain(grain)?;
89    if m.get(&hash).is_ok() {
90        rep.skipped += 1;
91        return Ok(());
92    }
93    m.add(grain)?;
94    rep.added += 1;
95    Ok(())
96}
97
98/// Short content digest for the `object` slot, so the term dictionary never
99/// stores bodies (same convention as `memory_tool::write_version`).
100fn digest6(content: &str) -> String {
101    use sha2::{Digest, Sha256};
102    hex::encode(&Sha256::digest(content.as_bytes())[..6])
103}
104
105/// Clip to the `embedding_text` byte cap on a char boundary.
106pub(crate) fn clip_et(s: &str) -> String {
107    if s.len() <= ET_MAX_BYTES {
108        return s.to_string();
109    }
110    let mut end = ET_MAX_BYTES;
111    while end > 0 && !s.is_char_boundary(end) {
112        end -= 1;
113    }
114    s[..end].to_string()
115}
116
117/// Days from 1970-01-01 for a civil date (Howard Hinnant's algorithm).
118fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
119    let y = if m <= 2 { y - 1 } else { y };
120    let era = if y >= 0 { y } else { y - 399 } / 400;
121    let yoe = y - era * 400;
122    let mp = if m > 2 { m - 3 } else { m + 9 };
123    let doy = (153 * mp + 2) / 5 + d - 1;
124    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
125    era * 146097 + doe - 719468
126}
127
128/// Parse an ISO-8601 timestamp ("2024-07-26T10:29:11.982509-07:00",
129/// "2026-01-05 09:00:00Z", "2024-07-26") to epoch milliseconds. Hand-rolled
130/// on purpose — the workspace takes no datetime dependency.
131pub fn iso8601_to_ms(s: &str) -> Option<i64> {
132    let s = s.trim();
133    let b = s.as_bytes();
134    if b.len() < 10 || b[4] != b'-' || b[7] != b'-' {
135        return None;
136    }
137    let num = |r: std::ops::Range<usize>| -> Option<i64> { s.get(r)?.parse().ok() };
138    let (y, mo, d) = (num(0..4)?, num(5..7)?, num(8..10)?);
139    if !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
140        return None;
141    }
142    let mut ms = days_from_civil(y, mo, d) * 86_400_000;
143    let mut i = 10;
144    if b.len() > i && (b[i] == b'T' || b[i] == b' ') {
145        i += 1;
146        if b.len() < i + 8 || b[i + 2] != b':' || b[i + 5] != b':' {
147            return None;
148        }
149        let (h, mi, sec) = (num(i..i + 2)?, num(i + 3..i + 5)?, num(i + 6..i + 8)?);
150        ms += (h * 3600 + mi * 60 + sec) * 1000;
151        i += 8;
152        // fractional seconds: keep milliseconds, skip the rest
153        if b.len() > i && b[i] == b'.' {
154            i += 1;
155            let start = i;
156            while i < b.len() && b[i].is_ascii_digit() {
157                i += 1;
158            }
159            let frac = &s[start..i];
160            if !frac.is_empty() {
161                let ms_str: String = frac.chars().chain("000".chars()).take(3).collect();
162                ms += ms_str.parse::<i64>().ok()?;
163            }
164        }
165        // offset: Z | ±HH:MM | ±HHMM | ±HH
166        if b.len() > i {
167            match b[i] {
168                b'Z' | b'z' => {}
169                b'+' | b'-' => {
170                    let sign: i64 = if b[i] == b'+' { 1 } else { -1 };
171                    i += 1;
172                    let oh = num(i..i + 2)?;
173                    i += 2;
174                    if b.len() > i && b[i] == b':' {
175                        i += 1;
176                    }
177                    let om = if b.len() >= i + 2 { num(i..i + 2).unwrap_or(0) } else { 0 };
178                    ms -= sign * (oh * 3600 + om * 60) * 1000;
179                }
180                _ => return None,
181            }
182        }
183    }
184    Some(ms)
185}
186
187/// Timestamp from a JSON value: ISO-8601 string, epoch seconds, or epoch ms
188/// (heuristic: ≥ 10^11 is already milliseconds).
189fn parse_ts(v: Option<&Value>) -> Option<i64> {
190    match v? {
191        Value::String(s) => iso8601_to_ms(s),
192        Value::Number(n) => {
193            let x = n.as_f64()?;
194            if x >= 1e11 {
195                Some(x as i64)
196            } else {
197                Some((x * 1000.0) as i64)
198            }
199        }
200        _ => None,
201    }
202}
203
204fn as_str<'a>(v: &'a Value, keys: &[&str]) -> Option<&'a str> {
205    keys.iter().find_map(|k| v.get(k).and_then(Value::as_str))
206}
207
208/// The list payload of an export that is either a bare array or wrapped in
209/// one of the given keys (`{"results": [...]}` etc.).
210fn item_array<'a>(v: &'a Value, keys: &[&str]) -> Option<&'a Vec<Value>> {
211    if let Some(arr) = v.as_array() {
212        return Some(arr);
213    }
214    keys.iter().find_map(|k| v.get(k).and_then(Value::as_array))
215}
216
217/// A prose-bearing Fact in the shared import shape (see module docs).
218#[allow(clippy::too_many_arguments)]
219fn import_fact(
220    ns: &str,
221    subject: &str,
222    relation: &str,
223    content: &str,
224    source: &str,
225    import_ctx: Value,
226    created_at: Option<i64>,
227    user_id: Option<&str>,
228    tags: Vec<String>,
229) -> Fact {
230    let mut f = Fact::new(subject, relation, &format!("v:{}", digest6(content)));
231    f.common.namespace = Some(ns.to_string());
232    f.common.source_type = Some("import".to_string());
233    f.common.embedding_text = Some(clip_et(content));
234    f.common.created_at = created_at;
235    f.common.user_id = user_id.map(str::to_string);
236    f.common.tags = tags;
237    let mut import = import_ctx;
238    import["source"] = json!(source);
239    f.common.context = Some(json!({ "content": content, "import": import }));
240    f
241}
242
243/// String-payload dispatcher for the language bindings (the CLI has its own
244/// file-based dispatcher, which adds the `basic-memory` vault-directory
245/// walk). Wraps the load in [`DejaDB::defer_text_index`] /
246/// [`DejaDB::rebuild_text_index`] so bulk imports skip the per-transaction
247/// FTS tax.
248pub fn migrate_payload(
249    m: &mut DejaDB,
250    ns: &str,
251    source: &str,
252    payload: &str,
253    history: Option<&str>,
254) -> Result<MigrateReport> {
255    let parse = |s: &str| {
256        serde_json::from_str::<Value>(s)
257            .map_err(|e| DejaDbError::Validation(format!("bad JSON payload: {e}")))
258    };
259    let deferred = m.defer_text_index()?;
260    let rep = match source {
261        "mem0" => {
262            let x = parse(payload)?;
263            let h = history.map(parse).transpose()?;
264            migrate_mem0(m, ns, Some(&x), h.as_ref())
265        }
266        "mem0-history" => {
267            let h = parse(payload)?;
268            migrate_mem0(m, ns, None, Some(&h))
269        }
270        "langgraph" | "langmem" => migrate_langgraph(m, ns, payload),
271        "letta" => {
272            let af = parse(payload)?;
273            migrate_letta(m, ns, &af)
274        }
275        "letta-archival" => migrate_letta_archival(m, ns, payload),
276        "zep" | "graphiti" => {
277            let v = parse(payload)?;
278            migrate_zep(m, ns, &v)
279        }
280        "jsonl" => migrate_jsonl(m, ns, payload),
281        other => Err(DejaDbError::Validation(format!(
282            "unknown migrate source '{other}' — mem0, mem0-history, langgraph, letta, \
283             letta-archival, zep, jsonl (basic-memory is CLI-only: deja migrate)"
284        ))),
285    };
286    if deferred {
287        m.rebuild_text_index()?;
288    }
289    rep
290}
291
292// ---- mem0 -----------------------------------------------------------------
293
294/// Import a mem0 export: `export` is the get-all payload (Platform
295/// `POST /v3/memories/` pages or OSS `Memory.get_all()` — bare array or
296/// `{"results": [...]}`), `history` optionally the concatenated per-memory
297/// `history()` events (Platform) or an OSS `history` table dump.
298///
299/// With history, each memory id becomes a real supersession chain — ADD →
300/// add, UPDATE → supersede, DELETE → forget — with original timestamps, so
301/// the pre-import evolution is queryable via `HISTORY`. (The official
302/// mem0→Zep and mem0→Supermemory guides drop this history; we keep it.)
303pub fn migrate_mem0(
304    m: &mut DejaDB,
305    ns: &str,
306    export: Option<&Value>,
307    history: Option<&Value>,
308) -> Result<MigrateReport> {
309    let mut rep = MigrateReport::default();
310    if export.is_none() && history.is_none() {
311        return Err(DejaDbError::Validation(
312            "mem0 import needs an export payload, a history payload, or both".into(),
313        ));
314    }
315
316    let subject_of = |id: &str| format!("mem0/{id}");
317    // Memory ids the history replay settled (imported, pre-existing, or
318    // deleted) — the export pass must not touch them again.
319    let mut handled: std::collections::HashSet<String> = Default::default();
320
321    if let Some(h) = history {
322        let empty = Vec::new();
323        let mut events: Vec<&Value> = item_array(h, &["results", "history", "memories"])
324            .unwrap_or(&empty)
325            .iter()
326            .collect();
327        // Source order: by timestamp; ties keep input order (stable sort).
328        events.sort_by_key(|e| {
329            parse_ts(e.get("created_at").or_else(|| e.get("updated_at"))).unwrap_or(0)
330        });
331        // Group into per-memory chains, preserving time order.
332        let mut chains: Vec<(String, Vec<&Value>)> = Vec::new();
333        let mut idx: HashMap<String, usize> = HashMap::new();
334        for e in events {
335            let Some(id) = as_str(e, &["memory_id", "id"]) else {
336                rep.skipped += 1;
337                rep.note("history event without memory_id".into());
338                continue;
339            };
340            match idx.get(id) {
341                Some(&i) => chains[i].1.push(e),
342                None => {
343                    idx.insert(id.to_string(), chains.len());
344                    chains.push((id.to_string(), vec![e]));
345                }
346            }
347        }
348        for (id, evs) in chains {
349            let subject = subject_of(&id);
350            // Re-run safety: a chain already in the store was imported
351            // before — leave it (and any edits made since) alone.
352            if !m.history(ns, &subject, "mem0_memory")?.is_empty() {
353                rep.skipped += evs.len();
354                rep.note(format!("mem0 {id}: already imported — skipped"));
355                handled.insert(id);
356                continue;
357            }
358            let mut head: Option<dejadb_core::error::Hash> = None;
359            for e in evs {
360                let action = as_str(e, &["event", "action"]).unwrap_or("").to_ascii_uppercase();
361                let new_text = as_str(e, &["new_memory", "new_value", "memory"]);
362                let ts = parse_ts(e.get("created_at").or_else(|| e.get("updated_at")));
363                match action.as_str() {
364                    "ADD" | "UPDATE" => {
365                        let Some(text) = new_text.filter(|t| !t.trim().is_empty()) else {
366                            rep.skipped += 1;
367                            rep.note(format!("{action} event for {id} without new text"));
368                            continue;
369                        };
370                        let mut f = import_fact(
371                            ns,
372                            &subject,
373                            "mem0_memory",
374                            text,
375                            "mem0",
376                            json!({ "id": id, "event": action }),
377                            ts,
378                            None,
379                            Vec::new(),
380                        );
381                        head = Some(match head {
382                            Some(prev) => {
383                                rep.superseded += 1;
384                                m.supersede(&prev, &mut f)?
385                            }
386                            None => {
387                                rep.added += 1;
388                                m.add(&f)?
389                            }
390                        });
391                    }
392                    "DELETE" => {
393                        for v in m.history(ns, &subject, "mem0_memory")? {
394                            m.forget(&v.hash)?;
395                            rep.forgotten += 1;
396                        }
397                        head = None;
398                    }
399                    "NOOP" | "" => {}
400                    other => {
401                        rep.skipped += 1;
402                        rep.note(format!("unknown mem0 history event '{other}' for {id}"));
403                    }
404                }
405            }
406            handled.insert(id);
407        }
408    }
409
410    if let Some(x) = export {
411        let empty = Vec::new();
412        let items = item_array(x, &["results", "memories"]).unwrap_or(&empty);
413        for it in items {
414            let Some(text) = as_str(it, &["memory", "content", "text"]) else {
415                rep.skipped += 1;
416                rep.note("export item without memory text".into());
417                continue;
418            };
419            let id = as_str(it, &["id"]).map(str::to_string).unwrap_or_else(|| digest6(text));
420            // History already settled this memory — the export is just its
421            // final state. A chain from a previous run is likewise left alone.
422            if handled.contains(&id) {
423                continue;
424            }
425            if !m.history(ns, &subject_of(&id), "mem0_memory")?.is_empty() {
426                rep.skipped += 1;
427                continue;
428            }
429            let user = as_str(it, &["user_id", "agent_id", "run_id", "app_id"]);
430            let tags: Vec<String> = it
431                .get("categories")
432                .and_then(Value::as_array)
433                .map(|a| a.iter().filter_map(|t| t.as_str().map(str::to_string)).collect())
434                .unwrap_or_default();
435            let mut import = json!({ "id": id });
436            for k in ["user_id", "agent_id", "run_id", "app_id"] {
437                if let Some(v) = it.get(k).and_then(Value::as_str) {
438                    import[k] = json!(v);
439                }
440            }
441            if let Some(md) = it.get("metadata").filter(|v| !v.is_null()) {
442                import["metadata"] = md.clone();
443            }
444            let f = import_fact(
445                ns,
446                &subject_of(&id),
447                "mem0_memory",
448                text,
449                "mem0",
450                import,
451                parse_ts(it.get("created_at")).or_else(|| parse_ts(it.get("updated_at"))),
452                user,
453                tags,
454            );
455            m.add(&f)?;
456            handled.insert(id);
457            rep.added += 1;
458        }
459    }
460    Ok(rep)
461}
462
463// ---- LangGraph / LangMem store --------------------------------------------
464
465/// Import a LangGraph BaseStore dump (LangMem persists through it): JSONL,
466/// one `{"prefix": ..., "key": ..., "value": {...}, "created_at": ...}` per
467/// line — the shape of `SELECT row_to_json(t) FROM store t` on the
468/// `langgraph` Postgres schema (`prefix` may be a string or an array).
469pub fn migrate_langgraph(m: &mut DejaDB, ns: &str, jsonl: &str) -> Result<MigrateReport> {
470    let mut rep = MigrateReport::default();
471    for (lineno, line) in jsonl.lines().enumerate() {
472        let line = line.trim();
473        if line.is_empty() {
474            continue;
475        }
476        let Ok(v) = serde_json::from_str::<Value>(line) else {
477            rep.skipped += 1;
478            rep.note(format!("line {}: not valid JSON", lineno + 1));
479            continue;
480        };
481        let prefix = match v.get("prefix") {
482            Some(Value::String(s)) => s.clone(),
483            Some(Value::Array(a)) => a
484                .iter()
485                .filter_map(Value::as_str)
486                .collect::<Vec<_>>()
487                .join("/"),
488            _ => String::new(),
489        };
490        let Some(key) = as_str(&v, &["key"]) else {
491            rep.skipped += 1;
492            rep.note(format!("line {}: missing key", lineno + 1));
493            continue;
494        };
495        let Some(value) = v.get("value") else {
496            rep.skipped += 1;
497            rep.note(format!("line {}: missing value", lineno + 1));
498            continue;
499        };
500        // A store value is arbitrary JSON; when it is (or wraps) a single
501        // prose string, index that — otherwise the compact JSON.
502        let content = value
503            .as_str()
504            .map(str::to_string)
505            .or_else(|| as_str(value, &["content", "text", "memory"]).map(str::to_string))
506            .unwrap_or_else(|| value.to_string());
507        let subject = if prefix.is_empty() {
508            format!("langgraph/{key}")
509        } else {
510            format!("langgraph/{prefix}/{key}")
511        };
512        let mut f = import_fact(
513            ns,
514            &subject,
515            "langgraph_item",
516            &content,
517            "langgraph",
518            json!({ "prefix": prefix, "key": key }),
519            parse_ts(v.get("created_at")).or_else(|| parse_ts(v.get("updated_at"))),
520            None,
521            Vec::new(),
522        );
523        // Keep the full structured value when the prose was extracted from it.
524        if !value.is_string() {
525            if let Some(ctx) = f.common.context.as_mut() {
526                ctx["value"] = value.clone();
527            }
528        }
529        add_dedup(m, &f, &mut rep)?;
530    }
531    Ok(rep)
532}
533
534// ---- Basic Memory ---------------------------------------------------------
535
536/// Import one Basic Memory note (a markdown file with optional YAML
537/// frontmatter). Notes become `memory_file` chains under
538/// `/memories/<permalink|path>` — the exact shape the Anthropic memory-tool
539/// backend serves, so an imported vault is immediately editable by an agent.
540/// The caller walks the vault directory and supplies each file's relative
541/// path and mtime (used only when the frontmatter has no date).
542pub fn migrate_basic_memory_note(
543    m: &mut DejaDB,
544    ns: &str,
545    rel_path: &str,
546    markdown: &str,
547    mtime_ms: Option<i64>,
548    rep: &mut MigrateReport,
549) -> Result<()> {
550    let (title, permalink, tags, created) = parse_frontmatter(markdown);
551    let stem = permalink.unwrap_or_else(|| {
552        rel_path.trim_end_matches(".md").trim_matches('/').to_string()
553    });
554    if stem.is_empty() || markdown.trim().is_empty() {
555        rep.skipped += 1;
556        rep.note(format!("{rel_path}: empty note or path"));
557        return Ok(());
558    }
559    let subject = format!("/memories/{stem}");
560    // Re-run safety: an already-imported note may have been edited by the
561    // memory tool since — never clobber its chain.
562    if !m.history(ns, &subject, MEMORY_FILE_RELATION)?.is_empty() {
563        rep.skipped += 1;
564        return Ok(());
565    }
566    let et = match &title {
567        Some(t) => format!("{t}\n{markdown}"),
568        None => markdown.to_string(),
569    };
570    let mut f = import_fact(
571        ns,
572        &subject,
573        MEMORY_FILE_RELATION,
574        markdown,
575        "basic-memory",
576        json!({ "path": rel_path, "title": title }),
577        created.or(mtime_ms),
578        None,
579        tags,
580    );
581    f.common.embedding_text = Some(clip_et(&et));
582    m.add(&f)?;
583    rep.added += 1;
584    Ok(())
585}
586
587/// Minimal YAML frontmatter reader: `key: value` lines and one-level `- item`
588/// lists between `---` fences. Returns (title, permalink, tags, created-ms).
589/// No YAML dependency by policy; anything fancier is preserved verbatim in
590/// the note body anyway.
591fn parse_frontmatter(md: &str) -> (Option<String>, Option<String>, Vec<String>, Option<i64>) {
592    let mut title = None;
593    let mut permalink = None;
594    let mut tags: Vec<String> = Vec::new();
595    let mut created = None;
596    let mut lines = md.lines();
597    if lines.next().map(str::trim) != Some("---") {
598        return (title, permalink, tags, created);
599    }
600    let mut in_tags = false;
601    for line in lines {
602        let t = line.trim();
603        if t == "---" {
604            break;
605        }
606        if in_tags {
607            if let Some(item) = t.strip_prefix("- ") {
608                tags.push(item.trim().trim_matches('"').to_string());
609                continue;
610            }
611            in_tags = false;
612        }
613        let Some((k, v)) = t.split_once(':') else { continue };
614        let (k, v) = (k.trim(), v.trim().trim_matches('"'));
615        match k {
616            "title" => title = Some(v.to_string()),
617            "permalink" => permalink = Some(v.trim_matches('/').to_string()),
618            "created" | "date" => created = iso8601_to_ms(v),
619            "tags" => {
620                if v.is_empty() {
621                    in_tags = true; // dash-list follows
622                } else {
623                    tags = v
624                        .trim_start_matches('[')
625                        .trim_end_matches(']')
626                        .split(',')
627                        .map(|s| s.trim().trim_matches('"').to_string())
628                        .filter(|s| !s.is_empty())
629                        .collect();
630                }
631            }
632            _ => {}
633        }
634    }
635    (title, permalink, tags, created)
636}
637
638// ---- Letta ----------------------------------------------------------------
639
640/// Import a Letta agent file (`.af` v1/v2 JSON: a single agent object or
641/// `{"agents": [...]}`). Core-memory blocks become editable `memory_file`
642/// chains under `/memories/letta/<agent>/<label>`; the message history
643/// becomes thread-indexed Events. Archival passages are NOT in `.af` —
644/// export them separately and use [`migrate_letta_archival`].
645pub fn migrate_letta(m: &mut DejaDB, ns: &str, af: &Value) -> Result<MigrateReport> {
646    let mut rep = MigrateReport::default();
647    let agents: Vec<&Value> = match af.get("agents").and_then(Value::as_array) {
648        Some(a) => a.iter().collect(),
649        None => vec![af],
650    };
651    for agent in agents {
652        let name = as_str(agent, &["name", "id"]).unwrap_or("agent").to_string();
653        // Blocks live at .core_memory, .blocks, or .memory.blocks across
654        // .af revisions; each is {"label": ..., "value": ...}.
655        let blocks = agent
656            .get("core_memory")
657            .and_then(Value::as_array)
658            .or_else(|| agent.get("blocks").and_then(Value::as_array))
659            .or_else(|| agent.get("memory").and_then(|mm| mm.get("blocks")).and_then(Value::as_array));
660        if let Some(blocks) = blocks {
661            for b in blocks {
662                let Some(label) = as_str(b, &["label", "name"]) else {
663                    rep.skipped += 1;
664                    continue;
665                };
666                let Some(value) = as_str(b, &["value", "content"]) else {
667                    rep.skipped += 1;
668                    continue;
669                };
670                let subject = format!("/memories/letta/{name}/{label}");
671                // An already-imported block may have been edited since.
672                if !m.history(ns, &subject, MEMORY_FILE_RELATION)?.is_empty() {
673                    rep.skipped += 1;
674                    continue;
675                }
676                let f = import_fact(
677                    ns,
678                    &subject,
679                    MEMORY_FILE_RELATION,
680                    value,
681                    "letta",
682                    json!({ "agent": name, "block": label }),
683                    parse_ts(agent.get("created_at")),
684                    None,
685                    Vec::new(),
686                );
687                m.add(&f)?;
688                rep.added += 1;
689            }
690        }
691        if let Some(messages) = agent.get("messages").and_then(Value::as_array) {
692            for msg in messages {
693                let role = as_str(msg, &["role"]).unwrap_or("");
694                if role != "user" && role != "assistant" {
695                    continue; // skip system/tool plumbing
696                }
697                let text = match msg.get("content") {
698                    Some(Value::String(s)) => s.clone(),
699                    Some(Value::Array(parts)) => parts
700                        .iter()
701                        .filter_map(|p| p.get("text").and_then(Value::as_str))
702                        .collect::<Vec<_>>()
703                        .join("\n"),
704                    _ => as_str(msg, &["text"]).unwrap_or("").to_string(),
705                };
706                if text.trim().is_empty() {
707                    continue;
708                }
709                let mut e = Event::new(&text);
710                e.common.namespace = Some(ns.to_string());
711                e.common.source_type = Some("import".to_string());
712                e.common.created_at = parse_ts(msg.get("created_at"));
713                e.session_id = Some(format!("letta/{name}"));
714                e.role = Role::from_str(role);
715                add_dedup(m, &e, &mut rep)?;
716            }
717        }
718    }
719    Ok(rep)
720}
721
722/// Import Letta archival memory (Passages): JSONL, one passage per line —
723/// the items of paginated `GET /v1/agents/{id}/archival-memory` responses
724/// (`{"text": ..., "created_at": ..., "tags": [...]}`).
725pub fn migrate_letta_archival(m: &mut DejaDB, ns: &str, jsonl: &str) -> Result<MigrateReport> {
726    let mut rep = MigrateReport::default();
727    for (lineno, line) in jsonl.lines().enumerate() {
728        let line = line.trim();
729        if line.is_empty() {
730            continue;
731        }
732        let Ok(v) = serde_json::from_str::<Value>(line) else {
733            rep.skipped += 1;
734            rep.note(format!("line {}: not valid JSON", lineno + 1));
735            continue;
736        };
737        let Some(text) = as_str(&v, &["text", "content", "memory"]) else {
738            rep.skipped += 1;
739            rep.note(format!("line {}: no passage text", lineno + 1));
740            continue;
741        };
742        let mut e = Event::new(text);
743        e.common.namespace = Some(ns.to_string());
744        e.common.source_type = Some("import".to_string());
745        e.common.created_at = parse_ts(v.get("created_at"));
746        e.common.tags = v
747            .get("tags")
748            .and_then(Value::as_array)
749            .map(|a| a.iter().filter_map(|t| t.as_str().map(str::to_string)).collect())
750            .unwrap_or_default();
751        if let Some(id) = as_str(&v, &["id"]) {
752            e.common.context = Some(json!({ "import": { "source": "letta", "id": id } }));
753        }
754        add_dedup(m, &e, &mut rep)?;
755    }
756    Ok(rep)
757}
758
759// ---- Zep / Graphiti --------------------------------------------------------
760
761/// Import Zep Cloud / Graphiti data: `{"edges": [...], "episodes": [...]}`
762/// (either key optional), or a bare array classified per item. Edge facts
763/// carry Zep's bi-temporal `valid_at`/`invalid_at` on DejaDB's world-time
764/// validity axis (`valid_from`/`valid_to`), so invalidated facts import as
765/// no-longer-valid instead of current. Episodes become thread-indexed Events.
766pub fn migrate_zep(m: &mut DejaDB, ns: &str, payload: &Value) -> Result<MigrateReport> {
767    let mut rep = MigrateReport::default();
768    let mut edges: Vec<&Value> = Vec::new();
769    let mut episodes: Vec<&Value> = Vec::new();
770    if let Some(arr) = payload.as_array() {
771        for it in arr {
772            if it.get("fact").is_some() {
773                edges.push(it);
774            } else {
775                episodes.push(it);
776            }
777        }
778    } else {
779        if let Some(a) = payload.get("edges").and_then(Value::as_array) {
780            edges.extend(a.iter());
781        }
782        if let Some(a) = payload.get("episodes").and_then(Value::as_array) {
783            episodes.extend(a.iter());
784        }
785    }
786    if edges.is_empty() && episodes.is_empty() {
787        return Err(DejaDbError::Validation(
788            "zep import: no edges or episodes found in payload".into(),
789        ));
790    }
791    for e in edges {
792        let Some(fact) = as_str(e, &["fact"]).filter(|f| !f.trim().is_empty()) else {
793            rep.skipped += 1;
794            rep.note("edge without fact text".into());
795            continue;
796        };
797        let uuid = as_str(e, &["uuid", "id"]).unwrap_or("");
798        let source = as_str(e, &["source_node_uuid"]).unwrap_or("graph");
799        let relation = as_str(e, &["name"]).filter(|n| !n.is_empty()).unwrap_or("zep_fact");
800        let mut import = json!({ "uuid": uuid });
801        if let Some(t) = as_str(e, &["target_node_uuid"]) {
802            import["target_node_uuid"] = json!(t);
803        }
804        let mut f = import_fact(
805            ns,
806            &format!("zep/{source}"),
807            relation,
808            fact,
809            "zep",
810            import,
811            parse_ts(e.get("created_at")).or_else(|| parse_ts(e.get("valid_at"))),
812            None,
813            Vec::new(),
814        );
815        f.common.valid_from = parse_ts(e.get("valid_at"));
816        f.common.valid_to =
817            parse_ts(e.get("invalid_at")).or_else(|| parse_ts(e.get("expired_at")));
818        add_dedup(m, &f, &mut rep)?;
819    }
820    for ep in episodes {
821        let Some(content) = as_str(ep, &["content", "text"]).filter(|c| !c.trim().is_empty())
822        else {
823            rep.skipped += 1;
824            rep.note("episode without content".into());
825            continue;
826        };
827        let mut e = Event::new(content);
828        e.common.namespace = Some(ns.to_string());
829        e.common.source_type = Some("import".to_string());
830        e.common.created_at = parse_ts(ep.get("created_at"));
831        e.session_id = as_str(ep, &["thread_id", "session_id"]).map(|s| format!("zep/{s}"));
832        if let Some(role) = as_str(ep, &["role"]) {
833            e.role = Role::from_str(role);
834        }
835        if let Some(id) = as_str(ep, &["uuid", "id"]) {
836            e.common.context = Some(json!({ "import": { "source": "zep", "id": id } }));
837        }
838        add_dedup(m, &e, &mut rep)?;
839    }
840    Ok(rep)
841}
842
843// ---- generic JSONL ---------------------------------------------------------
844
845/// Import generic JSONL — the escape hatch for pgvector/Chroma/homegrown
846/// stores: dump your table with one JSON object per line. `subject` +
847/// `relation` + `object` → Fact; otherwise `content`/`text` → Event.
848/// Optional per-line fields: `created_at`, `confidence`, `tags`, `user_id`,
849/// `session_id`, `embedding_text`.
850pub fn migrate_jsonl(m: &mut DejaDB, ns: &str, jsonl: &str) -> Result<MigrateReport> {
851    let mut rep = MigrateReport::default();
852    for (lineno, line) in jsonl.lines().enumerate() {
853        let line = line.trim();
854        if line.is_empty() {
855            continue;
856        }
857        let Ok(v) = serde_json::from_str::<Value>(line) else {
858            rep.skipped += 1;
859            rep.note(format!("line {}: not valid JSON", lineno + 1));
860            continue;
861        };
862        let created = parse_ts(v.get("created_at"));
863        let tags: Vec<String> = v
864            .get("tags")
865            .and_then(Value::as_array)
866            .map(|a| a.iter().filter_map(|t| t.as_str().map(str::to_string)).collect())
867            .unwrap_or_default();
868        let conf = v.get("confidence").and_then(Value::as_f64);
869        let et = as_str(&v, &["embedding_text"]).map(clip_et);
870        let content = as_str(&v, &["content", "text", "memory"]);
871        if let (Some(s), Some(r), Some(o)) = (
872            as_str(&v, &["subject"]),
873            as_str(&v, &["relation"]),
874            as_str(&v, &["object"]),
875        ) {
876            let mut f = Fact::new(s, r, o);
877            f.common.namespace = Some(ns.to_string());
878            f.common.source_type = Some("import".to_string());
879            f.common.created_at = created;
880            f.common.tags = tags;
881            f.common.user_id = as_str(&v, &["user_id"]).map(str::to_string);
882            if let Some(c) = conf {
883                f.common.confidence = c;
884            }
885            if let Some(c) = content {
886                f.common.context = Some(json!({ "content": c }));
887                f.common.embedding_text = Some(clip_et(&format!("{s} {r} {o} {c}")));
888            }
889            if let Some(et) = et {
890                f.common.embedding_text = Some(et);
891            }
892            add_dedup(m, &f, &mut rep)?;
893        } else if let Some(c) = content.filter(|c| !c.trim().is_empty()) {
894            let mut e = Event::new(c);
895            e.common.namespace = Some(ns.to_string());
896            e.common.source_type = Some("import".to_string());
897            e.common.created_at = created;
898            e.common.tags = tags;
899            e.common.user_id = as_str(&v, &["user_id"]).map(str::to_string);
900            e.session_id = as_str(&v, &["session_id"]).map(str::to_string);
901            if let Some(et) = et {
902                e.common.embedding_text = Some(et);
903            }
904            add_dedup(m, &e, &mut rep)?;
905        } else {
906            rep.skipped += 1;
907            rep.note(format!(
908                "line {}: needs subject+relation+object or content",
909                lineno + 1
910            ));
911        }
912    }
913    Ok(rep)
914}
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919
920    #[test]
921    fn iso8601_parses_common_shapes() {
922        // spot values cross-checked against `date -u -d ... +%s`
923        assert_eq!(iso8601_to_ms("1970-01-01T00:00:00Z"), Some(0));
924        assert_eq!(iso8601_to_ms("2024-01-01"), Some(1_704_067_200_000));
925        assert_eq!(iso8601_to_ms("2024-01-01T00:00:00Z"), Some(1_704_067_200_000));
926        assert_eq!(iso8601_to_ms("2024-01-01 00:00:00"), Some(1_704_067_200_000));
927        assert_eq!(
928            iso8601_to_ms("2024-01-01T00:00:00.500Z"),
929            Some(1_704_067_200_500)
930        );
931        // -07:00 means the instant is 7h LATER in UTC
932        assert_eq!(
933            iso8601_to_ms("2023-12-31T17:00:00-07:00"),
934            Some(1_704_067_200_000)
935        );
936        assert_eq!(
937            iso8601_to_ms("2024-01-01T01:00:00+01:00"),
938            Some(1_704_067_200_000)
939        );
940        // fractional micros truncate to ms
941        assert_eq!(
942            iso8601_to_ms("2024-01-01T00:00:00.982509Z"),
943            Some(1_704_067_200_982)
944        );
945        assert_eq!(iso8601_to_ms("not a date"), None);
946        assert_eq!(iso8601_to_ms("2024-13-01"), None);
947    }
948
949    #[test]
950    fn parse_ts_handles_seconds_and_ms() {
951        assert_eq!(parse_ts(Some(&json!(1_704_067_200))), Some(1_704_067_200_000));
952        assert_eq!(parse_ts(Some(&json!(1_704_067_200_000i64))), Some(1_704_067_200_000));
953        assert_eq!(parse_ts(Some(&json!("2024-01-01T00:00:00Z"))), Some(1_704_067_200_000));
954        assert_eq!(parse_ts(None), None);
955    }
956
957    #[test]
958    fn frontmatter_variants() {
959        let (t, p, tags, _) = parse_frontmatter(
960            "---\ntitle: Coffee Brewing\npermalink: notes/coffee\ntags:\n- brewing\n- espresso\n---\nbody",
961        );
962        assert_eq!(t.as_deref(), Some("Coffee Brewing"));
963        assert_eq!(p.as_deref(), Some("notes/coffee"));
964        assert_eq!(tags, vec!["brewing", "espresso"]);
965        let (_, _, tags, _) = parse_frontmatter("---\ntags: [a, b]\n---\nbody");
966        assert_eq!(tags, vec!["a", "b"]);
967        let (t, p, _, _) = parse_frontmatter("no frontmatter at all");
968        assert!(t.is_none() && p.is_none());
969    }
970
971    #[test]
972    fn clip_et_respects_char_boundaries() {
973        let s = "é".repeat(5000); // 2 bytes each
974        let clipped = clip_et(&s);
975        assert!(clipped.len() <= ET_MAX_BYTES);
976        assert!(clipped.chars().all(|c| c == 'é'));
977    }
978}