1use crate::config::{
7 ConsolidationConfig, LlmProvider, StorageBackendType, SubcogConfig, parse_duration_to_seconds,
8};
9use crate::llm::ResilientLlmProvider;
10use crate::mcp::prompt_understanding::PROMPT_UNDERSTANDING;
11use crate::mcp::tool_types::{
12 CaptureArgs, ConsolidateArgs, DeleteArgs, EnrichArgs, GetArgs, InitArgs, RecallArgs,
13 ReindexArgs, UpdateArgs, build_filter_description, format_content_for_detail,
14 parse_domain_scope, parse_namespace, parse_search_mode,
15};
16#[cfg(test)]
17use crate::models::SearchResult;
18use crate::models::{
19 CaptureRequest, DetailLevel, Domain, EventMeta, MemoryEvent, MemoryId, MemoryStatus, Namespace,
20 SearchFilter, SearchMode, Urn,
21};
22use crate::observability::current_request_id;
23use crate::security::record_event;
24use crate::services::{ConsolidationService, ServiceContainer, parse_filter_query};
25use crate::storage::index::SqliteBackend;
26use crate::storage::persistence::FilesystemBackend;
27use crate::{Error, Result};
28use serde_json::Value;
29use std::str::FromStr;
30use std::sync::Arc;
31
32use super::super::{ToolContent, ToolResult};
33
34const MAX_CONTENT_LENGTH: usize = 1_048_576; const MAX_QUERY_LENGTH: usize = 10_240; fn validate_input_length(input: &str, field_name: &str, max_length: usize) -> Result<()> {
52 if input.len() > max_length {
53 return Err(Error::InvalidInput(format!(
54 "{field_name} exceeds maximum length ({} > {max_length} bytes)",
55 input.len()
56 )));
57 }
58 Ok(())
59}
60
61#[cfg(test)]
62fn fetch_consolidation_candidates(
63 recall: &crate::services::RecallService,
64 filter: &SearchFilter,
65 query: Option<&str>,
66 limit: usize,
67) -> Result<SearchResult> {
68 let query = query.unwrap_or("*");
69 if query == "*" || query.is_empty() {
70 recall.list_all(filter, limit)
71 } else {
72 recall.search(query, SearchMode::Hybrid, filter, limit)
73 }
74}
75
76pub fn execute_capture(arguments: Value) -> Result<ToolResult> {
78 let args: CaptureArgs =
79 serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
80
81 validate_input_length(&args.content, "content", MAX_CONTENT_LENGTH)?;
83
84 let namespace = parse_namespace(&args.namespace);
85
86 let ttl_seconds = args.ttl.as_ref().and_then(|s| parse_duration_to_seconds(s));
88
89 let scope = parse_domain_scope(args.domain.as_deref());
91
92 let domain = match scope {
94 crate::storage::index::DomainScope::User => Domain::for_user(),
95 crate::storage::index::DomainScope::Org => Domain::for_org(),
96 crate::storage::index::DomainScope::Project => Domain::default_for_context(),
97 };
98
99 let request = CaptureRequest {
100 content: args.content,
101 namespace,
102 domain,
103 tags: args.tags.unwrap_or_default(),
104 source: args.source,
105 skip_security_check: false,
106 ttl_seconds,
107 scope: Some(scope),
108 #[cfg(feature = "group-scope")]
109 group_id: None,
110 };
111
112 let services = ServiceContainer::from_current_dir_or_user()?;
113 let result = services.capture().capture(request)?;
114
115 Ok(ToolResult {
116 content: vec![ToolContent::Text {
117 text: format!(
118 "Memory captured successfully!\n\nID: {}\nURN: {}\nRedacted: {}",
119 result.memory_id, result.urn, result.content_modified
120 ),
121 }],
122 is_error: false,
123 })
124}
125
126pub fn execute_recall(arguments: Value) -> Result<ToolResult> {
131 let args: RecallArgs =
132 serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
133
134 let query = args.query.as_deref().unwrap_or("");
136 let is_list_mode = query.is_empty() || query == "*";
137
138 if !is_list_mode {
140 validate_input_length(query, "query", MAX_QUERY_LENGTH)?;
141 }
142
143 let mode = args
144 .mode
145 .as_deref()
146 .map_or(SearchMode::Hybrid, parse_search_mode);
147
148 let detail = args
149 .detail
150 .as_deref()
151 .and_then(DetailLevel::parse)
152 .unwrap_or_default();
153
154 let mut filter = if let Some(filter_query) = &args.filter {
156 parse_filter_query(filter_query)
157 } else {
158 SearchFilter::new()
159 };
160
161 if let Some(ns) = &args.namespace {
163 filter = filter.with_namespace(parse_namespace(ns));
164 }
165
166 if let Some(ref entity_arg) = args.entity {
168 let entities: Vec<String> = entity_arg
169 .split(',')
170 .map(str::trim)
171 .filter(|s| !s.is_empty())
172 .map(ToString::to_string)
173 .collect();
174 filter = filter.with_entities(entities);
175 }
176
177 if let Some(ref user_id) = args.user_id {
180 filter = filter.with_tag(format!("user:{user_id}"));
181 }
182 if let Some(ref agent_id) = args.agent_id {
183 filter = filter.with_tag(format!("agent:{agent_id}"));
184 }
185
186 let limit = if is_list_mode {
190 args.limit.unwrap_or(50).min(1000)
191 } else {
192 args.limit.unwrap_or(10).min(50)
193 };
194
195 let services = ServiceContainer::from_current_dir_or_user()?;
196 let recall = services.recall()?;
197
198 let result = if is_list_mode {
201 recall.list_all(&filter, limit)?
202 } else {
203 recall.search(query, mode, &filter, limit)?
204 };
205
206 let filter_desc = build_filter_description(&filter);
208
209 let mut output = format!(
210 "Found {} memories (searched in {}ms using {} mode, detail: {}{})\n\n",
211 result.total_count, result.execution_time_ms, result.mode, detail, filter_desc
212 );
213
214 for (i, hit) in result.memories.iter().enumerate() {
215 let content_display = format_content_for_detail(&hit.memory.content, detail);
217
218 let tags_display = if hit.memory.tags.is_empty() {
219 String::new()
220 } else {
221 format!(" [{}]", hit.memory.tags.join(", "))
222 };
223
224 let domain_part = if hit.memory.domain.is_project_scoped() {
227 "project".to_string()
228 } else {
229 hit.memory.domain.to_string()
230 };
231 let urn = format!(
232 "subcog://{}/{}/{}",
233 domain_part, hit.memory.namespace, hit.memory.id
234 );
235
236 let score_display = if (hit.score - hit.raw_score).abs() < f32::EPSILON {
239 format!("{:.2}", hit.score)
240 } else {
241 format!("{:.2} (raw: {:.4})", hit.score, hit.raw_score)
242 };
243
244 output.push_str(&format!(
245 "{}. {} | {}{}{}\n\n",
246 i + 1,
247 urn,
248 score_display,
249 tags_display,
250 content_display,
251 ));
252 }
253
254 Ok(ToolResult {
255 content: vec![ToolContent::Text { text: output }],
256 is_error: false,
257 })
258}
259
260#[derive(Debug, Clone, serde::Serialize)]
262struct ComponentHealth {
263 name: String,
265 status: String,
267 #[serde(skip_serializing_if = "Option::is_none")]
269 details: Option<String>,
270 #[serde(skip_serializing_if = "Option::is_none")]
272 response_time_ms: Option<u64>,
273}
274
275impl ComponentHealth {
276 fn healthy(name: impl Into<String>) -> Self {
277 Self {
278 name: name.into(),
279 status: "healthy".to_string(),
280 details: None,
281 response_time_ms: None,
282 }
283 }
284
285 fn healthy_with_time(name: impl Into<String>, elapsed: std::time::Duration) -> Self {
286 let response_time_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
288 Self {
289 name: name.into(),
290 status: "healthy".to_string(),
291 details: None,
292 response_time_ms: Some(response_time_ms),
293 }
294 }
295
296 fn unhealthy(name: impl Into<String>, details: impl Into<String>) -> Self {
297 Self {
298 name: name.into(),
299 status: "unhealthy".to_string(),
300 details: Some(details.into()),
301 response_time_ms: None,
302 }
303 }
304
305 fn degraded(name: impl Into<String>, details: impl Into<String>) -> Self {
306 Self {
307 name: name.into(),
308 status: "degraded".to_string(),
309 details: Some(details.into()),
310 response_time_ms: None,
311 }
312 }
313}
314
315fn check_persistence_health(services: &ServiceContainer) -> ComponentHealth {
317 let start = std::time::Instant::now();
318 match services.recall() {
319 Ok(recall) => {
320 let filter = SearchFilter::new();
322 match recall.list_all(&filter, 1) {
323 Ok(_) => {
324 ComponentHealth::healthy_with_time("persistence (sqlite)", start.elapsed())
325 },
326 Err(e) => ComponentHealth::degraded("persistence (sqlite)", e.to_string()),
327 }
328 },
329 Err(e) => ComponentHealth::unhealthy("persistence (sqlite)", e.to_string()),
330 }
331}
332
333fn check_index_health(services: &ServiceContainer) -> ComponentHealth {
335 let start = std::time::Instant::now();
336 match services.recall() {
337 Ok(recall) => {
338 let filter = SearchFilter::new();
340 match recall.search("health_check_probe", SearchMode::Text, &filter, 1) {
341 Ok(_) => ComponentHealth::healthy_with_time("index (sqlite-fts5)", start.elapsed()),
342 Err(e) => ComponentHealth::degraded("index (sqlite-fts5)", e.to_string()),
343 }
344 },
345 Err(e) => ComponentHealth::unhealthy("index (sqlite-fts5)", e.to_string()),
346 }
347}
348
349fn check_vector_health(services: &ServiceContainer) -> ComponentHealth {
351 let start = std::time::Instant::now();
352 match services.recall() {
353 Ok(recall) => {
354 let filter = SearchFilter::new();
356 match recall.search("health_check_probe", SearchMode::Vector, &filter, 1) {
357 Ok(_) => ComponentHealth::healthy_with_time("vector (usearch)", start.elapsed()),
358 Err(e) => {
359 if e.to_string().contains("no embeddings")
361 || e.to_string().contains("not configured")
362 {
363 ComponentHealth::degraded("vector (usearch)", "No embeddings available")
364 } else {
365 ComponentHealth::degraded("vector (usearch)", e.to_string())
366 }
367 },
368 }
369 },
370 Err(e) => ComponentHealth::unhealthy("vector (usearch)", e.to_string()),
371 }
372}
373
374fn check_capture_health(services: &ServiceContainer) -> ComponentHealth {
376 let _capture = services.capture();
379 ComponentHealth::healthy("capture_service")
380}
381
382pub fn execute_status(_arguments: Value) -> Result<ToolResult> {
395 let mut components = Vec::new();
396 let mut any_unhealthy = false;
397 let mut any_degraded = false;
398
399 let services_result = ServiceContainer::from_current_dir_or_user();
401
402 match services_result {
403 Ok(services) => {
404 let persistence = check_persistence_health(&services);
406 if persistence.status == "unhealthy" {
407 any_unhealthy = true;
408 } else if persistence.status == "degraded" {
409 any_degraded = true;
410 }
411 components.push(persistence);
412
413 let index = check_index_health(&services);
415 if index.status == "unhealthy" {
416 any_unhealthy = true;
417 } else if index.status == "degraded" {
418 any_degraded = true;
419 }
420 components.push(index);
421
422 let vector = check_vector_health(&services);
424 if vector.status == "unhealthy" {
425 any_unhealthy = true;
426 } else if vector.status == "degraded" {
427 any_degraded = true;
428 }
429 components.push(vector);
430
431 let capture = check_capture_health(&services);
433 if capture.status == "unhealthy" {
434 any_unhealthy = true;
435 } else if capture.status == "degraded" {
436 any_degraded = true;
437 }
438 components.push(capture);
439 },
440 Err(e) => {
441 any_unhealthy = true;
442 components.push(ComponentHealth::unhealthy(
443 "service_container",
444 e.to_string(),
445 ));
446 },
447 }
448
449 let overall_status = if any_unhealthy {
451 "unhealthy"
452 } else if any_degraded {
453 "degraded"
454 } else {
455 "healthy"
456 };
457
458 let status = serde_json::json!({
459 "version": env!("CARGO_PKG_VERSION"),
460 "status": overall_status,
461 "components": components,
462 "features": {
463 "semantic_search": true,
464 "secret_detection": true,
465 "hooks": true,
466 "circuit_breakers": true,
467 "bulkhead_isolation": true,
468 "configurable_timeouts": true
469 }
470 });
471
472 Ok(ToolResult {
473 content: vec![ToolContent::Text {
474 text: serde_json::to_string_pretty(&status)
475 .unwrap_or_else(|_| "Status unavailable".to_string()),
476 }],
477 is_error: false,
478 })
479}
480
481pub fn execute_prompt_understanding(_arguments: Value) -> Result<ToolResult> {
483 Ok(ToolResult {
484 content: vec![ToolContent::Text {
485 text: PROMPT_UNDERSTANDING.to_string(),
486 }],
487 is_error: false,
488 })
489}
490
491pub fn execute_namespaces(_arguments: Value) -> Result<ToolResult> {
493 let namespaces = vec![
494 ("decisions", "Architectural and design decisions"),
495 ("patterns", "Discovered patterns and conventions"),
496 ("learnings", "Lessons learned from debugging or issues"),
497 ("context", "Important contextual information"),
498 ("tech-debt", "Technical debts and future improvements"),
499 ("apis", "API endpoints and contracts"),
500 ("config", "Configuration and environment details"),
501 ("security", "Security-related information"),
502 ("performance", "Performance optimizations and benchmarks"),
503 ("testing", "Testing strategies and edge cases"),
504 ];
505
506 let mut output = "Available Memory Namespaces:\n\n".to_string();
507 for (name, desc) in namespaces {
508 output.push_str(&format!("- **{name}**: {desc}\n"));
509 }
510
511 Ok(ToolResult {
512 content: vec![ToolContent::Text { text: output }],
513 is_error: false,
514 })
515}
516
517#[allow(clippy::too_many_lines)]
520pub fn execute_consolidate(arguments: Value) -> Result<ToolResult> {
521 let args: ConsolidateArgs =
522 serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
523
524 let dry_run = args.dry_run.unwrap_or(false);
525
526 let config = SubcogConfig::load_default();
528
529 if !config.consolidation.enabled {
530 return Ok(ToolResult {
531 content: vec![ToolContent::Text {
532 text: "Consolidation is disabled in configuration. To enable, set [consolidation] enabled = true in your config file.".to_string(),
533 }],
534 is_error: false,
535 });
536 }
537
538 let mut consolidation_config = config.consolidation.clone();
540
541 if let Some(ref namespaces) = args.namespaces {
543 let parsed_namespaces: std::result::Result<Vec<Namespace>, _> = namespaces
544 .iter()
545 .map(|ns| Namespace::from_str(ns))
546 .collect();
547
548 match parsed_namespaces {
549 Ok(ns) => {
550 consolidation_config.namespace_filter = Some(ns);
551 },
552 Err(e) => {
553 return Ok(ToolResult {
554 content: vec![ToolContent::Text {
555 text: format!("Invalid namespace: {e}"),
556 }],
557 is_error: true,
558 });
559 },
560 }
561 }
562
563 if let Some(d) = args.days {
565 consolidation_config.time_window_days = Some(d);
566 }
567
568 if let Some(min) = args.min_memories {
570 consolidation_config.min_memories_to_consolidate = min;
571 }
572
573 if let Some(sim) = args.similarity {
575 if !(0.0..=1.0).contains(&sim) {
576 return Ok(ToolResult {
577 content: vec![ToolContent::Text {
578 text: "Similarity threshold must be between 0.0 and 1.0".to_string(),
579 }],
580 is_error: true,
581 });
582 }
583 consolidation_config.similarity_threshold = sim;
584 }
585
586 let data_dir = &config.data_dir;
587 let storage_config = &config.storage.project;
588
589 let services = ServiceContainer::from_current_dir_or_user()?;
591 let recall_service = services.recall()?;
592 let index = Arc::new(services.index()?);
593
594 let llm_provider: Option<Arc<dyn crate::llm::LlmProvider + Send + Sync>> =
596 build_llm_provider_from_config(&config.llm);
597
598 let result_text = match storage_config.backend {
600 StorageBackendType::Sqlite => {
601 let db_path = storage_config
602 .path
603 .as_ref()
604 .map_or_else(|| data_dir.join("memories.db"), std::path::PathBuf::from);
605
606 let backend = SqliteBackend::new(&db_path)?;
607 let mut service = ConsolidationService::new(backend).with_index(index);
608
609 if let Some(llm) = llm_provider {
610 service = service.with_llm(llm);
611 }
612
613 run_mcp_consolidation(
614 &mut service,
615 &recall_service,
616 &consolidation_config,
617 dry_run,
618 )?
619 },
620 StorageBackendType::Filesystem => {
621 let backend = FilesystemBackend::new(data_dir);
622 let mut service = ConsolidationService::new(backend).with_index(index);
623
624 if let Some(llm) = llm_provider {
625 service = service.with_llm(llm);
626 }
627
628 run_mcp_consolidation(
629 &mut service,
630 &recall_service,
631 &consolidation_config,
632 dry_run,
633 )?
634 },
635 StorageBackendType::PostgreSQL | StorageBackendType::Redis => {
636 return Ok(ToolResult {
637 content: vec![ToolContent::Text {
638 text: format!(
639 "{:?} consolidation not yet implemented",
640 storage_config.backend
641 ),
642 }],
643 is_error: true,
644 });
645 },
646 };
647
648 Ok(ToolResult {
649 content: vec![ToolContent::Text { text: result_text }],
650 is_error: false,
651 })
652}
653
654fn build_llm_provider_from_config(
658 llm_config: &crate::config::LlmConfig,
659) -> Option<Arc<dyn crate::llm::LlmProvider + Send + Sync>> {
660 use crate::llm::{
661 AnthropicClient, LlmResilienceConfig, LmStudioClient, OllamaClient, OpenAiClient,
662 };
663
664 let resilience_config = LlmResilienceConfig {
666 max_retries: llm_config.max_retries.unwrap_or(3),
667 retry_backoff_ms: llm_config.retry_backoff_ms.unwrap_or(1000),
668 breaker_failure_threshold: llm_config.breaker_failure_threshold.unwrap_or(5),
669 breaker_reset_timeout_ms: llm_config.breaker_reset_ms.unwrap_or(30_000),
670 breaker_half_open_max_calls: llm_config.breaker_half_open_max_calls.unwrap_or(3),
671 latency_slo_ms: llm_config.latency_slo_ms.unwrap_or(5000),
672 error_budget_ratio: llm_config.error_budget_ratio.unwrap_or(0.01),
673 error_budget_window_secs: llm_config.error_budget_window_secs.unwrap_or(3600),
674 };
675
676 match llm_config.provider {
677 LlmProvider::OpenAi => {
678 let client = OpenAiClient::new();
679 Some(Arc::new(ResilientLlmProvider::new(
680 client,
681 resilience_config,
682 )))
683 },
684 LlmProvider::Anthropic => {
685 let client = AnthropicClient::new();
686 Some(Arc::new(ResilientLlmProvider::new(
687 client,
688 resilience_config,
689 )))
690 },
691 LlmProvider::Ollama => {
692 let client = OllamaClient::new();
693 Some(Arc::new(ResilientLlmProvider::new(
694 client,
695 resilience_config,
696 )))
697 },
698 LlmProvider::LmStudio => {
699 let client = LmStudioClient::new();
700 Some(Arc::new(ResilientLlmProvider::new(
701 client,
702 resilience_config,
703 )))
704 },
705 LlmProvider::None => None,
706 }
707}
708
709fn run_mcp_consolidation<P: crate::storage::PersistenceBackend>(
711 service: &mut ConsolidationService<P>,
712 recall_service: &crate::services::RecallService,
713 consolidation_config: &ConsolidationConfig,
714 dry_run: bool,
715) -> Result<String> {
716 if dry_run {
717 let groups = service.find_related_memories(recall_service, consolidation_config)?;
719
720 let mut output = String::from("**Dry-run results (no changes made)**\n\n");
721
722 let mut total_groups = 0;
723 let mut total_memories = 0;
724
725 for (namespace, namespace_groups) in &groups {
726 if namespace_groups.is_empty() {
727 continue;
728 }
729
730 output.push_str(&format!(
731 "**{:?}**: {} group(s)\n",
732 namespace,
733 namespace_groups.len()
734 ));
735 for (idx, group) in namespace_groups.iter().enumerate() {
736 output.push_str(&format!(
737 " - Group {}: {} memories\n",
738 idx + 1,
739 group.len()
740 ));
741 total_groups += 1;
742 total_memories += group.len();
743 }
744 output.push('\n');
745 }
746
747 if total_groups == 0 {
748 output.push_str("No related memory groups found.\n");
749 } else {
750 output.push_str("**Summary:**\n");
751 output.push_str(&format!(
752 " - Would create {total_groups} summary node(s)\n"
753 ));
754 output.push_str(&format!(
755 " - Would consolidate {total_memories} memory/memories\n"
756 ));
757 }
758
759 Ok(output)
760 } else {
761 let stats = service.consolidate_memories(recall_service, consolidation_config)?;
763
764 let mut output = String::from("**Consolidation completed**\n\n");
765
766 if stats.processed == 0 {
767 output.push_str("No memories found to consolidate.\n");
768 } else {
769 output.push_str("**Statistics:**\n");
770 output.push_str(&format!(" - Processed: {} memories\n", stats.processed));
771 output.push_str(&format!(
772 " - Summary nodes created: {}\n",
773 stats.summaries_created
774 ));
775 output.push_str(&format!(" - Merged: {}\n", stats.merged));
776 output.push_str(&format!(" - Archived: {}\n", stats.archived));
777 if stats.contradictions > 0 {
778 output.push_str(&format!(
779 " - Contradictions detected: {}\n",
780 stats.contradictions
781 ));
782 }
783 }
784
785 Ok(output)
786 }
787}
788
789fn format_source_memory_entry<I: crate::storage::traits::IndexBackend>(
791 index: &I,
792 source_id: &crate::models::MemoryId,
793 position: usize,
794) -> Result<String> {
795 let mut entry = String::new();
796
797 match index.get_memory(source_id)? {
798 Some(source_memory) => {
799 entry.push_str(&format!("{}. **{}**\n", position, source_id.as_str()));
800 entry.push_str(&format!(" - Namespace: {:?}\n", source_memory.namespace));
801 if !source_memory.tags.is_empty() {
802 entry.push_str(&format!(" - Tags: {}\n", source_memory.tags.join(", ")));
803 }
804 let preview: &str = if source_memory.content.len() > 150 {
806 &source_memory.content[..150]
807 } else {
808 &source_memory.content
809 };
810 if source_memory.content.len() > 150 {
811 entry.push_str(&format!(" - Content: {preview}...\n"));
812 } else {
813 entry.push_str(&format!(" - Content: {preview}\n"));
814 }
815 entry.push('\n');
816 },
817 None => {
818 entry.push_str(&format!(
819 "{}. {} (not found)\n\n",
820 position,
821 source_id.as_str()
822 ));
823 },
824 }
825
826 Ok(entry)
827}
828
829pub fn execute_get_summary(arguments: Value) -> Result<ToolResult> {
832 use crate::mcp::tool_types::GetSummaryArgs;
833 use crate::models::{EdgeType, MemoryId};
834 use crate::storage::traits::IndexBackend;
835
836 let args: GetSummaryArgs =
837 serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
838
839 let services = ServiceContainer::from_current_dir_or_user()?;
840 let index = services.index()?;
841
842 let memory_id = MemoryId::new(args.memory_id.clone());
844 let memory = index
845 .get_memory(&memory_id)?
846 .ok_or_else(|| Error::InvalidInput(format!("Memory not found: {}", args.memory_id)))?;
847
848 if !memory.is_summary {
850 return Ok(ToolResult {
851 content: vec![ToolContent::Text {
852 text: format!(
853 "Memory '{}' is not a summary node.\n\nTo retrieve a regular memory, use subcog_recall instead.",
854 args.memory_id
855 ),
856 }],
857 is_error: true,
858 });
859 }
860
861 let mut output = String::from("**Summary Memory**\n\n");
862
863 output.push_str(&format!("**ID:** {}\n", memory.id.as_str()));
865 output.push_str(&format!("**Namespace:** {:?}\n", memory.namespace));
866 if !memory.tags.is_empty() {
867 output.push_str(&format!("**Tags:** {}\n", memory.tags.join(", ")));
868 }
869 if let Some(ts) = memory.consolidation_timestamp {
870 output.push_str(&format!("**Consolidated at:** {ts}\n"));
871 }
872 output.push_str(&format!("\n**Summary:**\n{}\n\n", memory.content));
873
874 let source_ids = index.query_edges(&memory_id, EdgeType::SourceOf)?;
876
877 if source_ids.is_empty() {
878 output.push_str(
879 "**Source Memories:** None (edges not stored or summary created without service)\n",
880 );
881 } else {
882 output.push_str(&format!("**Source Memories ({}):**\n\n", source_ids.len()));
883
884 for (idx, source_id) in source_ids.iter().enumerate() {
886 let entry = format_source_memory_entry(&index, source_id, idx + 1)?;
887 output.push_str(&entry);
888 }
889 }
890
891 Ok(ToolResult {
892 content: vec![ToolContent::Text { text: output }],
893 is_error: false,
894 })
895}
896
897pub fn execute_enrich(arguments: Value) -> Result<ToolResult> {
900 let args: EnrichArgs =
901 serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
902
903 let enrich_tags = args.enrich_tags.unwrap_or(true);
904 let enrich_structure = args.enrich_structure.unwrap_or(true);
905 let add_context = args.add_context.unwrap_or(false);
906
907 let mut enrichments = Vec::new();
910 if enrich_tags {
911 enrichments.push("- Generate relevant tags for searchability");
912 }
913 if enrich_structure {
914 enrichments
915 .push("- Restructure content for clarity (add context, rationale, consequences)");
916 }
917 if add_context {
918 enrichments.push("- Infer and add missing context or rationale");
919 }
920
921 let sampling_prompt = format!(
922 "Enrich the memory with ID '{}'.\n\nRequested enrichments:\n{}\n\nProvide the enriched version with:\n1. Improved content structure\n2. Suggested tags (if requested)\n3. Inferred namespace (if content suggests different category)",
923 args.memory_id,
924 enrichments.join("\n")
925 );
926
927 Ok(ToolResult {
928 content: vec![ToolContent::Text {
929 text: format!(
930 "SAMPLING_REQUEST\n\nmemory_id: {}\nenrich_tags: {}\nenrich_structure: {}\nadd_context: {}\n\nprompt: {}",
931 args.memory_id, enrich_tags, enrich_structure, add_context, sampling_prompt
932 ),
933 }],
934 is_error: false,
935 })
936}
937
938pub fn execute_reindex(arguments: Value) -> Result<ToolResult> {
940 let args: ReindexArgs =
941 serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
942
943 let services = match args.repo_path {
944 Some(repo_path) => ServiceContainer::for_repo(std::path::PathBuf::from(repo_path), None)?,
945 None => ServiceContainer::from_current_dir_or_user()?,
946 };
947
948 let scope_label = match services.repo_path() {
949 Some(repo_root) => format!("Repository: {}", repo_root.display()),
950 None => "Scope: user".to_string(),
951 };
952
953 match services.reindex() {
954 Ok(count) => Ok(ToolResult {
955 content: vec![ToolContent::Text {
956 text: format!(
957 "Reindex completed successfully!\n\nMemories indexed: {count}\n{scope_label}"
958 ),
959 }],
960 is_error: false,
961 }),
962 Err(e) => Ok(ToolResult {
963 content: vec![ToolContent::Text {
964 text: format!("Reindex failed: {e}"),
965 }],
966 is_error: true,
967 }),
968 }
969}
970
971pub fn execute_gdpr_export(_arguments: Value) -> Result<ToolResult> {
976 let services = ServiceContainer::from_current_dir_or_user()?;
977 let data_subject = services.data_subject()?;
978
979 match data_subject.export_user_data() {
980 Ok(export) => {
981 let json =
983 serde_json::to_string_pretty(&export).map_err(|e| Error::OperationFailed {
984 operation: "serialize_export".to_string(),
985 cause: e.to_string(),
986 })?;
987
988 Ok(ToolResult {
989 content: vec![ToolContent::Text {
990 text: format!(
991 "GDPR Data Export (Article 20 - Right to Data Portability)\n\n\
992 Exported {} memories at {}\n\
993 Format: {}\n\
994 Generator: {} v{}\n\n\
995 ---\n\n{}",
996 export.memory_count,
997 export.exported_at,
998 export.metadata.format,
999 export.metadata.generator,
1000 export.metadata.generator_version,
1001 json
1002 ),
1003 }],
1004 is_error: false,
1005 })
1006 },
1007 Err(e) => Ok(ToolResult {
1008 content: vec![ToolContent::Text {
1009 text: format!("GDPR export failed: {e}"),
1010 }],
1011 is_error: true,
1012 }),
1013 }
1014}
1015
1016pub fn execute_get(arguments: Value) -> Result<ToolResult> {
1025 use crate::storage::traits::IndexBackend;
1026
1027 let args: GetArgs =
1028 serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
1029
1030 let services = ServiceContainer::from_current_dir_or_user()?;
1031 let index = services.index()?;
1032
1033 let memory_id = MemoryId::new(Urn::extract_memory_id(&args.memory_id));
1035
1036 match index.get_memory(&memory_id)? {
1037 Some(memory) => {
1038 let domain_part = if memory.domain.is_project_scoped() {
1040 "project".to_string()
1041 } else {
1042 memory.domain.to_string()
1043 };
1044 let urn = format!(
1045 "subcog://{}/{}/{}",
1046 domain_part, memory.namespace, memory.id
1047 );
1048
1049 let tags_display = if memory.tags.is_empty() {
1051 "None".to_string()
1052 } else {
1053 memory.tags.join(", ")
1054 };
1055
1056 let status_display = match memory.status {
1058 MemoryStatus::Active => "Active",
1059 MemoryStatus::Archived => "Archived",
1060 MemoryStatus::Superseded => "Superseded",
1061 MemoryStatus::Pending => "Pending",
1062 MemoryStatus::Deleted => "Deleted",
1063 MemoryStatus::Tombstoned => "Tombstoned (soft deleted)",
1064 MemoryStatus::Consolidated => "Consolidated",
1065 };
1066
1067 let output = format!(
1068 "**Memory: {}**\n\n\
1069 **URN:** {}\n\
1070 **Namespace:** {:?}\n\
1071 **Status:** {}\n\
1072 **Tags:** {}\n\
1073 **Created:** {}\n\
1074 **Updated:** {}\n\
1075 {}\n\
1076 **Content:**\n{}",
1077 memory.id.as_str(),
1078 urn,
1079 memory.namespace,
1080 status_display,
1081 tags_display,
1082 memory.created_at,
1083 memory.updated_at,
1084 memory
1085 .source
1086 .as_ref()
1087 .map_or(String::new(), |s| format!("**Source:** {s}\n")),
1088 memory.content
1089 );
1090
1091 Ok(ToolResult {
1092 content: vec![ToolContent::Text { text: output }],
1093 is_error: false,
1094 })
1095 },
1096 None => Ok(ToolResult {
1097 content: vec![ToolContent::Text {
1098 text: format!("Memory not found: {}", args.memory_id),
1099 }],
1100 is_error: true,
1101 }),
1102 }
1103}
1104
1105pub fn execute_delete(arguments: Value) -> Result<ToolResult> {
1110 use crate::storage::traits::IndexBackend;
1111 use chrono::TimeZone;
1112
1113 let args: DeleteArgs =
1114 serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
1115
1116 let services = ServiceContainer::from_current_dir_or_user()?;
1117 let index = services.index()?;
1118
1119 let memory_id = MemoryId::new(Urn::extract_memory_id(&args.memory_id));
1121
1122 let Some(memory) = index.get_memory(&memory_id)? else {
1124 return Ok(ToolResult {
1125 content: vec![ToolContent::Text {
1126 text: format!("Memory not found: {}", args.memory_id),
1127 }],
1128 is_error: true,
1129 });
1130 };
1131
1132 if args.hard {
1133 if index.remove(&memory_id)? {
1135 record_event(MemoryEvent::Deleted {
1136 meta: EventMeta::new("mcp.delete", current_request_id()),
1137 memory_id,
1138 reason: "mcp.subcog_delete --hard".to_string(),
1139 });
1140
1141 metrics::counter!("mcp_delete_hard_total").increment(1);
1142
1143 Ok(ToolResult {
1144 content: vec![ToolContent::Text {
1145 text: format!(
1146 "Memory permanently deleted: {}\n\n\
1147 ⚠️ This action is irreversible.",
1148 args.memory_id
1149 ),
1150 }],
1151 is_error: false,
1152 })
1153 } else {
1154 Ok(ToolResult {
1155 content: vec![ToolContent::Text {
1156 text: format!("Failed to delete memory: {}", args.memory_id),
1157 }],
1158 is_error: true,
1159 })
1160 }
1161 } else {
1162 let now = crate::current_timestamp();
1164 let now_i64 = i64::try_from(now).unwrap_or(i64::MAX);
1165 let now_dt = chrono::Utc
1166 .timestamp_opt(now_i64, 0)
1167 .single()
1168 .unwrap_or_else(chrono::Utc::now);
1169
1170 let mut updated_memory = memory;
1171 updated_memory.status = MemoryStatus::Tombstoned;
1172 updated_memory.tombstoned_at = Some(now_dt);
1173 updated_memory.updated_at = now;
1174
1175 index.index(&updated_memory)?;
1177
1178 record_event(MemoryEvent::Updated {
1179 meta: EventMeta::with_timestamp("mcp.delete", current_request_id(), now),
1180 memory_id,
1181 modified_fields: vec!["status".to_string(), "tombstoned_at".to_string()],
1182 });
1183
1184 metrics::counter!("mcp_delete_soft_total").increment(1);
1185
1186 Ok(ToolResult {
1187 content: vec![ToolContent::Text {
1188 text: format!(
1189 "Memory tombstoned (soft deleted): {}\n\n\
1190 The memory can be restored or permanently purged with `subcog gc --purge`.",
1191 args.memory_id
1192 ),
1193 }],
1194 is_error: false,
1195 })
1196 }
1197}
1198
1199pub fn execute_update(arguments: Value) -> Result<ToolResult> {
1203 use crate::storage::traits::IndexBackend;
1204
1205 let args: UpdateArgs =
1206 serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
1207
1208 if args.content.is_none() && args.tags.is_none() {
1210 return Err(Error::InvalidInput(
1211 "At least one of 'content' or 'tags' must be provided for update".to_string(),
1212 ));
1213 }
1214
1215 if let Some(ref content) = args.content {
1217 validate_input_length(content, "content", MAX_CONTENT_LENGTH)?;
1218 }
1219
1220 let services = ServiceContainer::from_current_dir_or_user()?;
1221 let index = services.index()?;
1222
1223 let memory_id = MemoryId::new(Urn::extract_memory_id(&args.memory_id));
1224
1225 let Some(mut memory) = index.get_memory(&memory_id)? else {
1227 return Ok(ToolResult {
1228 content: vec![ToolContent::Text {
1229 text: format!("Memory not found: {}", args.memory_id),
1230 }],
1231 is_error: true,
1232 });
1233 };
1234
1235 if memory.status == MemoryStatus::Tombstoned {
1237 return Ok(ToolResult {
1238 content: vec![ToolContent::Text {
1239 text: format!(
1240 "Cannot update tombstoned memory: {}\n\n\
1241 Restore the memory first or create a new one.",
1242 args.memory_id
1243 ),
1244 }],
1245 is_error: true,
1246 });
1247 }
1248
1249 let mut modified_fields = Vec::new();
1251
1252 if let Some(new_content) = args.content {
1254 memory.content = new_content;
1255 modified_fields.push("content".to_string());
1256 }
1257
1258 if let Some(new_tags) = args.tags {
1260 memory.tags = new_tags;
1261 modified_fields.push("tags".to_string());
1262 }
1263
1264 let now = crate::current_timestamp();
1266 memory.updated_at = now;
1267 modified_fields.push("updated_at".to_string());
1268
1269 index.index(&memory)?;
1271
1272 record_event(MemoryEvent::Updated {
1273 meta: EventMeta::with_timestamp("mcp.update", current_request_id(), now),
1274 memory_id,
1275 modified_fields: modified_fields.clone(),
1276 });
1277
1278 metrics::counter!("mcp_update_total").increment(1);
1279
1280 let fields_updated = modified_fields
1282 .iter()
1283 .filter(|f| *f != "updated_at")
1284 .cloned()
1285 .collect::<Vec<_>>()
1286 .join(", ");
1287
1288 let tags_display = if memory.tags.is_empty() {
1289 "None".to_string()
1290 } else {
1291 memory.tags.join(", ")
1292 };
1293
1294 Ok(ToolResult {
1295 content: vec![ToolContent::Text {
1296 text: format!(
1297 "Memory updated: {}\n\n\
1298 **Updated fields:** {}\n\
1299 **Current tags:** {}\n\
1300 **Updated at:** {}",
1301 args.memory_id, fields_updated, tags_display, now
1302 ),
1303 }],
1304 is_error: false,
1305 })
1306}
1307
1308pub fn execute_list(arguments: Value) -> Result<ToolResult> {
1317 use crate::mcp::tool_types::ListArgs;
1318
1319 let args: ListArgs =
1320 serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
1321
1322 let mut filter = if let Some(filter_query) = &args.filter {
1324 parse_filter_query(filter_query)
1325 } else {
1326 SearchFilter::new()
1327 };
1328
1329 if let Some(ref user_id) = args.user_id {
1331 filter = filter.with_tag(format!("user:{user_id}"));
1332 }
1333
1334 if let Some(ref agent_id) = args.agent_id {
1336 filter = filter.with_tag(format!("agent:{agent_id}"));
1337 }
1338
1339 let limit = args.limit.unwrap_or(50).min(1000);
1340 let offset = args.offset.unwrap_or(0);
1341
1342 let services = ServiceContainer::from_current_dir_or_user()?;
1343 let recall = services.recall()?;
1344
1345 let fetch_count = limit.saturating_add(offset);
1348 let result = recall.list_all(&filter, fetch_count)?;
1349
1350 let paginated_memories: Vec<_> = result
1352 .memories
1353 .into_iter()
1354 .skip(offset)
1355 .take(limit)
1356 .collect();
1357 let displayed_count = paginated_memories.len();
1358
1359 let filter_desc = build_filter_description(&filter);
1361
1362 let mut output = format!(
1363 "**Memory List** (showing {displayed_count} of {} total, offset: {offset}{filter_desc})\n\n",
1364 result.total_count
1365 );
1366
1367 if paginated_memories.is_empty() {
1368 output.push_str("No memories found matching the criteria.\n");
1369 } else {
1370 for (i, hit) in paginated_memories.iter().enumerate() {
1371 let tags_display = if hit.memory.tags.is_empty() {
1372 String::new()
1373 } else {
1374 format!(" [{}]", hit.memory.tags.join(", "))
1375 };
1376
1377 let domain_part = if hit.memory.domain.is_project_scoped() {
1379 "project".to_string()
1380 } else {
1381 hit.memory.domain.to_string()
1382 };
1383 let urn = format!(
1384 "subcog://{}/{}/{}",
1385 domain_part, hit.memory.namespace, hit.memory.id
1386 );
1387
1388 let status_indicator = match hit.memory.status {
1390 MemoryStatus::Tombstoned => " ⚠️ [tombstoned]",
1391 MemoryStatus::Archived => " 📦 [archived]",
1392 MemoryStatus::Superseded => " ↩️ [superseded]",
1393 _ => "",
1394 };
1395
1396 output.push_str(&format!(
1397 "{}. {}{}{}\n",
1398 offset + i + 1,
1399 urn,
1400 tags_display,
1401 status_indicator
1402 ));
1403 }
1404 }
1405
1406 if result.total_count > offset + displayed_count {
1408 output.push_str(&format!(
1409 "\n_Use offset={} to see more results._",
1410 offset + displayed_count
1411 ));
1412 }
1413
1414 metrics::counter!("mcp_list_total").increment(1);
1415
1416 Ok(ToolResult {
1417 content: vec![ToolContent::Text { text: output }],
1418 is_error: false,
1419 })
1420}
1421
1422#[allow(clippy::too_many_lines)]
1426pub fn execute_delete_all(arguments: Value) -> Result<ToolResult> {
1427 use crate::mcp::tool_types::DeleteAllArgs;
1428
1429 let args: DeleteAllArgs =
1430 serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
1431
1432 let mut filter = args
1434 .filter
1435 .as_ref()
1436 .map_or_else(SearchFilter::new, |q| parse_filter_query(q));
1437
1438 if let Some(ref user_id) = args.user_id {
1440 filter = filter.with_tag(format!("user:{user_id}"));
1441 }
1442
1443 if !has_any_filter_criteria(&filter, args.user_id.is_some()) {
1445 return Ok(ToolResult {
1446 content: vec![ToolContent::Text {
1447 text: "**Safety check failed**: At least one filter criterion is required for bulk deletion.\n\n\
1448 Examples:\n\
1449 - `filter: \"ns:decisions\"` - delete all decisions\n\
1450 - `filter: \"tag:deprecated\"` - delete tagged memories\n\
1451 - `user_id: \"user123\"` - delete user's memories".to_string(),
1452 }],
1453 is_error: true,
1454 });
1455 }
1456
1457 let services = ServiceContainer::from_current_dir_or_user()?;
1458 let recall = services.recall()?;
1459 let index = services.index()?;
1460
1461 let result = recall.list_all(&filter, 10000)?;
1463 let matching_count = result.memories.len();
1464
1465 if matching_count == 0 {
1466 let msg = if args.dry_run {
1467 "[DRY-RUN] No memories would be deleted - no memories found matching the filter criteria."
1468 } else {
1469 "No memories found matching the filter criteria."
1470 };
1471 return Ok(ToolResult {
1472 content: vec![ToolContent::Text {
1473 text: msg.to_string(),
1474 }],
1475 is_error: false,
1476 });
1477 }
1478
1479 let filter_desc = build_filter_description(&filter);
1480
1481 if args.dry_run {
1482 let output =
1483 build_dry_run_output(&result.memories, matching_count, &filter_desc, args.hard);
1484 return Ok(ToolResult {
1485 content: vec![ToolContent::Text { text: output }],
1486 is_error: false,
1487 });
1488 }
1489
1490 let (deleted_count, failed_count) = execute_bulk_delete(&result.memories, args.hard, &index)?;
1492
1493 let output = build_delete_result_output(deleted_count, failed_count, &filter_desc, args.hard);
1494
1495 metrics::counter!(
1496 "mcp_delete_all_total",
1497 "type" => if args.hard { "hard" } else { "soft" }
1498 )
1499 .increment(deleted_count);
1500
1501 Ok(ToolResult {
1502 content: vec![ToolContent::Text { text: output }],
1503 is_error: false,
1504 })
1505}
1506
1507const fn has_any_filter_criteria(filter: &SearchFilter, has_user_id: bool) -> bool {
1509 !filter.namespaces.is_empty()
1510 || !filter.tags.is_empty()
1511 || !filter.excluded_tags.is_empty()
1512 || has_user_id
1513 || filter.created_after.is_some()
1514 || filter.created_before.is_some()
1515}
1516
1517fn build_dry_run_output(
1519 memories: &[crate::models::SearchHit],
1520 matching_count: usize,
1521 filter_desc: &str,
1522 hard: bool,
1523) -> String {
1524 let delete_type = if hard {
1525 "permanently delete"
1526 } else {
1527 "tombstone (soft delete)"
1528 };
1529
1530 let mut output = format!(
1531 "**Dry-run: Would delete {matching_count} memories**{filter_desc}\n\n\
1532 Action: {delete_type}\n\n"
1533 );
1534
1535 let preview_count = matching_count.min(10);
1537 output.push_str(&format!("Preview (first {preview_count}):\n"));
1538
1539 for hit in memories.iter().take(preview_count) {
1540 let domain_part = if hit.memory.domain.is_project_scoped() {
1541 "project".to_string()
1542 } else {
1543 hit.memory.domain.to_string()
1544 };
1545 let urn = format!(
1546 "subcog://{}/{}/{}",
1547 domain_part, hit.memory.namespace, hit.memory.id
1548 );
1549 output.push_str(&format!(" - {urn}\n"));
1550 }
1551
1552 if matching_count > preview_count {
1553 output.push_str(&format!(
1554 " ... and {} more\n",
1555 matching_count - preview_count
1556 ));
1557 }
1558
1559 output.push_str("\n_Set `dry_run: false` to execute the deletion._");
1560 output
1561}
1562
1563fn execute_bulk_delete(
1565 memories: &[crate::models::SearchHit],
1566 hard: bool,
1567 index: &crate::storage::index::SqliteBackend,
1568) -> Result<(u64, u64)> {
1569 let mut deleted_count = 0u64;
1570 let mut failed_count = 0u64;
1571 let now = crate::current_timestamp();
1572
1573 for hit in memories {
1574 let memory_id = hit.memory.id.clone();
1575 let success = if hard {
1576 delete_memory_hard(index, &memory_id, now)
1577 } else {
1578 delete_memory_soft(index, &hit.memory, now)
1579 };
1580
1581 if success {
1582 deleted_count += 1;
1583 } else {
1584 failed_count += 1;
1585 }
1586 }
1587
1588 Ok((deleted_count, failed_count))
1589}
1590
1591fn delete_memory_hard(
1593 index: &crate::storage::index::SqliteBackend,
1594 memory_id: &MemoryId,
1595 now: u64,
1596) -> bool {
1597 use crate::storage::traits::IndexBackend;
1598
1599 match index.remove(memory_id) {
1600 Ok(true) => {
1601 record_event(MemoryEvent::Deleted {
1602 meta: EventMeta::with_timestamp("mcp.delete_all", current_request_id(), now),
1603 memory_id: memory_id.clone(),
1604 reason: "mcp.subcog_delete_all --hard".to_string(),
1605 });
1606 true
1607 },
1608 _ => false,
1609 }
1610}
1611
1612fn delete_memory_soft(
1614 index: &crate::storage::index::SqliteBackend,
1615 memory: &crate::models::Memory,
1616 now: u64,
1617) -> bool {
1618 use crate::storage::traits::IndexBackend;
1619 use chrono::TimeZone;
1620
1621 let now_i64 = i64::try_from(now).unwrap_or(i64::MAX);
1622 let now_dt = chrono::Utc
1623 .timestamp_opt(now_i64, 0)
1624 .single()
1625 .unwrap_or_else(chrono::Utc::now);
1626
1627 let mut updated_memory = memory.clone();
1628 updated_memory.status = MemoryStatus::Tombstoned;
1629 updated_memory.tombstoned_at = Some(now_dt);
1630 updated_memory.updated_at = now;
1631
1632 match index.index(&updated_memory) {
1633 Ok(()) => {
1634 record_event(MemoryEvent::Updated {
1635 meta: EventMeta::with_timestamp("mcp.delete_all", current_request_id(), now),
1636 memory_id: memory.id.clone(),
1637 modified_fields: vec!["status".to_string(), "tombstoned_at".to_string()],
1638 });
1639 true
1640 },
1641 Err(_) => false,
1642 }
1643}
1644
1645fn build_delete_result_output(
1647 deleted_count: u64,
1648 failed_count: u64,
1649 filter_desc: &str,
1650 hard: bool,
1651) -> String {
1652 let delete_type = if hard {
1653 "permanently deleted"
1654 } else {
1655 "tombstoned"
1656 };
1657
1658 let mut output = format!(
1659 "**Bulk delete completed**{filter_desc}\n\n\
1660 - {delete_type}: {deleted_count}\n"
1661 );
1662
1663 if failed_count > 0 {
1664 output.push_str(&format!("- Failed: {failed_count}\n"));
1665 }
1666
1667 if !hard {
1668 output.push_str(
1669 "\n_Tombstoned memories can be restored with `subcog_restore` \
1670 or permanently removed with `subcog gc --purge`._",
1671 );
1672 }
1673
1674 output
1675}
1676
1677pub fn execute_restore(arguments: Value) -> Result<ToolResult> {
1681 use crate::mcp::tool_types::RestoreArgs;
1682 use crate::storage::traits::IndexBackend;
1683
1684 let args: RestoreArgs =
1685 serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
1686
1687 let services = ServiceContainer::from_current_dir_or_user()?;
1688 let index = services.index()?;
1689
1690 let memory_id = MemoryId::new(Urn::extract_memory_id(&args.memory_id));
1691
1692 let Some(mut memory) = index.get_memory(&memory_id)? else {
1694 return Ok(ToolResult {
1695 content: vec![ToolContent::Text {
1696 text: format!("Memory not found: {}", args.memory_id),
1697 }],
1698 is_error: true,
1699 });
1700 };
1701
1702 if memory.status != MemoryStatus::Tombstoned {
1704 return Ok(ToolResult {
1705 content: vec![ToolContent::Text {
1706 text: format!(
1707 "Memory '{}' is not tombstoned (current status: {:?}).\n\n\
1708 Only tombstoned memories can be restored.",
1709 args.memory_id, memory.status
1710 ),
1711 }],
1712 is_error: true,
1713 });
1714 }
1715
1716 let now = crate::current_timestamp();
1718 memory.status = MemoryStatus::Active;
1719 memory.tombstoned_at = None;
1720 memory.updated_at = now;
1721
1722 index.index(&memory)?;
1724
1725 record_event(MemoryEvent::Updated {
1726 meta: EventMeta::with_timestamp("mcp.restore", current_request_id(), now),
1727 memory_id,
1728 modified_fields: vec!["status".to_string(), "tombstoned_at".to_string()],
1729 });
1730
1731 metrics::counter!("mcp_restore_total").increment(1);
1732
1733 let domain_part = if memory.domain.is_project_scoped() {
1735 "project".to_string()
1736 } else {
1737 memory.domain.to_string()
1738 };
1739 let urn = format!(
1740 "subcog://{}/{}/{}",
1741 domain_part, memory.namespace, memory.id
1742 );
1743
1744 Ok(ToolResult {
1745 content: vec![ToolContent::Text {
1746 text: format!(
1747 "Memory restored: {}\n\n\
1748 **URN:** {}\n\
1749 **Status:** Active\n\
1750 **Restored at:** {}",
1751 args.memory_id, urn, now
1752 ),
1753 }],
1754 is_error: false,
1755 })
1756}
1757
1758pub fn execute_history(arguments: Value) -> Result<ToolResult> {
1763 use crate::mcp::tool_types::HistoryArgs;
1764 use crate::storage::traits::IndexBackend;
1765
1766 let args: HistoryArgs =
1767 serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
1768
1769 let services = ServiceContainer::from_current_dir_or_user()?;
1770 let index = services.index()?;
1771
1772 let memory_id = MemoryId::new(Urn::extract_memory_id(&args.memory_id));
1773 let _limit = args.limit.unwrap_or(20).min(100);
1774
1775 let mut output = format!("**Memory History: {}**\n\n", args.memory_id);
1781
1782 if let Some(memory) = index.get_memory(&memory_id)? {
1783 output.push_str("**Current State:**\n");
1785 output.push_str(&format!("- Status: {:?}\n", memory.status));
1786 output.push_str(&format!("- Created: {}\n", memory.created_at));
1787 output.push_str(&format!("- Last Updated: {}\n", memory.updated_at));
1788
1789 if let Some(ref tombstoned_at) = memory.tombstoned_at {
1790 output.push_str(&format!("- Tombstoned: {tombstoned_at}\n"));
1791 }
1792
1793 if memory.is_summary {
1794 output.push_str("- Type: Summary node\n");
1795 if let Some(ref source_ids) = memory.source_memory_ids {
1796 output.push_str(&format!(
1797 "- Consolidated from: {} memories\n",
1798 source_ids.len()
1799 ));
1800 }
1801 }
1802
1803 output.push_str("\n**Event Types Tracked:**\n");
1804 output.push_str("- `Captured`: Initial creation\n");
1805 output.push_str("- `Updated`: Content or tag changes\n");
1806 output.push_str("- `Deleted`: Soft or hard deletion\n");
1807 output.push_str("- `Archived`: Archival status change\n");
1808
1809 output.push_str(&format!(
1810 "\n_Note: Full event history requires log aggregation. \
1811 Events are emitted via tracing and can be queried from your \
1812 log backend (e.g., Elasticsearch, Datadog, Splunk). \
1813 Filter by `memory_id=\"{}\"`._",
1814 args.memory_id
1815 ));
1816 } else {
1817 output.push_str("⚠️ Memory not found in current storage.\n\n");
1818 output.push_str("The memory may have been:\n");
1819 output.push_str("- Permanently deleted (hard delete)\n");
1820 output.push_str("- Never existed with this ID\n");
1821 output.push_str("- Stored in a different domain scope\n");
1822
1823 output.push_str(&format!(
1824 "\n_Check your log backend for historical events with `memory_id=\"{}\"`._",
1825 args.memory_id
1826 ));
1827 }
1828
1829 metrics::counter!("mcp_history_total").increment(1);
1830
1831 Ok(ToolResult {
1832 content: vec![ToolContent::Text { text: output }],
1833 is_error: false,
1834 })
1835}
1836
1837fn escape_xml(s: &str) -> String {
1839 s.replace('&', "&")
1840 .replace('<', "<")
1841 .replace('>', ">")
1842 .replace('"', """)
1843 .replace('\'', "'")
1844}
1845
1846const NAMESPACE_DEFS: &[(&str, &str)] = &[
1848 ("decisions", "Architectural and design decisions"),
1849 ("patterns", "Coding conventions and standards"),
1850 ("learnings", "Insights and discoveries"),
1851 ("context", "Project background and state"),
1852 ("tech_debt", "Known issues and TODOs"),
1853 ("apis", "API documentation and contracts"),
1854 ("config", "Configuration details"),
1855 ("security", "Security policies and findings"),
1856 ("performance", "Performance observations"),
1857 ("testing", "Test strategies and edge cases"),
1858];
1859
1860fn build_xml_namespaces(services: Option<&ServiceContainer>) -> String {
1862 let mut xml = String::from("<namespaces>");
1863
1864 let ns_counts: std::collections::HashMap<String, usize> = services
1866 .and_then(|svc| svc.recall().ok())
1867 .and_then(|recall| {
1868 let filter = SearchFilter::new();
1869 recall.search("*", SearchMode::Text, &filter, 1000).ok()
1870 })
1871 .map(|result| {
1872 let mut counts = std::collections::HashMap::new();
1873 for hit in &result.memories {
1874 let ns_name = format!("{:?}", hit.memory.namespace).to_lowercase();
1875 *counts.entry(ns_name).or_insert(0) += 1;
1876 }
1877 counts
1878 })
1879 .unwrap_or_default();
1880
1881 for (name, desc) in NAMESPACE_DEFS {
1882 let count = ns_counts.get(*name).copied().unwrap_or(0);
1883 xml.push_str(&format!(
1884 "<ns name=\"{name}\" count=\"{count}\">{}</ns>",
1885 escape_xml(desc)
1886 ));
1887 }
1888
1889 xml.push_str("</namespaces>");
1890 xml
1891}
1892
1893fn build_xml_domains() -> String {
1895 "<domains><domain name=\"project\" default=\"true\">Repository-scoped</domain><domain name=\"user\">Cross-project</domain><domain name=\"org\">Organization-shared</domain></domains>".to_string()
1896}
1897
1898fn build_xml_tools() -> String {
1900 "<tools>\
1901<tool name=\"subcog_capture\" req=\"content,namespace\" opt=\"tags,source,domain,ttl\"/>\
1902<tool name=\"subcog_recall\" opt=\"query,filter,mode,detail,limit\"/>\
1903<tool name=\"subcog_get\" req=\"memory_id\"/>\
1904<tool name=\"subcog_update\" req=\"memory_id\"/>\
1905<tool name=\"subcog_delete\" req=\"memory_id\"/>\
1906<tool name=\"subcog_status\"/>\
1907<tool name=\"subcog_entities\" req=\"action\"/>\
1908<tool name=\"subcog_graph\" req=\"operation\"/>\
1909<tool name=\"prompt_understanding\">Full docs</tool>\
1910</tools>"
1911 .to_string()
1912}
1913
1914fn build_xml_status(services: Option<&ServiceContainer>) -> String {
1916 let version = env!("CARGO_PKG_VERSION");
1917
1918 let (health, memory_count) = services
1919 .and_then(|svc| svc.recall().ok())
1920 .and_then(|recall| {
1921 let filter = SearchFilter::new();
1922 recall
1923 .search("*", SearchMode::Text, &filter, 1000)
1924 .ok()
1925 .map(|r| ("healthy", r.memories.len()))
1926 })
1927 .unwrap_or(("healthy", 0));
1928
1929 format!("<status health=\"{health}\" memory_count=\"{memory_count}\" version=\"{version}\"/>")
1930}
1931
1932fn format_init_recall_xml(services: &ServiceContainer, query: &str, limit: usize) -> String {
1934 let mut xml = String::from("<memories count=\"");
1935
1936 let Ok(recall) = services.recall() else {
1937 return "<memories count=\"0\"/>".to_string();
1938 };
1939
1940 let filter = SearchFilter::new();
1941 match recall.search(query, SearchMode::Hybrid, &filter, limit) {
1942 Ok(result) if !result.memories.is_empty() => {
1943 xml.push_str(&format!(
1944 "{}\" hint=\"use subcog_get for full\">",
1945 result.memories.len()
1946 ));
1947 for hit in &result.memories {
1948 let preview = if hit.memory.content.len() > 100 {
1949 format!("{}...", &hit.memory.content[..100])
1950 } else {
1951 hit.memory.content.clone()
1952 };
1953 let ns_name = format!("{:?}", hit.memory.namespace).to_lowercase();
1954 xml.push_str(&format!(
1955 "<m id=\"{}\" ns=\"{}\" s=\"{:.2}\">{}</m>",
1956 hit.memory.id,
1957 ns_name,
1958 hit.score,
1959 escape_xml(&preview.replace('\n', " "))
1960 ));
1961 }
1962 xml.push_str("</memories>");
1963 },
1964 Ok(_) => {
1965 return "<memories count=\"0\"/>".to_string();
1966 },
1967 Err(_) => {
1968 return "<memories count=\"0\"/>".to_string();
1969 },
1970 }
1971
1972 xml
1973}
1974
1975pub fn execute_init(arguments: Value) -> Result<ToolResult> {
1980 let args: InitArgs =
1981 serde_json::from_value(arguments).map_err(|e| Error::InvalidInput(e.to_string()))?;
1982
1983 crate::mcp::session::mark_initialized();
1985
1986 let services_result = ServiceContainer::from_current_dir_or_user();
1987 let services = services_result.as_ref().ok();
1988
1989 let mut xml = String::from("<subcog>");
1991 xml.push_str(&build_xml_namespaces(services));
1992 xml.push_str(&build_xml_domains());
1993 xml.push_str(&build_xml_tools());
1994 xml.push_str(&build_xml_status(services));
1995
1996 if args.include_recall {
1998 let query = args
1999 .recall_query
2000 .unwrap_or_else(|| "project setup OR architecture OR conventions".to_string());
2001 let limit = args.recall_limit.unwrap_or(5).min(20) as usize;
2002
2003 if let Some(svc) = services {
2004 xml.push_str(&format_init_recall_xml(svc, &query, limit));
2005 } else {
2006 xml.push_str("<memories count=\"0\"/>");
2007 }
2008 }
2009
2010 xml.push_str("<tip>prompt_understanding for full docs</tip></subcog>");
2011
2012 metrics::counter!("mcp_init_total").increment(1);
2013
2014 Ok(ToolResult {
2015 content: vec![ToolContent::Text { text: xml }],
2016 is_error: false,
2017 })
2018}
2019
2020#[cfg(test)]
2021mod tests {
2022 use super::*;
2023 use crate::models::{Memory, MemoryId, MemoryStatus, Namespace};
2024 use crate::services::RecallService;
2025 use crate::storage::index::SqliteBackend;
2026 use crate::storage::traits::IndexBackend;
2027
2028 #[test]
2029 fn test_validate_input_length_within_limit() {
2030 let input = "a".repeat(100);
2031 assert!(validate_input_length(&input, "test", 1000).is_ok());
2032 }
2033
2034 #[test]
2035 fn test_validate_input_length_at_limit() {
2036 let input = "a".repeat(1000);
2037 assert!(validate_input_length(&input, "test", 1000).is_ok());
2038 }
2039
2040 #[test]
2041 fn test_validate_input_length_exceeds_limit() {
2042 let input = "a".repeat(1001);
2043 let result = validate_input_length(&input, "test", 1000);
2044 assert!(result.is_err());
2045 let err = result.unwrap_err();
2046 assert!(matches!(err, Error::InvalidInput(_)));
2047 assert!(err.to_string().contains("exceeds maximum length"));
2048 assert!(err.to_string().contains("1001 > 1000"));
2049 }
2050
2051 #[test]
2052 fn test_validate_input_length_empty() {
2053 assert!(validate_input_length("", "test", 1000).is_ok());
2054 }
2055
2056 #[test]
2057 fn test_max_content_length_constant() {
2058 assert_eq!(MAX_CONTENT_LENGTH, 1_048_576);
2060 }
2061
2062 #[test]
2063 fn test_max_query_length_constant() {
2064 assert_eq!(MAX_QUERY_LENGTH, 10_240);
2066 }
2067
2068 #[test]
2069 fn test_capture_rejects_oversized_content() {
2070 let oversized_content = "x".repeat(MAX_CONTENT_LENGTH + 1);
2071 let args = serde_json::json!({
2072 "content": oversized_content,
2073 "namespace": "decisions"
2074 });
2075
2076 let result = execute_capture(args);
2077 assert!(result.is_err());
2078 let err = result.unwrap_err();
2079 assert!(matches!(err, Error::InvalidInput(_)));
2080 assert!(err.to_string().contains("content"));
2081 }
2082
2083 #[test]
2084 fn test_recall_rejects_oversized_query() {
2085 let oversized_query = "x".repeat(MAX_QUERY_LENGTH + 1);
2086 let args = serde_json::json!({
2087 "query": oversized_query
2088 });
2089
2090 let result = execute_recall(args);
2091 assert!(result.is_err());
2092 let err = result.unwrap_err();
2093 assert!(matches!(err, Error::InvalidInput(_)));
2094 assert!(err.to_string().contains("query"));
2095 }
2096
2097 fn create_test_memory(id: &str, content: &str, namespace: Namespace) -> Memory {
2098 Memory {
2099 id: MemoryId::new(id),
2100 content: content.to_string(),
2101 namespace,
2102 domain: Domain::new(),
2103 project_id: None,
2104 branch: None,
2105 file_path: None,
2106 status: MemoryStatus::Active,
2107 created_at: 1,
2108 updated_at: 1,
2109 tombstoned_at: None,
2110 expires_at: None,
2111 embedding: None,
2112 tags: Vec::new(),
2113 #[cfg(feature = "group-scope")]
2114 group_id: None,
2115 source: None,
2116 is_summary: false,
2117 source_memory_ids: None,
2118 consolidation_timestamp: None,
2119 }
2120 }
2121
2122 #[test]
2123 fn test_consolidate_candidates_uses_list_all_for_wildcard() {
2124 let backend = SqliteBackend::in_memory().unwrap();
2125 let memory = create_test_memory("id1", "hello world", Namespace::Decisions);
2126 backend.index(&memory).unwrap();
2127
2128 let recall = RecallService::with_index(backend);
2129 let filter = SearchFilter::new().with_namespace(Namespace::Decisions);
2130 let result = fetch_consolidation_candidates(&recall, &filter, Some("*"), 10).unwrap();
2131
2132 assert_eq!(result.mode, SearchMode::Text);
2133 assert_eq!(result.memories.len(), 1);
2134 }
2135
2136 #[test]
2137 fn test_consolidate_candidates_uses_search_for_query() {
2138 let backend = SqliteBackend::in_memory().unwrap();
2139 let memory = create_test_memory("id1", "hello world", Namespace::Decisions);
2140 backend.index(&memory).unwrap();
2141
2142 let recall = RecallService::with_index(backend);
2143 let filter = SearchFilter::new().with_namespace(Namespace::Decisions);
2144 let result = fetch_consolidation_candidates(&recall, &filter, Some("hello"), 10).unwrap();
2145
2146 assert_eq!(result.mode, SearchMode::Hybrid);
2147 assert_eq!(result.memories.len(), 1);
2148 }
2149}