1use async_trait::async_trait;
2use serde_json::json;
3
4use bamboo_agent_core::tools::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
5use bamboo_agent_core::Session;
6use bamboo_memory::memory_store::{
7 DurableMemoryStatus, LegacyProjectMemoryReadRoot, MemoryQueryOptions, MemoryScope, MemoryStore,
8 MAX_MAX_CHARS, MAX_QUERY_LIMIT,
9};
10use bamboo_tools::tools::session_memory::{
11 execute_session_memory_action, SessionMemoryAction, MEMORY_SESSION_ACTION_NAMES,
12};
13
14mod args;
15mod parsing;
16
17#[cfg(test)]
18mod tests;
19
20use args::MemoryArgs;
21
22#[derive(Clone)]
23pub struct MemoryTool {
24 session_repo: bamboo_engine::SessionRepository,
25 memory_store: MemoryStore,
26 project_store: Option<std::sync::Arc<bamboo_projects::ProjectStore>>,
27}
28
29struct ResolvedProjectMemoryAccess {
30 store: MemoryStore,
31 project_key: Option<String>,
32 writable: bool,
33}
34
35impl MemoryTool {
36 pub fn new(
37 session_repo: bamboo_engine::SessionRepository,
38 data_dir: impl Into<std::path::PathBuf>,
39 ) -> Self {
40 Self {
41 session_repo,
42 memory_store: MemoryStore::new(data_dir),
43 project_store: None,
44 }
45 }
46
47 pub fn with_project_store(
48 mut self,
49 project_store: std::sync::Arc<bamboo_projects::ProjectStore>,
50 ) -> Self {
51 self.project_store = Some(project_store);
52 self
53 }
54
55 async fn session_for_context(&self, session_id: Option<&str>) -> Option<Session> {
56 self.session_repo.load(session_id?).await
57 }
58
59 async fn resolve_project_memory_access(
60 &self,
61 explicit: Option<&str>,
62 session_id: Option<&str>,
63 ) -> Result<ResolvedProjectMemoryAccess, ToolError> {
64 let explicit = explicit
65 .map(str::trim)
66 .filter(|value| !value.is_empty())
67 .map(ToString::to_string);
68 let session = self.session_for_context(session_id).await;
69 if let Some(session) = session.as_ref() {
70 if let bamboo_engine::project_context::SessionProjectIdentity::Invalid {
71 raw,
72 message,
73 } = bamboo_engine::project_context::ProjectContextResolver::session_project_identity(
74 session,
75 ) {
76 return Err(ToolError::InvalidArguments(format!(
77 "session carries an invalid Project identity '{raw}': {message}"
78 )));
79 }
80 }
81 if let Some(project_id) = session.as_ref().and_then(
82 bamboo_engine::project_context::ProjectContextResolver::project_id_from_session,
83 ) {
84 if explicit
85 .as_deref()
86 .is_some_and(|requested| requested != project_id.as_str())
87 {
88 return Err(ToolError::InvalidArguments(
89 "project_key cannot override the session's assigned Project".to_string(),
90 ));
91 }
92 let project_store = self.project_store.as_ref().ok_or_else(|| {
93 ToolError::Execution(
94 "Project memory resolver is unavailable for this assigned session".to_string(),
95 )
96 })?;
97 let roots = project_store
98 .project_memory_read_roots(&project_id)
99 .map_err(|error| {
100 ToolError::Execution(format!("Failed to resolve Project memory roots: {error}"))
101 })?;
102 let aliases = roots
103 .legacy_aliases
104 .into_iter()
105 .map(|legacy| LegacyProjectMemoryReadRoot {
106 project_key: legacy.legacy_project_key,
107 root: legacy.root,
108 })
109 .collect();
110 return Ok(ResolvedProjectMemoryAccess {
111 store: self
112 .memory_store
113 .for_project_with_legacy_read_roots(&project_id, aliases),
114 project_key: Some(project_id.to_string()),
115 writable: true,
116 });
117 }
118
119 let derived_legacy_key = session.as_ref().and_then(|session| {
120 bamboo_engine::project_context::ProjectContextResolver::memory_read_scope_for_session(
121 session,
122 )
123 });
124 if explicit.as_deref() != derived_legacy_key.as_deref() && explicit.is_some() {
125 return Err(ToolError::InvalidArguments(
126 "project_key cannot override the session's legacy Project read scope".to_string(),
127 ));
128 }
129 Ok(ResolvedProjectMemoryAccess {
130 store: self.memory_store.clone(),
131 project_key: derived_legacy_key,
132 writable: false,
133 })
134 }
135
136 async fn ensure_memory_mutation_allowed(
137 &self,
138 access: &ResolvedProjectMemoryAccess,
139 id: &str,
140 ) -> Result<(), ToolError> {
141 let Some(doc) = access
142 .store
143 .get_memory(id, access.project_key.as_deref())
144 .await
145 .map_err(|error| {
146 ToolError::Execution(format!("Failed to resolve memory mutation scope: {error}"))
147 })?
148 else {
149 return Ok(());
150 };
151 if doc.frontmatter.scope == MemoryScope::Project && !access.writable {
152 return Err(ToolError::Execution(
153 "Unassigned sessions may read legacy Project memory but cannot mutate it"
154 .to_string(),
155 ));
156 }
157 if access.store.is_read_only_project_memory_path(&doc.path) {
158 return Err(ToolError::Execution(
159 "Legacy Project memory aliases are read-only; migrate the memory before mutating it"
160 .to_string(),
161 ));
162 }
163 Ok(())
164 }
165}
166
167#[async_trait]
168impl Tool for MemoryTool {
169 fn name(&self) -> &str {
170 "memory"
171 }
172
173 fn description(&self) -> &str {
174 "Unified memory management tool for Bamboo. Use session_* actions for session continuity notes, and query/get/write/merge/split/consolidate/purge/inspect/rebuild for durable project/global memory backed by canonical topic files and derived indexes."
175 }
176
177 fn parameters_schema(&self) -> serde_json::Value {
178 json!({
179 "type": "object",
180 "properties": {
181 "action": {
182 "type": "string",
183 "enum": [
184 "session_read",
185 "session_append",
186 "session_replace",
187 "session_clear",
188 "session_list_topics",
189 "query",
190 "get",
191 "find_duplicates",
192 "write",
193 "merge",
194 "split",
195 "consolidate",
196 "purge",
197 "inspect",
198 "rebuild",
199 "scan_blobs",
200 "scan_duplicates"
201 ]
202 },
203 "scope": {"type": "string", "enum": ["session", "project", "global"]},
204 "granularity": {
205 "type": "string",
206 "enum": ["day", "week", "month", "quarter", "year"],
207 "description": "Optional temporal granularity for `write`, orthogonal to scope: day (today's working context), week (sprint), month, quarter (direction), year (long-term goals). Omit if the memory has no time horizon. Coarser granularities are prefix-cache friendly and recalled ahead of finer ones at equal relevance."
208 },
209 "project_key": {"type": "string"},
210 "topic": {"type": "string"},
211 "id": {"type": "string"},
212 "query": {"type": "string"},
213 "type": {"type": "string", "enum": ["user", "feedback", "project", "reference"]},
214 "title": {"type": "string"},
215 "content": {"type": "string"},
216 "tags": {"type": "array", "items": {"type": "string"}},
217 "pieces": {"type": "array", "items": {"type": "object"}},
218 "ids": {"type": "array", "items": {"type": "string"}},
219 "min_score": {"type": "number"},
220 "filters": {
221 "type": "object",
222 "description": "Optional narrowing for `query`/`purge`. Each sub-filter is independent and defaults to unfiltered when omitted or empty.",
223 "properties": {
224 "type": {
225 "type": "array",
226 "items": {"type": "string", "enum": ["user", "feedback", "project", "reference"]},
227 "description": "Restrict results to these memory types. Omit for no type filtering."
228 },
229 "status": {
230 "type": "array",
231 "items": {"type": "string", "enum": ["active", "stale", "superseded", "contradicted", "archived"]},
232 "description": "Restrict results to these statuses. Omit for no status filtering."
233 },
234 "granularity": {
235 "type": "array",
236 "items": {
237 "type": "string",
238 "enum": ["day", "week", "month", "quarter", "year"]
239 },
240 "description": "Restrict results to these temporal granularities: day (today's working context), week (sprint), month, quarter (direction), year (long-term goals). This is a hard filter (unlike the passive recall-into-prompt ordering): a memory whose granularity doesn't appear in this list is excluded entirely, and a memory with no granularity never matches a non-empty filter here. Omit for no granularity filtering."
241 }
242 }
243 },
244 "options": {"type": "object"},
245 "reason": {"type": "string"}
246 },
247 "required": ["action"]
248 })
249 }
250
251 fn classify(&self, args: &serde_json::Value) -> ToolClass {
252 let action = args
253 .get("action")
254 .and_then(|value| value.as_str())
255 .unwrap_or("")
256 .trim()
257 .to_ascii_lowercase();
258 match action.as_str() {
259 "session_read"
260 | "session_list_topics"
261 | "query"
262 | "get"
263 | "find_duplicates"
264 | "scan_blobs"
265 | "scan_duplicates"
266 | "inspect" => ToolClass::READONLY_PARALLEL,
267 _ => ToolClass::MUTATING_SERIAL,
268 }
269 }
270
271 async fn invoke(
272 &self,
273 args: serde_json::Value,
274 ctx: ToolCtx,
275 ) -> Result<ToolOutcome, ToolError> {
276 let session_id = ctx.session_id().ok_or_else(|| {
277 ToolError::Execution("memory requires a session_id in tool context".to_string())
278 })?;
279
280 let parsed: MemoryArgs = serde_json::from_value(args).map_err(|error| {
281 ToolError::InvalidArguments(format!("Invalid memory args: {error}"))
282 })?;
283
284 let result = match parsed {
285 MemoryArgs::SessionRead { topic, options } => {
286 let max_chars = options.and_then(|value| value.max_chars);
287 execute_session_memory_action(
288 &self.memory_store,
289 session_id,
290 SessionMemoryAction::Read,
291 topic.as_deref(),
292 None,
293 max_chars,
294 MEMORY_SESSION_ACTION_NAMES,
295 )
296 .await
297 }
298 MemoryArgs::SessionAppend { topic, content } => {
299 execute_session_memory_action(
300 &self.memory_store,
301 session_id,
302 SessionMemoryAction::Append,
303 topic.as_deref(),
304 Some(content.as_str()),
305 None,
306 MEMORY_SESSION_ACTION_NAMES,
307 )
308 .await
309 }
310 MemoryArgs::SessionReplace { topic, content } => {
311 execute_session_memory_action(
312 &self.memory_store,
313 session_id,
314 SessionMemoryAction::Replace,
315 topic.as_deref(),
316 Some(content.as_str()),
317 None,
318 MEMORY_SESSION_ACTION_NAMES,
319 )
320 .await
321 }
322 MemoryArgs::SessionClear { topic } => {
323 execute_session_memory_action(
324 &self.memory_store,
325 session_id,
326 SessionMemoryAction::Clear,
327 topic.as_deref(),
328 None,
329 None,
330 MEMORY_SESSION_ACTION_NAMES,
331 )
332 .await
333 }
334 MemoryArgs::SessionListTopics => {
335 execute_session_memory_action(
336 &self.memory_store,
337 session_id,
338 SessionMemoryAction::ListTopics,
339 None,
340 None,
341 None,
342 MEMORY_SESSION_ACTION_NAMES,
343 )
344 .await
345 }
346 MemoryArgs::Query {
347 scope,
348 query,
349 filters,
350 project_key,
351 options,
352 } => {
353 let scope = Self::parse_scope(Some(&scope))?;
354 if scope == MemoryScope::Session {
355 return Err(ToolError::InvalidArguments(
356 "query supports durable scopes only; use session_read/session_list_topics for session scope"
357 .to_string(),
358 ));
359 }
360 let memory_access = self
361 .resolve_project_memory_access(project_key.as_deref(), Some(session_id))
362 .await?;
363 let options = MemoryQueryOptions {
364 limit: options
365 .as_ref()
366 .and_then(|value| value.limit)
367 .map(|value| value.min(MAX_QUERY_LIMIT)),
368 max_chars: options
369 .as_ref()
370 .and_then(|value| value.max_chars)
371 .map(|value| value.min(MAX_MAX_CHARS)),
372 cursor: options.as_ref().and_then(|value| value.cursor.clone()),
373 include_related: options
374 .as_ref()
375 .and_then(|value| value.include_related)
376 .unwrap_or(false),
377 };
378 let (filter_types, filter_statuses, filter_granularity) =
379 Self::parse_query_filters(filters.as_ref())?;
380 let result = memory_access
381 .store
382 .query_scope(
383 scope,
384 memory_access.project_key.as_deref(),
385 query.as_deref(),
386 filter_types.as_ref(),
387 filter_statuses.as_ref(),
388 filter_granularity.as_ref(),
389 &options,
390 )
391 .await
392 .map_err(|error| {
393 ToolError::Execution(format!("Failed to query memory: {error}"))
394 })?;
395 Ok(ToolResult {
396 success: true,
397 result: json!({
398 "action": "query",
399 "success": true,
400 "data": result,
401 "summary": bamboo_memory::memory_store::summary_json(result.returned_count, result.matched_count),
402 "warnings": [],
403 }).to_string(),
404 display_preference: Some("json".to_string()),
405 images: Vec::new(),
406 })
407 }
408 MemoryArgs::Get {
409 id,
410 project_key,
411 options,
412 } => {
413 let memory_access = self
414 .resolve_project_memory_access(project_key.as_deref(), Some(session_id))
415 .await?;
416 let max_chars = options
417 .and_then(|value| value.max_chars)
418 .unwrap_or(MAX_MAX_CHARS)
419 .min(MAX_MAX_CHARS);
420 let Some(mut doc) = memory_access
421 .store
422 .get_memory(id.trim(), memory_access.project_key.as_deref())
423 .await
424 .map_err(|error| {
425 ToolError::Execution(format!("Failed to get memory: {error}"))
426 })?
427 else {
428 return Err(ToolError::Execution(format!(
429 "memory not found: {}",
430 id.trim()
431 )));
432 };
433 let (body, truncated) =
434 bamboo_memory::memory_store::truncate_chars(&doc.body, max_chars);
435 doc.body = body;
436 Ok(ToolResult {
437 success: true,
438 result: json!({
439 "action": "get",
440 "id": doc.frontmatter.id,
441 "memory": {
442 "frontmatter": doc.frontmatter,
443 "body": doc.body,
444 "path": doc.path,
445 "body_truncated": truncated,
446 }
447 })
448 .to_string(),
449 display_preference: Some("json".to_string()),
450 images: Vec::new(),
451 })
452 }
453 MemoryArgs::Write {
454 scope,
455 r#type,
456 title,
457 content,
458 tags,
459 project_key,
460 granularity,
461 options,
462 } => {
463 let scope = Self::parse_scope(Some(&scope))?;
464 if scope == MemoryScope::Session {
465 return Err(ToolError::InvalidArguments(
466 "write supports durable scopes only; use session_replace/session_append for session scope"
467 .to_string(),
468 ));
469 }
470 let granularity = Self::parse_granularity(granularity.as_deref())?;
471 let memory_access = self
472 .resolve_project_memory_access(project_key.as_deref(), Some(session_id))
473 .await?;
474 if scope == MemoryScope::Project && !memory_access.writable {
475 return Err(ToolError::Execution(
476 "Unassigned sessions may read legacy Project memory but cannot write it"
477 .to_string(),
478 ));
479 }
480 let doc = memory_access
481 .store
482 .write_memory(
483 scope,
484 memory_access.project_key.as_deref(),
485 Self::parse_type(&r#type)?,
486 &title,
487 &content,
488 &tags,
489 Some(session_id),
490 "main-model",
491 options
492 .and_then(|value| value.allow_merge_if_similar)
493 .unwrap_or(false),
494 granularity,
495 )
496 .await
497 .map_err(|error| {
498 ToolError::Execution(format!("Failed to write memory: {error}"))
499 })?;
500 Ok(ToolResult {
501 success: true,
502 result: json!({
503 "action": "write",
504 "memory": {
505 "id": doc.frontmatter.id,
506 "title": doc.frontmatter.title,
507 "type": doc.frontmatter.r#type,
508 "scope": doc.frontmatter.scope,
509 "status": doc.frontmatter.status,
510 "project_key": doc.frontmatter.project_key,
511 "path": doc.path,
512 }
513 })
514 .to_string(),
515 display_preference: Some("json".to_string()),
516 images: Vec::new(),
517 })
518 }
519 MemoryArgs::Merge {
520 id,
521 content,
522 tags,
523 project_key,
524 source_memory_ids,
525 mode,
526 reason,
527 } => {
528 let memory_access = self
529 .resolve_project_memory_access(project_key.as_deref(), Some(session_id))
530 .await?;
531 self.ensure_memory_mutation_allowed(&memory_access, id.trim())
532 .await?;
533 let mode = Self::parse_merge_mode(mode.as_deref())?;
534 if matches!(mode.as_deref(), Some("contradict")) {
535 let Some(result) = memory_access
536 .store
537 .mark_memory_contradicted(
538 id.trim(),
539 memory_access.project_key.as_deref(),
540 &source_memory_ids,
541 reason.as_deref().or(Some(content.trim())),
542 Some(session_id),
543 "main-model",
544 )
545 .await
546 .map_err(|error| {
547 ToolError::Execution(format!("Failed to contradict memory: {error}"))
548 })?
549 else {
550 return Err(ToolError::Execution(format!(
551 "memory not found: {}",
552 id.trim()
553 )));
554 };
555 Ok(ToolResult {
556 success: true,
557 result: json!({
558 "action": "merge",
559 "mode": "contradict",
560 "data": result,
561 })
562 .to_string(),
563 display_preference: Some("json".to_string()),
564 images: Vec::new(),
565 })
566 } else {
567 let Some(result) = memory_access
568 .store
569 .merge_memory(
570 id.trim(),
571 memory_access.project_key.as_deref(),
572 &content,
573 &tags,
574 Some(session_id),
575 "main-model",
576 &source_memory_ids,
577 )
578 .await
579 .map_err(|error| {
580 ToolError::Execution(format!("Failed to merge memory: {error}"))
581 })?
582 else {
583 return Err(ToolError::Execution(format!(
584 "memory not found: {}",
585 id.trim()
586 )));
587 };
588 Ok(ToolResult {
589 success: true,
590 result: json!({
591 "action": "merge",
592 "mode": mode.unwrap_or_else(|| "merge".to_string()),
593 "data": result,
594 })
595 .to_string(),
596 display_preference: Some("json".to_string()),
597 images: Vec::new(),
598 })
599 }
600 }
601 MemoryArgs::FindDuplicates {
602 scope,
603 title,
604 content,
605 r#type,
606 tags,
607 project_key,
608 options,
609 } => {
610 let scope = Self::parse_scope(Some(&scope))?;
611 if scope == MemoryScope::Session {
612 return Err(ToolError::InvalidArguments(
613 "find_duplicates supports durable scopes only".to_string(),
614 ));
615 }
616 let r#type = match r#type.as_deref() {
617 Some(value) => Some(Self::parse_type(value)?),
618 None => None,
619 };
620 let memory_access = self
621 .resolve_project_memory_access(project_key.as_deref(), Some(session_id))
622 .await?;
623 let limit = options
624 .and_then(|value| value.limit)
625 .unwrap_or(5)
626 .clamp(1, MAX_QUERY_LIMIT);
627 let candidates = memory_access
628 .store
629 .find_duplicate_candidates(
630 scope,
631 memory_access.project_key.as_deref(),
632 r#type,
633 &title,
634 content.as_deref().unwrap_or(""),
635 &tags,
636 limit,
637 )
638 .await
639 .map_err(|error| {
640 ToolError::Execution(format!("Failed to find duplicates: {error}"))
641 })?;
642 Ok(ToolResult {
643 success: true,
644 result: json!({
645 "action": "find_duplicates",
646 "candidates": candidates,
647 })
648 .to_string(),
649 display_preference: Some("json".to_string()),
650 images: Vec::new(),
651 })
652 }
653 MemoryArgs::Split {
654 id,
655 project_key,
656 pieces,
657 } => {
658 if pieces.is_empty() {
659 return Err(ToolError::InvalidArguments(
660 "split requires at least one piece".to_string(),
661 ));
662 }
663 let memory_access = self
664 .resolve_project_memory_access(project_key.as_deref(), Some(session_id))
665 .await?;
666 self.ensure_memory_mutation_allowed(&memory_access, id.trim())
667 .await?;
668 let mut split_pieces = Vec::with_capacity(pieces.len());
669 for piece in pieces {
670 let r#type = match piece.r#type.as_deref() {
671 Some(value) => Some(Self::parse_type(value)?),
672 None => None,
673 };
674 split_pieces.push(bamboo_memory::memory_store::MemorySplitPiece {
675 title: piece.title,
676 r#type,
677 content: piece.content,
678 tags: piece.tags,
679 });
680 }
681 let Some(result) = memory_access
682 .store
683 .split_memory(
684 id.trim(),
685 memory_access.project_key.as_deref(),
686 &split_pieces,
687 Some(session_id),
688 "main-model",
689 )
690 .await
691 .map_err(|error| {
692 ToolError::Execution(format!("Failed to split memory: {error}"))
693 })?
694 else {
695 return Err(ToolError::Execution(format!(
696 "memory not found: {}",
697 id.trim()
698 )));
699 };
700 Ok(ToolResult {
701 success: true,
702 result: json!({
703 "action": "split",
704 "data": result,
705 })
706 .to_string(),
707 display_preference: Some("json".to_string()),
708 images: Vec::new(),
709 })
710 }
711 MemoryArgs::ScanBlobs {
712 scope,
713 project_key,
714 min_sections,
715 options,
716 } => {
717 let scope = Self::parse_scope(Some(&scope))?;
718 if scope == MemoryScope::Session {
719 return Err(ToolError::InvalidArguments(
720 "scan_blobs supports durable scopes only".to_string(),
721 ));
722 }
723 let memory_access = self
724 .resolve_project_memory_access(project_key.as_deref(), Some(session_id))
725 .await?;
726 let min_sections = min_sections.unwrap_or(3);
727 let limit = options
728 .and_then(|value| value.limit)
729 .unwrap_or(20)
730 .clamp(1, 200);
731 let report = memory_access
732 .store
733 .scan_blob_candidates(
734 scope,
735 memory_access.project_key.as_deref(),
736 min_sections,
737 limit,
738 )
739 .await
740 .map_err(|error| {
741 ToolError::Execution(format!("Failed to scan blobs: {error}"))
742 })?;
743 Ok(ToolResult {
744 success: true,
745 result: json!({
746 "action": "scan_blobs",
747 "report": report,
748 })
749 .to_string(),
750 display_preference: Some("json".to_string()),
751 images: Vec::new(),
752 })
753 }
754 MemoryArgs::ScanDuplicates {
755 scope,
756 project_key,
757 min_score,
758 options,
759 } => {
760 let scope = Self::parse_scope(Some(&scope))?;
761 if scope == MemoryScope::Session {
762 return Err(ToolError::InvalidArguments(
763 "scan_duplicates supports durable scopes only".to_string(),
764 ));
765 }
766 let memory_access = self
767 .resolve_project_memory_access(project_key.as_deref(), Some(session_id))
768 .await?;
769 let min_score = min_score.unwrap_or(0.6);
770 let limit = options
771 .and_then(|value| value.limit)
772 .unwrap_or(20)
773 .clamp(1, 200);
774 let report = memory_access
775 .store
776 .scan_duplicate_clusters(
777 scope,
778 memory_access.project_key.as_deref(),
779 min_score,
780 5,
781 limit,
782 )
783 .await
784 .map_err(|error| {
785 ToolError::Execution(format!("Failed to scan duplicates: {error}"))
786 })?;
787 Ok(ToolResult {
788 success: true,
789 result: json!({
790 "action": "scan_duplicates",
791 "report": report,
792 })
793 .to_string(),
794 display_preference: Some("json".to_string()),
795 images: Vec::new(),
796 })
797 }
798 MemoryArgs::Consolidate {
799 ids,
800 title,
801 content,
802 r#type,
803 tags,
804 project_key,
805 } => {
806 if ids.len() < 2 {
807 return Err(ToolError::InvalidArguments(
808 "consolidate requires at least two source memory ids".to_string(),
809 ));
810 }
811 let r#type = match r#type.as_deref() {
812 Some(value) => Some(Self::parse_type(value)?),
813 None => None,
814 };
815 let memory_access = self
816 .resolve_project_memory_access(project_key.as_deref(), Some(session_id))
817 .await?;
818 let merged = bamboo_memory::memory_store::MemorySplitPiece {
819 title,
820 r#type,
821 content,
822 tags,
823 };
824 let ids: Vec<String> = ids.iter().map(|id| id.trim().to_string()).collect();
825 for id in &ids {
826 self.ensure_memory_mutation_allowed(&memory_access, id)
827 .await?;
828 }
829 let Some(result) = memory_access
830 .store
831 .consolidate_memories(
832 &ids,
833 memory_access.project_key.as_deref(),
834 &merged,
835 Some(session_id),
836 "main-model",
837 )
838 .await
839 .map_err(|error| {
840 ToolError::Execution(format!("Failed to consolidate memories: {error}"))
841 })?
842 else {
843 return Err(ToolError::Execution(
844 "one or more source memories not found".to_string(),
845 ));
846 };
847 Ok(ToolResult {
848 success: true,
849 result: json!({
850 "action": "consolidate",
851 "data": result,
852 })
853 .to_string(),
854 display_preference: Some("json".to_string()),
855 images: Vec::new(),
856 })
857 }
858 MemoryArgs::Purge {
859 id,
860 scope,
861 reason,
862 project_key,
863 filters,
864 mode,
865 } => {
866 let mode = match mode
867 .as_deref()
868 .map(str::trim)
869 .filter(|value| !value.is_empty())
870 {
871 Some(value) => Self::parse_status(value)?,
872 None => DurableMemoryStatus::Archived,
873 };
874 let memory_access = self
875 .resolve_project_memory_access(project_key.as_deref(), Some(session_id))
876 .await?;
877
878 if let Some(id) = id
879 .as_deref()
880 .map(str::trim)
881 .filter(|value| !value.is_empty())
882 {
883 self.ensure_memory_mutation_allowed(&memory_access, id)
884 .await?;
885 let Some(doc) = memory_access
886 .store
887 .archive_memory(
888 id,
889 memory_access.project_key.as_deref(),
890 mode,
891 reason.as_deref(),
892 )
893 .await
894 .map_err(|error| {
895 ToolError::Execution(format!("Failed to purge memory: {error}"))
896 })?
897 else {
898 return Err(ToolError::Execution(format!("memory not found: {}", id)));
899 };
900 Ok(ToolResult {
901 success: true,
902 result: json!({
903 "action": "purge",
904 "id": doc.frontmatter.id,
905 "status": doc.frontmatter.status,
906 })
907 .to_string(),
908 display_preference: Some("json".to_string()),
909 images: Vec::new(),
910 })
911 } else {
912 let scope = Self::parse_scope(scope.as_deref())?;
913 if scope == MemoryScope::Session {
914 return Err(ToolError::InvalidArguments(
915 "purge supports durable scopes only in v1".to_string(),
916 ));
917 }
918 if scope == MemoryScope::Project && !memory_access.writable {
919 return Err(ToolError::Execution(
920 "Unassigned sessions may read legacy Project memory but cannot purge it"
921 .to_string(),
922 ));
923 }
924 if scope == MemoryScope::Project {
925 let contains_alias = memory_access
926 .store
927 .list_memory_documents(scope, memory_access.project_key.as_deref())
928 .await
929 .map_err(|error| {
930 ToolError::Execution(format!(
931 "Failed to inspect Project memory aliases: {error}"
932 ))
933 })?
934 .iter()
935 .any(|doc| {
936 memory_access
937 .store
938 .is_read_only_project_memory_path(&doc.path)
939 });
940 if contains_alias {
941 return Err(ToolError::Execution(
942 "Batch purge is unavailable while read-only legacy Project memory aliases are present; migrate them first"
943 .to_string(),
944 ));
945 }
946 }
947 let (filter_types, filter_statuses, filter_granularity) =
948 Self::parse_query_filters(filters.as_ref())?;
949 let result = memory_access
950 .store
951 .purge_memories(
952 scope,
953 memory_access.project_key.as_deref(),
954 filter_types.as_ref(),
955 filter_statuses.as_ref(),
956 filter_granularity.as_ref(),
957 mode,
958 reason.as_deref(),
959 )
960 .await
961 .map_err(|error| {
962 ToolError::Execution(format!("Failed to purge memory: {error}"))
963 })?;
964 Ok(ToolResult {
965 success: true,
966 result: json!({
967 "action": "purge",
968 "data": result,
969 })
970 .to_string(),
971 display_preference: Some("json".to_string()),
972 images: Vec::new(),
973 })
974 }
975 }
976 MemoryArgs::Inspect { scope, project_key } => {
977 let scope = Self::parse_scope(Some(&scope))?;
978 if scope == MemoryScope::Session {
979 return Err(ToolError::InvalidArguments(
980 "inspect supports durable scopes only in v1".to_string(),
981 ));
982 }
983 let memory_access = self
984 .resolve_project_memory_access(project_key.as_deref(), Some(session_id))
985 .await?;
986 let result = memory_access
987 .store
988 .inspect_scope(scope, memory_access.project_key.as_deref())
989 .await
990 .map_err(|error| {
991 ToolError::Execution(format!("Failed to inspect memory: {error}"))
992 })?;
993 Ok(ToolResult {
994 success: true,
995 result: json!({
996 "action": "inspect",
997 "data": result,
998 })
999 .to_string(),
1000 display_preference: Some("json".to_string()),
1001 images: Vec::new(),
1002 })
1003 }
1004 MemoryArgs::Rebuild { scope, project_key } => {
1005 let scope = Self::parse_scope(Some(&scope))?;
1006 if scope == MemoryScope::Session {
1007 return Err(ToolError::InvalidArguments(
1008 "rebuild supports durable scopes only in v1".to_string(),
1009 ));
1010 }
1011 let memory_access = self
1012 .resolve_project_memory_access(project_key.as_deref(), Some(session_id))
1013 .await?;
1014 if scope == MemoryScope::Project && !memory_access.writable {
1015 return Err(ToolError::Execution(
1016 "Unassigned sessions may read legacy Project memory but cannot rebuild it"
1017 .to_string(),
1018 ));
1019 }
1020 memory_access
1021 .store
1022 .rebuild_scope(scope, memory_access.project_key.as_deref())
1023 .await
1024 .map_err(|error| {
1025 ToolError::Execution(format!("Failed to rebuild memory artifacts: {error}"))
1026 })?;
1027 let inspect = memory_access
1028 .store
1029 .inspect_scope(scope, memory_access.project_key.as_deref())
1030 .await
1031 .map_err(|error| {
1032 ToolError::Execution(format!("Failed to inspect rebuilt memory: {error}"))
1033 })?;
1034 Ok(ToolResult {
1035 success: true,
1036 result: json!({
1037 "action": "rebuild",
1038 "scope": scope,
1039 "project_key": memory_access.project_key,
1040 "data": inspect,
1041 })
1042 .to_string(),
1043 display_preference: Some("json".to_string()),
1044 images: Vec::new(),
1045 })
1046 }
1047 }?;
1048 Ok(ToolOutcome::Completed(result))
1049 }
1050}