Skip to main content

subcog/mcp/tools/handlers/
core.rs

1//! Core tool execution handlers.
2//!
3//! Contains handlers for subcog's core memory operations:
4//! capture, recall, status, namespaces, prompt understanding, consolidate, enrich, reindex.
5
6use 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
34/// Maximum allowed input length for content fields (SEC-M5).
35///
36/// Prevents `DoS` attacks via extremely large inputs that could exhaust memory
37/// or cause excessive processing time. Set to 1MB which is generous for
38/// any reasonable memory content while preventing abuse.
39const MAX_CONTENT_LENGTH: usize = 1_048_576; // 1 MB
40
41/// Maximum allowed input length for query fields (SEC-M5).
42///
43/// Queries should be concise - 10KB is more than enough for any search query.
44const MAX_QUERY_LENGTH: usize = 10_240; // 10 KB
45
46/// Validates that a string input does not exceed the maximum allowed length.
47///
48/// # Errors
49///
50/// Returns `Error::InvalidInput` if the input exceeds `max_length`.
51fn 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
76/// Executes the capture tool.
77pub 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    // SEC-M5: Validate input length before processing
82    validate_input_length(&args.content, "content", MAX_CONTENT_LENGTH)?;
83
84    let namespace = parse_namespace(&args.namespace);
85
86    // Parse TTL from duration string if provided
87    let ttl_seconds = args.ttl.as_ref().and_then(|s| parse_duration_to_seconds(s));
88
89    // Parse domain scope from argument, defaulting to context-aware detection
90    let scope = parse_domain_scope(args.domain.as_deref());
91
92    // Determine domain based on scope
93    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
126/// Executes the recall tool.
127///
128/// When `query` is omitted or empty, behaves like `subcog_list` and returns
129/// all memories matching the filter criteria (with pagination support).
130pub 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    // Get query, treating None and empty string as "list all"
135    let query = args.query.as_deref().unwrap_or("");
136    let is_list_mode = query.is_empty() || query == "*";
137
138    // SEC-M5: Validate query length before processing (skip for empty/wildcard)
139    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    // Build filter from the filter query string
155    let mut filter = if let Some(filter_query) = &args.filter {
156        parse_filter_query(filter_query)
157    } else {
158        SearchFilter::new()
159    };
160
161    // Support legacy namespace parameter (deprecated but still works)
162    if let Some(ns) = &args.namespace {
163        filter = filter.with_namespace(parse_namespace(ns));
164    }
165
166    // Apply entity filter if provided (comma-separated for OR logic)
167    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    // Apply user_id and agent_id filters if provided (for multi-tenant scoping)
178    // These are added as tag filters: user:<id> and agent:<id>
179    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    // Different defaults for search vs list mode
187    // Search: default 10, max 50
188    // List: default 50, max 1000
189    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    // Use list_all for wildcard queries or filter-only queries
199    // Use search for actual text queries
200    let result = if is_list_mode {
201        recall.list_all(&filter, limit)?
202    } else {
203        recall.search(query, mode, &filter, limit)?
204    };
205
206    // Build filter description for output
207    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        // Format content based on detail level
216        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        // Build URN: subcog://{domain}/{namespace}/{id}
225        // Domain: project, user, or org/repo path
226        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        // Display both normalized score and raw score for transparency
237        // Format: "1.00 (raw: 0.0325)" or just "1.00" if they're the same
238        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/// Health status for a backend component.
261#[derive(Debug, Clone, serde::Serialize)]
262struct ComponentHealth {
263    /// Component name.
264    name: String,
265    /// Health status: "healthy", "degraded", or "unhealthy".
266    status: String,
267    /// Optional details about the health check.
268    #[serde(skip_serializing_if = "Option::is_none")]
269    details: Option<String>,
270    /// Response time in milliseconds (if applicable).
271    #[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        // Convert duration to ms, saturating at u64::MAX for safety
287        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
315/// Checks health of the persistence layer by attempting a simple operation.
316fn check_persistence_health(services: &ServiceContainer) -> ComponentHealth {
317    let start = std::time::Instant::now();
318    match services.recall() {
319        Ok(recall) => {
320            // Try a simple list operation with limit 1
321            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
333/// Checks health of the index layer.
334fn check_index_health(services: &ServiceContainer) -> ComponentHealth {
335    let start = std::time::Instant::now();
336    match services.recall() {
337        Ok(recall) => {
338            // Try a simple search operation
339            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
349/// Checks health of the vector layer (embedding search).
350fn check_vector_health(services: &ServiceContainer) -> ComponentHealth {
351    let start = std::time::Instant::now();
352    match services.recall() {
353        Ok(recall) => {
354            // Try a vector search - this will work even without embeddings
355            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                    // Vector search may fail if no embeddings exist - that's degraded, not unhealthy
360                    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
374/// Checks health of the capture service.
375fn check_capture_health(services: &ServiceContainer) -> ComponentHealth {
376    // capture() returns &CaptureService directly (infallible)
377    // Just verify the service exists
378    let _capture = services.capture();
379    ComponentHealth::healthy("capture_service")
380}
381
382/// Executes the status tool with comprehensive health checks (CHAOS-HIGH-006).
383///
384/// Performs actual health probes against all backend components:
385/// - Persistence layer (`SQLite`)
386/// - Index layer (`SQLite` FTS5)
387/// - Vector layer (usearch)
388/// - Capture service
389///
390/// Returns overall system health based on component health:
391/// - "healthy": All components operational
392/// - "degraded": Some components have issues but system is functional
393/// - "unhealthy": Critical components are down
394pub 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    // Try to get services - if this fails, the system is unhealthy
400    let services_result = ServiceContainer::from_current_dir_or_user();
401
402    match services_result {
403        Ok(services) => {
404            // Check persistence health
405            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            // Check index health
414            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            // Check vector health
423            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            // Check capture service health
432            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    // Determine overall status
450    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
481/// Executes the `prompt_understanding` tool.
482pub 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
491/// Executes the namespaces tool.
492pub 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/// Executes the consolidate tool.
518/// Triggers memory consolidation and returns statistics.
519#[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    // Load config to check if consolidation is enabled
527    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    // Build effective consolidation config from config file + MCP args
539    let mut consolidation_config = config.consolidation.clone();
540
541    // Override namespace filter if provided
542    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    // Override time window if provided
564    if let Some(d) = args.days {
565        consolidation_config.time_window_days = Some(d);
566    }
567
568    // Override min memories if provided
569    if let Some(min) = args.min_memories {
570        consolidation_config.min_memories_to_consolidate = min;
571    }
572
573    // Override similarity threshold if provided
574    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    // Create service container for recall service and index
590    let services = ServiceContainer::from_current_dir_or_user()?;
591    let recall_service = services.recall()?;
592    let index = Arc::new(services.index()?);
593
594    // Build LLM provider (optional, for summarization)
595    let llm_provider: Option<Arc<dyn crate::llm::LlmProvider + Send + Sync>> =
596        build_llm_provider_from_config(&config.llm);
597
598    // Run consolidation based on configured backend
599    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
654/// Builds an LLM provider from configuration.
655///
656/// Returns `None` if the provider is set to `None` or if client creation fails.
657fn 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    // Build resilience config from LLM settings
665    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
709/// Helper function to run consolidation and format results for MCP tool response.
710fn 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        // Dry-run mode: show what would be consolidated
718        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        // Normal mode: perform actual consolidation
762        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
789/// Formats a single source memory entry for display.
790fn 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            // Show truncated content (first 150 chars)
805            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
829/// Executes the get summary tool.
830/// Retrieves a summary memory and its linked source memories.
831pub 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    // Get the summary memory using IndexBackend::get_memory
843    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    // Check if this is actually a summary
849    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    // Display summary content
864    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    // Query for source memories using SourceOf edges
875    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        // Retrieve each source memory using IndexBackend::get_memory
885        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
897/// Executes the enrich tool.
898/// Returns a sampling request for the LLM to enrich a memory.
899pub 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    // For now, return a sampling request template
908    // In full implementation, would fetch the memory by ID first
909    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
938/// Executes the reindex tool.
939pub 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
971/// Executes the GDPR data export tool.
972///
973/// Implements GDPR Article 20 (Right to Data Portability).
974/// Returns all user data in a portable JSON format.
975pub 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            // Format the export as pretty JSON for readability
982            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
1016// ============================================================================
1017// Core CRUD Handlers (Industry Parity: Mem0, Zep, LangMem)
1018// ============================================================================
1019
1020/// Executes the get tool - retrieves a memory by ID.
1021///
1022/// This is a fundamental CRUD operation that provides direct access to
1023/// a specific memory without requiring a search query.
1024pub 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    // Support both raw IDs and full URNs (e.g., "subcog://project/patterns/abc123")
1034    let memory_id = MemoryId::new(Urn::extract_memory_id(&args.memory_id));
1035
1036    match index.get_memory(&memory_id)? {
1037        Some(memory) => {
1038            // Build URN for the memory
1039            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            // Format tags
1050            let tags_display = if memory.tags.is_empty() {
1051                "None".to_string()
1052            } else {
1053                memory.tags.join(", ")
1054            };
1055
1056            // Format status
1057            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
1105/// Executes the delete tool - soft or hard deletes a memory.
1106///
1107/// Defaults to soft delete (tombstone) which can be restored later.
1108/// Use `hard: true` for permanent deletion.
1109pub 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    // Support both raw IDs and full URNs
1120    let memory_id = MemoryId::new(Urn::extract_memory_id(&args.memory_id));
1121
1122    // First check if memory exists
1123    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        // Hard delete - permanent removal
1134        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        // Soft delete - tombstone the memory
1163        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        // Re-index with updated status (INSERT OR REPLACE)
1176        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
1199/// Executes the update tool - modifies an existing memory's content and/or tags.
1200///
1201/// This is a partial update operation - only provided fields are changed.
1202pub 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    // Validate that at least one field is being updated
1209    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    // SEC-M5: Validate content length if provided
1216    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    // Get existing memory
1226    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    // Check if memory is tombstoned
1236    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    // Track what we're updating for the audit log
1250    let mut modified_fields = Vec::new();
1251
1252    // Update content if provided
1253    if let Some(new_content) = args.content {
1254        memory.content = new_content;
1255        modified_fields.push("content".to_string());
1256    }
1257
1258    // Update tags if provided
1259    if let Some(new_tags) = args.tags {
1260        memory.tags = new_tags;
1261        modified_fields.push("tags".to_string());
1262    }
1263
1264    // Update timestamp
1265    let now = crate::current_timestamp();
1266    memory.updated_at = now;
1267    modified_fields.push("updated_at".to_string());
1268
1269    // Re-index the memory (INSERT OR REPLACE)
1270    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    // Format response
1281    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
1308// ============================================================================
1309// Mem0 Parity: List, DeleteAll, Restore, History
1310// ============================================================================
1311
1312/// Executes the list tool - lists memories with optional filtering and pagination.
1313///
1314/// Unlike recall, this doesn't require a search query. Matches Mem0's `get_all()`
1315/// and Zep's `list_memories()` patterns.
1316pub 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    // Build filter from the filter query string
1323    let mut filter = if let Some(filter_query) = &args.filter {
1324        parse_filter_query(filter_query)
1325    } else {
1326        SearchFilter::new()
1327    };
1328
1329    // Apply user_id filter via tag if provided
1330    if let Some(ref user_id) = args.user_id {
1331        filter = filter.with_tag(format!("user:{user_id}"));
1332    }
1333
1334    // Apply agent_id filter via tag if provided
1335    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    // Use list_all which returns all matching memories without a search query
1346    // We fetch limit + offset and then skip manually for pagination
1347    let fetch_count = limit.saturating_add(offset);
1348    let result = recall.list_all(&filter, fetch_count)?;
1349
1350    // Apply offset pagination manually
1351    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    // Build filter description for output
1360    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            // Build URN
1378            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            // Status indicator for non-active memories
1389            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    // Add pagination hint if there are more results
1407    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/// Executes the `delete_all` tool - bulk deletes memories matching filter criteria.
1423///
1424/// Defaults to dry-run mode for safety. Implements Mem0's `delete_all()` pattern.
1425#[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    // Build filter from the filter query string
1433    let mut filter = args
1434        .filter
1435        .as_ref()
1436        .map_or_else(SearchFilter::new, |q| parse_filter_query(q));
1437
1438    // Apply user_id filter via tag if provided
1439    if let Some(ref user_id) = args.user_id {
1440        filter = filter.with_tag(format!("user:{user_id}"));
1441    }
1442
1443    // Safety check: require at least some filter criteria
1444    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    // Fetch all matching memories (up to 10000 for safety)
1462    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    // Execute the deletion
1491    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
1507/// Checks if the filter has any criteria set.
1508const 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
1517/// Builds the dry-run output message.
1518fn 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    // Show first 10 memories that would be deleted
1536    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
1563/// Executes bulk deletion and returns (`deleted_count`, `failed_count`).
1564fn 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
1591/// Performs hard (permanent) deletion of a memory.
1592fn 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
1612/// Performs soft deletion (tombstone) of a memory.
1613fn 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
1645/// Builds the deletion result output message.
1646fn 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
1677/// Executes the restore tool - restores a tombstoned (soft-deleted) memory.
1678///
1679/// Sets the memory status back to Active and clears the tombstone timestamp.
1680pub 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    // Get existing memory
1693    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    // Check if memory is actually tombstoned
1703    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    // Restore the memory
1717    let now = crate::current_timestamp();
1718    memory.status = MemoryStatus::Active;
1719    memory.tombstoned_at = None;
1720    memory.updated_at = now;
1721
1722    // Re-index with updated status
1723    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    // Build URN for response
1734    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
1758/// Executes the history tool - retrieves change history for a memory.
1759///
1760/// Queries the event log for events related to the specified memory ID.
1761/// Note: This provides audit trail visibility but doesn't store full version snapshots.
1762pub 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    // Query event log for this memory
1776    // Note: The event log is currently stored via tracing/metrics, not in SQLite.
1777    // For a full implementation, we'd need to query a persistent event store.
1778    // For now, we provide status info and suggest using external log aggregation.
1779
1780    let mut output = format!("**Memory History: {}**\n\n", args.memory_id);
1781
1782    if let Some(memory) = index.get_memory(&memory_id)? {
1783        // Build basic history from memory metadata
1784        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
1837/// Escapes special XML characters for safe embedding in XML output.
1838fn escape_xml(s: &str) -> String {
1839    s.replace('&', "&amp;")
1840        .replace('<', "&lt;")
1841        .replace('>', "&gt;")
1842        .replace('"', "&quot;")
1843        .replace('\'', "&apos;")
1844}
1845
1846/// Namespace definitions with descriptions for XML output.
1847const 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
1860/// Builds the namespaces XML section with counts from the service (single-line).
1861fn build_xml_namespaces(services: Option<&ServiceContainer>) -> String {
1862    let mut xml = String::from("<namespaces>");
1863
1864    // Get counts if services available
1865    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
1893/// Builds the domains XML section (single-line).
1894fn 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
1898/// Builds the tools XML section with essential parameters (single-line).
1899fn 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
1914/// Builds the status XML element (single-line).
1915fn 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
1932/// Formats the recall section as XML for init output (single-line).
1933fn 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
1975/// Executes the init tool for session initialization.
1976///
1977/// Returns compressed single-line XML output with namespaces, domains, tools, status,
1978/// and optionally recalled memories. Use `prompt_understanding` for full docs.
1979pub 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    // Mark session as initialized
1984    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    // Build single-line XML output
1990    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    // Optional recalled memories (previews only)
1997    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        // Verify constant is 1 MB
2059        assert_eq!(MAX_CONTENT_LENGTH, 1_048_576);
2060    }
2061
2062    #[test]
2063    fn test_max_query_length_constant() {
2064        // Verify constant is 10 KB
2065        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}