Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 68 additions & 8 deletions crates/agentic-memory/src/cli/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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:<n>); `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;
}
}
}
}

Expand All @@ -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;
}
Expand All @@ -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(())
}

Expand Down
32 changes: 32 additions & 0 deletions crates/agentic-memory/src/graph/memory_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> {
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
Expand Down