Files
tkmind_go/crates/goose-mcp/src/memory/mod.rs
T

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",
&params.category,
&params.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(&params.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(&params.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(
&params.category,
&params.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);
}
}