669 lines
21 KiB
Rust
669 lines
21 KiB
Rust
use etcetera::{choose_app_strategy, AppStrategy};
|
|
use indoc::formatdoc;
|
|
use rmcp::{
|
|
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
|
model::{
|
|
CallToolResult, Content, ErrorCode, ErrorData, Implementation, InitializeResult, Meta,
|
|
ServerCapabilities, ServerInfo,
|
|
},
|
|
schemars::JsonSchema,
|
|
service::RequestContext,
|
|
tool, tool_handler, tool_router, RoleServer, ServerHandler,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
collections::HashMap,
|
|
fs,
|
|
io::{self, Read, Write},
|
|
path::PathBuf,
|
|
};
|
|
|
|
const WORKING_DIR_HEADER: &str = "agent-working-dir";
|
|
|
|
fn extract_working_dir_from_meta(meta: &Meta) -> Option<PathBuf> {
|
|
meta.0
|
|
.get(WORKING_DIR_HEADER)
|
|
.and_then(|v| v.as_str())
|
|
.filter(|s| !s.is_empty())
|
|
.map(PathBuf::from)
|
|
}
|
|
|
|
/// Parameters for the remember_memory tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct RememberMemoryParams {
|
|
/// The category to store the memory in
|
|
pub category: String,
|
|
/// The data to remember
|
|
pub data: String,
|
|
/// Optional tags for the memory
|
|
#[serde(default)]
|
|
pub tags: Vec<String>,
|
|
/// Whether to store globally or locally
|
|
pub is_global: bool,
|
|
}
|
|
|
|
/// Parameters for the retrieve_memories tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct RetrieveMemoriesParams {
|
|
/// The category to retrieve memories from (use "*" for all)
|
|
pub category: String,
|
|
/// Whether to retrieve from global or local storage
|
|
pub is_global: bool,
|
|
}
|
|
|
|
/// Parameters for the remove_memory_category tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct RemoveMemoryCategoryParams {
|
|
/// The category to remove (use "*" for all)
|
|
pub category: String,
|
|
/// Whether to remove from global or local storage
|
|
pub is_global: bool,
|
|
}
|
|
|
|
/// Parameters for the remove_specific_memory tool
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct RemoveSpecificMemoryParams {
|
|
/// The category containing the memory
|
|
pub category: String,
|
|
/// The content of the memory to remove
|
|
pub memory_content: String,
|
|
/// Whether to remove from global or local storage
|
|
pub is_global: bool,
|
|
}
|
|
|
|
/// Memory MCP Server using official RMCP SDK
|
|
#[derive(Clone)]
|
|
pub struct MemoryServer {
|
|
tool_router: ToolRouter<Self>,
|
|
instructions: String,
|
|
global_memory_dir: PathBuf,
|
|
}
|
|
|
|
impl Default for MemoryServer {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
#[tool_router(router = tool_router)]
|
|
impl MemoryServer {
|
|
pub fn new() -> Self {
|
|
let instructions = formatdoc! {r#"
|
|
This extension stores and retrieves categorized information with tagging support.
|
|
|
|
Storage:
|
|
- Local: .goose/memory/ (project-specific)
|
|
- Global: ~/.config/goose/memory/ (user-wide)
|
|
|
|
Save proactively when users share preferences, project configurations, workflow patterns,
|
|
or recurring commands. Always confirm with the user before saving. Suggest relevant
|
|
categories and tags, and clarify storage scope (local vs global).
|
|
|
|
Use category "*" with retrieve_memories or remove_memory_category to access all entries.
|
|
"#};
|
|
|
|
let global_memory_dir = choose_app_strategy(crate::APP_STRATEGY.clone())
|
|
.map(|strategy| strategy.in_config_dir("memory"))
|
|
.unwrap_or_else(|_| PathBuf::from(".config/goose/memory"));
|
|
|
|
let mut memory_router = Self {
|
|
tool_router: Self::tool_router(),
|
|
instructions: instructions.clone(),
|
|
global_memory_dir,
|
|
};
|
|
|
|
let retrieved_global_memories = memory_router.retrieve_all(true, None);
|
|
|
|
let mut updated_instructions = instructions;
|
|
|
|
let memories_follow_up_instructions = formatdoc! {r#"
|
|
**Here are the user's currently saved memories:**
|
|
Please keep this information in mind when answering future questions.
|
|
Do not bring up memories unless relevant.
|
|
Note: if the user has not saved any memories, this section will be empty.
|
|
Note: if the user removes a memory that was previously loaded into the system, please remove it from the system instructions.
|
|
"#};
|
|
|
|
updated_instructions.push_str("\n\n");
|
|
updated_instructions.push_str(&memories_follow_up_instructions);
|
|
|
|
if let Ok(global_memories) = retrieved_global_memories {
|
|
if !global_memories.is_empty() {
|
|
updated_instructions.push_str("\n\nGlobal Memories:\n");
|
|
for (category, memories) in global_memories {
|
|
updated_instructions.push_str(&format!("\nCategory: {}\n", category));
|
|
for memory in memories {
|
|
updated_instructions.push_str(&format!("- {}\n", memory));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
memory_router.set_instructions(updated_instructions);
|
|
|
|
memory_router
|
|
}
|
|
|
|
// Add a setter method for instructions
|
|
pub fn set_instructions(&mut self, new_instructions: String) {
|
|
self.instructions = new_instructions;
|
|
}
|
|
|
|
pub fn get_instructions(&self) -> &str {
|
|
&self.instructions
|
|
}
|
|
|
|
fn get_memory_file(
|
|
&self,
|
|
category: &str,
|
|
is_global: bool,
|
|
working_dir: Option<&PathBuf>,
|
|
) -> PathBuf {
|
|
let base_dir = if is_global {
|
|
self.global_memory_dir.clone()
|
|
} else {
|
|
let local_base = working_dir
|
|
.cloned()
|
|
.or_else(|| std::env::current_dir().ok())
|
|
.unwrap_or_else(|| PathBuf::from("."));
|
|
local_base.join(".goose").join("memory")
|
|
};
|
|
base_dir.join(format!("{}.txt", category))
|
|
}
|
|
|
|
pub fn retrieve_all(
|
|
&self,
|
|
is_global: bool,
|
|
working_dir: Option<&PathBuf>,
|
|
) -> io::Result<HashMap<String, Vec<String>>> {
|
|
let base_dir = if is_global {
|
|
self.global_memory_dir.clone()
|
|
} else {
|
|
let local_base = working_dir
|
|
.cloned()
|
|
.or_else(|| std::env::current_dir().ok())
|
|
.unwrap_or_else(|| PathBuf::from("."));
|
|
local_base.join(".goose").join("memory")
|
|
};
|
|
let mut memories = HashMap::new();
|
|
if base_dir.exists() {
|
|
for entry in fs::read_dir(&base_dir)? {
|
|
let entry = entry?;
|
|
if entry.file_type()?.is_file() {
|
|
let category = entry.file_name().to_string_lossy().replace(".txt", "");
|
|
let category_memories = self.retrieve(&category, is_global, working_dir)?;
|
|
memories.insert(
|
|
category,
|
|
category_memories.into_values().flatten().collect(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
Ok(memories)
|
|
}
|
|
|
|
pub fn remember(
|
|
&self,
|
|
_context: &str,
|
|
category: &str,
|
|
data: &str,
|
|
tags: &[&str],
|
|
is_global: bool,
|
|
working_dir: Option<&PathBuf>,
|
|
) -> io::Result<()> {
|
|
let memory_file_path = self.get_memory_file(category, is_global, working_dir);
|
|
|
|
if let Some(parent) = memory_file_path.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
let mut file = fs::OpenOptions::new()
|
|
.append(true)
|
|
.create(true)
|
|
.open(&memory_file_path)?;
|
|
if !tags.is_empty() {
|
|
writeln!(file, "# {}", tags.join(" "))?;
|
|
}
|
|
writeln!(file, "{}\n", data)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn retrieve(
|
|
&self,
|
|
category: &str,
|
|
is_global: bool,
|
|
working_dir: Option<&PathBuf>,
|
|
) -> io::Result<HashMap<String, Vec<String>>> {
|
|
let memory_file_path = self.get_memory_file(category, is_global, working_dir);
|
|
if !memory_file_path.exists() {
|
|
return Ok(HashMap::new());
|
|
}
|
|
|
|
let mut file = fs::File::open(memory_file_path)?;
|
|
let mut content = String::new();
|
|
file.read_to_string(&mut content)?;
|
|
|
|
let mut memories = HashMap::new();
|
|
for entry in content.split("\n\n") {
|
|
let mut lines = entry.lines();
|
|
if let Some(first_line) = lines.next() {
|
|
if let Some(stripped) = first_line.strip_prefix('#') {
|
|
let tags = stripped
|
|
.split_whitespace()
|
|
.map(String::from)
|
|
.collect::<Vec<_>>();
|
|
memories.insert(tags.join(" "), lines.map(String::from).collect());
|
|
} else {
|
|
let entry_data: Vec<String> = std::iter::once(first_line.to_string())
|
|
.chain(lines.map(String::from))
|
|
.collect();
|
|
memories
|
|
.entry("untagged".to_string())
|
|
.or_insert_with(Vec::new)
|
|
.extend(entry_data);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(memories)
|
|
}
|
|
|
|
pub fn remove_specific_memory_internal(
|
|
&self,
|
|
category: &str,
|
|
memory_content: &str,
|
|
is_global: bool,
|
|
working_dir: Option<&PathBuf>,
|
|
) -> io::Result<()> {
|
|
let memory_file_path = self.get_memory_file(category, is_global, working_dir);
|
|
if !memory_file_path.exists() {
|
|
return Ok(());
|
|
}
|
|
|
|
let mut file = fs::File::open(&memory_file_path)?;
|
|
let mut content = String::new();
|
|
file.read_to_string(&mut content)?;
|
|
|
|
let memories: Vec<&str> = content.split("\n\n").collect();
|
|
let new_content: Vec<String> = memories
|
|
.into_iter()
|
|
.filter(|entry| !entry.contains(memory_content))
|
|
.map(|s| s.to_string())
|
|
.collect();
|
|
|
|
fs::write(memory_file_path, new_content.join("\n\n"))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn clear_memory(
|
|
&self,
|
|
category: &str,
|
|
is_global: bool,
|
|
working_dir: Option<&PathBuf>,
|
|
) -> io::Result<()> {
|
|
let memory_file_path = self.get_memory_file(category, is_global, working_dir);
|
|
if memory_file_path.exists() {
|
|
fs::remove_file(memory_file_path)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn clear_all_global_or_local_memories(
|
|
&self,
|
|
is_global: bool,
|
|
working_dir: Option<&PathBuf>,
|
|
) -> io::Result<()> {
|
|
let base_dir = if is_global {
|
|
self.global_memory_dir.clone()
|
|
} else {
|
|
let local_base = working_dir
|
|
.cloned()
|
|
.or_else(|| std::env::current_dir().ok())
|
|
.unwrap_or_else(|| PathBuf::from("."));
|
|
local_base.join(".goose").join("memory")
|
|
};
|
|
if base_dir.exists() {
|
|
fs::remove_dir_all(&base_dir)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Stores a memory with optional tags in a specified category
|
|
#[tool(
|
|
name = "remember_memory",
|
|
description = "Stores a memory with optional tags in a specified category"
|
|
)]
|
|
pub async fn remember_memory(
|
|
&self,
|
|
params: Parameters<RememberMemoryParams>,
|
|
context: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
let working_dir = extract_working_dir_from_meta(&context.meta);
|
|
|
|
if params.data.is_empty() {
|
|
return Err(ErrorData::new(
|
|
ErrorCode::INVALID_PARAMS,
|
|
"Data must not be empty when remembering a memory".to_string(),
|
|
None,
|
|
));
|
|
}
|
|
|
|
let tags: Vec<&str> = params.tags.iter().map(|s| s.as_str()).collect();
|
|
self.remember(
|
|
"context",
|
|
¶ms.category,
|
|
¶ms.data,
|
|
&tags,
|
|
params.is_global,
|
|
working_dir.as_ref(),
|
|
)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
|
|
Ok(CallToolResult::success(vec![Content::text(format!(
|
|
"Stored memory in category: {}",
|
|
params.category
|
|
))]))
|
|
}
|
|
|
|
/// Retrieves all memories from a specified category
|
|
#[tool(
|
|
name = "retrieve_memories",
|
|
description = "Retrieves all memories from a specified category"
|
|
)]
|
|
pub async fn retrieve_memories(
|
|
&self,
|
|
params: Parameters<RetrieveMemoriesParams>,
|
|
context: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
let working_dir = extract_working_dir_from_meta(&context.meta);
|
|
|
|
let memories = if params.category == "*" {
|
|
self.retrieve_all(params.is_global, working_dir.as_ref())
|
|
} else {
|
|
self.retrieve(¶ms.category, params.is_global, working_dir.as_ref())
|
|
}
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
|
|
Ok(CallToolResult::success(vec![Content::text(format!(
|
|
"Retrieved memories: {:?}",
|
|
memories
|
|
))]))
|
|
}
|
|
|
|
/// Removes all memories within a specified category
|
|
#[tool(
|
|
name = "remove_memory_category",
|
|
description = "Removes all memories within a specified category"
|
|
)]
|
|
pub async fn remove_memory_category(
|
|
&self,
|
|
params: Parameters<RemoveMemoryCategoryParams>,
|
|
context: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
let working_dir = extract_working_dir_from_meta(&context.meta);
|
|
|
|
let message = if params.category == "*" {
|
|
self.clear_all_global_or_local_memories(params.is_global, working_dir.as_ref())
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
format!(
|
|
"Cleared all memory {} categories",
|
|
if params.is_global { "global" } else { "local" }
|
|
)
|
|
} else {
|
|
self.clear_memory(¶ms.category, params.is_global, working_dir.as_ref())
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
format!("Cleared memories in category: {}", params.category)
|
|
};
|
|
|
|
Ok(CallToolResult::success(vec![Content::text(message)]))
|
|
}
|
|
|
|
/// Removes a specific memory within a specified category
|
|
#[tool(
|
|
name = "remove_specific_memory",
|
|
description = "Removes a specific memory within a specified category"
|
|
)]
|
|
pub async fn remove_specific_memory(
|
|
&self,
|
|
params: Parameters<RemoveSpecificMemoryParams>,
|
|
context: RequestContext<RoleServer>,
|
|
) -> Result<CallToolResult, ErrorData> {
|
|
let params = params.0;
|
|
let working_dir = extract_working_dir_from_meta(&context.meta);
|
|
|
|
self.remove_specific_memory_internal(
|
|
¶ms.category,
|
|
¶ms.memory_content,
|
|
params.is_global,
|
|
working_dir.as_ref(),
|
|
)
|
|
.map_err(|e| ErrorData::new(ErrorCode::INTERNAL_ERROR, e.to_string(), None))?;
|
|
|
|
Ok(CallToolResult::success(vec![Content::text(format!(
|
|
"Removed specific memory from category: {}",
|
|
params.category
|
|
))]))
|
|
}
|
|
}
|
|
|
|
#[tool_handler(router = self.tool_router)]
|
|
impl ServerHandler for MemoryServer {
|
|
fn get_info(&self) -> ServerInfo {
|
|
InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
|
|
.with_server_info(Implementation::new(
|
|
"goose-memory",
|
|
env!("CARGO_PKG_VERSION"),
|
|
))
|
|
.with_instructions(self.instructions.clone())
|
|
}
|
|
}
|
|
|
|
// Remove the old MemoryArgs struct since we're using the new parameter structs
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn test_lazy_directory_creation() {
|
|
let temp_dir = tempdir().unwrap();
|
|
let memory_base = temp_dir.path().join("test_memory");
|
|
let working_dir = memory_base.join("working");
|
|
|
|
let router = MemoryServer {
|
|
tool_router: ToolRouter::new(),
|
|
instructions: String::new(),
|
|
global_memory_dir: memory_base.join("global"),
|
|
};
|
|
|
|
let local_memory_dir = working_dir.join(".goose").join("memory");
|
|
|
|
assert!(!router.global_memory_dir.exists());
|
|
assert!(!local_memory_dir.exists());
|
|
|
|
router
|
|
.remember(
|
|
"test_context",
|
|
"test_category",
|
|
"test_data",
|
|
&["tag1"],
|
|
false,
|
|
Some(&working_dir),
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(local_memory_dir.exists());
|
|
assert!(!router.global_memory_dir.exists());
|
|
|
|
router
|
|
.remember(
|
|
"test_context",
|
|
"global_category",
|
|
"global_data",
|
|
&["global_tag"],
|
|
true,
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(router.global_memory_dir.exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_clear_nonexistent_directories() {
|
|
let temp_dir = tempdir().unwrap();
|
|
let memory_base = temp_dir.path().join("nonexistent_memory");
|
|
let working_dir = memory_base.join("working");
|
|
|
|
let router = MemoryServer {
|
|
tool_router: ToolRouter::new(),
|
|
instructions: String::new(),
|
|
global_memory_dir: memory_base.join("global"),
|
|
};
|
|
|
|
assert!(router
|
|
.clear_all_global_or_local_memories(false, Some(&working_dir))
|
|
.is_ok());
|
|
assert!(router
|
|
.clear_all_global_or_local_memories(true, None)
|
|
.is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_remember_retrieve_clear_workflow() {
|
|
let temp_dir = tempdir().unwrap();
|
|
let memory_base = temp_dir.path().join("workflow_test");
|
|
let working_dir = memory_base.join("working");
|
|
|
|
let router = MemoryServer {
|
|
tool_router: ToolRouter::new(),
|
|
instructions: String::new(),
|
|
global_memory_dir: memory_base.join("global"),
|
|
};
|
|
|
|
router
|
|
.remember(
|
|
"context",
|
|
"test_category",
|
|
"test_data_content",
|
|
&["test_tag"],
|
|
false,
|
|
Some(&working_dir),
|
|
)
|
|
.unwrap();
|
|
|
|
let memories = router
|
|
.retrieve("test_category", false, Some(&working_dir))
|
|
.unwrap();
|
|
assert!(!memories.is_empty());
|
|
|
|
let has_content = memories.values().any(|v| {
|
|
v.iter()
|
|
.any(|content| content.contains("test_data_content"))
|
|
});
|
|
assert!(has_content);
|
|
|
|
router
|
|
.clear_memory("test_category", false, Some(&working_dir))
|
|
.unwrap();
|
|
|
|
let memories_after_clear = router
|
|
.retrieve("test_category", false, Some(&working_dir))
|
|
.unwrap();
|
|
assert!(memories_after_clear.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_directory_creation_on_write() {
|
|
let temp_dir = tempdir().unwrap();
|
|
let memory_base = temp_dir.path().join("write_test");
|
|
let working_dir = memory_base.join("working");
|
|
|
|
let router = MemoryServer {
|
|
tool_router: ToolRouter::new(),
|
|
instructions: String::new(),
|
|
global_memory_dir: memory_base.join("global"),
|
|
};
|
|
|
|
let local_memory_dir = working_dir.join(".goose").join("memory");
|
|
assert!(!local_memory_dir.exists());
|
|
|
|
router
|
|
.remember(
|
|
"context",
|
|
"category",
|
|
"data",
|
|
&[],
|
|
false,
|
|
Some(&working_dir),
|
|
)
|
|
.unwrap();
|
|
|
|
assert!(local_memory_dir.exists());
|
|
assert!(local_memory_dir.join("category.txt").exists());
|
|
}
|
|
|
|
#[test]
|
|
fn test_remove_specific_memory() {
|
|
let temp_dir = tempdir().unwrap();
|
|
let memory_base = temp_dir.path().join("remove_test");
|
|
let working_dir = memory_base.join("working");
|
|
|
|
let router = MemoryServer {
|
|
tool_router: ToolRouter::new(),
|
|
instructions: String::new(),
|
|
global_memory_dir: memory_base.join("global"),
|
|
};
|
|
|
|
router
|
|
.remember(
|
|
"context",
|
|
"category",
|
|
"keep_this",
|
|
&[],
|
|
false,
|
|
Some(&working_dir),
|
|
)
|
|
.unwrap();
|
|
router
|
|
.remember(
|
|
"context",
|
|
"category",
|
|
"remove_this",
|
|
&[],
|
|
false,
|
|
Some(&working_dir),
|
|
)
|
|
.unwrap();
|
|
|
|
let memories = router
|
|
.retrieve("category", false, Some(&working_dir))
|
|
.unwrap();
|
|
assert_eq!(memories.len(), 1);
|
|
|
|
router
|
|
.remove_specific_memory_internal("category", "remove_this", false, Some(&working_dir))
|
|
.unwrap();
|
|
|
|
let memories_after = router
|
|
.retrieve("category", false, Some(&working_dir))
|
|
.unwrap();
|
|
let has_removed = memories_after
|
|
.values()
|
|
.any(|v| v.iter().any(|content| content.contains("remove_this")));
|
|
assert!(!has_removed);
|
|
|
|
let has_kept = memories_after
|
|
.values()
|
|
.any(|v| v.iter().any(|content| content.contains("keep_this")));
|
|
assert!(has_kept);
|
|
}
|
|
}
|