1use 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
22pub 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#[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
214pub 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
238pub struct RecallResult {
240 pub results: Vec<crate::item::SearchResult>,
241 pub graph_expanded: Vec<Value>,
242 pub suggested: Vec<Value>,
243}
244
245pub 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 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 let tracker = AccessTracker::open(&ctx_ref.access_db_path)
268 .map_err(|e| format!("Failed to open access tracker: {}", e))?;
269
270 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
319async 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 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 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 let replaced_id = if let Some(ref replace_id) = params.replace {
360 match db.delete_item(replace_id).await {
361 Ok(true) => {
362 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 let mut tags = params.tags.unwrap_or_default();
383 let mut item = Item::new(¶ms.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 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 if scope == StoreScope::Project
413 && let Some(project_id) = db.project_id()
414 {
415 item = item.with_project_id(project_id);
416 }
417
418 if tags.is_empty()
420 && let Ok(similar) = db.find_similar_items(¶ms.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 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 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 if let Some(ref old_id) = replaced_id {
457 let _ = graph.add_supersedes_edge(&new_id, old_id);
459 let _ = graph.remove_node(old_id);
460 }
461
462 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 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
505pub 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 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 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 for result in &results {
576 let created_at = result.created_at.timestamp();
577 let _ = tracker.record_access(&result.id, created_at);
578 }
579
580 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 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 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, ¶ms.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 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 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 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 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 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(¶ms.id).await {
876 Ok(true) => {
877 let _ = graph.remove_node(¶ms.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 match db.get_item(¶ms.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(¶ms.id) {
912 Ok(connections) => {
913 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 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
951fn 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}