Skip to main content

remembrall_server/tools/
memory.rs

1//! Memory tool parameter structs and implementation helpers.
2//!
3//! The `#[tool]` wrapper methods live in `lib.rs` (required by `#[tool_router]`).
4//! This module holds the param structs and the actual logic so each tool's
5//! implementation can be read and modified in isolation.
6
7use std::sync::Arc;
8
9use rmcp::{ErrorData as McpError, model::*, schemars};
10use serde_json::json;
11
12use remembrall_core::{
13    embed::Embedder,
14    memory::{
15        store::MemoryStore,
16        types::{CreateMemory, MatchType, MemoryQuery, MemoryType, Scope, Source},
17    },
18};
19
20// Similarity threshold above which we flag a near-duplicate as a potential
21// contradiction. Defined here so the constant is co-located with the logic
22// that uses it.
23pub(crate) const CONTRADICTION_THRESHOLD: f64 = 0.75;
24
25// ---------------------------------------------------------------------------
26// Parameter structs
27// ---------------------------------------------------------------------------
28
29#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
30pub struct StoreParams {
31    #[schemars(description = "The knowledge to store. Be specific - include the why, not just the what.")]
32    pub content: String,
33    #[schemars(description = "One of: decision, pattern, error_pattern, preference, outcome, code_context, guideline, incident, architecture")]
34    pub memory_type: String,
35    #[schemars(description = "One-line summary")]
36    pub summary: Option<String>,
37    #[schemars(description = "Categorization tags")]
38    pub tags: Option<Vec<String>>,
39    #[schemars(description = "0.0 to 1.0, default 0.5. Use 0.8+ for architectural decisions.")]
40    pub importance: Option<f32>,
41    #[schemars(description = "Where this came from (PR URL, file path, etc.)")]
42    pub source_identifier: Option<String>,
43    #[schemars(description = "Tenant/organization scope for multi-tenant isolation. When set, only that tenant can retrieve this memory.")]
44    pub organization: Option<String>,
45    #[schemars(description = "Optional sub-project scope within the organization.")]
46    pub project: Option<String>,
47}
48
49#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
50pub struct RecallParams {
51    #[schemars(description = "What to search for. Can be a question ('how does auth work?') or keywords ('DATABASE_URL timeout').")]
52    pub query: String,
53    #[schemars(description = "Maximum number of results (default 10, max 25)")]
54    pub limit: Option<i64>,
55    #[schemars(description = "Filter by type: decision, pattern, error_pattern, preference, outcome, code_context, guideline, incident, architecture. Comma-separated for multiple.")]
56    pub memory_types: Option<String>,
57    #[schemars(description = "Filter by tags (comma-separated). Returns memories matching ALL supplied tags.")]
58    pub tags: Option<String>,
59    #[schemars(description = "Filter to memories about a specific project")]
60    pub project: Option<String>,
61    #[schemars(description = "Tenant/organization scope. When set, only memories stored under this organization are returned.")]
62    pub organization: Option<String>,
63}
64
65#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
66pub struct UpdateParams {
67    #[schemars(description = "UUID of the memory to update")]
68    pub id: String,
69    #[schemars(description = "New content (replaces existing). If provided, a new embedding will be generated.")]
70    pub content: Option<String>,
71    #[schemars(description = "New summary")]
72    pub summary: Option<String>,
73    #[schemars(description = "New tags (replaces existing)")]
74    pub tags: Option<Vec<String>>,
75    #[schemars(description = "New importance (0.0 to 1.0)")]
76    pub importance: Option<f32>,
77}
78
79#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
80pub struct DeleteParams {
81    #[schemars(description = "UUID of the memory to delete")]
82    pub id: String,
83}
84
85// ---------------------------------------------------------------------------
86// Logic helpers
87// ---------------------------------------------------------------------------
88
89pub async fn store_impl(
90    memory: &Arc<MemoryStore>,
91    embedder: &Arc<dyn Embedder>,
92    params: StoreParams,
93) -> Result<CallToolResult, McpError> {
94    let StoreParams { content, memory_type, summary, tags, importance, source_identifier, organization, project } = params;
95
96    let mtype: MemoryType = memory_type.parse().map_err(|e: String| {
97        McpError::invalid_params(format!("invalid memory_type: {e}"), None)
98    })?;
99
100    let embedder = Arc::clone(embedder);
101    let content_clone = content.clone();
102    let embedding = tokio::task::spawn_blocking(move || embedder.embed(&content_clone))
103        .await
104        .map_err(|e| McpError::internal_error(format!("spawn_blocking failed: {e}"), None))?
105        .map_err(|e| McpError::internal_error(format!("embedding failed: {e}"), None))?;
106
107    let near_dupes = memory
108        .search_semantic(embedding.clone(), 3, CONTRADICTION_THRESHOLD, None)
109        .await
110        .unwrap_or_default();
111
112    let mut contradiction_list: Vec<serde_json::Value> = Vec::new();
113    for (dupe_id, similarity) in near_dupes.iter().take(3) {
114        if let Ok(mem) = memory.get_readonly(*dupe_id).await {
115            contradiction_list.push(json!({
116                "id": mem.id.to_string(),
117                "similarity": (similarity * 100.0).round() / 100.0,
118                "content": mem.content,
119                "memory_type": mem.memory_type.to_string(),
120            }));
121        }
122    }
123
124    let identifier = source_identifier.unwrap_or_else(|| "mcp".to_string());
125    let content_len = content.len();
126
127    let input = CreateMemory {
128        content,
129        summary,
130        memory_type: mtype,
131        source: Source {
132            system: "mcp".to_string(),
133            identifier,
134            author: None,
135        },
136        scope: Scope {
137            organization,
138            team: None,
139            project,
140        },
141        tags: tags.unwrap_or_default(),
142        metadata: None,
143        importance,
144        expires_at: None,
145    };
146
147    let id = memory
148        .store(input, embedding)
149        .await
150        .map_err(|e| McpError::internal_error(format!("store failed: {e}"), None))?;
151
152    let mut response = json!({ "id": id.to_string(), "stored": true });
153    if !contradiction_list.is_empty() {
154        response["contradictions"] = json!(contradiction_list);
155    }
156    if content_len > 2000 {
157        response["note"] = json!(
158            "Content exceeds 2000 characters. The semantic search embedding represents only the first ~500 words."
159        );
160    }
161
162    Ok(CallToolResult::success(vec![Content::text(response.to_string())]))
163}
164
165pub async fn recall_impl(
166    memory: &Arc<MemoryStore>,
167    embedder: &Arc<dyn Embedder>,
168    params: RecallParams,
169) -> Result<CallToolResult, McpError> {
170    let RecallParams { query, limit, memory_types, tags, project, organization } = params;
171
172    if query.trim().is_empty() {
173        let text = json!({
174            "query": "",
175            "total_results": 0,
176            "results": [],
177            "suggestion": "Please provide a search query."
178        })
179        .to_string();
180        return Ok(CallToolResult::success(vec![Content::text(text)]));
181    }
182
183    let limit = limit.unwrap_or(10).min(25).max(1);
184
185    let type_filter: Option<Vec<MemoryType>> = memory_types
186        .map(|s| {
187            s.split(',')
188                .map(|t| t.trim().parse::<MemoryType>())
189                .collect::<Result<Vec<_>, _>>()
190        })
191        .transpose()
192        .map_err(|e: String| McpError::invalid_params(format!("invalid memory_type: {e}"), None))?;
193
194    let tag_filter: Option<Vec<String>> = tags.map(|s| {
195        s.split(',').map(|t| t.trim().to_string()).collect()
196    });
197
198    let scope = if organization.is_some() || project.is_some() {
199        Some(Scope { organization, team: None, project })
200    } else {
201        None
202    };
203
204    let embedder = Arc::clone(embedder);
205    let query_clone = query.clone();
206    let embedding = tokio::task::spawn_blocking(move || embedder.embed(&query_clone))
207        .await
208        .map_err(|e| McpError::internal_error(format!("spawn_blocking failed: {e}"), None))?
209        .map_err(|e| McpError::internal_error(format!("embedding failed: {e}"), None))?;
210
211    let mq = MemoryQuery {
212        query: query.clone(),
213        memory_types: type_filter,
214        scope,
215        tags: tag_filter,
216        limit: Some(limit),
217        min_similarity: None,
218    };
219
220    let results = memory
221        .search_hybrid(embedding, &mq)
222        .await
223        .map_err(|e| McpError::internal_error(format!("recall failed: {e}"), None))?;
224
225    let result_json: Vec<serde_json::Value> = results
226        .iter()
227        .map(|r| {
228            let content = if r.memory.content.len() > 2000 {
229                let mut end = 2000;
230                while !r.memory.content.is_char_boundary(end) {
231                    end -= 1;
232                }
233                format!("{}...[truncated]", &r.memory.content[..end])
234            } else {
235                r.memory.content.clone()
236            };
237
238            let match_type = match r.match_type {
239                MatchType::Semantic => "semantic",
240                MatchType::FullText => "fulltext",
241                MatchType::Hybrid => "hybrid",
242            };
243
244            json!({
245                "id": r.memory.id.to_string(),
246                "score": r.score,
247                "match_type": match_type,
248                "memory_type": r.memory.memory_type.to_string(),
249                "content": content,
250                "summary": r.memory.summary,
251                "tags": r.memory.tags,
252                "importance": r.memory.importance,
253                "source": r.memory.source.identifier,
254                "created_at": r.memory.created_at.to_rfc3339(),
255                "access_count": r.memory.access_count,
256            })
257        })
258        .collect();
259
260    let suggestion = if result_json.is_empty() {
261        Some("No memories found. Store context with remembrall_store as you work to build up searchable knowledge.")
262    } else {
263        None
264    };
265
266    let text = json!({
267        "query": query,
268        "total_results": result_json.len(),
269        "results": result_json,
270        "suggestion": suggestion,
271    })
272    .to_string();
273
274    Ok(CallToolResult::success(vec![Content::text(text)]))
275}
276
277pub async fn update_impl(
278    memory: &Arc<MemoryStore>,
279    embedder: &Arc<dyn Embedder>,
280    params: UpdateParams,
281) -> Result<CallToolResult, McpError> {
282    let UpdateParams { id, content, summary, tags, importance } = params;
283
284    let uid: uuid::Uuid = id.parse().map_err(|e| {
285        McpError::invalid_params(format!("invalid UUID: {e}"), None)
286    })?;
287
288    let embedding: Option<Vec<f32>> = if let Some(ref c) = content {
289        let embedder = Arc::clone(embedder);
290        let c_clone = c.clone();
291        let emb = tokio::task::spawn_blocking(move || embedder.embed(&c_clone))
292            .await
293            .map_err(|e| McpError::internal_error(format!("spawn_blocking failed: {e}"), None))?
294            .map_err(|e| McpError::internal_error(format!("embedding failed: {e}"), None))?;
295        Some(emb)
296    } else {
297        None
298    };
299
300    let updated = memory
301        .update(uid, content, summary, tags, importance, embedding)
302        .await
303        .map_err(|e| McpError::internal_error(format!("update failed: {e}"), None))?;
304
305    let text = if updated {
306        json!({ "id": id, "updated": true }).to_string()
307    } else {
308        json!({ "id": id, "updated": false, "reason": "not found" }).to_string()
309    };
310
311    Ok(CallToolResult::success(vec![Content::text(text)]))
312}
313
314pub async fn delete_impl(
315    memory: &Arc<MemoryStore>,
316    params: DeleteParams,
317) -> Result<CallToolResult, McpError> {
318    let DeleteParams { id } = params;
319
320    let uid: uuid::Uuid = id.parse().map_err(|e| {
321        McpError::invalid_params(format!("invalid UUID: {e}"), None)
322    })?;
323
324    let deleted = memory
325        .delete(uid)
326        .await
327        .map_err(|e| McpError::internal_error(format!("delete failed: {e}"), None))?;
328
329    let text = json!({ "id": id, "deleted": deleted }).to_string();
330    Ok(CallToolResult::success(vec![Content::text(text)]))
331}