Skip to main content

sediment/mcp/
tools.rs

1//! MCP Tool definitions for Sediment
2//!
3//! 5 tools: store, recall, list, forget, connections
4
5use std::sync::Arc;
6
7use chrono::DateTime;
8use serde::Deserialize;
9use serde_json::{Value, json};
10
11use crate::access::AccessTracker;
12use crate::consolidation::{ConsolidationQueue, spawn_consolidation};
13use crate::db::score_with_decay;
14use crate::graph::GraphStore;
15use crate::item::{Item, ItemFilters};
16use crate::retry::{RetryConfig, with_retry};
17use crate::{Database, ListScope, StoreScope};
18
19use super::protocol::{CallToolResult, Tool};
20use super::server::ServerContext;
21
22/// Get all available tools (5 total)
23pub fn get_tools() -> Vec<Tool> {
24    vec![
25        Tool {
26            name: "store".to_string(),
27            description: "Store content for later retrieval. Use for preferences, facts, reference material, docs, or any information worth remembering. Long content is automatically chunked for better search.".to_string(),
28            input_schema: json!({
29                "type": "object",
30                "properties": {
31                    "content": {
32                        "type": "string",
33                        "description": "The content to store"
34                    },
35                    "title": {
36                        "type": "string",
37                        "description": "Optional title (recommended for long content)"
38                    },
39                    "tags": {
40                        "type": "array",
41                        "items": { "type": "string" },
42                        "description": "Tags for categorization"
43                    },
44                    "source": {
45                        "type": "string",
46                        "description": "Source attribution (e.g., URL, file path, 'conversation')"
47                    },
48                    "metadata": {
49                        "type": "object",
50                        "description": "Custom JSON metadata"
51                    },
52                    "expires_at": {
53                        "type": "string",
54                        "description": "ISO datetime when this should expire (optional)"
55                    },
56                    "scope": {
57                        "type": "string",
58                        "enum": ["project", "global"],
59                        "default": "project",
60                        "description": "Where to store: 'project' (current project) or 'global' (all projects)"
61                    },
62                    "replace": {
63                        "type": "string",
64                        "description": "ID of an existing item to replace (atomically delete before storing)"
65                    },
66                    "related": {
67                        "type": "array",
68                        "items": { "type": "string" },
69                        "description": "IDs of related items to link in the knowledge graph"
70                    }
71                },
72                "required": ["content"]
73            }),
74        },
75        Tool {
76            name: "recall".to_string(),
77            description: "Search stored content by semantic similarity. Returns matching items with relevant excerpts for chunked content.".to_string(),
78            input_schema: json!({
79                "type": "object",
80                "properties": {
81                    "query": {
82                        "type": "string",
83                        "description": "What to search for (semantic search)"
84                    },
85                    "limit": {
86                        "type": "number",
87                        "default": 5,
88                        "description": "Maximum number of results"
89                    },
90                    "tags": {
91                        "type": "array",
92                        "items": { "type": "string" },
93                        "description": "Filter by tags (any match)"
94                    },
95                    "min_similarity": {
96                        "type": "number",
97                        "default": 0.3,
98                        "description": "Minimum similarity threshold (0.0-1.0). Lower values return more results."
99                    }
100                },
101                "required": ["query"]
102            }),
103        },
104        Tool {
105            name: "list".to_string(),
106            description: "List stored items with optional filtering.".to_string(),
107            input_schema: json!({
108                "type": "object",
109                "properties": {
110                    "tags": {
111                        "type": "array",
112                        "items": { "type": "string" },
113                        "description": "Filter by tags"
114                    },
115                    "limit": {
116                        "type": "number",
117                        "default": 10,
118                        "description": "Maximum number of results"
119                    },
120                    "scope": {
121                        "type": "string",
122                        "enum": ["project", "global", "all"],
123                        "default": "project",
124                        "description": "Which items to list: 'project', 'global', or 'all'"
125                    }
126                }
127            }),
128        },
129        Tool {
130            name: "forget".to_string(),
131            description: "Delete a stored item by its ID.".to_string(),
132            input_schema: json!({
133                "type": "object",
134                "properties": {
135                    "id": {
136                        "type": "string",
137                        "description": "The item ID to delete"
138                    }
139                },
140                "required": ["id"]
141            }),
142        },
143        Tool {
144            name: "connections".to_string(),
145            description: "Show the relationship graph for a stored item. Returns all connections including related items, superseded items, and frequently co-accessed items.".to_string(),
146            input_schema: json!({
147                "type": "object",
148                "properties": {
149                    "id": {
150                        "type": "string",
151                        "description": "The item ID to show connections for"
152                    }
153                },
154                "required": ["id"]
155            }),
156        },
157    ]
158}
159
160// ========== Parameter Structs ==========
161
162#[derive(Debug, Deserialize)]
163pub struct StoreParams {
164    pub content: String,
165    #[serde(default)]
166    pub title: Option<String>,
167    #[serde(default)]
168    pub tags: Option<Vec<String>>,
169    #[serde(default)]
170    pub source: Option<String>,
171    #[serde(default)]
172    pub metadata: Option<Value>,
173    #[serde(default)]
174    pub expires_at: Option<String>,
175    #[serde(default)]
176    pub scope: Option<String>,
177    #[serde(default)]
178    pub replace: Option<String>,
179    #[serde(default)]
180    pub related: Option<Vec<String>>,
181}
182
183#[derive(Debug, Deserialize)]
184pub struct RecallParams {
185    pub query: String,
186    #[serde(default)]
187    pub limit: Option<usize>,
188    #[serde(default)]
189    pub tags: Option<Vec<String>>,
190    #[serde(default)]
191    pub min_similarity: Option<f32>,
192}
193
194#[derive(Debug, Deserialize)]
195pub struct ListParams {
196    #[serde(default)]
197    pub tags: Option<Vec<String>>,
198    #[serde(default)]
199    pub limit: Option<usize>,
200    #[serde(default)]
201    pub scope: Option<String>,
202}
203
204#[derive(Debug, Deserialize)]
205pub struct ForgetParams {
206    pub id: String,
207}
208
209#[derive(Debug, Deserialize)]
210pub struct ConnectionsParams {
211    pub id: String,
212}
213
214// ========== Recall Configuration ==========
215
216/// Controls which graph and scoring features are enabled during recall.
217/// Used by benchmarks to measure the impact of individual features.
218pub struct RecallConfig {
219    pub enable_graph_backfill: bool,
220    pub enable_graph_expansion: bool,
221    pub enable_co_access: bool,
222    pub enable_decay_scoring: bool,
223    pub enable_background_tasks: bool,
224}
225
226impl Default for RecallConfig {
227    fn default() -> Self {
228        Self {
229            enable_graph_backfill: true,
230            enable_graph_expansion: true,
231            enable_co_access: true,
232            enable_decay_scoring: true,
233            enable_background_tasks: true,
234        }
235    }
236}
237
238/// Result of a recall pipeline execution (for benchmark consumption).
239pub struct RecallResult {
240    pub results: Vec<crate::item::SearchResult>,
241    pub graph_expanded: Vec<Value>,
242    pub suggested: Vec<Value>,
243}
244
245// ========== Tool Execution ==========
246
247pub async fn execute_tool(ctx: &ServerContext, name: &str, args: Option<Value>) -> CallToolResult {
248    let config = RetryConfig::default();
249    let args_for_retry = args.clone();
250
251    let result = with_retry(&config, || {
252        let ctx_ref = ctx;
253        let name_ref = name;
254        let args_clone = args_for_retry.clone();
255
256        async move {
257            // Open fresh connection with shared embedder
258            let mut db = Database::open_with_embedder(
259                &ctx_ref.db_path,
260                ctx_ref.project_id.clone(),
261                ctx_ref.embedder.clone(),
262            )
263            .await
264            .map_err(|e| format!("Failed to open database: {}", e))?;
265
266            // Open access tracker
267            let tracker = AccessTracker::open(&ctx_ref.access_db_path)
268                .map_err(|e| format!("Failed to open access tracker: {}", e))?;
269
270            // Open graph store (shares access.db)
271            let graph = GraphStore::open(&ctx_ref.access_db_path)
272                .map_err(|e| format!("Failed to open graph store: {}", e))?;
273
274            let result = match name_ref {
275                "store" => execute_store(&mut db, &tracker, &graph, ctx_ref, args_clone).await,
276                "recall" => execute_recall(&mut db, &tracker, &graph, ctx_ref, args_clone).await,
277                "list" => execute_list(&mut db, args_clone).await,
278                "forget" => execute_forget(&mut db, &graph, args_clone).await,
279                "connections" => execute_connections(&mut db, &graph, args_clone).await,
280                _ => return Ok(CallToolResult::error(format!("Unknown tool: {}", name_ref))),
281            };
282
283            if result.is_error.unwrap_or(false)
284                && let Some(content) = result.content.first()
285                && is_retryable_error(&content.text)
286            {
287                return Err(content.text.clone());
288            }
289
290            Ok(result)
291        }
292    })
293    .await;
294
295    match result {
296        Ok(call_result) => call_result,
297        Err(e) => CallToolResult::error(format!("Operation failed after retries: {}", e)),
298    }
299}
300
301fn is_retryable_error(error_msg: &str) -> bool {
302    let retryable_patterns = [
303        "connection",
304        "timeout",
305        "temporarily unavailable",
306        "resource busy",
307        "lock",
308        "I/O error",
309        "Failed to open",
310        "Failed to connect",
311    ];
312
313    let lower = error_msg.to_lowercase();
314    retryable_patterns
315        .iter()
316        .any(|p| lower.contains(&p.to_lowercase()))
317}
318
319// ========== Tool Implementations ==========
320
321async fn execute_store(
322    db: &mut Database,
323    tracker: &AccessTracker,
324    graph: &GraphStore,
325    ctx: &ServerContext,
326    args: Option<Value>,
327) -> CallToolResult {
328    let params: StoreParams = match args {
329        Some(v) => match serde_json::from_value(v) {
330            Ok(p) => p,
331            Err(e) => return CallToolResult::error(format!("Invalid parameters: {}", e)),
332        },
333        None => return CallToolResult::error("Missing parameters"),
334    };
335
336    // Parse scope
337    let scope = params
338        .scope
339        .as_deref()
340        .map(|s| s.parse::<StoreScope>())
341        .transpose();
342
343    let scope = match scope {
344        Ok(s) => s.unwrap_or(StoreScope::Project),
345        Err(e) => return CallToolResult::error(e),
346    };
347
348    // Parse expires_at if provided
349    let expires_at = if let Some(ref exp_str) = params.expires_at {
350        match DateTime::parse_from_rfc3339(exp_str) {
351            Ok(dt) => Some(dt.with_timezone(&chrono::Utc)),
352            Err(e) => return CallToolResult::error(format!("Invalid expires_at: {}", e)),
353        }
354    } else {
355        None
356    };
357
358    // Handle replace: delete the existing item first, record provenance
359    let replaced_id = if let Some(ref replace_id) = params.replace {
360        match db.delete_item(replace_id).await {
361            Ok(true) => {
362                // Record validation for the new item being stored
363                let now = chrono::Utc::now().timestamp();
364                let _ = tracker.record_validation(replace_id, now);
365                Some(replace_id.clone())
366            }
367            Ok(false) => {
368                return CallToolResult::error(format!(
369                    "Cannot replace: item not found: {}",
370                    replace_id
371                ));
372            }
373            Err(e) => {
374                return CallToolResult::error(format!("Failed to delete item for replace: {}", e));
375            }
376        }
377    } else {
378        None
379    };
380
381    // Build item
382    let mut tags = params.tags.unwrap_or_default();
383    let mut item = Item::new(&params.content).with_tags(tags.clone());
384
385    if let Some(title) = params.title {
386        item = item.with_title(title);
387    }
388
389    if let Some(source) = params.source {
390        item = item.with_source(source);
391    }
392
393    // Build metadata with provenance
394    let mut metadata = params.metadata.unwrap_or(json!({}));
395    if let Some(obj) = metadata.as_object_mut() {
396        let mut provenance = json!({
397            "v": 1,
398            "project_path": ctx.cwd.to_string_lossy()
399        });
400        if let Some(ref rid) = replaced_id {
401            provenance["supersedes"] = json!(rid);
402        }
403        obj.insert("_provenance".to_string(), provenance);
404    }
405    item = item.with_metadata(metadata);
406
407    if let Some(exp) = expires_at {
408        item = item.with_expires_at(exp);
409    }
410
411    // Set project_id based on scope
412    if scope == StoreScope::Project
413        && let Some(project_id) = db.project_id()
414    {
415        item = item.with_project_id(project_id);
416    }
417
418    // Auto-tag inference (Phase 4a): if no user tags, infer from similar items
419    if tags.is_empty()
420        && let Ok(similar) = db.find_similar_items(&params.content, 0.85, 5).await
421    {
422        let mut tag_counts: std::collections::HashMap<String, usize> =
423            std::collections::HashMap::new();
424        for conflict in &similar {
425            if let Some(similar_item) = db.get_item(&conflict.id).await.ok().flatten() {
426                for tag in &similar_item.tags {
427                    if !tag.starts_with("auto:") {
428                        *tag_counts.entry(tag.clone()).or_insert(0) += 1;
429                    }
430                }
431            }
432        }
433        // If 2+ similar items share a tag, auto-apply it
434        let auto_tags: Vec<String> = tag_counts
435            .into_iter()
436            .filter(|(_, count)| *count >= 2)
437            .map(|(tag, _)| format!("auto:{}", tag))
438            .collect();
439        if !auto_tags.is_empty() {
440            tags = item.tags.clone();
441            tags.extend(auto_tags);
442            item = item.with_tags(tags);
443        }
444    }
445
446    match db.store_item(item).await {
447        Ok(store_result) => {
448            let new_id = store_result.id.clone();
449
450            // Create graph node
451            let now = chrono::Utc::now().timestamp();
452            let project_id = db.project_id().map(|s| s.to_string());
453            let _ = graph.add_node(&new_id, project_id.as_deref(), now);
454
455            // Create SUPERSEDES edge if replacing
456            if let Some(ref old_id) = replaced_id {
457                // The old node might still exist in graph; create edge then remove
458                let _ = graph.add_supersedes_edge(&new_id, old_id);
459                let _ = graph.remove_node(old_id);
460            }
461
462            // Create RELATED edges if specified
463            if let Some(ref related_ids) = params.related {
464                for rid in related_ids {
465                    let _ = graph.add_related_edge(&new_id, rid, 1.0, "user_linked");
466                }
467            }
468
469            // Enqueue consolidation candidates from conflicts
470            if !store_result.potential_conflicts.is_empty()
471                && let Ok(queue) = ConsolidationQueue::open(&ctx.access_db_path)
472            {
473                for conflict in &store_result.potential_conflicts {
474                    let _ = queue.enqueue(&new_id, &conflict.id, conflict.similarity as f64);
475                }
476            }
477
478            let mut result = json!({
479                "success": true,
480                "id": new_id,
481                "message": format!("Stored in {} scope", scope)
482            });
483
484            if !store_result.potential_conflicts.is_empty() {
485                let conflicts: Vec<Value> = store_result
486                    .potential_conflicts
487                    .iter()
488                    .map(|c| {
489                        json!({
490                            "id": c.id,
491                            "content": c.content,
492                            "similarity": format!("{:.2}", c.similarity)
493                        })
494                    })
495                    .collect();
496                result["potential_conflicts"] = json!(conflicts);
497            }
498
499            CallToolResult::success(serde_json::to_string_pretty(&result).unwrap())
500        }
501        Err(e) => CallToolResult::error(format!("Failed to store: {}", e)),
502    }
503}
504
505/// Core recall pipeline, extracted for benchmarking.
506///
507/// Performs: vector search, optional decay scoring, optional graph backfill,
508/// optional 1-hop graph expansion, and optional co-access suggestions.
509pub async fn recall_pipeline(
510    db: &mut Database,
511    tracker: &AccessTracker,
512    graph: &GraphStore,
513    query: &str,
514    limit: usize,
515    filters: ItemFilters,
516    config: &RecallConfig,
517) -> std::result::Result<RecallResult, String> {
518    let mut results = db
519        .search_items(query, limit, filters)
520        .await
521        .map_err(|e| format!("Search failed: {}", e))?;
522
523    if results.is_empty() {
524        return Ok(RecallResult {
525            results: Vec::new(),
526            graph_expanded: Vec::new(),
527            suggested: Vec::new(),
528        });
529    }
530
531    // Lazy graph backfill (uses project_id from SearchResult, no extra queries)
532    if config.enable_graph_backfill {
533        for result in &results {
534            let _ = graph.ensure_node_exists(
535                &result.id,
536                result.project_id.as_deref(),
537                result.created_at.timestamp(),
538            );
539        }
540    }
541
542    // Decay scoring
543    if config.enable_decay_scoring {
544        let item_ids: Vec<&str> = results.iter().map(|r| r.id.as_str()).collect();
545        let access_records = tracker.get_accesses(&item_ids).unwrap_or_default();
546        let now = chrono::Utc::now().timestamp();
547
548        for result in &mut results {
549            let created_at = result.created_at.timestamp();
550            let (access_count, last_accessed) = match access_records.get(&result.id) {
551                Some(rec) => (rec.access_count, Some(rec.last_accessed_at)),
552                None => (0, None),
553            };
554
555            let base_score = score_with_decay(
556                result.similarity,
557                now,
558                created_at,
559                access_count,
560                last_accessed,
561            );
562
563            let validation_count = tracker.get_validation_count(&result.id).unwrap_or(0);
564            let edge_count = graph.get_edge_count(&result.id).unwrap_or(0);
565            let trust_bonus =
566                1.0 + 0.05 * (1.0 + validation_count as f64).ln() as f32 + 0.02 * edge_count as f32;
567
568            result.similarity = base_score * trust_bonus;
569        }
570
571        results.sort_by(|a, b| b.similarity.partial_cmp(&a.similarity).unwrap());
572    }
573
574    // Record access
575    for result in &results {
576        let created_at = result.created_at.timestamp();
577        let _ = tracker.record_access(&result.id, created_at);
578    }
579
580    // Graph expansion
581    let existing_ids: std::collections::HashSet<String> =
582        results.iter().map(|r| r.id.clone()).collect();
583
584    let mut graph_expanded = Vec::new();
585    if config.enable_graph_expansion {
586        let top_ids: Vec<&str> = results.iter().take(5).map(|r| r.id.as_str()).collect();
587        if let Ok(neighbors) = graph.get_neighbors(&top_ids, 0.5) {
588            // Collect neighbor IDs not already in results, then batch fetch
589            let neighbor_info: Vec<(String, String)> = neighbors
590                .into_iter()
591                .filter(|(id, _, _)| !existing_ids.contains(id))
592                .map(|(id, rel_type, _)| (id, rel_type))
593                .collect();
594
595            let neighbor_ids: Vec<&str> = neighbor_info.iter().map(|(id, _)| id.as_str()).collect();
596            if let Ok(items) = db.get_items_batch(&neighbor_ids).await {
597                let item_map: std::collections::HashMap<&str, &Item> =
598                    items.iter().map(|item| (item.id.as_str(), item)).collect();
599
600                for (neighbor_id, rel_type) in &neighbor_info {
601                    if let Some(item) = item_map.get(neighbor_id.as_str()) {
602                        let sr = crate::item::SearchResult::from_item(item, 0.05);
603                        graph_expanded.push(json!({
604                            "id": sr.id,
605                            "content": sr.content,
606                            "similarity": "graph",
607                            "created": sr.created_at.to_rfc3339(),
608                            "graph_expanded": true,
609                            "rel_type": rel_type,
610                        }));
611                    }
612                }
613            }
614        }
615    }
616
617    // Co-access suggestions (batch fetch)
618    let mut suggested = Vec::new();
619    if config.enable_co_access {
620        let top3_ids: Vec<&str> = results.iter().take(3).map(|r| r.id.as_str()).collect();
621        if let Ok(co_accessed) = graph.get_co_accessed(&top3_ids, 3) {
622            let co_info: Vec<(String, i64)> = co_accessed
623                .into_iter()
624                .filter(|(id, _)| !existing_ids.contains(id))
625                .collect();
626
627            let co_ids: Vec<&str> = co_info.iter().map(|(id, _)| id.as_str()).collect();
628            if let Ok(items) = db.get_items_batch(&co_ids).await {
629                let item_map: std::collections::HashMap<&str, &Item> =
630                    items.iter().map(|item| (item.id.as_str(), item)).collect();
631
632                for (co_id, co_count) in &co_info {
633                    if let Some(item) = item_map.get(co_id.as_str()) {
634                        suggested.push(json!({
635                            "id": item.id,
636                            "content": truncate(&item.content, 100),
637                            "reason": format!("frequently recalled with result (co-accessed {} times)", co_count),
638                        }));
639                    }
640                }
641            }
642        }
643    }
644
645    Ok(RecallResult {
646        results,
647        graph_expanded,
648        suggested,
649    })
650}
651
652async fn execute_recall(
653    db: &mut Database,
654    tracker: &AccessTracker,
655    graph: &GraphStore,
656    ctx: &ServerContext,
657    args: Option<Value>,
658) -> CallToolResult {
659    let params: RecallParams = match args {
660        Some(v) => match serde_json::from_value(v) {
661            Ok(p) => p,
662            Err(e) => return CallToolResult::error(format!("Invalid parameters: {}", e)),
663        },
664        None => return CallToolResult::error("Missing parameters"),
665    };
666
667    let limit = params.limit.unwrap_or(5);
668    let min_similarity = params.min_similarity.unwrap_or(0.3);
669
670    let mut filters = ItemFilters::new().with_min_similarity(min_similarity);
671
672    if let Some(tags) = params.tags {
673        filters = filters.with_tags(tags);
674    }
675
676    let config = RecallConfig::default();
677
678    let recall_result =
679        match recall_pipeline(db, tracker, graph, &params.query, limit, filters, &config).await {
680            Ok(r) => r,
681            Err(e) => return CallToolResult::error(e),
682        };
683
684    if recall_result.results.is_empty() {
685        return CallToolResult::success("No items found matching your query.");
686    }
687
688    let results = &recall_result.results;
689
690    let formatted: Vec<Value> = results
691        .iter()
692        .map(|r| {
693            let mut obj = json!({
694                "id": r.id,
695                "content": r.content,
696                "similarity": format!("{:.2}", r.similarity),
697                "created": r.created_at.to_rfc3339(),
698            });
699
700            if let Some(ref excerpt) = r.relevant_excerpt {
701                obj["relevant_excerpt"] = json!(excerpt);
702            }
703            if !r.tags.is_empty() {
704                obj["tags"] = json!(r.tags);
705            }
706            if let Some(ref source) = r.source {
707                obj["source"] = json!(source);
708            }
709
710            // Cross-project flag (Phase 3c) — uses cached project_id/metadata from SearchResult
711            if let Some(ref current_pid) = ctx.project_id
712                && let Some(ref item_pid) = r.project_id
713                && item_pid != current_pid
714            {
715                obj["cross_project"] = json!(true);
716                if let Some(ref meta) = r.metadata
717                    && let Some(prov) = meta.get("_provenance")
718                    && let Some(pp) = prov.get("project_path")
719                {
720                    obj["project_path"] = pp.clone();
721                }
722            }
723
724            // Related IDs from graph (Phase 1d)
725            if let Ok(neighbors) = graph.get_neighbors(&[r.id.as_str()], 0.5) {
726                let related: Vec<String> = neighbors.iter().map(|(id, _, _)| id.clone()).collect();
727                if !related.is_empty() {
728                    obj["related_ids"] = json!(related);
729                }
730            }
731
732            obj
733        })
734        .collect();
735
736    let mut result_json = json!({
737        "count": results.len(),
738        "results": formatted
739    });
740
741    if !recall_result.graph_expanded.is_empty() {
742        result_json["graph_expanded"] = json!(recall_result.graph_expanded);
743    }
744
745    if !recall_result.suggested.is_empty() {
746        result_json["suggested"] = json!(recall_result.suggested);
747    }
748
749    // Fire-and-forget: background consolidation (Phase 2b)
750    spawn_consolidation(
751        Arc::new(ctx.db_path.clone()),
752        Arc::new(ctx.access_db_path.clone()),
753        ctx.project_id.clone(),
754        ctx.embedder.clone(),
755        ctx.consolidation_semaphore.clone(),
756    );
757
758    // Fire-and-forget: co-access recording (Phase 3a)
759    let result_ids: Vec<String> = results.iter().map(|r| r.id.clone()).collect();
760    let access_db_path = ctx.access_db_path.clone();
761    tokio::spawn(async move {
762        if let Ok(g) = GraphStore::open(&access_db_path) {
763            let _ = g.record_co_access(&result_ids);
764        }
765    });
766
767    // Periodic clustering (Phase 4b): every 10th consolidation run
768    let run_count = ctx
769        .consolidation_run_count
770        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
771    if run_count % 10 == 9 {
772        let access_db_path = ctx.access_db_path.clone();
773        tokio::spawn(async move {
774            if let Ok(g) = GraphStore::open(&access_db_path)
775                && let Ok(clusters) = g.detect_clusters()
776            {
777                for (a, b, c) in &clusters {
778                    let label = format!("cluster-{}", &a[..8.min(a.len())]);
779                    let _ = g.add_related_edge(a, b, 0.8, &label);
780                    let _ = g.add_related_edge(b, c, 0.8, &label);
781                    let _ = g.add_related_edge(a, c, 0.8, &label);
782                }
783                if !clusters.is_empty() {
784                    tracing::info!("Detected {} clusters", clusters.len());
785                }
786            }
787        });
788    }
789
790    CallToolResult::success(serde_json::to_string_pretty(&result_json).unwrap())
791}
792
793async fn execute_list(db: &mut Database, args: Option<Value>) -> CallToolResult {
794    let params: ListParams =
795        args.and_then(|v| serde_json::from_value(v).ok())
796            .unwrap_or(ListParams {
797                tags: None,
798                limit: None,
799                scope: None,
800            });
801
802    let limit = params.limit.unwrap_or(10);
803
804    let mut filters = ItemFilters::new();
805
806    if let Some(tags) = params.tags {
807        filters = filters.with_tags(tags);
808    }
809
810    let scope = params
811        .scope
812        .as_deref()
813        .map(|s| s.parse::<ListScope>())
814        .transpose();
815
816    let scope = match scope {
817        Ok(s) => s.unwrap_or(ListScope::Project),
818        Err(e) => return CallToolResult::error(e),
819    };
820
821    match db.list_items(filters, Some(limit), scope).await {
822        Ok(items) => {
823            if items.is_empty() {
824                CallToolResult::success("No items stored yet.")
825            } else {
826                let formatted: Vec<Value> = items
827                    .iter()
828                    .map(|item| {
829                        let content_preview = truncate(&item.content, 100);
830                        let mut obj = json!({
831                            "id": item.id,
832                            "content": content_preview,
833                            "created": item.created_at.to_rfc3339(),
834                        });
835
836                        if let Some(ref title) = item.title {
837                            obj["title"] = json!(title);
838                        }
839                        if !item.tags.is_empty() {
840                            obj["tags"] = json!(item.tags);
841                        }
842                        if item.is_chunked {
843                            obj["chunked"] = json!(true);
844                        }
845
846                        obj
847                    })
848                    .collect();
849
850                let result = json!({
851                    "count": items.len(),
852                    "items": formatted
853                });
854
855                CallToolResult::success(serde_json::to_string_pretty(&result).unwrap())
856            }
857        }
858        Err(e) => CallToolResult::error(format!("Failed to list items: {}", e)),
859    }
860}
861
862async fn execute_forget(
863    db: &mut Database,
864    graph: &GraphStore,
865    args: Option<Value>,
866) -> CallToolResult {
867    let params: ForgetParams = match args {
868        Some(v) => match serde_json::from_value(v) {
869            Ok(p) => p,
870            Err(e) => return CallToolResult::error(format!("Invalid parameters: {}", e)),
871        },
872        None => return CallToolResult::error("Missing parameters"),
873    };
874
875    match db.delete_item(&params.id).await {
876        Ok(true) => {
877            // Remove from graph
878            let _ = graph.remove_node(&params.id);
879
880            let result = json!({
881                "success": true,
882                "message": format!("Deleted item: {}", params.id)
883            });
884            CallToolResult::success(serde_json::to_string_pretty(&result).unwrap())
885        }
886        Ok(false) => CallToolResult::error(format!("Item not found: {}", params.id)),
887        Err(e) => CallToolResult::error(format!("Failed to delete: {}", e)),
888    }
889}
890
891async fn execute_connections(
892    db: &mut Database,
893    graph: &GraphStore,
894    args: Option<Value>,
895) -> CallToolResult {
896    let params: ConnectionsParams = match args {
897        Some(v) => match serde_json::from_value(v) {
898            Ok(p) => p,
899            Err(e) => return CallToolResult::error(format!("Invalid parameters: {}", e)),
900        },
901        None => return CallToolResult::error("Missing parameters"),
902    };
903
904    // Verify item exists
905    match db.get_item(&params.id).await {
906        Ok(None) => return CallToolResult::error(format!("Item not found: {}", params.id)),
907        Err(e) => return CallToolResult::error(format!("Failed to get item: {}", e)),
908        Ok(Some(_)) => {}
909    }
910
911    match graph.get_full_connections(&params.id) {
912        Ok(connections) => {
913            // Batch fetch all connected items
914            let target_ids: Vec<&str> = connections.iter().map(|c| c.target_id.as_str()).collect();
915            let items = db.get_items_batch(&target_ids).await.unwrap_or_default();
916            let item_map: std::collections::HashMap<&str, &Item> =
917                items.iter().map(|item| (item.id.as_str(), item)).collect();
918
919            let mut conn_json: Vec<Value> = Vec::new();
920
921            for conn in &connections {
922                let mut obj = json!({
923                    "id": conn.target_id,
924                    "type": conn.rel_type,
925                    "strength": conn.strength,
926                });
927
928                if let Some(count) = conn.count {
929                    obj["count"] = json!(count);
930                }
931
932                // Add content preview from batch
933                if let Some(item) = item_map.get(conn.target_id.as_str()) {
934                    obj["content_preview"] = json!(truncate(&item.content, 80));
935                }
936
937                conn_json.push(obj);
938            }
939
940            let result = json!({
941                "item_id": params.id,
942                "connections": conn_json
943            });
944
945            CallToolResult::success(serde_json::to_string_pretty(&result).unwrap())
946        }
947        Err(e) => CallToolResult::error(format!("Failed to get connections: {}", e)),
948    }
949}
950
951// ========== Utilities ==========
952
953fn truncate(s: &str, max_len: usize) -> String {
954    if s.len() <= max_len {
955        s.to_string()
956    } else {
957        format!("{}...", &s[..max_len - 3])
958    }
959}