1use 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
37const ET_MAX_BYTES: usize = 8192;
39const NOTES_MAX: usize = 50;
41
42#[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
74fn 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
98fn digest6(content: &str) -> String {
101 use sha2::{Digest, Sha256};
102 hex::encode(&Sha256::digest(content.as_bytes())[..6])
103}
104
105pub(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
117fn 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
128pub 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 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 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
187fn 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
208fn 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#[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
243pub 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
292pub 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 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 events.sort_by_key(|e| {
329 parse_ts(e.get("created_at").or_else(|| e.get("updated_at"))).unwrap_or(0)
330 });
331 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 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 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
463pub 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 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 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
534pub 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 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
587fn 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; } 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
638pub 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 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 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; }
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
722pub 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
759pub 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
843pub 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 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 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 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); let clipped = clip_et(&s);
975 assert!(clipped.len() <= ET_MAX_BYTES);
976 assert!(clipped.chars().all(|c| c == 'é'));
977 }
978}