use async_trait::async_trait;
use serde_json::json;
use bamboo_agent_core::tools::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
use bamboo_agent_core::Session;
use bamboo_memory::memory_store::{
DurableMemoryStatus, LegacyProjectMemoryReadRoot, MemoryQueryOptions, MemoryScope, MemoryStore,
MAX_MAX_CHARS, MAX_QUERY_LIMIT,
};
use bamboo_tools::tools::session_memory::{
execute_session_memory_action, SessionMemoryAction, MEMORY_SESSION_ACTION_NAMES,
};
mod args;
mod parsing;
#[cfg(test)]
mod tests;
use args::MemoryArgs;
#[derive(Clone)]
pub struct MemoryTool {
session_repo: bamboo_engine::SessionRepository,
memory_store: MemoryStore,
project_store: Option<std::sync::Arc<bamboo_projects::ProjectStore>>,
}
struct ResolvedProjectMemoryAccess {
store: MemoryStore,
project_key: Option<String>,
writable: bool,
}
impl MemoryTool {
pub fn new(
session_repo: bamboo_engine::SessionRepository,
data_dir: impl Into<std::path::PathBuf>,
) -> Self {
Self {
session_repo,
memory_store: MemoryStore::new(data_dir),
project_store: None,
}
}
pub fn with_project_store(
mut self,
project_store: std::sync::Arc<bamboo_projects::ProjectStore>,
) -> Self {
self.project_store = Some(project_store);
self
}
async fn session_for_context(&self, session_id: Option<&str>) -> Option<Session> {
self.session_repo.load(session_id?).await
}
async fn resolve_project_memory_access(
&self,
explicit: Option<&str>,
session_id: Option<&str>,
) -> Result<ResolvedProjectMemoryAccess, ToolError> {
let explicit = explicit
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string);
let session = self.session_for_context(session_id).await;
if let Some(session) = session.as_ref() {
if let bamboo_engine::project_context::SessionProjectIdentity::Invalid {
raw,
message,
} = bamboo_engine::project_context::ProjectContextResolver::session_project_identity(
session,
) {
return Err(ToolError::InvalidArguments(format!(
"session carries an invalid Project identity '{raw}': {message}"
)));
}
}
if let Some(project_id) = session.as_ref().and_then(
bamboo_engine::project_context::ProjectContextResolver::project_id_from_session,
) {
if explicit
.as_deref()
.is_some_and(|requested| requested != project_id.as_str())
{
return Err(ToolError::InvalidArguments(
"project_key cannot override the session's assigned Project".to_string(),
));
}
let project_store = self.project_store.as_ref().ok_or_else(|| {
ToolError::Execution(
"Project memory resolver is unavailable for this assigned session".to_string(),
)
})?;
let roots = project_store
.project_memory_read_roots(&project_id)
.map_err(|error| {
ToolError::Execution(format!("Failed to resolve Project memory roots: {error}"))
})?;
let aliases = roots
.legacy_aliases
.into_iter()
.map(|legacy| LegacyProjectMemoryReadRoot {
project_key: legacy.legacy_project_key,
root: legacy.root,
})
.collect();
return Ok(ResolvedProjectMemoryAccess {
store: self
.memory_store
.for_project_with_legacy_read_roots(&project_id, aliases),
project_key: Some(project_id.to_string()),
writable: true,
});
}
let derived_legacy_key = session.as_ref().and_then(|session| {
bamboo_engine::project_context::ProjectContextResolver::memory_read_scope_for_session(
session,
)
});
if explicit.as_deref() != derived_legacy_key.as_deref() && explicit.is_some() {
return Err(ToolError::InvalidArguments(
"project_key cannot override the session's legacy Project read scope".to_string(),
));
}
Ok(ResolvedProjectMemoryAccess {
store: self.memory_store.clone(),
project_key: derived_legacy_key,
writable: false,
})
}
async fn ensure_memory_mutation_allowed(
&self,
access: &ResolvedProjectMemoryAccess,
id: &str,
) -> Result<(), ToolError> {
let Some(doc) = access
.store
.get_memory(id, access.project_key.as_deref())
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to resolve memory mutation scope: {error}"))
})?
else {
return Ok(());
};
if doc.frontmatter.scope == MemoryScope::Project && !access.writable {
return Err(ToolError::Execution(
"Unassigned sessions may read legacy Project memory but cannot mutate it"
.to_string(),
));
}
if access.store.is_read_only_project_memory_path(&doc.path) {
return Err(ToolError::Execution(
"Legacy Project memory aliases are read-only; migrate the memory before mutating it"
.to_string(),
));
}
Ok(())
}
}
#[async_trait]
impl Tool for MemoryTool {
fn name(&self) -> &str {
"memory"
}
fn description(&self) -> &str {
"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."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": [
"session_read",
"session_append",
"session_replace",
"session_clear",
"session_list_topics",
"query",
"get",
"find_duplicates",
"write",
"merge",
"split",
"consolidate",
"purge",
"inspect",
"rebuild",
"scan_blobs",
"scan_duplicates"
]
},
"scope": {"type": "string", "enum": ["session", "project", "global"]},
"granularity": {
"type": "string",
"enum": ["day", "week", "month", "quarter", "year"],
"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."
},
"project_key": {"type": "string"},
"topic": {"type": "string"},
"id": {"type": "string"},
"query": {"type": "string"},
"type": {"type": "string", "enum": ["user", "feedback", "project", "reference"]},
"title": {"type": "string"},
"content": {"type": "string"},
"tags": {"type": "array", "items": {"type": "string"}},
"pieces": {"type": "array", "items": {"type": "object"}},
"ids": {"type": "array", "items": {"type": "string"}},
"min_score": {"type": "number"},
"filters": {
"type": "object",
"description": "Optional narrowing for `query`/`purge`. Each sub-filter is independent and defaults to unfiltered when omitted or empty.",
"properties": {
"type": {
"type": "array",
"items": {"type": "string", "enum": ["user", "feedback", "project", "reference"]},
"description": "Restrict results to these memory types. Omit for no type filtering."
},
"status": {
"type": "array",
"items": {"type": "string", "enum": ["active", "stale", "superseded", "contradicted", "archived"]},
"description": "Restrict results to these statuses. Omit for no status filtering."
},
"granularity": {
"type": "array",
"items": {
"type": "string",
"enum": ["day", "week", "month", "quarter", "year"]
},
"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."
}
}
},
"options": {"type": "object"},
"reason": {"type": "string"}
},
"required": ["action"]
})
}
fn classify(&self, args: &serde_json::Value) -> ToolClass {
let action = args
.get("action")
.and_then(|value| value.as_str())
.unwrap_or("")
.trim()
.to_ascii_lowercase();
match action.as_str() {
"session_read"
| "session_list_topics"
| "query"
| "get"
| "find_duplicates"
| "scan_blobs"
| "scan_duplicates"
| "inspect" => ToolClass::READONLY_PARALLEL,
_ => ToolClass::MUTATING_SERIAL,
}
}
async fn invoke(
&self,
args: serde_json::Value,
ctx: ToolCtx,
) -> Result<ToolOutcome, ToolError> {
let session_id = ctx.session_id().ok_or_else(|| {
ToolError::Execution("memory requires a session_id in tool context".to_string())
})?;
let parsed: MemoryArgs = serde_json::from_value(args).map_err(|error| {
ToolError::InvalidArguments(format!("Invalid memory args: {error}"))
})?;
let result = match parsed {
MemoryArgs::SessionRead { topic, options } => {
let max_chars = options.and_then(|value| value.max_chars);
execute_session_memory_action(
&self.memory_store,
session_id,
SessionMemoryAction::Read,
topic.as_deref(),
None,
max_chars,
MEMORY_SESSION_ACTION_NAMES,
)
.await
}
MemoryArgs::SessionAppend { topic, content } => {
execute_session_memory_action(
&self.memory_store,
session_id,
SessionMemoryAction::Append,
topic.as_deref(),
Some(content.as_str()),
None,
MEMORY_SESSION_ACTION_NAMES,
)
.await
}
MemoryArgs::SessionReplace { topic, content } => {
execute_session_memory_action(
&self.memory_store,
session_id,
SessionMemoryAction::Replace,
topic.as_deref(),
Some(content.as_str()),
None,
MEMORY_SESSION_ACTION_NAMES,
)
.await
}
MemoryArgs::SessionClear { topic } => {
execute_session_memory_action(
&self.memory_store,
session_id,
SessionMemoryAction::Clear,
topic.as_deref(),
None,
None,
MEMORY_SESSION_ACTION_NAMES,
)
.await
}
MemoryArgs::SessionListTopics => {
execute_session_memory_action(
&self.memory_store,
session_id,
SessionMemoryAction::ListTopics,
None,
None,
None,
MEMORY_SESSION_ACTION_NAMES,
)
.await
}
MemoryArgs::Query {
scope,
query,
filters,
project_key,
options,
} => {
let scope = Self::parse_scope(Some(&scope))?;
if scope == MemoryScope::Session {
return Err(ToolError::InvalidArguments(
"query supports durable scopes only; use session_read/session_list_topics for session scope"
.to_string(),
));
}
let memory_access = self
.resolve_project_memory_access(project_key.as_deref(), Some(session_id))
.await?;
let options = MemoryQueryOptions {
limit: options
.as_ref()
.and_then(|value| value.limit)
.map(|value| value.min(MAX_QUERY_LIMIT)),
max_chars: options
.as_ref()
.and_then(|value| value.max_chars)
.map(|value| value.min(MAX_MAX_CHARS)),
cursor: options.as_ref().and_then(|value| value.cursor.clone()),
include_related: options
.as_ref()
.and_then(|value| value.include_related)
.unwrap_or(false),
};
let (filter_types, filter_statuses, filter_granularity) =
Self::parse_query_filters(filters.as_ref())?;
let result = memory_access
.store
.query_scope(
scope,
memory_access.project_key.as_deref(),
query.as_deref(),
filter_types.as_ref(),
filter_statuses.as_ref(),
filter_granularity.as_ref(),
&options,
)
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to query memory: {error}"))
})?;
Ok(ToolResult {
success: true,
result: json!({
"action": "query",
"success": true,
"data": result,
"summary": bamboo_memory::memory_store::summary_json(result.returned_count, result.matched_count),
"warnings": [],
}).to_string(),
display_preference: Some("json".to_string()),
images: Vec::new(),
})
}
MemoryArgs::Get {
id,
project_key,
options,
} => {
let memory_access = self
.resolve_project_memory_access(project_key.as_deref(), Some(session_id))
.await?;
let max_chars = options
.and_then(|value| value.max_chars)
.unwrap_or(MAX_MAX_CHARS)
.min(MAX_MAX_CHARS);
let Some(mut doc) = memory_access
.store
.get_memory(id.trim(), memory_access.project_key.as_deref())
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to get memory: {error}"))
})?
else {
return Err(ToolError::Execution(format!(
"memory not found: {}",
id.trim()
)));
};
let (body, truncated) =
bamboo_memory::memory_store::truncate_chars(&doc.body, max_chars);
doc.body = body;
Ok(ToolResult {
success: true,
result: json!({
"action": "get",
"id": doc.frontmatter.id,
"memory": {
"frontmatter": doc.frontmatter,
"body": doc.body,
"path": doc.path,
"body_truncated": truncated,
}
})
.to_string(),
display_preference: Some("json".to_string()),
images: Vec::new(),
})
}
MemoryArgs::Write {
scope,
r#type,
title,
content,
tags,
project_key,
granularity,
options,
} => {
let scope = Self::parse_scope(Some(&scope))?;
if scope == MemoryScope::Session {
return Err(ToolError::InvalidArguments(
"write supports durable scopes only; use session_replace/session_append for session scope"
.to_string(),
));
}
let granularity = Self::parse_granularity(granularity.as_deref())?;
let memory_access = self
.resolve_project_memory_access(project_key.as_deref(), Some(session_id))
.await?;
if scope == MemoryScope::Project && !memory_access.writable {
return Err(ToolError::Execution(
"Unassigned sessions may read legacy Project memory but cannot write it"
.to_string(),
));
}
let doc = memory_access
.store
.write_memory(
scope,
memory_access.project_key.as_deref(),
Self::parse_type(&r#type)?,
&title,
&content,
&tags,
Some(session_id),
"main-model",
options
.and_then(|value| value.allow_merge_if_similar)
.unwrap_or(false),
granularity,
)
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to write memory: {error}"))
})?;
Ok(ToolResult {
success: true,
result: json!({
"action": "write",
"memory": {
"id": doc.frontmatter.id,
"title": doc.frontmatter.title,
"type": doc.frontmatter.r#type,
"scope": doc.frontmatter.scope,
"status": doc.frontmatter.status,
"project_key": doc.frontmatter.project_key,
"path": doc.path,
}
})
.to_string(),
display_preference: Some("json".to_string()),
images: Vec::new(),
})
}
MemoryArgs::Merge {
id,
content,
tags,
project_key,
source_memory_ids,
mode,
reason,
} => {
let memory_access = self
.resolve_project_memory_access(project_key.as_deref(), Some(session_id))
.await?;
self.ensure_memory_mutation_allowed(&memory_access, id.trim())
.await?;
let mode = Self::parse_merge_mode(mode.as_deref())?;
if matches!(mode.as_deref(), Some("contradict")) {
let Some(result) = memory_access
.store
.mark_memory_contradicted(
id.trim(),
memory_access.project_key.as_deref(),
&source_memory_ids,
reason.as_deref().or(Some(content.trim())),
Some(session_id),
"main-model",
)
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to contradict memory: {error}"))
})?
else {
return Err(ToolError::Execution(format!(
"memory not found: {}",
id.trim()
)));
};
Ok(ToolResult {
success: true,
result: json!({
"action": "merge",
"mode": "contradict",
"data": result,
})
.to_string(),
display_preference: Some("json".to_string()),
images: Vec::new(),
})
} else {
let Some(result) = memory_access
.store
.merge_memory(
id.trim(),
memory_access.project_key.as_deref(),
&content,
&tags,
Some(session_id),
"main-model",
&source_memory_ids,
)
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to merge memory: {error}"))
})?
else {
return Err(ToolError::Execution(format!(
"memory not found: {}",
id.trim()
)));
};
Ok(ToolResult {
success: true,
result: json!({
"action": "merge",
"mode": mode.unwrap_or_else(|| "merge".to_string()),
"data": result,
})
.to_string(),
display_preference: Some("json".to_string()),
images: Vec::new(),
})
}
}
MemoryArgs::FindDuplicates {
scope,
title,
content,
r#type,
tags,
project_key,
options,
} => {
let scope = Self::parse_scope(Some(&scope))?;
if scope == MemoryScope::Session {
return Err(ToolError::InvalidArguments(
"find_duplicates supports durable scopes only".to_string(),
));
}
let r#type = match r#type.as_deref() {
Some(value) => Some(Self::parse_type(value)?),
None => None,
};
let memory_access = self
.resolve_project_memory_access(project_key.as_deref(), Some(session_id))
.await?;
let limit = options
.and_then(|value| value.limit)
.unwrap_or(5)
.clamp(1, MAX_QUERY_LIMIT);
let candidates = memory_access
.store
.find_duplicate_candidates(
scope,
memory_access.project_key.as_deref(),
r#type,
&title,
content.as_deref().unwrap_or(""),
&tags,
limit,
)
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to find duplicates: {error}"))
})?;
Ok(ToolResult {
success: true,
result: json!({
"action": "find_duplicates",
"candidates": candidates,
})
.to_string(),
display_preference: Some("json".to_string()),
images: Vec::new(),
})
}
MemoryArgs::Split {
id,
project_key,
pieces,
} => {
if pieces.is_empty() {
return Err(ToolError::InvalidArguments(
"split requires at least one piece".to_string(),
));
}
let memory_access = self
.resolve_project_memory_access(project_key.as_deref(), Some(session_id))
.await?;
self.ensure_memory_mutation_allowed(&memory_access, id.trim())
.await?;
let mut split_pieces = Vec::with_capacity(pieces.len());
for piece in pieces {
let r#type = match piece.r#type.as_deref() {
Some(value) => Some(Self::parse_type(value)?),
None => None,
};
split_pieces.push(bamboo_memory::memory_store::MemorySplitPiece {
title: piece.title,
r#type,
content: piece.content,
tags: piece.tags,
});
}
let Some(result) = memory_access
.store
.split_memory(
id.trim(),
memory_access.project_key.as_deref(),
&split_pieces,
Some(session_id),
"main-model",
)
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to split memory: {error}"))
})?
else {
return Err(ToolError::Execution(format!(
"memory not found: {}",
id.trim()
)));
};
Ok(ToolResult {
success: true,
result: json!({
"action": "split",
"data": result,
})
.to_string(),
display_preference: Some("json".to_string()),
images: Vec::new(),
})
}
MemoryArgs::ScanBlobs {
scope,
project_key,
min_sections,
options,
} => {
let scope = Self::parse_scope(Some(&scope))?;
if scope == MemoryScope::Session {
return Err(ToolError::InvalidArguments(
"scan_blobs supports durable scopes only".to_string(),
));
}
let memory_access = self
.resolve_project_memory_access(project_key.as_deref(), Some(session_id))
.await?;
let min_sections = min_sections.unwrap_or(3);
let limit = options
.and_then(|value| value.limit)
.unwrap_or(20)
.clamp(1, 200);
let report = memory_access
.store
.scan_blob_candidates(
scope,
memory_access.project_key.as_deref(),
min_sections,
limit,
)
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to scan blobs: {error}"))
})?;
Ok(ToolResult {
success: true,
result: json!({
"action": "scan_blobs",
"report": report,
})
.to_string(),
display_preference: Some("json".to_string()),
images: Vec::new(),
})
}
MemoryArgs::ScanDuplicates {
scope,
project_key,
min_score,
options,
} => {
let scope = Self::parse_scope(Some(&scope))?;
if scope == MemoryScope::Session {
return Err(ToolError::InvalidArguments(
"scan_duplicates supports durable scopes only".to_string(),
));
}
let memory_access = self
.resolve_project_memory_access(project_key.as_deref(), Some(session_id))
.await?;
let min_score = min_score.unwrap_or(0.6);
let limit = options
.and_then(|value| value.limit)
.unwrap_or(20)
.clamp(1, 200);
let report = memory_access
.store
.scan_duplicate_clusters(
scope,
memory_access.project_key.as_deref(),
min_score,
5,
limit,
)
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to scan duplicates: {error}"))
})?;
Ok(ToolResult {
success: true,
result: json!({
"action": "scan_duplicates",
"report": report,
})
.to_string(),
display_preference: Some("json".to_string()),
images: Vec::new(),
})
}
MemoryArgs::Consolidate {
ids,
title,
content,
r#type,
tags,
project_key,
} => {
if ids.len() < 2 {
return Err(ToolError::InvalidArguments(
"consolidate requires at least two source memory ids".to_string(),
));
}
let r#type = match r#type.as_deref() {
Some(value) => Some(Self::parse_type(value)?),
None => None,
};
let memory_access = self
.resolve_project_memory_access(project_key.as_deref(), Some(session_id))
.await?;
let merged = bamboo_memory::memory_store::MemorySplitPiece {
title,
r#type,
content,
tags,
};
let ids: Vec<String> = ids.iter().map(|id| id.trim().to_string()).collect();
for id in &ids {
self.ensure_memory_mutation_allowed(&memory_access, id)
.await?;
}
let Some(result) = memory_access
.store
.consolidate_memories(
&ids,
memory_access.project_key.as_deref(),
&merged,
Some(session_id),
"main-model",
)
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to consolidate memories: {error}"))
})?
else {
return Err(ToolError::Execution(
"one or more source memories not found".to_string(),
));
};
Ok(ToolResult {
success: true,
result: json!({
"action": "consolidate",
"data": result,
})
.to_string(),
display_preference: Some("json".to_string()),
images: Vec::new(),
})
}
MemoryArgs::Purge {
id,
scope,
reason,
project_key,
filters,
mode,
} => {
let mode = match mode
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
Some(value) => Self::parse_status(value)?,
None => DurableMemoryStatus::Archived,
};
let memory_access = self
.resolve_project_memory_access(project_key.as_deref(), Some(session_id))
.await?;
if let Some(id) = id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
{
self.ensure_memory_mutation_allowed(&memory_access, id)
.await?;
let Some(doc) = memory_access
.store
.archive_memory(
id,
memory_access.project_key.as_deref(),
mode,
reason.as_deref(),
)
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to purge memory: {error}"))
})?
else {
return Err(ToolError::Execution(format!("memory not found: {}", id)));
};
Ok(ToolResult {
success: true,
result: json!({
"action": "purge",
"id": doc.frontmatter.id,
"status": doc.frontmatter.status,
})
.to_string(),
display_preference: Some("json".to_string()),
images: Vec::new(),
})
} else {
let scope = Self::parse_scope(scope.as_deref())?;
if scope == MemoryScope::Session {
return Err(ToolError::InvalidArguments(
"purge supports durable scopes only in v1".to_string(),
));
}
if scope == MemoryScope::Project && !memory_access.writable {
return Err(ToolError::Execution(
"Unassigned sessions may read legacy Project memory but cannot purge it"
.to_string(),
));
}
if scope == MemoryScope::Project {
let contains_alias = memory_access
.store
.list_memory_documents(scope, memory_access.project_key.as_deref())
.await
.map_err(|error| {
ToolError::Execution(format!(
"Failed to inspect Project memory aliases: {error}"
))
})?
.iter()
.any(|doc| {
memory_access
.store
.is_read_only_project_memory_path(&doc.path)
});
if contains_alias {
return Err(ToolError::Execution(
"Batch purge is unavailable while read-only legacy Project memory aliases are present; migrate them first"
.to_string(),
));
}
}
let (filter_types, filter_statuses, filter_granularity) =
Self::parse_query_filters(filters.as_ref())?;
let result = memory_access
.store
.purge_memories(
scope,
memory_access.project_key.as_deref(),
filter_types.as_ref(),
filter_statuses.as_ref(),
filter_granularity.as_ref(),
mode,
reason.as_deref(),
)
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to purge memory: {error}"))
})?;
Ok(ToolResult {
success: true,
result: json!({
"action": "purge",
"data": result,
})
.to_string(),
display_preference: Some("json".to_string()),
images: Vec::new(),
})
}
}
MemoryArgs::Inspect { scope, project_key } => {
let scope = Self::parse_scope(Some(&scope))?;
if scope == MemoryScope::Session {
return Err(ToolError::InvalidArguments(
"inspect supports durable scopes only in v1".to_string(),
));
}
let memory_access = self
.resolve_project_memory_access(project_key.as_deref(), Some(session_id))
.await?;
let result = memory_access
.store
.inspect_scope(scope, memory_access.project_key.as_deref())
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to inspect memory: {error}"))
})?;
Ok(ToolResult {
success: true,
result: json!({
"action": "inspect",
"data": result,
})
.to_string(),
display_preference: Some("json".to_string()),
images: Vec::new(),
})
}
MemoryArgs::Rebuild { scope, project_key } => {
let scope = Self::parse_scope(Some(&scope))?;
if scope == MemoryScope::Session {
return Err(ToolError::InvalidArguments(
"rebuild supports durable scopes only in v1".to_string(),
));
}
let memory_access = self
.resolve_project_memory_access(project_key.as_deref(), Some(session_id))
.await?;
if scope == MemoryScope::Project && !memory_access.writable {
return Err(ToolError::Execution(
"Unassigned sessions may read legacy Project memory but cannot rebuild it"
.to_string(),
));
}
memory_access
.store
.rebuild_scope(scope, memory_access.project_key.as_deref())
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to rebuild memory artifacts: {error}"))
})?;
let inspect = memory_access
.store
.inspect_scope(scope, memory_access.project_key.as_deref())
.await
.map_err(|error| {
ToolError::Execution(format!("Failed to inspect rebuilt memory: {error}"))
})?;
Ok(ToolResult {
success: true,
result: json!({
"action": "rebuild",
"scope": scope,
"project_key": memory_access.project_key,
"data": inspect,
})
.to_string(),
display_preference: Some("json".to_string()),
images: Vec::new(),
})
}
}?;
Ok(ToolOutcome::Completed(result))
}
}