diff --git a/crates/agentic-memory/src/cli/commands.rs b/crates/agentic-memory/src/cli/commands.rs index b46eb30..e5265e9 100644 --- a/crates/agentic-memory/src/cli/commands.rs +++ b/crates/agentic-memory/src/cli/commands.rs @@ -516,6 +516,8 @@ pub fn cmd_export( .iter() .map(|n| { serde_json::json!({ + "@id": format!("urn:amem:node:{}", n.id), + "@type": n.event_type.name(), "id": n.id, "event_type": n.event_type.name(), "created_at": n.created_at, @@ -567,13 +569,16 @@ pub fn cmd_import(path: &Path, json_path: &Path) -> AmemResult<()> { .map_err(|e| crate::types::AmemError::Compression(e.to_string()))?; let mut added_nodes = 0; + let mut skipped_nodes = 0; let mut added_edges = 0; + let mut skipped_edges = 0; if let Some(nodes) = parsed.get("nodes").and_then(|v| v.as_array()) { for node_val in nodes { let event_type = node_val .get("event_type") .and_then(|v| v.as_str()) + .or_else(|| node_val.get("@type").and_then(|v| v.as_str())) .and_then(EventType::from_name) .unwrap_or(EventType::Fact); let content = node_val @@ -588,13 +593,48 @@ pub fn cmd_import(path: &Path, json_path: &Path) -> AmemResult<()> { .get("confidence") .and_then(|v| v.as_f64()) .unwrap_or(1.0) as f32; - - let event = CognitiveEventBuilder::new(event_type, content) + // Authoritative identity/history fields: preserve when present. + // `@id` is the JSON-LD form (urn:amem:node:); `id` is legacy. + let explicit_id = node_val.get("id").and_then(|v| v.as_u64()).or_else(|| { + node_val + .get("@id") + .and_then(|v| v.as_str()) + .and_then(|s| s.rsplit(':').next()) + .and_then(|s| s.parse().ok()) + }); + let created_at = node_val.get("created_at").and_then(|v| v.as_u64()); + + let mut builder = CognitiveEventBuilder::new(event_type, content) .session_id(session_id) - .confidence(confidence) - .build(); - graph.add_node(event)?; - added_nodes += 1; + .confidence(confidence); + if let Some(ts) = created_at { + builder = builder.created_at(ts); + } + let mut event = builder.build(); + // Derived/compiled-layer state: carry through when exported. + if let Some(v) = node_val.get("access_count").and_then(|v| v.as_u64()) { + event.access_count = v as u32; + } + if let Some(v) = node_val.get("last_accessed").and_then(|v| v.as_u64()) { + event.last_accessed = v; + } + if let Some(v) = node_val.get("decay_score").and_then(|v| v.as_f64()) { + event.decay_score = v as f32; + } + + match explicit_id { + Some(id) => { + if graph.add_node_with_id(event, id)? { + added_nodes += 1; + } else { + skipped_nodes += 1; + } + } + None => { + graph.add_node(event)?; + added_nodes += 1; + } + } } } @@ -618,7 +658,24 @@ pub fn cmd_import(path: &Path, json_path: &Path) -> AmemResult<()> { .and_then(|v| v.as_f64()) .unwrap_or(1.0) as f32; - let edge = Edge::new(source_id, target_id, edge_type, weight); + // Idempotent: an identical (source, target, type) edge is the same edge. + if graph + .edges() + .iter() + .any(|e| { + e.source_id == source_id + && e.target_id == target_id + && e.edge_type == edge_type + }) + { + skipped_edges += 1; + continue; + } + + let edge = match edge_val.get("created_at").and_then(|v| v.as_u64()) { + Some(ts) => Edge::with_timestamp(source_id, target_id, edge_type, weight, ts), + None => Edge::new(source_id, target_id, edge_type, weight), + }; if graph.add_edge(edge).is_ok() { added_edges += 1; } @@ -628,7 +685,10 @@ pub fn cmd_import(path: &Path, json_path: &Path) -> AmemResult<()> { let writer = AmemWriter::new(graph.dimension()); writer.write_to_file(&graph, path)?; - println!("Imported {} nodes and {} edges", added_nodes, added_edges); + println!( + "Imported {} nodes and {} edges ({} duplicate nodes, {} duplicate edges skipped)", + added_nodes, added_edges, skipped_nodes, skipped_edges + ); Ok(()) } diff --git a/crates/agentic-memory/src/graph/memory_graph.rs b/crates/agentic-memory/src/graph/memory_graph.rs index 76dad0a..727c1c7 100644 --- a/crates/agentic-memory/src/graph/memory_graph.rs +++ b/crates/agentic-memory/src/graph/memory_graph.rs @@ -207,6 +207,38 @@ impl MemoryGraph { Ok(id) } + /// Add a node with an explicit ID (lossless import path). + /// + /// Returns `Ok(false)` if a node with this ID already exists (idempotent + /// skip — duplicate `@id` means the same node), `Ok(true)` if inserted. + /// `next_id` is bumped past the inserted ID so later auto-assigned IDs + /// never collide. + pub fn add_node_with_id(&mut self, mut event: CognitiveEvent, id: u64) -> AmemResult { + if self.get_node(id).is_some() { + return Ok(false); + } + + event.validate(self.dimension)?; + if event.feature_vec.is_empty() { + event.feature_vec = vec![0.0; self.dimension]; + } else if event.feature_vec.len() != self.dimension { + return Err(AmemError::DimensionMismatch { + expected: self.dimension, + got: event.feature_vec.len(), + }); + } + + event.id = id; + self.next_id = self.next_id.max(id + 1); + + self.type_index.add_node(&event); + self.temporal_index.add_node(&event); + self.session_index.add_node(&event); + self.nodes.push(event); + + Ok(true) + } + /// Add an edge between two existing nodes. pub fn add_edge(&mut self, edge: Edge) -> AmemResult<()> { // Validate: no self-edges