1use crate::cli::CliOutput;
7use crate::{config, db, identity, mine, models, validate};
8use anyhow::Result;
9use chrono::{Duration, Utc};
10use clap::Args;
11use models::Tier;
12use std::path::{Path, PathBuf};
13
14#[derive(Args)]
15pub struct ImportArgs {
16 #[arg(long, default_value_t = false)]
19 pub trust_source: bool,
20}
21
22#[derive(Args)]
23pub struct MineArgs {
24 pub path: PathBuf,
26 #[arg(long, short)]
28 pub format: String,
29 #[arg(long, short)]
31 pub namespace: Option<String>,
32 #[arg(long, short, default_value = "mid")]
34 pub tier: String,
35 #[arg(long, default_value_t = 3)]
37 pub min_messages: usize,
38 #[arg(long, default_value_t = false)]
40 pub dry_run: bool,
41}
42
43pub fn export(db_path: &Path, out: &mut CliOutput<'_>) -> Result<()> {
45 let conn = db::open(db_path)?;
46 let memories = db::export_all(&conn)?;
47 let links = db::export_links(&conn)?;
48 writeln!(
49 out.stdout,
50 "{}",
51 serde_json::to_string_pretty(&serde_json::json!({
52 "memories": memories, "links": links, "count": memories.len(),
53 "exported_at": Utc::now().to_rfc3339(),
54 }))?
55 )?;
56 Ok(())
57}
58
59pub fn import(
62 db_path: &Path,
63 args: &ImportArgs,
64 json_out: bool,
65 cli_agent_id: Option<&str>,
66 out: &mut CliOutput<'_>,
67) -> Result<()> {
68 let mut buf = String::new();
69 use std::io::Read as _;
70 std::io::stdin().read_to_string(&mut buf)?;
71 import_from_str(&buf, db_path, args, json_out, cli_agent_id, out)
72}
73
74pub(crate) fn import_from_str(
77 payload: &str,
78 db_path: &Path,
79 args: &ImportArgs,
80 json_out: bool,
81 cli_agent_id: Option<&str>,
82 out: &mut CliOutput<'_>,
83) -> Result<()> {
84 let data: serde_json::Value = serde_json::from_str(payload)?;
85 let memories: Vec<models::Memory> =
86 serde_json::from_value(data.get("memories").cloned().unwrap_or_default())?;
87 let links: Vec<models::MemoryLink> =
88 serde_json::from_value(data.get("links").cloned().unwrap_or_default()).unwrap_or_default();
89
90 let caller_id = identity::resolve_agent_id(cli_agent_id, None)?;
91
92 let conn = db::open(db_path)?;
93 let mut imported = 0usize;
94 let mut restamped = 0usize;
95 let mut errors = Vec::new();
96 for mut mem in memories {
97 if !args.trust_source {
98 let original = mem
99 .metadata
100 .get("agent_id")
101 .and_then(serde_json::Value::as_str)
102 .map(ToString::to_string);
103 if let Some(obj) = mem.metadata.as_object_mut() {
104 obj.insert(
105 "agent_id".to_string(),
106 serde_json::Value::String(caller_id.clone()),
107 );
108 if let Some(orig) = original.as_ref()
109 && orig.as_str() != caller_id
110 {
111 obj.insert(
112 "imported_from_agent_id".to_string(),
113 serde_json::Value::String(orig.clone()),
114 );
115 restamped += 1;
116 }
117 }
118 }
119 if let Err(e) = validate::validate_memory(&mem) {
120 errors.push(format!("{}: {}", mem.id, e));
121 continue;
122 }
123 match db::insert(&conn, &mem) {
124 Ok(_) => imported += 1,
125 Err(e) => errors.push(format!("{}: {}", mem.id, e)),
126 }
127 }
128 for link in links {
129 if validate::validate_link(&link.source_id, &link.target_id, &link.relation).is_err() {
130 continue;
131 }
132 let _ = db::create_link(&conn, &link.source_id, &link.target_id, &link.relation);
133 }
134 if json_out {
135 writeln!(
136 out.stdout,
137 "{}",
138 serde_json::json!({
139 "imported": imported,
140 "restamped": restamped,
141 "trusted_source": args.trust_source,
142 "errors": errors
143 })
144 )?;
145 } else {
146 writeln!(
147 out.stdout,
148 "imported: {imported} (restamped agent_id on {restamped})"
149 )?;
150 if args.trust_source {
151 writeln!(
152 out.stderr,
153 "warning: --trust-source: agent_id from imported JSON was preserved as-is"
154 )?;
155 }
156 if !errors.is_empty() {
157 for e in &errors {
158 writeln!(out.stderr, " {e}")?;
159 }
160 }
161 }
162 Ok(())
163}
164
165#[allow(clippy::too_many_lines)]
167pub fn mine(
168 db_path: &Path,
169 args: MineArgs,
170 json_out: bool,
171 app_config: &config::AppConfig,
172 cli_agent_id: Option<&str>,
173 out: &mut CliOutput<'_>,
174) -> Result<()> {
175 let miner_agent_id = identity::resolve_agent_id(cli_agent_id, None)?;
176 let format = mine::Format::from_str(&args.format).ok_or_else(|| {
177 anyhow::anyhow!(
178 "invalid format: {} (use claude, chatgpt, slack)",
179 args.format
180 )
181 })?;
182 let tier = Tier::from_str(&args.tier)
183 .ok_or_else(|| anyhow::anyhow!("invalid tier: {} (use short, mid, long)", args.tier))?;
184 let namespace = args.namespace.unwrap_or_else(|| match format {
185 mine::Format::Claude => "claude-export".to_string(),
186 mine::Format::ChatGpt => "chatgpt-export".to_string(),
187 mine::Format::Slack => "slack-export".to_string(),
188 });
189
190 let path = std::path::Path::new(&args.path);
191
192 let conversations = match format {
193 mine::Format::Claude => mine::parse_claude(path)?,
194 mine::Format::ChatGpt => mine::parse_chatgpt(path)?,
195 mine::Format::Slack => mine::parse_slack(path)?,
196 };
197
198 let filtered: Vec<_> = conversations
199 .iter()
200 .filter(|c| c.messages.len() >= args.min_messages)
201 .collect();
202
203 if args.dry_run {
204 if json_out {
205 let items: Vec<serde_json::Value> = filtered
206 .iter()
207 .filter_map(|c| {
208 mine::conversation_to_memory(c, format).map(|m| {
209 serde_json::json!({
210 "title": m.title,
211 "content_length": m.content.len(),
212 "messages": c.messages.len(),
213 "source": m.source_format,
214 })
215 })
216 })
217 .collect();
218 writeln!(
219 out.stdout,
220 "{}",
221 serde_json::to_string_pretty(&serde_json::json!({
222 "dry_run": true,
223 "total_conversations": conversations.len(),
224 "filtered": filtered.len(),
225 "would_import": items.len(),
226 "namespace": namespace,
227 "tier": tier.as_str(),
228 "memories": items,
229 }))?
230 )?;
231 } else {
232 writeln!(out.stdout, "Dry run — no memories will be stored\n")?;
233 writeln!(
234 out.stdout,
235 "Total conversations found: {}",
236 conversations.len()
237 )?;
238 writeln!(
239 out.stdout,
240 "After filter (>={} messages): {}",
241 args.min_messages,
242 filtered.len()
243 )?;
244 writeln!(out.stdout, "Namespace: {namespace}")?;
245 writeln!(out.stdout, "Tier: {tier}\n")?;
246 for c in &filtered {
247 if let Some(m) = mine::conversation_to_memory(c, format) {
248 writeln!(
249 out.stdout,
250 " {} ({} msgs, {} bytes)",
251 m.title,
252 c.messages.len(),
253 m.content.len()
254 )?;
255 }
256 }
257 }
258 return Ok(());
259 }
260
261 let conn = db::open(db_path)?;
262 let _ = db::gc_if_needed(&conn, app_config.effective_archive_on_gc());
263 let now = Utc::now();
264
265 let mut imported = 0usize;
266 let mut skipped = 0usize;
267 let mut errors = 0usize;
268
269 conn.execute_batch("BEGIN")?;
270
271 for conv in &filtered {
272 let Some(mined) = mine::conversation_to_memory(conv, format) else {
273 skipped += 1;
274 continue;
275 };
276
277 let expires_at = app_config
278 .effective_ttl()
279 .ttl_for_tier(&tier)
280 .map(|s| (now + Duration::seconds(s)).to_rfc3339());
281
282 let mut metadata = models::default_metadata();
283 if let Some(obj) = metadata.as_object_mut() {
284 obj.insert(
285 "agent_id".to_string(),
286 serde_json::Value::String(miner_agent_id.clone()),
287 );
288 obj.insert(
289 "mined_from".to_string(),
290 serde_json::Value::String(format.source_tag().to_string()),
291 );
292 }
293 let mem = models::Memory {
294 id: uuid::Uuid::new_v4().to_string(),
295 tier: tier.clone(),
296 namespace: namespace.clone(),
297 title: mined.title,
298 content: mined.content,
299 tags: vec![format.source_tag().to_string()],
300 priority: 5,
301 confidence: 0.8,
302 source: mined.source_format,
303 access_count: 0,
304 created_at: mined.created_at.unwrap_or_else(|| now.to_rfc3339()),
305 updated_at: now.to_rfc3339(),
306 last_accessed_at: None,
307 expires_at,
308 metadata,
309 };
310
311 match db::insert(&conn, &mem) {
312 Ok(_) => imported += 1,
313 Err(e) => {
314 errors += 1;
315 writeln!(
316 out.stderr,
317 "warning: failed to store '{}': {}",
318 mem.title, e
319 )?;
320 }
321 }
322
323 if imported.is_multiple_of(100) && imported > 0 {
324 conn.execute_batch("COMMIT")?;
325 conn.execute_batch("BEGIN")?;
326 }
327 }
328
329 conn.execute_batch("COMMIT")?;
330
331 if json_out {
332 writeln!(
333 out.stdout,
334 "{}",
335 serde_json::to_string(&serde_json::json!({
336 "imported": imported,
337 "skipped": skipped,
338 "errors": errors,
339 "total_conversations": conversations.len(),
340 "namespace": namespace,
341 "tier": tier.as_str(),
342 }))?
343 )?;
344 } else {
345 writeln!(
346 out.stdout,
347 "Imported {} memories from {} conversations (skipped: {}, errors: {})",
348 imported,
349 conversations.len(),
350 skipped,
351 errors
352 )?;
353 writeln!(out.stdout, "Namespace: {namespace}, Tier: {tier}")?;
354 }
355
356 Ok(())
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use crate::cli::test_utils::{TestEnv, seed_memory};
363
364 #[test]
367 fn test_export_empty_db() {
368 let mut env = TestEnv::fresh();
369 let db = env.db_path.clone();
370 let _ = seed_memory(&db, "ns-init", "init", "init");
372 {
373 let mut out = env.output();
374 export(&db, &mut out).unwrap();
375 }
376 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
377 assert!(v["memories"].is_array());
378 assert!(v["links"].is_array());
379 assert!(v["count"].is_u64());
380 assert!(v["exported_at"].is_string());
381 }
382
383 #[test]
384 fn test_export_with_memories_includes_links() {
385 let mut env = TestEnv::fresh();
386 let db = env.db_path.clone();
387 let id1 = seed_memory(&db, "ns", "a", "content-a");
388 let id2 = seed_memory(&db, "ns", "b", "content-b");
389 let conn = db::open(&db).unwrap();
390 db::create_link(&conn, &id1, &id2, "relates").unwrap();
391 drop(conn);
392 {
393 let mut out = env.output();
394 export(&db, &mut out).unwrap();
395 }
396 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
397 let mems = v["memories"].as_array().unwrap();
398 assert_eq!(mems.len(), 2);
399 let links = v["links"].as_array().unwrap();
400 assert_eq!(links.len(), 1);
401 }
402
403 #[test]
404 fn test_export_pretty_printed_json() {
405 let mut env = TestEnv::fresh();
406 let db = env.db_path.clone();
407 let _ = seed_memory(&db, "ns", "x", "y");
408 {
409 let mut out = env.output();
410 export(&db, &mut out).unwrap();
411 }
412 let s = env.stdout_str();
414 assert!(s.contains('\n'));
415 assert!(s.contains(" \"memories\""));
416 }
417
418 fn export_payload_at(db_path: &Path) -> String {
421 let mut buf = Vec::<u8>::new();
422 let mut errbuf = Vec::<u8>::new();
423 let mut out = CliOutput::from_std(&mut buf, &mut errbuf);
424 export(db_path, &mut out).unwrap();
425 String::from_utf8(buf).unwrap()
426 }
427
428 #[test]
429 fn test_import_default_restamps_agent_id() {
430 let src = TestEnv::fresh();
432 let src_db = src.db_path.clone();
433 let id = seed_memory(&src_db, "ns", "src-title", "src-content");
434 {
435 let conn = db::open(&src_db).unwrap();
436 conn.execute(
437 "UPDATE memories SET metadata = json_set(metadata, '$.agent_id', 'other-agent') WHERE id = ?1",
438 rusqlite::params![id],
439 )
440 .unwrap();
441 }
442 let payload = export_payload_at(&src_db);
443
444 let mut dst = TestEnv::fresh();
445 let dst_db = dst.db_path.clone();
446 let args = ImportArgs {
447 trust_source: false,
448 };
449 {
450 let mut out = dst.output();
451 import_from_str(
452 &payload,
453 &dst_db,
454 &args,
455 true,
456 Some("caller-agent"),
457 &mut out,
458 )
459 .unwrap();
460 }
461 let conn = db::open(&dst_db).unwrap();
462 let mem = db::get(&conn, &id).unwrap().unwrap();
463 assert_eq!(
464 mem.metadata.get("agent_id").and_then(|v| v.as_str()),
465 Some("caller-agent")
466 );
467 assert_eq!(
468 mem.metadata
469 .get("imported_from_agent_id")
470 .and_then(|v| v.as_str()),
471 Some("other-agent")
472 );
473 }
474
475 #[test]
476 fn test_import_trust_source_preserves_agent_id() {
477 let src = TestEnv::fresh();
478 let src_db = src.db_path.clone();
479 let id = seed_memory(&src_db, "ns", "tt", "cc");
480 {
481 let conn = db::open(&src_db).unwrap();
482 conn.execute(
483 "UPDATE memories SET metadata = json_set(metadata, '$.agent_id', 'preserved-agent') WHERE id = ?1",
484 rusqlite::params![id],
485 )
486 .unwrap();
487 }
488 let payload = export_payload_at(&src_db);
489
490 let mut dst = TestEnv::fresh();
491 let dst_db = dst.db_path.clone();
492 let args = ImportArgs { trust_source: true };
493 {
494 let mut out = dst.output();
495 import_from_str(&payload, &dst_db, &args, false, Some("caller"), &mut out).unwrap();
496 }
497 let conn = db::open(&dst_db).unwrap();
498 let mem = db::get(&conn, &id).unwrap().unwrap();
499 assert_eq!(
500 mem.metadata.get("agent_id").and_then(|v| v.as_str()),
501 Some("preserved-agent")
502 );
503 assert!(dst.stderr_str().contains("trust-source"));
504 }
505
506 #[test]
507 fn test_import_invalid_memory_skipped_with_error() {
508 let mut dst = TestEnv::fresh();
509 let dst_db = dst.db_path.clone();
510 let payload = serde_json::json!({
512 "memories": [
513 {
514 "id": "11111111-1111-1111-1111-111111111111",
515 "tier": "mid",
516 "namespace": "ns",
517 "title": "", "content": "c",
519 "tags": [],
520 "priority": 5,
521 "confidence": 1.0,
522 "source": "import",
523 "access_count": 0,
524 "created_at": "2026-01-01T00:00:00+00:00",
525 "updated_at": "2026-01-01T00:00:00+00:00",
526 "last_accessed_at": null,
527 "expires_at": null,
528 "metadata": {"agent_id": "x"}
529 },
530 {
531 "id": "22222222-2222-2222-2222-222222222222",
532 "tier": "mid",
533 "namespace": "ns",
534 "title": "valid-row",
535 "content": "c",
536 "tags": [],
537 "priority": 5,
538 "confidence": 1.0,
539 "source": "import",
540 "access_count": 0,
541 "created_at": "2026-01-01T00:00:00+00:00",
542 "updated_at": "2026-01-01T00:00:00+00:00",
543 "last_accessed_at": null,
544 "expires_at": null,
545 "metadata": {"agent_id": "x"}
546 }
547 ],
548 "links": [],
549 "count": 2,
550 "exported_at": "2026-01-01T00:00:00+00:00"
551 })
552 .to_string();
553 let args = ImportArgs { trust_source: true };
554 {
555 let mut out = dst.output();
556 import_from_str(&payload, &dst_db, &args, true, Some("caller"), &mut out).unwrap();
557 }
558 let v: serde_json::Value = serde_json::from_str(dst.stdout_str().trim()).unwrap();
559 assert_eq!(v["imported"].as_u64().unwrap(), 1);
560 let errs = v["errors"].as_array().unwrap();
561 assert!(!errs.is_empty(), "expected at least one error");
562 }
563
564 #[test]
565 fn test_import_invalid_link_skipped() {
566 let mut dst = TestEnv::fresh();
567 let dst_db = dst.db_path.clone();
568 let id1 = seed_memory(&dst_db, "ns", "a", "ca");
571 let id2 = seed_memory(&dst_db, "ns", "b", "cb");
572 let payload = serde_json::json!({
573 "memories": [],
574 "links": [
575 { "source_id": id1, "target_id": id2, "relation": "" },
576 { "source_id": id1, "target_id": id2, "relation": "supersedes" }
577 ],
578 "count": 0,
579 "exported_at": "2026-01-01T00:00:00+00:00"
580 })
581 .to_string();
582 let args = ImportArgs { trust_source: true };
583 {
584 let mut out = dst.output();
585 import_from_str(&payload, &dst_db, &args, true, Some("caller"), &mut out).unwrap();
586 }
587 let v: serde_json::Value = serde_json::from_str(dst.stdout_str().trim()).unwrap();
588 assert_eq!(v["imported"].as_u64().unwrap(), 0);
589 }
590
591 #[test]
592 fn test_import_roundtrip_export_import_preserves_data() {
593 let src = TestEnv::fresh();
594 let src_db = src.db_path.clone();
595 let _id = seed_memory(&src_db, "rt-ns", "rt-title", "rt-content");
596 let payload = export_payload_at(&src_db);
597
598 let mut dst = TestEnv::fresh();
599 let dst_db = dst.db_path.clone();
600 let args = ImportArgs { trust_source: true };
601 {
602 let mut out = dst.output();
603 import_from_str(&payload, &dst_db, &args, true, Some("caller"), &mut out).unwrap();
604 }
605 let conn = db::open(&dst_db).unwrap();
606 let all = db::export_all(&conn).unwrap();
607 assert_eq!(all.len(), 1);
608 assert_eq!(all[0].title, "rt-title");
609 assert_eq!(all[0].content, "rt-content");
610 assert_eq!(all[0].namespace, "rt-ns");
611 }
612
613 fn write_minimal_claude_export(dir: &Path) -> PathBuf {
616 let conv1 = serde_json::json!({
618 "uuid": "conv-1",
619 "name": "Conv with 5 messages",
620 "created_at": "2026-01-01T00:00:00.000Z",
621 "updated_at": "2026-01-01T00:00:00.000Z",
622 "chat_messages": [
623 { "uuid": "m1", "text": "hello", "sender": "human", "created_at": "2026-01-01T00:00:00.000Z" },
624 { "uuid": "m2", "text": "hi there", "sender": "assistant", "created_at": "2026-01-01T00:00:00.000Z" },
625 { "uuid": "m3", "text": "how are you", "sender": "human", "created_at": "2026-01-01T00:00:00.000Z" },
626 { "uuid": "m4", "text": "fine thanks", "sender": "assistant", "created_at": "2026-01-01T00:00:00.000Z" },
627 { "uuid": "m5", "text": "ok bye", "sender": "human", "created_at": "2026-01-01T00:00:00.000Z" }
628 ]
629 });
630 let conv2 = serde_json::json!({
631 "uuid": "conv-2",
632 "name": "Short Conv",
633 "created_at": "2026-01-01T00:00:00.000Z",
634 "updated_at": "2026-01-01T00:00:00.000Z",
635 "chat_messages": [
636 { "uuid": "m6", "text": "ping", "sender": "human", "created_at": "2026-01-01T00:00:00.000Z" }
637 ]
638 });
639 let p = dir.join("claude.jsonl");
640 let body = format!("{}\n{}\n", conv1, conv2);
641 std::fs::write(&p, body).unwrap();
642 p
643 }
644
645 #[test]
646 fn test_mine_dry_run_writes_nothing() {
647 let mut env = TestEnv::fresh();
648 let db = env.db_path.clone();
649 let cfg = config::AppConfig::default();
650 let tmp = tempfile::tempdir().unwrap();
651 let claude_path = write_minimal_claude_export(tmp.path());
652 let args = MineArgs {
653 path: claude_path,
654 format: "claude".to_string(),
655 namespace: Some("mined-ns".to_string()),
656 tier: "mid".to_string(),
657 min_messages: 3,
658 dry_run: true,
659 };
660 {
661 let mut out = env.output();
662 mine(&db, args, true, &cfg, Some("miner"), &mut out).unwrap();
663 }
664 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
665 assert_eq!(v["dry_run"].as_bool().unwrap(), true);
666 if db.exists() {
669 let conn = db::open(&db).unwrap();
670 let all = db::export_all(&conn).unwrap();
671 assert_eq!(all.len(), 0);
672 }
673 }
674
675 #[test]
676 fn test_mine_filters_by_min_messages() {
677 let mut env = TestEnv::fresh();
678 let db = env.db_path.clone();
679 let cfg = config::AppConfig::default();
680 let tmp = tempfile::tempdir().unwrap();
681 let claude_path = write_minimal_claude_export(tmp.path());
682 let args = MineArgs {
684 path: claude_path,
685 format: "claude".to_string(),
686 namespace: Some("mined-ns".to_string()),
687 tier: "mid".to_string(),
688 min_messages: 3,
689 dry_run: true,
690 };
691 {
692 let mut out = env.output();
693 mine(&db, args, true, &cfg, Some("miner"), &mut out).unwrap();
694 }
695 let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
696 assert_eq!(v["total_conversations"].as_u64().unwrap(), 2);
697 assert_eq!(v["filtered"].as_u64().unwrap(), 1);
698 }
699}