1use serde::{Deserialize, Serialize};
22use uuid::Uuid;
23
24use crate::error::{Error, Result};
25use crate::hash::{compute_chain_hash, compute_content_hash};
26use crate::model::acl::Permission;
27use crate::model::event::EventType;
28use crate::model::memory::{ConsolidationState, MemoryRecord, MemoryType, Scope, SourceType};
29use crate::model::relation::Relation;
30use crate::query::MnemoEngine;
31#[allow(unused_imports)]
32use base64::Engine as _;
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ConsolidateRequest {
37 pub memory_ids: Vec<Uuid>,
41 pub topic_name: String,
44 pub agent_id: Option<String>,
47 pub summary: Option<String>,
50 pub supersede: Option<Uuid>,
57 pub thread_id: Option<String>,
59 pub metadata: Option<serde_json::Value>,
62}
63
64impl ConsolidateRequest {
65 pub fn new(memory_ids: Vec<Uuid>, topic_name: String) -> Self {
67 Self {
68 memory_ids,
69 topic_name,
70 agent_id: None,
71 summary: None,
72 supersede: None,
73 thread_id: None,
74 metadata: None,
75 }
76 }
77}
78
79#[non_exhaustive]
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct ConsolidateResponse {
83 pub topic_document_id: Uuid,
85 pub topic_name: String,
87 pub source_count: usize,
89 pub version: u32,
91 pub superseded_id: Option<Uuid>,
93 pub member_ids: Vec<Uuid>,
95 pub content_hash: String,
97 pub consolidation_event_id: Uuid,
99 pub revision_event_id: Option<Uuid>,
101}
102
103fn decrypt_in_place(engine: &MnemoEngine, record: &mut MemoryRecord) {
106 if let Some(ref enc) = engine.encryption {
107 match base64::engine::general_purpose::STANDARD.decode(&record.content) {
108 Ok(bytes) => match enc.decrypt(&bytes) {
109 Ok(plain) => match String::from_utf8(plain) {
110 Ok(text) => record.content = text,
111 Err(e) => {
112 tracing::error!(memory_id = %record.id, error = %e, "decrypted content is not valid UTF-8");
113 record.content = "[content unavailable: decryption error]".to_string();
114 }
115 },
116 Err(e) => {
117 tracing::error!(memory_id = %record.id, error = %e, "failed to decrypt member content");
118 record.content = "[content unavailable: decryption error]".to_string();
119 }
120 },
121 Err(e) => {
122 tracing::error!(memory_id = %record.id, error = %e, "failed to decode encrypted member content");
123 record.content = "[content unavailable: decryption error]".to_string();
124 }
125 }
126 }
127}
128
129pub async fn execute(
130 engine: &MnemoEngine,
131 request: ConsolidateRequest,
132) -> Result<ConsolidateResponse> {
133 if request.memory_ids.is_empty() {
135 return Err(Error::Validation("memory_ids cannot be empty".to_string()));
136 }
137 let topic = request.topic_name.trim().to_string();
138 if topic.is_empty() {
139 return Err(Error::Validation("topic_name cannot be empty".to_string()));
140 }
141 let agent_id = request
142 .agent_id
143 .clone()
144 .unwrap_or_else(|| engine.default_agent_id.clone());
145 super::validate_agent_id(&agent_id)?;
146
147 let mut members: Vec<MemoryRecord> = Vec::with_capacity(request.memory_ids.len());
151 let mut seen: std::collections::HashSet<Uuid> = std::collections::HashSet::new();
152 for id in &request.memory_ids {
153 if !seen.insert(*id) {
154 continue;
155 }
156 let mut record = engine
157 .storage
158 .get_memory(*id)
159 .await?
160 .ok_or_else(|| Error::NotFound(format!("memory {id} not found")))?;
161 if record.is_deleted() {
162 return Err(Error::Validation(format!(
163 "memory {id} is deleted and cannot be consolidated"
164 )));
165 }
166 if !engine
167 .storage
168 .check_permission(*id, &agent_id, Permission::Read)
169 .await?
170 {
171 return Err(Error::PermissionDenied(format!(
172 "agent {agent_id} cannot read memory {id}"
173 )));
174 }
175 decrypt_in_place(engine, &mut record);
176 members.push(record);
177 }
178 members.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id)));
180
181 let (version, prev_version_id, superseded_id) = match request.supersede {
183 Some(old_id) => {
184 let old = engine.storage.get_memory(old_id).await?.ok_or_else(|| {
185 Error::NotFound(format!("topic document {old_id} to supersede not found"))
186 })?;
187 if old.agent_id != agent_id {
188 return Err(Error::PermissionDenied(format!(
189 "agent {agent_id} cannot supersede topic document {old_id}"
190 )));
191 }
192 (old.version.saturating_add(1), Some(old_id), Some(old_id))
193 }
194 None => (1, None, None),
195 };
196
197 let content_plain = match request.summary.as_ref() {
199 Some(s) if !s.trim().is_empty() => s.clone(),
200 _ => {
201 let mut body = format!("# {topic}\n\n");
202 for (i, m) in members.iter().enumerate() {
203 if i > 0 {
204 body.push_str("\n\n");
205 }
206 body.push_str(&m.content);
207 }
208 body
209 }
210 };
211
212 let consolidated_from: Vec<String> = members.iter().map(|m| m.id.to_string()).collect();
214 let member_meta: Vec<serde_json::Value> = members
215 .iter()
216 .map(|m| {
217 serde_json::json!({
218 "id": m.id.to_string(),
219 "source_type": m.source_type.to_string(),
220 "source_id": m.source_id,
221 "created_at": m.created_at,
222 "importance": m.importance,
223 })
224 })
225 .collect();
226 let mut meta_map = match request.metadata.clone() {
227 Some(serde_json::Value::Object(m)) => m,
228 _ => serde_json::Map::new(),
229 };
230 meta_map.insert("topic".to_string(), serde_json::json!(topic));
231 meta_map.insert(
232 "consolidated_from".to_string(),
233 serde_json::json!(consolidated_from),
234 );
235 meta_map.insert("members".to_string(), serde_json::json!(member_meta));
236 if let Some(sid) = superseded_id {
237 meta_map.insert(
238 "revision_of".to_string(),
239 serde_json::json!(sid.to_string()),
240 );
241 }
242 let metadata = serde_json::Value::Object(meta_map);
243
244 let now = chrono::Utc::now().to_rfc3339();
246 let id = Uuid::now_v7();
247 let content_hash = compute_content_hash(&content_plain, &agent_id, &now);
248 let prev_hash_raw = engine
249 .storage
250 .get_latest_memory_hash(&agent_id, request.thread_id.as_deref())
251 .await?;
252 let prev_hash = Some(compute_chain_hash(&content_hash, prev_hash_raw.as_deref()));
253 let importance = members.iter().map(|m| m.importance).fold(0.0_f32, f32::max);
254 let scope = members.first().map(|m| m.scope).unwrap_or(Scope::Private);
255 let org_id = members
256 .iter()
257 .find_map(|m| m.org_id.clone())
258 .or_else(|| engine.default_org_id.clone());
259 let embedding = engine.embedding.embed(&content_plain).await?;
260
261 let mut record = MemoryRecord {
262 id,
263 agent_id: agent_id.clone(),
264 content: content_plain.clone(),
265 memory_type: MemoryType::Semantic,
266 scope,
267 importance,
268 tags: vec![topic.clone()],
269 metadata,
270 embedding: Some(embedding.clone()),
271 content_hash: content_hash.clone(),
272 prev_hash,
273 source_type: SourceType::Consolidation,
274 source_id: None,
275 consolidation_state: ConsolidationState::Active,
276 access_count: 0,
277 org_id,
278 thread_id: request.thread_id.clone(),
279 created_at: now.clone(),
280 updated_at: now.clone(),
281 last_accessed_at: None,
282 expires_at: None,
283 deleted_at: None,
284 decay_rate: None,
285 created_by: Some("consolidate".to_string()),
286 version,
287 prev_version_id,
288 quarantined: false,
289 quarantine_reason: None,
290 decay_function: None,
291 };
292
293 if let Some(ref enc) = engine.encryption {
295 let encrypted = enc.encrypt(record.content.as_bytes())?;
296 record.content = base64::engine::general_purpose::STANDARD.encode(&encrypted);
297 }
298
299 engine.storage.insert_memory(&record).await?;
301 engine.index.add(id, &embedding)?;
302 if let Some(ref ft) = engine.full_text {
303 ft.add(id, &record.content)?;
304 ft.commit()?;
305 }
306
307 for m in &members {
309 let relation = Relation {
310 id: Uuid::now_v7(),
311 source_id: id,
312 target_id: m.id,
313 relation_type: "consolidated_from".to_string(),
314 weight: 1.0,
315 metadata: serde_json::Value::Object(serde_json::Map::new()),
316 created_at: now.clone(),
317 };
318 if let Err(e) = engine.storage.insert_relation(&relation).await {
319 tracing::error!(relation_id = %relation.id, error = %e, "failed to insert consolidation relation");
320 }
321 }
322
323 if let Some(old_id) = superseded_id
332 && let Some(mut old) = engine.storage.get_memory(old_id).await?
333 {
334 if let serde_json::Value::Object(ref mut m) = old.metadata {
335 m.insert(
336 "superseded_by".to_string(),
337 serde_json::json!(id.to_string()),
338 );
339 }
340 old.consolidation_state = ConsolidationState::Consolidated;
341 old.updated_at = chrono::Utc::now().to_rfc3339();
342 if let Err(e) = engine.storage.update_memory(&old).await {
343 tracing::error!(memory_id = %old_id, error = %e, "failed to mark superseded topic document");
344 }
345 }
346
347 let consolidation_event = super::event_builder::build_event(
349 engine,
350 &agent_id,
351 EventType::MemoryConsolidated,
352 serde_json::json!({
353 "topic": topic,
354 "consolidated_from": consolidated_from,
355 "topic_document_id": id.to_string(),
356 "version": version,
357 }),
358 &id.to_string(),
359 request.thread_id.clone(),
360 )
361 .await;
362 let consolidation_event_id = consolidation_event.id;
363 if let Err(e) = engine.storage.insert_event(&consolidation_event).await {
364 tracing::error!(event_id = %consolidation_event.id, error = %e, "failed to insert consolidation event");
365 }
366
367 let revision_event_id = if let Some(old_id) = superseded_id {
368 let revision_event = super::event_builder::build_event(
369 engine,
370 &agent_id,
371 EventType::MemoryRevised,
372 serde_json::json!({
373 "superseded_id": old_id.to_string(),
374 "superseded_by": id.to_string(),
375 "topic": topic,
376 }),
377 &id.to_string(),
378 request.thread_id.clone(),
379 )
380 .await;
381 let rid = revision_event.id;
382 if let Err(e) = engine.storage.insert_event(&revision_event).await {
383 tracing::error!(event_id = %revision_event.id, error = %e, "failed to insert revision event");
384 }
385 Some(rid)
386 } else {
387 None
388 };
389
390 let member_ids: Vec<Uuid> = members.iter().map(|m| m.id).collect();
391 let source_count = members.len();
392
393 if let Some(ref cache) = engine.cache {
395 cache.put(record);
396 }
397
398 Ok(ConsolidateResponse {
399 topic_document_id: id,
400 topic_name: topic,
401 source_count,
402 version,
403 superseded_id,
404 member_ids,
405 content_hash: hex::encode(&content_hash),
406 consolidation_event_id,
407 revision_event_id,
408 })
409}