Skip to main content

bamboo_server_tools/memory/
mod.rs

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}