Skip to main content

mneme/store/
memory.rs

1use std::collections::HashMap;
2use std::str::FromStr;
3use std::sync::{Arc, Mutex};
4
5use chrono::{DateTime, Utc};
6use fuzzy_matcher::FuzzyMatcher;
7use rusqlite::{params, Connection};
8use serde::{Deserialize, Serialize};
9use uuid::Uuid;
10
11/// Categoría de una memoria.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
13#[serde(rename_all = "lowercase")]
14pub enum MemoryType {
15    Architecture,
16    Decision,
17    Bugfix,
18    Pattern,
19    Convention,
20    Dependency,
21    Workflow,
22    Note,
23    Config,
24    Discovery,
25    Learning,
26    /// Fact generated autonomously by an AI agent (not directly created by user).
27    AgentFact,
28}
29
30impl std::fmt::Display for MemoryType {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        let s = match self {
33            MemoryType::Architecture => "architecture",
34            MemoryType::Decision => "decision",
35            MemoryType::Bugfix => "bugfix",
36            MemoryType::Pattern => "pattern",
37            MemoryType::Convention => "convention",
38            MemoryType::Dependency => "dependency",
39            MemoryType::Workflow => "workflow",
40            MemoryType::Note => "note",
41            MemoryType::Config => "config",
42            MemoryType::Discovery => "discovery",
43            MemoryType::Learning => "learning",
44            MemoryType::AgentFact => "agent_fact",
45        };
46        write!(f, "{}", s)
47    }
48}
49
50impl std::str::FromStr for MemoryType {
51    type Err = crate::error::MnemeError;
52
53    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
54        match s.to_lowercase().as_str() {
55            "architecture" => Ok(MemoryType::Architecture),
56            "decision" => Ok(MemoryType::Decision),
57            "bugfix" => Ok(MemoryType::Bugfix),
58            "pattern" => Ok(MemoryType::Pattern),
59            "convention" => Ok(MemoryType::Convention),
60            "dependency" => Ok(MemoryType::Dependency),
61            "workflow" => Ok(MemoryType::Workflow),
62            "note" => Ok(MemoryType::Note),
63            "config" => Ok(MemoryType::Config),
64            "discovery" => Ok(MemoryType::Discovery),
65            "learning" => Ok(MemoryType::Learning),
66            "agent_fact" | "agentfact" | "agent-fact" => Ok(MemoryType::AgentFact),
67            other => Err(crate::error::MnemeError::InvalidMemoryType(
68                other.to_string(),
69            )),
70        }
71    }
72}
73
74/// Nivel de importancia de una memoria.
75#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
76#[serde(rename_all = "lowercase")]
77pub enum Importance {
78    Low,
79    Medium,
80    High,
81    Critical,
82}
83
84impl Importance {
85    /// Factor de ponderación para el algoritmo de relevancia.
86    pub fn boost_factor(&self) -> f64 {
87        match self {
88            Importance::Low => 0.7,
89            Importance::Medium => 1.0,
90            Importance::High => 1.5,
91            Importance::Critical => 2.0,
92        }
93    }
94}
95
96impl std::fmt::Display for Importance {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        let s = match self {
99            Importance::Low => "low",
100            Importance::Medium => "medium",
101            Importance::High => "high",
102            Importance::Critical => "critical",
103        };
104        write!(f, "{}", s)
105    }
106}
107
108impl std::str::FromStr for Importance {
109    type Err = crate::error::MnemeError;
110
111    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
112        match s.to_lowercase().as_str() {
113            "low" => Ok(Importance::Low),
114            "medium" => Ok(Importance::Medium),
115            "high" => Ok(Importance::High),
116            "critical" => Ok(Importance::Critical),
117            other => Err(crate::error::MnemeError::InvalidImportance(
118                other.to_string(),
119            )),
120        }
121    }
122}
123
124/// Alcance de una memoria.
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
126#[serde(rename_all = "lowercase")]
127pub enum Scope {
128    Project,
129    Personal,
130    Global,
131}
132
133impl std::fmt::Display for Scope {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        let s = match self {
136            Scope::Project => "project",
137            Scope::Personal => "personal",
138            Scope::Global => "global",
139        };
140        write!(f, "{}", s)
141    }
142}
143
144impl std::str::FromStr for Scope {
145    type Err = crate::error::MnemeError;
146
147    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
148        match s.to_lowercase().as_str() {
149            "project" => Ok(Scope::Project),
150            "personal" => Ok(Scope::Personal),
151            "global" => Ok(Scope::Global),
152            other => Err(crate::error::MnemeError::InvalidScope(other.to_string())),
153        }
154    }
155}
156
157/// Tipo de relación entre memorias.
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
159#[serde(rename_all = "snake_case")]
160pub enum RelationType {
161    SupersededBy,
162    Supersedes,
163    ConflictsWith,
164    Extends,
165    DependsOn,
166    RelatedTo,
167    Compatible,
168    Scoped,
169}
170
171impl std::fmt::Display for RelationType {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        let s = match self {
174            RelationType::SupersededBy => "superseded_by",
175            RelationType::Supersedes => "supersedes",
176            RelationType::ConflictsWith => "conflicts_with",
177            RelationType::Extends => "extends",
178            RelationType::DependsOn => "depends_on",
179            RelationType::RelatedTo => "related_to",
180            RelationType::Compatible => "compatible",
181            RelationType::Scoped => "scoped",
182        };
183        write!(f, "{}", s)
184    }
185}
186
187impl std::str::FromStr for RelationType {
188    type Err = crate::error::MnemeError;
189
190    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
191        match s.to_lowercase().as_str() {
192            "superseded_by" => Ok(RelationType::SupersededBy),
193            "supersedes" => Ok(RelationType::Supersedes),
194            "conflicts_with" => Ok(RelationType::ConflictsWith),
195            "extends" => Ok(RelationType::Extends),
196            "depends_on" => Ok(RelationType::DependsOn),
197            "related_to" => Ok(RelationType::RelatedTo),
198            "compatible" => Ok(RelationType::Compatible),
199            "scoped" => Ok(RelationType::Scoped),
200            other => Err(crate::error::MnemeError::InvalidRelationType(
201                other.to_string(),
202            )),
203        }
204    }
205}
206
207/// Representa una memoria persistente.
208#[derive(Debug, Clone, Serialize, Deserialize)]
209pub struct Memory {
210    pub id: Uuid,
211    pub project: String,
212    pub scope: Scope,
213    pub title: String,
214    pub content: String,
215    pub what: Option<String>,
216    pub why: Option<String>,
217    pub context: Option<String>,
218    pub learned: Option<String>,
219    pub memory_type: MemoryType,
220    pub importance: Importance,
221    pub tags: Vec<String>,
222    pub topic_key: Option<String>,
223    pub access_count: u32,
224    pub revision_count: u32,
225    pub duplicate_count: u32,
226    pub normalized_hash: Option<String>,
227    pub created_at: DateTime<Utc>,
228    pub updated_at: DateTime<Utc>,
229    pub last_accessed_at: Option<DateTime<Utc>>,
230    pub last_seen_at: Option<DateTime<Utc>>,
231    pub deleted_at: Option<DateTime<Utc>>,
232    pub deprecated_at: Option<DateTime<Utc>>,
233    pub deprecated_reason: Option<String>,
234    pub supersedes_id: Option<String>,
235    pub context_inject_count: u32,
236    pub origin_peer: Option<String>,
237    pub is_encrypted: bool,
238    pub encrypted_for: Option<String>,
239    /// When this memory's fact became valid (temporal window start).
240    pub valid_from: Option<DateTime<Utc>>,
241    /// When this memory's fact stopped being valid (temporal window end).
242    pub valid_until: Option<DateTime<Utc>>,
243    /// Provenance chain: JSON array of {agent, action, timestamp} describing how this fact was derived.
244    pub provenance: Option<String>,
245}
246
247/// Relación entre dos memorias.
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct MemoryRelation {
250    pub id: Uuid,
251    pub sync_id: String,
252    pub source_id: Uuid,
253    pub target_id: Uuid,
254    pub relation_type: RelationType,
255    pub confidence: f32,
256    pub judgment_status: String,
257    pub reason: Option<String>,
258    pub evidence: Option<String>,
259    pub marked_by_actor: String,
260    pub created_at: DateTime<Utc>,
261    pub updated_at: DateTime<Utc>,
262}
263
264/// Sesión de trabajo.
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct Session {
267    pub id: Uuid,
268    pub project: String,
269    pub directory: Option<String>,
270    pub summary: Option<String>,
271    pub memory_ids: Vec<Uuid>,
272    pub started_at: DateTime<Utc>,
273    pub ended_at: Option<DateTime<Utc>>,
274    pub status: String,
275}
276
277/// Resultado de una búsqueda.
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct SearchResult {
280    pub memory: Memory,
281    pub score: f64,
282    pub snippet: Option<String>,
283    pub match_type: MatchType,
284    pub cosine_score: Option<f32>,
285}
286
287/// Tipo de coincidencia en búsqueda.
288#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
289#[serde(rename_all = "lowercase")]
290pub enum MatchType {
291    Fts,
292    Fuzzy,
293    Exact,
294    Semantic,
295}
296
297/// Estadísticas de un proyecto.
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct MemoryStats {
300    pub project: String,
301    pub total_memories: u32,
302    pub by_type: std::collections::HashMap<String, u32>,
303    pub by_importance: std::collections::HashMap<String, u32>,
304    pub by_scope: std::collections::HashMap<String, u32>,
305    pub total_relations: u32,
306    pub total_sessions: u32,
307    pub total_prompts: u32,
308    pub oldest_memory: Option<DateTime<Utc>>,
309    pub newest_memory: Option<DateTime<Utc>>,
310    pub most_accessed: Option<String>,
311}
312
313/// Resumen de un proyecto.
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct ProjectSummary {
316    pub name: String,
317    pub memory_count: u32,
318    pub session_count: u32,
319    pub last_activity: Option<DateTime<Utc>>,
320}
321
322/// Entrada para crear una memoria.
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct CreateMemoryInput {
325    pub project: String,
326    pub scope: Option<Scope>,
327    pub title: String,
328    pub content: String,
329    pub what: Option<String>,
330    pub why: Option<String>,
331    pub context: Option<String>,
332    pub learned: Option<String>,
333    pub memory_type: MemoryType,
334    pub importance: Importance,
335    pub tags: Vec<String>,
336    pub topic_key: Option<String>,
337    pub capture_prompt: Option<bool>,
338    #[serde(default)]
339    pub encrypt: bool,
340    /// Opcional: when this fact becomes valid (defaults to created_at).
341    pub valid_from: Option<DateTime<Utc>>,
342    /// Opcional: when this fact stops being valid.
343    pub valid_until: Option<DateTime<Utc>>,
344    /// Opcional: provenance chain JSON.
345    pub provenance: Option<String>,
346}
347
348/// Entrada para actualizar una memoria.
349#[derive(Debug, Clone, Serialize, Deserialize, Default)]
350pub struct UpdateMemoryInput {
351    pub title: Option<String>,
352    pub content: Option<String>,
353    pub what: Option<String>,
354    pub why: Option<String>,
355    pub context: Option<String>,
356    pub learned: Option<String>,
357    pub memory_type: Option<MemoryType>,
358    pub importance: Option<Importance>,
359    pub tags: Option<Vec<String>>,
360    pub scope: Option<Scope>,
361    pub topic_key: Option<String>,
362}
363
364/// Entrada para crear una relación.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct CreateRelationInput {
367    pub source_id: Uuid,
368    pub target_id: Uuid,
369    pub relation_type: RelationType,
370    pub confidence: Option<f32>,
371    pub reason: Option<String>,
372}
373
374/// Consulta de búsqueda.
375#[derive(Debug, Clone, Serialize, Deserialize)]
376pub struct SearchQuery {
377    pub text: String,
378    pub project: Option<String>,
379    pub scope: Option<Scope>,
380    pub memory_type: Option<MemoryType>,
381    pub importance: Option<Importance>,
382    pub tags: Vec<String>,
383    pub limit: u32,
384    pub include_snippet: bool,
385    pub all_projects: bool,
386}
387
388/// Prompt de usuario registrado.
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct UserPrompt {
391    pub id: Uuid,
392    pub session_id: Option<Uuid>,
393    pub content: String,
394    pub project: String,
395    pub created_at: DateTime<Utc>,
396}
397
398/// Entrada para crear un prompt.
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct CreatePromptInput {
401    pub session_id: Option<Uuid>,
402    pub content: String,
403    pub project: String,
404}
405
406/// Estadísticas de reindexación de embeddings.
407#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct ReindexStats {
409    /// Total de memorias procesadas.
410    pub total: u32,
411    /// Memorias indexadas exitosamente.
412    pub indexed: u32,
413    /// Memorias omitidas (ya tenían embedding).
414    pub skipped: u32,
415    /// Memorias que fallaron.
416    pub failed: u32,
417    /// Duración en milisegundos.
418    pub duration_ms: u64,
419}
420
421/// Resultado de una auditoría de calidad.
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct AuditReport {
424    /// Memorias sin acceso reciente.
425    pub stale_memories: Vec<Memory>,
426    /// Memorias sin tags.
427    pub untagged_memories: Vec<Memory>,
428    /// Memorias con contenido muy corto.
429    pub short_memories: Vec<Memory>,
430    /// Distribución por tipo.
431    pub type_distribution: HashMap<String, u32>,
432    /// Promedio de revisiones.
433    pub average_revisions: f64,
434    /// Cantidad de grupos con duplicados.
435    pub duplicate_groups: u32,
436}
437
438/// Grupo de memorias duplicadas semánticamente.
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct DuplicateGroup {
441    /// IDs de memorias en el grupo.
442    pub memory_ids: Vec<String>,
443    /// Títulos de memorias en el grupo.
444    pub titles: Vec<String>,
445    /// Score coseno máximo dentro del grupo.
446    pub cosine_score: f32,
447}
448
449/// Nodo del grafo de conocimiento.
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct GraphNode {
452    /// ID de la memoria.
453    pub id: String,
454    /// Título de la memoria.
455    pub title: String,
456    /// Tipo de memoria.
457    pub memory_type: String,
458    /// Nivel de importancia.
459    pub importance: String,
460}
461
462/// Arista del grafo de conocimiento.
463#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct GraphEdge {
465    /// ID origen.
466    pub source: String,
467    /// ID destino.
468    pub target: String,
469    /// Tipo de relación.
470    pub relation_type: String,
471    /// Confianza de la relación.
472    pub confidence: f32,
473}
474
475/// Datos del grafo de conocimiento.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct GraphData {
478    /// Nodos del grafo.
479    pub nodes: Vec<GraphNode>,
480    /// Aristas del grafo.
481    pub edges: Vec<GraphEdge>,
482}
483
484/// Resultado de un resumen ejecutivo.
485#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct SummaryResult {
487    /// Texto del resumen.
488    pub summary: String,
489    /// Cantidad de memorias.
490    pub memory_count: u32,
491    /// Conteo por tipo.
492    pub by_type: HashMap<String, u32>,
493}
494
495/// Reporte de salud del sistema.
496#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct HealthReport {
498    /// Tamaño de la base de datos en MB.
499    pub db_size_mb: f64,
500    /// Total de memorias.
501    pub total_memories: u32,
502    /// Memorias huérfanas.
503    pub orphaned_memories: u32,
504    /// Memorias sin embedding.
505    pub unindexed_embeddings: u32,
506    /// Última sincronización.
507    pub last_sync: Option<String>,
508    /// Modelo de embeddings.
509    pub embedding_model: String,
510    /// Versión de mneme.
511    pub version: String,
512}
513
514/// Brecha de conocimiento detectada.
515#[derive(Debug, Clone, Serialize, Deserialize)]
516pub struct KnowledgeGap {
517    /// Área subrepresentada.
518    pub area: String,
519    /// Cantidad de memorias en el área.
520    pub count: u32,
521    /// Sugerencia para cubrir el gap.
522    pub suggestion: String,
523}
524
525/// Reporte de brechas de conocimiento.
526#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct KnowledgeGapsReport {
528    /// Lista de brechas.
529    pub gaps: Vec<KnowledgeGap>,
530    /// Score de cobertura (0-1).
531    pub coverage_score: f64,
532}
533
534/// Store para operaciones de memoria.
535#[allow(dead_code)]
536pub struct MemoryStore {
537    conn: Arc<Mutex<Connection>>,
538    crypto: Option<Arc<Mutex<crate::crypto::CryptoEngine>>>,
539}
540
541impl MemoryStore {
542    /// Crea un nuevo MemoryStore.
543    pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
544        Self { conn, crypto: None }
545    }
546
547    /// Asigna el motor de encriptación.
548    pub fn with_crypto(mut self, crypto: Arc<Mutex<crate::crypto::CryptoEngine>>) -> Self {
549        self.crypto = Some(crypto);
550        self
551    }
552
553    /// Compute normalized hash for deduplication.
554    /// Combines: project + scope + memory_type + title (lowercased, whitespace normalized).
555    fn compute_hash(project: &str, scope: &Scope, memory_type: &MemoryType, title: &str) -> String {
556        let normalized = format!(
557            "{}:{}:{}:{}",
558            project.to_lowercase(),
559            scope.to_string().to_lowercase(),
560            memory_type.to_string().to_lowercase(),
561            title
562                .to_lowercase()
563                .split_whitespace()
564                .collect::<Vec<_>>()
565                .join(" ")
566        );
567        format!("{:x}", md5::compute(normalized))
568    }
569
570    /// Check for duplicates in a rolling window (last 24 hours).
571    fn find_duplicate(&self, hash: &str, project: &str) -> crate::error::Result<Option<Memory>> {
572        let conn = self
573            .conn
574            .lock()
575            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
576        let cutoff = (Utc::now() - chrono::Duration::hours(24)).to_rfc3339();
577        let mut stmt = conn.prepare(
578            "SELECT id FROM memories
579             WHERE normalized_hash = ?1 AND project = ?2 AND deleted_at IS NULL
580             AND created_at > ?3
581             LIMIT 1",
582        )?;
583        let id: Result<String, _> =
584            stmt.query_row(params![hash, project, cutoff], |row| row.get(0));
585        match id {
586            Ok(id) => {
587                let id = Uuid::parse_str(&id)
588                    .map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
589                drop(stmt);
590                drop(conn);
591                self.get(id)
592            }
593            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
594            Err(e) => Err(e.into()),
595        }
596    }
597
598    /// Upsert a memory with topic_key (evolutionary updates).
599    fn upsert_by_topic_key(
600        &self,
601        input: &CreateMemoryInput,
602    ) -> crate::error::Result<Option<Memory>> {
603        let topic_key = match &input.topic_key {
604            Some(tk) => tk,
605            None => return Ok(None),
606        };
607
608        let id = {
609            let conn = self
610                .conn
611                .lock()
612                .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
613            let scope_str = input.scope.as_ref().unwrap_or(&Scope::Project).to_string();
614            let mut stmt = conn.prepare(
615                "SELECT id FROM memories
616                 WHERE project = ?1 AND scope = ?2 AND topic_key = ?3 AND deleted_at IS NULL
617                 LIMIT 1",
618            )?;
619            let id: Result<String, _> = stmt
620                .query_row(params![input.project, scope_str, topic_key], |row| {
621                    row.get(0)
622                });
623            match id {
624                Ok(id) => id,
625                Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
626                Err(e) => return Err(e.into()),
627            }
628        };
629
630        let id =
631            Uuid::parse_str(&id).map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
632
633        let update = UpdateMemoryInput {
634            title: Some(input.title.clone()),
635            content: Some(input.content.clone()),
636            what: input.what.clone(),
637            why: input.why.clone(),
638            context: input.context.clone(),
639            learned: input.learned.clone(),
640            memory_type: Some(input.memory_type.clone()),
641            importance: Some(input.importance.clone()),
642            tags: Some(input.tags.clone()),
643            scope: input.scope.clone(),
644            topic_key: input.topic_key.clone(),
645        };
646        self.update(id, update).map(Some)
647    }
648
649    /// Indexa embedding para una memoria.
650    pub async fn index_embedding(
651        &self,
652        memory: &Memory,
653        engine: &std::sync::Arc<crate::embeddings::engine::EmbeddingEngine>,
654        embedding_store: &crate::embeddings::store::EmbeddingStore,
655    ) -> crate::error::Result<()> {
656        let text = crate::embeddings::engine::EmbeddingEngine::memory_to_text(memory);
657        let embedding = engine.embed(&text).await?;
658        embedding_store.save(memory.id, &embedding, engine.model_name())?;
659        tracing::info!(memory_id = %memory.id, "indexed embedding");
660        Ok(())
661    }
662
663    /// Reindexa todas las memorias sin embedding.
664    pub async fn reindex_embeddings(
665        &self,
666        project: &str,
667        engine: &std::sync::Arc<crate::embeddings::engine::EmbeddingEngine>,
668        embedding_store: &crate::embeddings::store::EmbeddingStore,
669    ) -> crate::error::Result<ReindexStats> {
670        let start = std::time::Instant::now();
671        let unindexed = embedding_store.find_unindexed(project)?;
672        let total = unindexed.len() as u32;
673        let mut indexed = 0u32;
674        let mut failed = 0u32;
675
676        for id in unindexed {
677            match self.get(id)? {
678                Some(memory) => {
679                    match self.index_embedding(&memory, engine, embedding_store).await {
680                        Ok(()) => indexed += 1,
681                        Err(e) => {
682                            tracing::warn!(memory_id = %id, error = %e, "failed to index embedding");
683                            failed += 1;
684                        }
685                    }
686                }
687                None => {
688                    failed += 1;
689                }
690            }
691        }
692
693        let duration_ms = start.elapsed().as_millis() as u64;
694        let skipped = total.saturating_sub(indexed + failed);
695        Ok(ReindexStats {
696            total,
697            indexed,
698            skipped,
699            failed,
700            duration_ms,
701        })
702    }
703
704    /// Save a new memory. Handles dedupe, topic_key upserts, and returns the memory.
705    /// Si engine y embedding_store son proporcionados, intenta indexar el embedding
706    /// de forma no bloqueante.
707    pub fn save(
708        &self,
709        input: CreateMemoryInput,
710        engine: Option<std::sync::Arc<crate::embeddings::engine::EmbeddingEngine>>,
711        embedding_store: Option<crate::embeddings::store::EmbeddingStore>,
712    ) -> crate::error::Result<Memory> {
713        if let Some(existing) = self.upsert_by_topic_key(&input)? {
714            tracing::info!("upserted memory via topic_key: {}", existing.id);
715            return Ok(existing);
716        }
717
718        let scope = input.scope.clone().unwrap_or(Scope::Project);
719        let hash = Self::compute_hash(&input.project, &scope, &input.memory_type, &input.title);
720        if let Some(mut existing) = self.find_duplicate(&hash, &input.project)? {
721            existing.duplicate_count += 1;
722            existing.last_seen_at = Some(Utc::now());
723            existing.updated_at = Utc::now();
724
725            let conn = self
726                .conn
727                .lock()
728                .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
729            conn.execute(
730                "UPDATE memories SET duplicate_count = ?1, last_seen_at = ?2, updated_at = ?3
731                 WHERE id = ?4",
732                params![
733                    existing.duplicate_count,
734                    existing.last_seen_at.map(|d| d.to_rfc3339()),
735                    existing.updated_at.to_rfc3339(),
736                    existing.id.to_string()
737                ],
738            )?;
739            tracing::info!("detected duplicate memory: {}", existing.id);
740            return Ok(existing);
741        }
742
743        let id = Uuid::new_v4();
744        let now = Utc::now();
745
746        let (
747            content_to_save,
748            what_to_save,
749            why_to_save,
750            context_to_save,
751            learned_to_save,
752            is_encrypted,
753            encrypted_for,
754        ) = if input.encrypt {
755            if let Some(crypto_arc) = &self.crypto {
756                let crypto = crypto_arc
757                    .lock()
758                    .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
759                if crypto.has_recipients() {
760                    let enc_content = crypto.encrypt_str(&input.content)?;
761                    let enc_what = input
762                        .what
763                        .as_deref()
764                        .map(|s| crypto.encrypt_str(s))
765                        .transpose()?;
766                    let enc_why = input
767                        .why
768                        .as_deref()
769                        .map(|s| crypto.encrypt_str(s))
770                        .transpose()?;
771                    let enc_ctx = input
772                        .context
773                        .as_deref()
774                        .map(|s| crypto.encrypt_str(s))
775                        .transpose()?;
776                    let enc_learned = input
777                        .learned
778                        .as_deref()
779                        .map(|s| crypto.encrypt_str(s))
780                        .transpose()?;
781                    let label = crypto.encrypted_for_label();
782                    (
783                        enc_content,
784                        enc_what,
785                        enc_why,
786                        enc_ctx,
787                        enc_learned,
788                        true,
789                        Some(label),
790                    )
791                } else {
792                    return Err(crate::error::MnemeError::NoRecipientsConfigured);
793                }
794            } else {
795                return Err(crate::error::MnemeError::NoRecipientsConfigured);
796            }
797        } else {
798            (
799                input.content.clone(),
800                input.what.clone(),
801                input.why.clone(),
802                input.context.clone(),
803                input.learned.clone(),
804                false,
805                None,
806            )
807        };
808
809        let conn = self
810            .conn
811            .lock()
812            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
813        conn.execute(
814            "INSERT INTO memories (
815                id, project, scope, title, content, what, why, context, learned,
816                memory_type, importance, tags, topic_key, access_count, revision_count,
817                duplicate_count, normalized_hash, created_at, updated_at, last_accessed_at, last_seen_at,
818                is_encrypted, encrypted_for, valid_from, valid_until, provenance
819            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26)",
820            params![
821                id.to_string(),
822                &input.project,
823                scope.to_string(),
824                &input.title,
825                &content_to_save,
826                what_to_save.as_deref(),
827                why_to_save.as_deref(),
828                context_to_save.as_deref(),
829                learned_to_save.as_deref(),
830                input.memory_type.to_string(),
831                input.importance.to_string(),
832                serde_json::to_string(&input.tags)?,
833                input.topic_key.as_deref(),
834                0u32,
835                1u32,
836                0u32,
837                &hash,
838                now.to_rfc3339(),
839                now.to_rfc3339(),
840                Option::<String>::None,
841                now.to_rfc3339(),
842                is_encrypted,
843                encrypted_for.as_deref(),
844                input.valid_from.map(|d| d.to_rfc3339()),  // valid_from
845                input.valid_until.map(|d| d.to_rfc3339()),  // valid_until
846                input.provenance,  // provenance
847            ],
848        )?;
849        let rowid = conn.last_insert_rowid();
850        conn.execute(
851            "INSERT INTO memories_fts(rowid, title, content, what, why, context, learned, tags)
852             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
853            params![
854                rowid,
855                &input.title,
856                if is_encrypted { "" } else { &content_to_save },
857                what_to_save.as_deref(),
858                why_to_save.as_deref(),
859                context_to_save.as_deref(),
860                learned_to_save.as_deref(),
861                serde_json::to_string(&input.tags)?,
862            ],
863        )?;
864        drop(conn);
865
866        // Auto-index embedding non-blocking
867        if let (Some(engine), Some(_store)) = (engine, embedding_store) {
868            if let Ok(handle) = tokio::runtime::Handle::try_current() {
869                let memory_id = id;
870                let store_conn = self.conn.clone();
871                handle.spawn(async move {
872                    let embed_store = crate::embeddings::store::EmbeddingStore::new(store_conn.clone());
873                    let mem_store = MemoryStore::new(store_conn);
874                    if let Ok(Some(memory)) = mem_store.get(memory_id) {
875                        let text = crate::embeddings::engine::EmbeddingEngine::memory_to_text(&memory);
876                        match engine.embed(&text).await {
877                            Ok(embedding) => {
878                                if let Err(e) = embed_store.save(memory_id, &embedding, engine.model_name()) {
879                                    tracing::warn!(memory_id = %memory_id, error = %e, "auto-index failed");
880                                }
881                            }
882                            Err(e) => {
883                                tracing::warn!(memory_id = %memory_id, error = %e, "auto-index embed failed");
884                            }
885                        }
886                    }
887                });
888            } else {
889                tracing::debug!("no tokio runtime available for auto-index");
890            }
891        }
892
893        // Auto-extract entities (non-blocking)
894        if !is_encrypted {
895            let entity_store = crate::store::entities::EntityStore::new(self.conn.clone());
896            if let Ok(Some(saved_memory)) = self.get(id) {
897                if let Err(e) = entity_store.extract_and_save(&saved_memory) {
898                    tracing::warn!(memory_id = %id, error = %e, "entity extraction failed");
899                }
900
901                // Auto-detect conflict candidates
902                if let Ok(candidates) = self.detect_conflict_candidates(&saved_memory) {
903                    if !candidates.is_empty() {
904                        tracing::info!(
905                            memory_id = %id, candidate_count = candidates.len(),
906                            "detected potential conflicts"
907                        );
908                    }
909                }
910            }
911        }
912
913        // Optionally set valid_from (if not provided, defaults to created_at)
914        if input.valid_from.is_some() {
915            // Already handled above via the INSERT
916        }
917
918        tracing::info!("saved new memory: {}", id);
919        self.get(id)?
920            .ok_or_else(|| crate::error::MnemeError::NotFound(id))
921    }
922
923    /// Update a memory by ID. Increments revision_count.
924    pub fn update(&self, id: Uuid, input: UpdateMemoryInput) -> crate::error::Result<Memory> {
925        {
926            let conn = self
927                .conn
928                .lock()
929                .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
930            let deleted: Option<Option<String>> = conn
931                .query_row(
932                    "SELECT deleted_at FROM memories WHERE id = ?1",
933                    params![id.to_string()],
934                    |row| row.get::<_, Option<String>>(0),
935                )
936                .ok();
937
938            match deleted {
939                None => return Err(crate::error::MnemeError::NotFound(id)),
940                Some(Some(_)) => return Err(crate::error::MnemeError::NotFound(id)),
941                Some(None) => {}
942            }
943        }
944
945        {
946            let mut conn = self
947                .conn
948                .lock()
949                .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
950            let tx = conn.transaction()?;
951
952            if let Some(title) = &input.title {
953                tx.execute(
954                    "UPDATE memories SET title = ?1 WHERE id = ?2",
955                    params![title, id.to_string()],
956                )?;
957            }
958            if let Some(content) = &input.content {
959                tx.execute(
960                    "UPDATE memories SET content = ?1 WHERE id = ?2",
961                    params![content, id.to_string()],
962                )?;
963            }
964            if let Some(what) = &input.what {
965                tx.execute(
966                    "UPDATE memories SET what = ?1 WHERE id = ?2",
967                    params![what, id.to_string()],
968                )?;
969            }
970            if let Some(why) = &input.why {
971                tx.execute(
972                    "UPDATE memories SET why = ?1 WHERE id = ?2",
973                    params![why, id.to_string()],
974                )?;
975            }
976            if let Some(context) = &input.context {
977                tx.execute(
978                    "UPDATE memories SET context = ?1 WHERE id = ?2",
979                    params![context, id.to_string()],
980                )?;
981            }
982            if let Some(learned) = &input.learned {
983                tx.execute(
984                    "UPDATE memories SET learned = ?1 WHERE id = ?2",
985                    params![learned, id.to_string()],
986                )?;
987            }
988            if let Some(memory_type) = &input.memory_type {
989                tx.execute(
990                    "UPDATE memories SET memory_type = ?1 WHERE id = ?2",
991                    params![memory_type.to_string(), id.to_string()],
992                )?;
993            }
994            if let Some(importance) = &input.importance {
995                tx.execute(
996                    "UPDATE memories SET importance = ?1 WHERE id = ?2",
997                    params![importance.to_string(), id.to_string()],
998                )?;
999            }
1000            if let Some(tags) = &input.tags {
1001                let tags_json = serde_json::to_string(tags)?;
1002                tx.execute(
1003                    "UPDATE memories SET tags = ?1 WHERE id = ?2",
1004                    params![tags_json, id.to_string()],
1005                )?;
1006            }
1007            if let Some(scope) = &input.scope {
1008                tx.execute(
1009                    "UPDATE memories SET scope = ?1 WHERE id = ?2",
1010                    params![scope.to_string(), id.to_string()],
1011                )?;
1012            }
1013            if let Some(topic_key) = &input.topic_key {
1014                tx.execute(
1015                    "UPDATE memories SET topic_key = ?1 WHERE id = ?2",
1016                    params![topic_key, id.to_string()],
1017                )?;
1018            }
1019
1020            tx.execute(
1021                "UPDATE memories SET revision_count = revision_count + 1, updated_at = ?1 WHERE id = ?2",
1022                params![Utc::now().to_rfc3339(), id.to_string()],
1023            )?;
1024
1025            tx.commit()?;
1026
1027            // Update FTS5 index
1028            let rowid: i64 = conn.query_row(
1029                "SELECT rowid FROM memories WHERE id = ?1",
1030                params![id.to_string()],
1031                |row| row.get(0),
1032            )?;
1033            let (title, content, what, why, context, learned, tags_json) = conn.query_row(
1034                "SELECT title, content, what, why, context, learned, tags FROM memories WHERE id = ?1",
1035                params![id.to_string()],
1036                |row| {
1037                    Ok((
1038                        row.get::<_, String>(0)?,
1039                        row.get::<_, String>(1)?,
1040                        row.get::<_, Option<String>>(2)?,
1041                        row.get::<_, Option<String>>(3)?,
1042                        row.get::<_, Option<String>>(4)?,
1043                        row.get::<_, Option<String>>(5)?,
1044                        row.get::<_, String>(6)?,
1045                    ))
1046                },
1047            )?;
1048            conn.execute(
1049                "INSERT INTO memories_fts(rowid, title, content, what, why, context, learned, tags)
1050                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1051                params![rowid, title, content, what, why, context, learned, tags_json],
1052            )?;
1053        }
1054
1055        tracing::info!("updated memory: {}", id);
1056        self.get(id)?
1057            .ok_or_else(|| crate::error::MnemeError::NotFound(id))
1058    }
1059
1060    /// Soft-delete a memory (default). Hard-delete if hard=true.
1061    pub fn delete(&self, id: Uuid, hard: bool) -> crate::error::Result<()> {
1062        let conn = self
1063            .conn
1064            .lock()
1065            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1066        if hard {
1067            let rowid: i64 = conn.query_row(
1068                "SELECT rowid FROM memories WHERE id = ?1",
1069                params![id.to_string()],
1070                |row| row.get(0),
1071            )?;
1072            conn.execute("DELETE FROM memories_fts WHERE rowid = ?1", params![rowid])?;
1073            conn.execute(
1074                "DELETE FROM memories WHERE id = ?1",
1075                params![id.to_string()],
1076            )?;
1077            tracing::info!("hard-deleted memory: {}", id);
1078        } else {
1079            let now = Utc::now().to_rfc3339();
1080            conn.execute(
1081                "UPDATE memories SET deleted_at = ?1 WHERE id = ?2 AND deleted_at IS NULL",
1082                params![now, id.to_string()],
1083            )?;
1084            let rowid: i64 = conn.query_row(
1085                "SELECT rowid FROM memories WHERE id = ?1",
1086                params![id.to_string()],
1087                |row| row.get(0),
1088            )?;
1089            conn.execute("DELETE FROM memories_fts WHERE rowid = ?1", params![rowid])?;
1090            tracing::info!("soft-deleted memory: {}", id);
1091        }
1092        Ok(())
1093    }
1094
1095    /// Restore a soft-deleted memory.
1096    pub fn restore(&self, id: Uuid) -> crate::error::Result<Memory> {
1097        let conn = self
1098            .conn
1099            .lock()
1100            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1101        conn.execute(
1102            "UPDATE memories SET deleted_at = NULL WHERE id = ?1",
1103            params![id.to_string()],
1104        )?;
1105        let rowid: i64 = conn.query_row(
1106            "SELECT rowid FROM memories WHERE id = ?1",
1107            params![id.to_string()],
1108            |row| row.get(0),
1109        )?;
1110        let (title, content, what, why, context, learned, tags_json) = conn.query_row(
1111            "SELECT title, content, what, why, context, learned, tags FROM memories WHERE id = ?1",
1112            params![id.to_string()],
1113            |row| {
1114                Ok((
1115                    row.get::<_, String>(0)?,
1116                    row.get::<_, String>(1)?,
1117                    row.get::<_, Option<String>>(2)?,
1118                    row.get::<_, Option<String>>(3)?,
1119                    row.get::<_, Option<String>>(4)?,
1120                    row.get::<_, Option<String>>(5)?,
1121                    row.get::<_, String>(6)?,
1122                ))
1123            },
1124        )?;
1125        conn.execute(
1126            "INSERT INTO memories_fts(rowid, title, content, what, why, context, learned, tags)
1127             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1128            params![rowid, title, content, what, why, context, learned, tags_json],
1129        )?;
1130        drop(conn);
1131        tracing::info!("restored memory: {}", id);
1132        self.get(id)?
1133            .ok_or_else(|| crate::error::MnemeError::NotFound(id))
1134    }
1135
1136    /// Get a memory by ID, incrementing access_count. Ignores soft-deleted.
1137    pub fn get(&self, id: Uuid) -> crate::error::Result<Option<Memory>> {
1138        let conn = self
1139            .conn
1140            .lock()
1141            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1142        conn.execute(
1143            "UPDATE memories SET access_count = access_count + 1, last_accessed_at = ?1 WHERE id = ?2 AND deleted_at IS NULL",
1144            params![Utc::now().to_rfc3339(), id.to_string()],
1145        )?;
1146
1147        let mut stmt = conn.prepare(
1148            "SELECT id, project, scope, title, content, what, why, context, learned,
1149             memory_type, importance, tags, topic_key, access_count, revision_count,
1150             duplicate_count, normalized_hash, created_at, updated_at, last_accessed_at, last_seen_at, deleted_at,
1151             deprecated_at, deprecated_reason, supersedes_id, context_inject_count, origin_peer,
1152             is_encrypted, encrypted_for, valid_from, valid_until, provenance
1153             FROM memories WHERE id = ?1 AND deleted_at IS NULL",
1154        )?;
1155
1156        let result = stmt.query_row(params![id.to_string()], Self::row_to_memory);
1157
1158        match result {
1159            Ok(mut memory) => {
1160                if memory.is_encrypted {
1161                    if let Some(crypto_arc) = &self.crypto {
1162                        let mut crypto = crypto_arc.lock().map_err(|_| {
1163                            crate::error::MnemeError::Config("mutex poisoned".into())
1164                        })?;
1165                        if crypto.can_decrypt() {
1166                            memory.content = crypto
1167                                .decrypt_str(&memory.content)
1168                                .unwrap_or_else(|_| "[ENCRIPTADO]".to_string());
1169                            if let Some(ref s) = memory.what {
1170                                memory.what = Some(
1171                                    crypto
1172                                        .decrypt_str(s)
1173                                        .unwrap_or_else(|_| "[ENCRIPTADO]".to_string()),
1174                                );
1175                            }
1176                            if let Some(ref s) = memory.why {
1177                                memory.why = Some(
1178                                    crypto
1179                                        .decrypt_str(s)
1180                                        .unwrap_or_else(|_| "[ENCRIPTADO]".to_string()),
1181                                );
1182                            }
1183                            if let Some(ref s) = memory.context {
1184                                memory.context = Some(
1185                                    crypto
1186                                        .decrypt_str(s)
1187                                        .unwrap_or_else(|_| "[ENCRIPTADO]".to_string()),
1188                                );
1189                            }
1190                            if let Some(ref s) = memory.learned {
1191                                memory.learned = Some(
1192                                    crypto
1193                                        .decrypt_str(s)
1194                                        .unwrap_or_else(|_| "[ENCRIPTADO]".to_string()),
1195                                );
1196                            }
1197                        } else {
1198                            memory.content = "[ENCRIPTADO]".to_string();
1199                            memory.what = memory.what.as_ref().map(|_| "[ENCRIPTADO]".to_string());
1200                            memory.why = memory.why.as_ref().map(|_| "[ENCRIPTADO]".to_string());
1201                            memory.context =
1202                                memory.context.as_ref().map(|_| "[ENCRIPTADO]".to_string());
1203                            memory.learned =
1204                                memory.learned.as_ref().map(|_| "[ENCRIPTADO]".to_string());
1205                        }
1206                    } else {
1207                        memory.content = "[ENCRIPTADO]".to_string();
1208                        memory.what = memory.what.as_ref().map(|_| "[ENCRIPTADO]".to_string());
1209                        memory.why = memory.why.as_ref().map(|_| "[ENCRIPTADO]".to_string());
1210                        memory.context =
1211                            memory.context.as_ref().map(|_| "[ENCRIPTADO]".to_string());
1212                        memory.learned =
1213                            memory.learned.as_ref().map(|_| "[ENCRIPTADO]".to_string());
1214                    }
1215                }
1216                Ok(Some(memory))
1217            }
1218            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1219            Err(e) => Err(e.into()),
1220        }
1221    }
1222
1223    /// List memories for a project with optional filters. Ignores soft-deleted.
1224    pub fn list(
1225        &self,
1226        project: &str,
1227        memory_type: Option<&MemoryType>,
1228        importance: Option<&Importance>,
1229        scope: Option<&Scope>,
1230        limit: u32,
1231        offset: u32,
1232    ) -> crate::error::Result<Vec<Memory>> {
1233        let conn = self
1234            .conn
1235            .lock()
1236            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1237
1238        let mut conditions = vec!["project = ?1", "deleted_at IS NULL"];
1239        let mut param_values: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1240        param_values.push(Box::new(project.to_string()));
1241
1242        if let Some(mt) = memory_type {
1243            conditions.push("memory_type = ?");
1244            param_values.push(Box::new(mt.to_string()));
1245        }
1246        if let Some(imp) = importance {
1247            conditions.push("importance = ?");
1248            param_values.push(Box::new(imp.to_string()));
1249        }
1250        if let Some(sc) = scope {
1251            conditions.push("scope = ?");
1252            param_values.push(Box::new(sc.to_string()));
1253        }
1254
1255        let sql = format!(
1256            "SELECT id, project, scope, title, content, what, why, context, learned,
1257             memory_type, importance, tags, topic_key, access_count, revision_count,
1258             duplicate_count, normalized_hash, created_at, updated_at, last_accessed_at, last_seen_at, deleted_at,
1259             deprecated_at, deprecated_reason, supersedes_id, context_inject_count, origin_peer,
1260             is_encrypted, encrypted_for, valid_from, valid_until, provenance
1261             FROM memories WHERE {}
1262             ORDER BY updated_at DESC LIMIT ? OFFSET ?",
1263            conditions.join(" AND ")
1264        );
1265        param_values.push(Box::new(limit as i64));
1266        param_values.push(Box::new(offset as i64));
1267
1268        let param_refs: Vec<&dyn rusqlite::ToSql> =
1269            param_values.iter().map(|p| p.as_ref()).collect();
1270
1271        let mut stmt = conn.prepare(&sql)?;
1272        let rows = stmt.query_map(param_refs.as_slice(), Self::row_to_memory)?;
1273
1274        let mut memories = Vec::new();
1275        for row in rows {
1276            memories.push(row?);
1277        }
1278        Ok(memories)
1279    }
1280
1281    /// Get recent memories for context (session injection). Ignores soft-deleted.
1282    pub fn context(
1283        &self,
1284        project: &str,
1285        scope: Option<&Scope>,
1286        limit: u32,
1287    ) -> crate::error::Result<Vec<Memory>> {
1288        let conn = self
1289            .conn
1290            .lock()
1291            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1292
1293        let mut sql = String::from(
1294            "SELECT id, project, scope, title, content, what, why, context, learned,
1295             memory_type, importance, tags, topic_key, access_count, revision_count,
1296             duplicate_count, normalized_hash, created_at, updated_at, last_accessed_at, last_seen_at, deleted_at,
1297             deprecated_at, deprecated_reason, supersedes_id, context_inject_count, origin_peer,
1298             is_encrypted, encrypted_for, valid_from, valid_until, provenance
1299             FROM memories WHERE project = ?1 AND deleted_at IS NULL",
1300        );
1301        let mut param_values: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(project.to_string())];
1302
1303        if let Some(sc) = scope {
1304            sql.push_str(" AND scope = ?");
1305            param_values.push(Box::new(sc.to_string()));
1306        }
1307
1308        sql.push_str(
1309            " ORDER BY last_accessed_at IS NULL, last_accessed_at DESC, updated_at DESC LIMIT ?",
1310        );
1311        param_values.push(Box::new(limit as i64));
1312
1313        let param_refs: Vec<&dyn rusqlite::ToSql> =
1314            param_values.iter().map(|p| p.as_ref()).collect();
1315
1316        let mut stmt = conn.prepare(&sql)?;
1317        let rows = stmt.query_map(param_refs.as_slice(), Self::row_to_memory)?;
1318
1319        let mut memories = Vec::new();
1320        for row in rows {
1321            memories.push(row?);
1322        }
1323        Ok(memories)
1324    }
1325
1326    /// Get stats for a project.
1327    pub fn stats(&self, project: &str) -> crate::error::Result<MemoryStats> {
1328        let conn = self
1329            .conn
1330            .lock()
1331            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1332
1333        let total: u32 = conn.query_row(
1334            "SELECT COUNT(*) FROM memories WHERE project = ?1 AND deleted_at IS NULL",
1335            params![project],
1336            |row| row.get(0),
1337        )?;
1338
1339        let mut by_type = HashMap::new();
1340        let mut stmt = conn.prepare(
1341            "SELECT memory_type, COUNT(*) FROM memories WHERE project = ?1 AND deleted_at IS NULL GROUP BY memory_type",
1342        )?;
1343        let rows = stmt.query_map(params![project], |row| {
1344            Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?))
1345        })?;
1346        for row in rows {
1347            let (k, v) = row?;
1348            by_type.insert(k, v);
1349        }
1350
1351        let mut by_importance = HashMap::new();
1352        let mut stmt = conn.prepare(
1353            "SELECT importance, COUNT(*) FROM memories WHERE project = ?1 AND deleted_at IS NULL GROUP BY importance",
1354        )?;
1355        let rows = stmt.query_map(params![project], |row| {
1356            Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?))
1357        })?;
1358        for row in rows {
1359            let (k, v) = row?;
1360            by_importance.insert(k, v);
1361        }
1362
1363        let mut by_scope = HashMap::new();
1364        let mut stmt = conn.prepare(
1365            "SELECT scope, COUNT(*) FROM memories WHERE project = ?1 AND deleted_at IS NULL GROUP BY scope",
1366        )?;
1367        let rows = stmt.query_map(params![project], |row| {
1368            Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?))
1369        })?;
1370        for row in rows {
1371            let (k, v) = row?;
1372            by_scope.insert(k, v);
1373        }
1374
1375        let total_relations: u32 = conn.query_row(
1376            "SELECT COUNT(*) FROM memory_relations r
1377             JOIN memories m ON r.source_id = m.id
1378             WHERE m.project = ?1 AND m.deleted_at IS NULL",
1379            params![project],
1380            |row| row.get(0),
1381        )?;
1382
1383        let total_sessions: u32 = conn.query_row(
1384            "SELECT COUNT(*) FROM sessions WHERE project = ?1",
1385            params![project],
1386            |row| row.get(0),
1387        )?;
1388
1389        let total_prompts: u32 = conn.query_row(
1390            "SELECT COUNT(*) FROM user_prompts WHERE project = ?1",
1391            params![project],
1392            |row| row.get(0),
1393        )?;
1394
1395        let oldest: Option<String> = conn
1396            .query_row(
1397                "SELECT MIN(created_at) FROM memories WHERE project = ?1 AND deleted_at IS NULL",
1398                params![project],
1399                |row| row.get(0),
1400            )
1401            .ok()
1402            .flatten();
1403
1404        let newest: Option<String> = conn
1405            .query_row(
1406                "SELECT MAX(created_at) FROM memories WHERE project = ?1 AND deleted_at IS NULL",
1407                params![project],
1408                |row| row.get(0),
1409            )
1410            .ok()
1411            .flatten();
1412
1413        let most_accessed: Option<String> = conn
1414            .query_row(
1415                "SELECT title FROM memories WHERE project = ?1 AND deleted_at IS NULL ORDER BY access_count DESC LIMIT 1",
1416                params![project],
1417                |row| row.get(0),
1418            )
1419            .ok();
1420
1421        Ok(MemoryStats {
1422            project: project.to_string(),
1423            total_memories: total,
1424            by_type,
1425            by_importance,
1426            by_scope,
1427            total_relations,
1428            total_sessions,
1429            total_prompts,
1430            oldest_memory: oldest
1431                .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1432                .map(|d| d.with_timezone(&Utc)),
1433            newest_memory: newest
1434                .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1435                .map(|d| d.with_timezone(&Utc)),
1436            most_accessed,
1437        })
1438    }
1439
1440    /// List all projects with summary.
1441    pub fn list_projects(&self) -> crate::error::Result<Vec<ProjectSummary>> {
1442        let conn = self
1443            .conn
1444            .lock()
1445            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1446
1447        let mut stmt = conn.prepare(
1448            "SELECT project, COUNT(*), MAX(updated_at)
1449             FROM memories WHERE deleted_at IS NULL
1450             GROUP BY project ORDER BY project",
1451        )?;
1452        let rows = stmt.query_map([], |row| {
1453            Ok(ProjectSummary {
1454                name: row.get(0)?,
1455                memory_count: row.get(1)?,
1456                session_count: 0u32,
1457                last_activity: row
1458                    .get::<_, Option<String>>(2)?
1459                    .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1460                    .map(|d| d.with_timezone(&Utc)),
1461            })
1462        })?;
1463
1464        let mut projects: Vec<ProjectSummary> = Vec::new();
1465        for row in rows {
1466            projects.push(row?);
1467        }
1468
1469        for project in &mut projects {
1470            let count: u32 = conn
1471                .query_row(
1472                    "SELECT COUNT(*) FROM sessions WHERE project = ?1",
1473                    params![&project.name],
1474                    |row| row.get(0),
1475                )
1476                .unwrap_or(0);
1477            project.session_count = count;
1478        }
1479
1480        Ok(projects)
1481    }
1482
1483    /// Check if a project exists.
1484    pub fn project_exists(&self, project: &str) -> crate::error::Result<bool> {
1485        let conn = self
1486            .conn
1487            .lock()
1488            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1489        let count: u32 = conn.query_row(
1490            "SELECT COUNT(*) FROM memories WHERE project = ?1 AND deleted_at IS NULL LIMIT 1",
1491            params![project],
1492            |row| row.get(0),
1493        )?;
1494        Ok(count > 0)
1495    }
1496
1497    /// Search memories using the hybrid search engine.
1498    /// Si se proporcionan scores semánticos, se integran en la puntuación.
1499    pub fn search(
1500        &self,
1501        query: &SearchQuery,
1502        weights: &crate::store::search::SearchWeights,
1503        semantic_scores: Option<&HashMap<Uuid, f32>>,
1504    ) -> crate::error::Result<Vec<SearchResult>> {
1505        let conn = self
1506            .conn
1507            .lock()
1508            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1509        let engine = crate::store::search::SearchEngine::new();
1510        engine.search(&conn, query, weights, semantic_scores)
1511    }
1512
1513    /// Search with cross-encoder reranking.
1514    /// When embeddings feature is enabled, re-ranks top results using semantic refinement.
1515    pub fn search_reranked(
1516        &self,
1517        query: &SearchQuery,
1518        weights: &crate::store::search::SearchWeights,
1519        semantic_scores: Option<&HashMap<Uuid, f32>>,
1520        engine: &std::sync::Arc<crate::embeddings::engine::EmbeddingEngine>,
1521    ) -> crate::error::Result<Vec<SearchResult>> {
1522        let conn = self
1523            .conn
1524            .lock()
1525            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1526        let search_engine = crate::store::search::SearchEngine::new();
1527        let mut results = search_engine.search(&conn, query, weights, semantic_scores)?;
1528
1529        // Rerank top results using semantic similarity refinement
1530        if !results.is_empty() {
1531            crate::embeddings::rerank::rerank_search_results(
1532                &query.text,
1533                &mut results,
1534                Some(engine),
1535                weights,
1536            );
1537        }
1538
1539        Ok(results)
1540    }
1541
1542    /// Maps a SQL row to a Memory struct. Public for use by MCP tools.
1543    pub fn row_to_memory(row: &rusqlite::Row) -> Result<Memory, rusqlite::Error> {
1544        Ok(Memory {
1545            id: Uuid::parse_str(&row.get::<_, String>(0)?).map_err(|e| {
1546                rusqlite::Error::FromSqlConversionFailure(
1547                    0,
1548                    rusqlite::types::Type::Text,
1549                    Box::new(e),
1550                )
1551            })?,
1552            project: row.get(1)?,
1553            scope: Scope::from_str(&row.get::<_, String>(2)?).map_err(|e| {
1554                rusqlite::Error::FromSqlConversionFailure(
1555                    2,
1556                    rusqlite::types::Type::Text,
1557                    Box::new(e),
1558                )
1559            })?,
1560            title: row.get(3)?,
1561            content: row.get(4)?,
1562            what: row.get(5)?,
1563            why: row.get(6)?,
1564            context: row.get(7)?,
1565            learned: row.get(8)?,
1566            memory_type: MemoryType::from_str(&row.get::<_, String>(9)?).map_err(|e| {
1567                rusqlite::Error::FromSqlConversionFailure(
1568                    9,
1569                    rusqlite::types::Type::Text,
1570                    Box::new(e),
1571                )
1572            })?,
1573            importance: Importance::from_str(&row.get::<_, String>(10)?).map_err(|e| {
1574                rusqlite::Error::FromSqlConversionFailure(
1575                    10,
1576                    rusqlite::types::Type::Text,
1577                    Box::new(e),
1578                )
1579            })?,
1580            tags: serde_json::from_str(&row.get::<_, String>(11)?).unwrap_or_default(),
1581            topic_key: row.get(12)?,
1582            access_count: row.get(13)?,
1583            revision_count: row.get(14)?,
1584            duplicate_count: row.get(15)?,
1585            normalized_hash: row.get(16)?,
1586            created_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(17)?)
1587                .map_err(|e| {
1588                    rusqlite::Error::FromSqlConversionFailure(
1589                        17,
1590                        rusqlite::types::Type::Text,
1591                        Box::new(e),
1592                    )
1593                })?
1594                .with_timezone(&Utc),
1595            updated_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(18)?)
1596                .map_err(|e| {
1597                    rusqlite::Error::FromSqlConversionFailure(
1598                        18,
1599                        rusqlite::types::Type::Text,
1600                        Box::new(e),
1601                    )
1602                })?
1603                .with_timezone(&Utc),
1604            last_accessed_at: row
1605                .get::<_, Option<String>>(19)?
1606                .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1607                .map(|d| d.with_timezone(&Utc)),
1608            last_seen_at: row
1609                .get::<_, Option<String>>(20)?
1610                .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1611                .map(|d| d.with_timezone(&Utc)),
1612            deleted_at: row
1613                .get::<_, Option<String>>(21)?
1614                .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1615                .map(|d| d.with_timezone(&Utc)),
1616            deprecated_at: row
1617                .get::<_, Option<String>>(22)?
1618                .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1619                .map(|d| d.with_timezone(&Utc)),
1620            deprecated_reason: row.get(23)?,
1621            supersedes_id: row.get(24)?,
1622            context_inject_count: row.get(25)?,
1623            origin_peer: row.get(26)?,
1624            is_encrypted: row.get(27).unwrap_or(false),
1625            encrypted_for: row.get(28).unwrap_or(None),
1626            valid_from: row
1627                .get::<_, Option<String>>(29)?
1628                .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1629                .map(|d| d.with_timezone(&Utc)),
1630            valid_until: row
1631                .get::<_, Option<String>>(30)?
1632                .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
1633                .map(|d| d.with_timezone(&Utc)),
1634            provenance: row.get(31)?,
1635        })
1636    }
1637
1638    /// Guarda un prompt de usuario.
1639    pub fn save_prompt(&self, input: CreatePromptInput) -> crate::error::Result<UserPrompt> {
1640        let id = Uuid::new_v4();
1641        let now = Utc::now();
1642        let conn = self
1643            .conn
1644            .lock()
1645            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1646        conn.execute(
1647            "INSERT INTO user_prompts (id, session_id, content, project, created_at)
1648             VALUES (?1, ?2, ?3, ?4, ?5)",
1649            params![
1650                id.to_string(),
1651                input.session_id.map(|u| u.to_string()),
1652                input.content,
1653                input.project,
1654                now.to_rfc3339()
1655            ],
1656        )?;
1657        tracing::info!("saved prompt: {}", id);
1658        Ok(UserPrompt {
1659            id,
1660            session_id: input.session_id,
1661            content: input.content,
1662            project: input.project,
1663            created_at: now,
1664        })
1665    }
1666
1667    /// Crea una relación entre dos memorias.
1668    pub fn create_relation(
1669        &self,
1670        input: CreateRelationInput,
1671    ) -> crate::error::Result<MemoryRelation> {
1672        let id = Uuid::new_v4();
1673        let now = Utc::now();
1674        let conn = self
1675            .conn
1676            .lock()
1677            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1678
1679        if input.source_id == input.target_id {
1680            return Err(crate::error::MnemeError::SelfRelation(input.source_id));
1681        }
1682
1683        let existing: u32 = conn.query_row(
1684            "SELECT COUNT(*) FROM memory_relations
1685             WHERE source_id = ?1 AND target_id = ?2",
1686            params![input.source_id.to_string(), input.target_id.to_string()],
1687            |row| row.get(0),
1688        )?;
1689
1690        if existing > 0 {
1691            return Err(crate::error::MnemeError::RelationAlreadyExists(
1692                input.source_id,
1693                input.target_id,
1694            ));
1695        }
1696
1697        conn.execute(
1698            "INSERT INTO memory_relations (
1699                id, sync_id, source_id, target_id, relation_type, confidence,
1700                judgment_status, reason, evidence, marked_by_actor, created_at, updated_at
1701            ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
1702            params![
1703                id.to_string(),
1704                id.to_string(),
1705                input.source_id.to_string(),
1706                input.target_id.to_string(),
1707                input.relation_type.to_string(),
1708                input.confidence,
1709                "active",
1710                input.reason.as_deref(),
1711                Option::<String>::None,
1712                "user",
1713                now.to_rfc3339(),
1714                now.to_rfc3339(),
1715            ],
1716        )?;
1717
1718        tracing::info!(
1719            "created relation: {} -> {}",
1720            input.source_id,
1721            input.target_id
1722        );
1723        Ok(MemoryRelation {
1724            id,
1725            sync_id: id.to_string(),
1726            source_id: input.source_id,
1727            target_id: input.target_id,
1728            relation_type: input.relation_type,
1729            confidence: input.confidence.unwrap_or(1.0),
1730            judgment_status: "active".to_string(),
1731            reason: input.reason,
1732            evidence: None,
1733            marked_by_actor: "user".to_string(),
1734            created_at: now,
1735            updated_at: now,
1736        })
1737    }
1738
1739    /// Guarda un lote de memorias, detectando duplicados.
1740    pub fn save_batch(
1741        &self,
1742        inputs: Vec<CreateMemoryInput>,
1743        engine: Option<std::sync::Arc<crate::embeddings::engine::EmbeddingEngine>>,
1744        embedding_store: Option<crate::embeddings::store::EmbeddingStore>,
1745    ) -> crate::error::Result<(Vec<Memory>, Vec<Memory>)> {
1746        let mut saved = Vec::new();
1747        let mut duplicates = Vec::new();
1748        for input in inputs {
1749            match self.save(input, engine.clone(), embedding_store.clone()) {
1750                Ok(memory) => {
1751                    if memory.duplicate_count > 0 {
1752                        duplicates.push(memory.clone());
1753                    }
1754                    saved.push(memory);
1755                }
1756                Err(e) => {
1757                    tracing::warn!(error = %e, "batch save failed for one item");
1758                }
1759            }
1760        }
1761        tracing::info!(
1762            saved = saved.len(),
1763            duplicates = duplicates.len(),
1764            "batch save complete"
1765        );
1766        Ok((saved, duplicates))
1767    }
1768
1769    /// Elimina una relación por su ID.
1770    pub fn delete_relation(&self, relation_id: Uuid) -> crate::error::Result<bool> {
1771        let conn = self
1772            .conn
1773            .lock()
1774            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1775        let affected = conn.execute(
1776            "DELETE FROM memory_relations WHERE id = ?1",
1777            params![relation_id.to_string()],
1778        )?;
1779        tracing::info!(relation_id = %relation_id, affected_rows = affected, "deleted relation");
1780        Ok(affected > 0)
1781    }
1782
1783    /// Ejecuta una auditoría de calidad sobre un proyecto.
1784    pub fn audit(&self, project: &str, days_threshold: u32) -> crate::error::Result<AuditReport> {
1785        let conn = self
1786            .conn
1787            .lock()
1788            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1789        let cutoff = (Utc::now() - chrono::Duration::days(i64::from(days_threshold))).to_rfc3339();
1790
1791        // Stale memories
1792        let mut stmt = conn.prepare(
1793            "SELECT id, project, scope, title, content, what, why, context, learned,
1794             memory_type, importance, tags, topic_key, access_count, revision_count,
1795             duplicate_count, normalized_hash, created_at, updated_at, last_accessed_at, last_seen_at, deleted_at,
1796             deprecated_at, deprecated_reason, supersedes_id, context_inject_count, origin_peer,
1797             is_encrypted, encrypted_for, valid_from, valid_until, provenance
1798             FROM memories WHERE project = ?1 AND deleted_at IS NULL
1799             AND (last_accessed_at IS NULL OR last_accessed_at < ?2)
1800             ORDER BY updated_at DESC"
1801        )?;
1802        let rows = stmt.query_map(params![project, cutoff], Self::row_to_memory)?;
1803        let mut stale_memories = Vec::new();
1804        for row in rows {
1805            stale_memories.push(row?);
1806        }
1807
1808        // Untagged memories
1809        let mut stmt = conn.prepare(
1810            "SELECT id, project, scope, title, content, what, why, context, learned,
1811             memory_type, importance, tags, topic_key, access_count, revision_count,
1812             duplicate_count, normalized_hash, created_at, updated_at, last_accessed_at, last_seen_at, deleted_at,
1813             deprecated_at, deprecated_reason, supersedes_id, context_inject_count, origin_peer,
1814             is_encrypted, encrypted_for, valid_from, valid_until, provenance
1815             FROM memories WHERE project = ?1 AND deleted_at IS NULL
1816             AND (tags = '[]' OR tags = '')
1817             ORDER BY updated_at DESC"
1818        )?;
1819        let rows = stmt.query_map(params![project], Self::row_to_memory)?;
1820        let mut untagged_memories = Vec::new();
1821        for row in rows {
1822            untagged_memories.push(row?);
1823        }
1824
1825        // Short memories
1826        let mut stmt = conn.prepare(
1827            "SELECT id, project, scope, title, content, what, why, context, learned,
1828             memory_type, importance, tags, topic_key, access_count, revision_count,
1829             duplicate_count, normalized_hash, created_at, updated_at, last_accessed_at, last_seen_at, deleted_at,
1830             deprecated_at, deprecated_reason, supersedes_id, context_inject_count, origin_peer,
1831             is_encrypted, encrypted_for, valid_from, valid_until, provenance
1832             FROM memories WHERE project = ?1 AND deleted_at IS NULL
1833             AND LENGTH(content) < 20
1834             ORDER BY updated_at DESC"
1835        )?;
1836        let rows = stmt.query_map(params![project], Self::row_to_memory)?;
1837        let mut short_memories = Vec::new();
1838        for row in rows {
1839            short_memories.push(row?);
1840        }
1841
1842        // Type distribution
1843        let mut type_distribution = HashMap::new();
1844        let mut stmt = conn.prepare(
1845            "SELECT memory_type, COUNT(*) FROM memories WHERE project = ?1 AND deleted_at IS NULL GROUP BY memory_type"
1846        )?;
1847        let rows = stmt.query_map(params![project], |row| {
1848            Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?))
1849        })?;
1850        for row in rows {
1851            let (k, v) = row?;
1852            type_distribution.insert(k, v);
1853        }
1854
1855        // Average revisions
1856        let avg_revisions: f64 = conn.query_row(
1857            "SELECT COALESCE(AVG(revision_count), 0.0) FROM memories WHERE project = ?1 AND deleted_at IS NULL",
1858            params![project],
1859            |row| row.get(0),
1860        ).unwrap_or(0.0);
1861
1862        // Duplicate groups
1863        let duplicate_groups: u32 = conn.query_row(
1864            "SELECT COUNT(*) FROM memories WHERE project = ?1 AND deleted_at IS NULL AND duplicate_count > 0",
1865            params![project],
1866            |row| row.get(0),
1867        ).unwrap_or(0);
1868
1869        Ok(AuditReport {
1870            stale_memories,
1871            untagged_memories,
1872            short_memories,
1873            type_distribution,
1874            average_revisions: avg_revisions,
1875            duplicate_groups,
1876        })
1877    }
1878
1879    /// Encuentra duplicados semánticos usando embeddings.
1880    pub fn find_duplicates_semantic(
1881        &self,
1882        project: &str,
1883        threshold: f64,
1884        embedding_store: &crate::embeddings::store::EmbeddingStore,
1885    ) -> crate::error::Result<Vec<DuplicateGroup>> {
1886        let all = embedding_store.load_all_for_project(project)?;
1887        if all.len() < 2 {
1888            return Ok(Vec::new());
1889        }
1890
1891        let threshold_f32 = threshold as f32;
1892        let mut adjacency: std::collections::HashMap<usize, Vec<usize>> =
1893            std::collections::HashMap::new();
1894
1895        for i in 0..all.len() {
1896            for j in (i + 1)..all.len() {
1897                let score = crate::embeddings::similarity::cosine_similarity(&all[i].1, &all[j].1);
1898                if score >= threshold_f32 {
1899                    adjacency.entry(i).or_default().push(j);
1900                    adjacency.entry(j).or_default().push(i);
1901                }
1902            }
1903        }
1904
1905        if adjacency.is_empty() {
1906            return Ok(Vec::new());
1907        }
1908
1909        // Transitive closure via BFS
1910        let mut visited = vec![false; all.len()];
1911        let mut groups = Vec::new();
1912
1913        for start in 0..all.len() {
1914            if visited[start] || !adjacency.contains_key(&start) {
1915                continue;
1916            }
1917            let mut queue = std::collections::VecDeque::new();
1918            queue.push_back(start);
1919            visited[start] = true;
1920            let mut component = Vec::new();
1921            let mut max_score = 0.0f32;
1922
1923            while let Some(node) = queue.pop_front() {
1924                component.push(node);
1925                if let Some(neighbors) = adjacency.get(&node) {
1926                    for &neighbor in neighbors {
1927                        if !visited[neighbor] {
1928                            visited[neighbor] = true;
1929                            queue.push_back(neighbor);
1930                        }
1931                        let score = crate::embeddings::similarity::cosine_similarity(
1932                            &all[node].1,
1933                            &all[neighbor].1,
1934                        );
1935                        if score > max_score {
1936                            max_score = score;
1937                        }
1938                    }
1939                }
1940            }
1941
1942            if component.len() >= 2 {
1943                let mut memory_ids = Vec::new();
1944                let mut titles = Vec::new();
1945                for &idx in &component {
1946                    memory_ids.push(all[idx].0.to_string());
1947                    if let Ok(Some(mem)) = self.get(all[idx].0) {
1948                        titles.push(mem.title);
1949                    } else {
1950                        titles.push(String::new());
1951                    }
1952                }
1953                groups.push(DuplicateGroup {
1954                    memory_ids,
1955                    titles,
1956                    cosine_score: max_score,
1957                });
1958            }
1959        }
1960
1961        Ok(groups)
1962    }
1963
1964    /// Registra feedback sobre una memoria.
1965    pub fn add_feedback(
1966        &self,
1967        memory_id: Uuid,
1968        is_useful: bool,
1969        reason: Option<&str>,
1970    ) -> crate::error::Result<i64> {
1971        let conn = self
1972            .conn
1973            .lock()
1974            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
1975        let now = Utc::now().to_rfc3339();
1976        conn.execute(
1977            "INSERT INTO memory_feedback (memory_id, is_useful, reason, created_at)
1978             VALUES (?1, ?2, ?3, ?4)",
1979            params![
1980                memory_id.to_string(),
1981                if is_useful { 1 } else { 0 },
1982                reason,
1983                now
1984            ],
1985        )?;
1986        let id = conn.last_insert_rowid();
1987        tracing::info!(memory_id = %memory_id, feedback_id = id, "added feedback");
1988        Ok(id)
1989    }
1990
1991    /// Marca una memoria como deprecada.
1992    pub fn deprecate(
1993        &self,
1994        memory_id: Uuid,
1995        reason: &str,
1996        supersedes_id: Option<Uuid>,
1997    ) -> crate::error::Result<Memory> {
1998        let conn = self
1999            .conn
2000            .lock()
2001            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2002        let now = Utc::now().to_rfc3339();
2003        conn.execute(
2004            "UPDATE memories SET deprecated_at = ?1, deprecated_reason = ?2, supersedes_id = ?3, updated_at = ?4
2005             WHERE id = ?5 AND deleted_at IS NULL",
2006            params![
2007                now,
2008                reason,
2009                supersedes_id.map(|u| u.to_string()),
2010                now,
2011                memory_id.to_string()
2012            ],
2013        )?;
2014        drop(conn);
2015        tracing::info!(memory_id = %memory_id, "deprecated memory");
2016        self.get(memory_id)?
2017            .ok_or_else(|| crate::error::MnemeError::NotFound(memory_id))
2018    }
2019
2020    /// Obtiene el grafo de conocimiento de un proyecto.
2021    pub fn get_graph(&self, project: &str) -> crate::error::Result<GraphData> {
2022        let conn = self
2023            .conn
2024            .lock()
2025            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2026
2027        let mut nodes = Vec::new();
2028        let mut stmt = conn.prepare(
2029            "SELECT id, title, memory_type, importance FROM memories
2030             WHERE project = ?1 AND deleted_at IS NULL AND deprecated_at IS NULL",
2031        )?;
2032        let rows = stmt.query_map(params![project], |row| {
2033            Ok(GraphNode {
2034                id: row.get(0)?,
2035                title: row.get(1)?,
2036                memory_type: row.get(2)?,
2037                importance: row.get(3)?,
2038            })
2039        })?;
2040        for row in rows {
2041            nodes.push(row?);
2042        }
2043
2044        let mut edges = Vec::new();
2045        let mut stmt = conn.prepare(
2046            "SELECT r.source_id, r.target_id, r.relation_type, r.confidence
2047             FROM memory_relations r
2048             JOIN memories m1 ON r.source_id = m1.id
2049             JOIN memories m2 ON r.target_id = m2.id
2050             WHERE m1.project = ?1 AND m1.deleted_at IS NULL
2051             AND m2.project = ?1 AND m2.deleted_at IS NULL",
2052        )?;
2053        let rows = stmt.query_map(params![project], |row| {
2054            Ok(GraphEdge {
2055                source: row.get(0)?,
2056                target: row.get(1)?,
2057                relation_type: row.get(2)?,
2058                confidence: row.get(3)?,
2059            })
2060        })?;
2061        for row in rows {
2062            edges.push(row?);
2063        }
2064
2065        Ok(GraphData { nodes, edges })
2066    }
2067
2068    /// Genera un resumen ejecutivo de un proyecto o sesión.
2069    pub fn summarize(
2070        &self,
2071        project: &str,
2072        session_id: Option<Uuid>,
2073    ) -> crate::error::Result<SummaryResult> {
2074        let memories = if let Some(sid) = session_id {
2075            let conn = self
2076                .conn
2077                .lock()
2078                .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2079            let memory_ids_json: String = conn.query_row(
2080                "SELECT memory_ids FROM sessions WHERE id = ?1 AND project = ?2",
2081                params![sid.to_string(), project],
2082                |row| row.get(0),
2083            )?;
2084            let ids: Vec<Uuid> = serde_json::from_str(&memory_ids_json).map_err(|e| {
2085                crate::error::MnemeError::Config(format!("invalid session memory_ids: {}", e))
2086            })?;
2087            let mut result = Vec::new();
2088            for id in ids {
2089                if let Some(mem) = self.get(id)? {
2090                    result.push(mem);
2091                }
2092            }
2093            result
2094        } else {
2095            self.list(project, None, None, None, 1000, 0)?
2096        };
2097
2098        let memory_count = memories.len() as u32;
2099        let mut by_type = HashMap::new();
2100        let mut decisions = Vec::new();
2101        let mut bugs = Vec::new();
2102
2103        for mem in &memories {
2104            *by_type.entry(mem.memory_type.to_string()).or_insert(0u32) += 1;
2105            match mem.memory_type {
2106                MemoryType::Decision => decisions.push(mem.title.clone()),
2107                MemoryType::Bugfix => bugs.push(mem.title.clone()),
2108                _ => {}
2109            }
2110        }
2111
2112        let mut summary_parts = vec![format!("Resumen del proyecto '{}'", project)];
2113        summary_parts.push(format!("Total de memorias: {}", memory_count));
2114
2115        if !decisions.is_empty() {
2116            summary_parts.push(format!("\nDecisiones tomadas ({}):", decisions.len()));
2117            for d in decisions {
2118                summary_parts.push(format!("- {}", d));
2119            }
2120        }
2121
2122        if !bugs.is_empty() {
2123            summary_parts.push(format!("\nBugs corregidos ({}):", bugs.len()));
2124            for b in bugs {
2125                summary_parts.push(format!("- {}", b));
2126            }
2127        }
2128
2129        Ok(SummaryResult {
2130            summary: summary_parts.join("\n"),
2131            memory_count,
2132            by_type,
2133        })
2134    }
2135
2136    /// Genera un bloque de contexto formateado para inyección en prompts.
2137    pub fn inject_context(
2138        &self,
2139        project: &str,
2140        file: Option<&str>,
2141        limit: u32,
2142    ) -> crate::error::Result<String> {
2143        let mut lines = vec![
2144            format!("## Contexto del proyecto: {}", project),
2145            String::new(),
2146        ];
2147
2148        // Critical/high importance memories
2149        let critical = self.list(project, None, Some(&Importance::Critical), None, limit, 0)?;
2150        let high = self.list(project, None, Some(&Importance::High), None, limit, 0)?;
2151        let mut important = critical;
2152        important.extend(high);
2153        important.truncate(limit as usize);
2154
2155        if !important.is_empty() {
2156            lines.push("### Decisiones arquitectónicas críticas".to_string());
2157            for mem in &important {
2158                lines.push(format!(
2159                    "- {} ({}): {}",
2160                    mem.title,
2161                    mem.memory_type,
2162                    &mem.content[..mem.content.len().min(120)]
2163                ));
2164            }
2165            lines.push(String::new());
2166        }
2167
2168        // Recent memories related to file
2169        if let Some(file_path) = file {
2170            let related = self.list(project, None, None, None, limit, 0)?;
2171            let file_related: Vec<_> = related
2172                .into_iter()
2173                .filter(|m| {
2174                    m.context
2175                        .as_ref()
2176                        .map(|c| c.contains(file_path))
2177                        .unwrap_or(false)
2178                })
2179                .take(limit as usize)
2180                .collect();
2181            if !file_related.is_empty() {
2182                lines.push("### Memorias recientes relevantes".to_string());
2183                for mem in &file_related {
2184                    lines.push(format!(
2185                        "- {} ({}): {}",
2186                        mem.title,
2187                        mem.importance,
2188                        &mem.content[..mem.content.len().min(120)]
2189                    ));
2190                }
2191                lines.push(String::new());
2192            }
2193        }
2194
2195        // Architecture decisions and conventions
2196        let arch = self.list(
2197            project,
2198            Some(&MemoryType::Architecture),
2199            None,
2200            None,
2201            limit,
2202            0,
2203        )?;
2204        let conventions =
2205            self.list(project, Some(&MemoryType::Convention), None, None, limit, 0)?;
2206        let mut patterns = arch;
2207        patterns.extend(conventions);
2208        patterns.truncate(limit as usize);
2209
2210        if !patterns.is_empty() {
2211            lines.push("### Convenciones y patrones".to_string());
2212            for mem in &patterns {
2213                lines.push(format!(
2214                    "- {}: {}",
2215                    mem.title,
2216                    &mem.content[..mem.content.len().min(120)]
2217                ));
2218            }
2219            lines.push(String::new());
2220        }
2221
2222        // Update context_inject_count
2223        {
2224            let conn = self
2225                .conn
2226                .lock()
2227                .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2228            let ids: Vec<String> = important
2229                .iter()
2230                .chain(patterns.iter())
2231                .map(|m| m.id.to_string())
2232                .collect();
2233            for id in &ids {
2234                let _ = conn.execute(
2235                    "UPDATE memories SET context_inject_count = context_inject_count + 1 WHERE id = ?1",
2236                    params![id],
2237                );
2238            }
2239        }
2240
2241        Ok(lines.join("\n"))
2242    }
2243
2244    /// Elimina todas las memorias de un proyecto (hard delete).
2245    pub fn forget_project(&self, project: &str) -> crate::error::Result<u32> {
2246        let conn = self
2247            .conn
2248            .lock()
2249            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2250        let mut stmt = conn.prepare("SELECT rowid FROM memories WHERE project = ?1")?;
2251        let rows = stmt.query_map(params![project], |row| row.get::<_, i64>(0))?;
2252        let mut rowids = Vec::new();
2253        for row in rows {
2254            rowids.push(row?);
2255        }
2256        for rowid in rowids {
2257            conn.execute("DELETE FROM memories_fts WHERE rowid = ?1", params![rowid])?;
2258        }
2259        let affected = conn.execute("DELETE FROM memories WHERE project = ?1", params![project])?;
2260        tracing::info!(project = project, deleted = affected, "forgot project");
2261        Ok(affected as u32)
2262    }
2263
2264    /// Reporte de salud del sistema.
2265    pub fn health(&self, project: Option<&str>) -> crate::error::Result<HealthReport> {
2266        let conn = self
2267            .conn
2268            .lock()
2269            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2270
2271        let page_count: i64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
2272        let page_size: i64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
2273        let db_size_mb = (page_count * page_size) as f64 / (1024.0 * 1024.0);
2274
2275        let total_memories: u32 = if let Some(proj) = project {
2276            conn.query_row(
2277                "SELECT COUNT(*) FROM memories WHERE project = ?1 AND deleted_at IS NULL",
2278                params![proj],
2279                |row| row.get(0),
2280            )?
2281        } else {
2282            conn.query_row(
2283                "SELECT COUNT(*) FROM memories WHERE deleted_at IS NULL",
2284                [],
2285                |row| row.get(0),
2286            )?
2287        };
2288
2289        // Orphaned memories: those with empty project
2290        let orphaned_memories: u32 = conn.query_row(
2291            "SELECT COUNT(*) FROM memories WHERE (project IS NULL OR project = '') AND deleted_at IS NULL",
2292            [],
2293            |row| row.get(0),
2294        ).unwrap_or(0);
2295
2296        // Unindexed embeddings
2297        let unindexed_embeddings: u32 = conn
2298            .query_row(
2299                "SELECT COUNT(*) FROM memories m
2300             LEFT JOIN memory_embeddings e ON m.id = e.memory_id
2301             WHERE m.deleted_at IS NULL AND e.memory_id IS NULL",
2302                [],
2303                |row| row.get(0),
2304            )
2305            .unwrap_or(0);
2306
2307        // Try to get embedding model
2308        let embedding_model: String = conn
2309            .query_row(
2310                "SELECT model_name FROM memory_embeddings ORDER BY created_at DESC LIMIT 1",
2311                [],
2312                |row| row.get(0),
2313            )
2314            .unwrap_or_else(|_| "unknown".to_string());
2315
2316        Ok(HealthReport {
2317            db_size_mb,
2318            total_memories,
2319            orphaned_memories,
2320            unindexed_embeddings,
2321            last_sync: None,
2322            embedding_model,
2323            version: env!("CARGO_PKG_VERSION").to_string(),
2324        })
2325    }
2326
2327    /// Retorna memorias críticas/high como recordatorios.
2328    pub fn remind(
2329        &self,
2330        project: &str,
2331        importance: &Importance,
2332    ) -> crate::error::Result<Vec<Memory>> {
2333        let conn = self
2334            .conn
2335            .lock()
2336            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2337
2338        let mut sql = String::from(
2339            "SELECT id, project, scope, title, content, what, why, context, learned,
2340             memory_type, importance, tags, topic_key, access_count, revision_count,
2341             duplicate_count, normalized_hash, created_at, updated_at, last_accessed_at, last_seen_at, deleted_at,
2342             deprecated_at, deprecated_reason, supersedes_id, context_inject_count, origin_peer,
2343             is_encrypted, encrypted_for, valid_from, valid_until, provenance
2344             FROM memories WHERE project = ?1 AND deleted_at IS NULL AND deprecated_at IS NULL"
2345        );
2346        let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = vec![Box::new(project.to_string())];
2347
2348        match importance {
2349            Importance::Critical => {
2350                sql.push_str(" AND importance = 'critical'");
2351            }
2352            Importance::High => {
2353                sql.push_str(" AND importance IN ('high', 'critical')");
2354            }
2355            _ => {
2356                sql.push_str(" AND importance = ?2");
2357                params_vec.push(Box::new(importance.to_string()));
2358            }
2359        }
2360
2361        sql.push_str(
2362            " ORDER BY CASE importance WHEN 'critical' THEN 1 WHEN 'high' THEN 2 ELSE 3 END,
2363                      last_accessed_at IS NULL, last_accessed_at ASC LIMIT 50",
2364        );
2365
2366        let param_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
2367
2368        let mut stmt = conn.prepare(&sql)?;
2369        let rows = stmt.query_map(param_refs.as_slice(), Self::row_to_memory)?;
2370
2371        let mut memories = Vec::new();
2372        for row in rows {
2373            memories.push(row?);
2374        }
2375        Ok(memories)
2376    }
2377
2378    /// Sugiere tags basados en tags existentes y contenido.
2379    pub fn suggest_tags(
2380        &self,
2381        project: &str,
2382        title: &str,
2383        content: Option<&str>,
2384    ) -> crate::error::Result<Vec<String>> {
2385        let conn = self
2386            .conn
2387            .lock()
2388            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2389
2390        // Get all tags from project
2391        let mut stmt =
2392            conn.prepare("SELECT tags FROM memories WHERE project = ?1 AND deleted_at IS NULL")?;
2393        let rows = stmt.query_map(params![project], |row| row.get::<_, String>(0))?;
2394
2395        let mut tag_counts: std::collections::HashMap<String, u32> =
2396            std::collections::HashMap::new();
2397        for row in rows {
2398            let tags_json = row?;
2399            let tags: Vec<String> = serde_json::from_str(&tags_json).unwrap_or_default();
2400            for tag in tags {
2401                *tag_counts.entry(tag.to_lowercase()).or_insert(0) += 1;
2402            }
2403        }
2404
2405        // Sort by frequency and take top 20
2406        let mut sorted_tags: Vec<(String, u32)> = tag_counts.into_iter().collect();
2407        sorted_tags.sort_by_key(|b| std::cmp::Reverse(b.1));
2408        let top_tags: Vec<String> = sorted_tags.into_iter().take(20).map(|(t, _)| t).collect();
2409
2410        // Extract keywords from title + content
2411        let text = format!("{} {}", title, content.unwrap_or("")).to_lowercase();
2412        let stopwords: std::collections::HashSet<&str> = [
2413            "a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "have", "has",
2414            "had", "do", "does", "did", "will", "would", "could", "should", "of", "in", "for",
2415            "on", "with", "at", "by", "from", "as", "to", "and", "or", "but",
2416        ]
2417        .iter()
2418        .copied()
2419        .collect();
2420
2421        let words: Vec<String> = text
2422            .split(|c: char| !c.is_alphanumeric())
2423            .filter(|w| w.len() > 2 && !stopwords.contains(w))
2424            .map(|w| w.to_string())
2425            .collect();
2426
2427        // Suggest tags that appear in both top tags and keywords, or are similar
2428        let mut suggestions = Vec::new();
2429        for tag in &top_tags {
2430            if words.iter().any(|w| w.contains(tag) || tag.contains(w)) {
2431                suggestions.push(tag.clone());
2432            }
2433        }
2434
2435        // Also add keywords that look like tags (already exist in top_tags)
2436        for word in words {
2437            if top_tags.contains(&word) && !suggestions.contains(&word) {
2438                suggestions.push(word);
2439            }
2440        }
2441
2442        suggestions.truncate(10);
2443        Ok(suggestions)
2444    }
2445
2446    /// Analiza brechas de conocimiento en un proyecto.
2447    pub fn knowledge_gaps(&self, project: &str) -> crate::error::Result<KnowledgeGapsReport> {
2448        let conn = self
2449            .conn
2450            .lock()
2451            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2452
2453        let total: u32 = conn.query_row(
2454            "SELECT COUNT(*) FROM memories WHERE project = ?1 AND deleted_at IS NULL",
2455            params![project],
2456            |row| row.get(0),
2457        )?;
2458
2459        if total == 0 {
2460            return Ok(KnowledgeGapsReport {
2461                gaps: Vec::new(),
2462                coverage_score: 0.0,
2463            });
2464        }
2465
2466        let mut counts = HashMap::new();
2467        let mut stmt = conn.prepare(
2468            "SELECT memory_type, COUNT(*) FROM memories WHERE project = ?1 AND deleted_at IS NULL GROUP BY memory_type"
2469        )?;
2470        let rows = stmt.query_map(params![project], |row| {
2471            Ok((row.get::<_, String>(0)?, row.get::<_, u32>(1)?))
2472        })?;
2473        for row in rows {
2474            let (k, v) = row?;
2475            counts.insert(k, v);
2476        }
2477
2478        // Ideal distribution percentages
2479        let ideals: std::collections::HashMap<&str, f64> = [
2480            ("architecture", 0.15),
2481            ("decision", 0.15),
2482            ("bugfix", 0.10),
2483            ("pattern", 0.10),
2484            ("convention", 0.10),
2485            ("dependency", 0.05),
2486            ("workflow", 0.05),
2487            ("note", 0.10),
2488            ("config", 0.05),
2489            ("discovery", 0.05),
2490            ("learning", 0.10),
2491            ("agent_fact", 0.05),
2492        ]
2493        .iter()
2494        .copied()
2495        .collect();
2496
2497        let mut gaps = Vec::new();
2498        let mut covered = 0.0;
2499
2500        for (area, ideal_pct) in &ideals {
2501            let count = counts.get(*area).copied().unwrap_or(0);
2502            let actual_pct = f64::from(count) / f64::from(total);
2503            if actual_pct < ideal_pct * 0.5 && count < 5 {
2504                gaps.push(KnowledgeGap {
2505                    area: area.to_string(),
2506                    count,
2507                    suggestion: format!("Considera documentar más items de tipo '{}'", area),
2508                });
2509            } else if actual_pct >= ideal_pct * 0.8 {
2510                covered += ideal_pct;
2511            } else {
2512                covered += ideal_pct * (actual_pct / ideal_pct);
2513            }
2514        }
2515
2516        // Check topic_key coverage
2517        let without_topic_key: u32 = conn.query_row(
2518            "SELECT COUNT(*) FROM memories WHERE project = ?1 AND deleted_at IS NULL AND topic_key IS NULL",
2519            params![project],
2520            |row| row.get(0),
2521        ).unwrap_or(0);
2522
2523        if without_topic_key > total / 2 {
2524            gaps.push(KnowledgeGap {
2525                area: "topic_key".to_string(),
2526                count: without_topic_key,
2527                suggestion:
2528                    "Muchas memorias carecen de topic_key; esto dificulta la evolución organizada"
2529                        .to_string(),
2530            });
2531        }
2532
2533        let coverage_score = (covered / ideals.values().sum::<f64>()).clamp(0.0, 1.0);
2534
2535        Ok(KnowledgeGapsReport {
2536            gaps,
2537            coverage_score,
2538        })
2539    }
2540
2541    /// Encripta una memoria existente in-place.
2542    pub fn encrypt_existing(&self, memory_id: Uuid) -> crate::error::Result<Memory> {
2543        let memory = self
2544            .get(memory_id)?
2545            .ok_or(crate::error::MnemeError::NotFound(memory_id))?;
2546        if memory.is_encrypted {
2547            return Err(crate::error::MnemeError::AlreadyEncrypted(memory_id));
2548        }
2549        let crypto_arc = self
2550            .crypto
2551            .as_ref()
2552            .ok_or(crate::error::MnemeError::NoRecipientsConfigured)?;
2553        let crypto = crypto_arc
2554            .lock()
2555            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2556        if !crypto.has_recipients() {
2557            return Err(crate::error::MnemeError::NoRecipientsConfigured);
2558        }
2559        let enc_content = crypto.encrypt_str(&memory.content)?;
2560        let enc_what = memory
2561            .what
2562            .as_deref()
2563            .map(|s| crypto.encrypt_str(s))
2564            .transpose()?;
2565        let enc_why = memory
2566            .why
2567            .as_deref()
2568            .map(|s| crypto.encrypt_str(s))
2569            .transpose()?;
2570        let enc_ctx = memory
2571            .context
2572            .as_deref()
2573            .map(|s| crypto.encrypt_str(s))
2574            .transpose()?;
2575        let enc_learned = memory
2576            .learned
2577            .as_deref()
2578            .map(|s| crypto.encrypt_str(s))
2579            .transpose()?;
2580        let label = crypto.encrypted_for_label();
2581        drop(crypto);
2582
2583        let conn = self
2584            .conn
2585            .lock()
2586            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2587        conn.execute(
2588            "UPDATE memories SET content=?1, what=?2, why=?3, context=?4, learned=?5, is_encrypted=1, encrypted_for=?6, updated_at=?7 WHERE id=?8",
2589            params![enc_content, enc_what, enc_why, enc_ctx, enc_learned, label, Utc::now().to_rfc3339(), memory_id.to_string()],
2590        )?;
2591        drop(conn);
2592        self.get(memory_id)?
2593            .ok_or(crate::error::MnemeError::NotFound(memory_id))
2594    }
2595
2596    /// Desencripta una memoria encriptada (permanentemente).
2597    pub fn decrypt_existing(&self, memory_id: Uuid) -> crate::error::Result<Memory> {
2598        let memory = self
2599            .get(memory_id)?
2600            .ok_or(crate::error::MnemeError::NotFound(memory_id))?;
2601        if !memory.is_encrypted {
2602            return Err(crate::error::MnemeError::NotEncrypted(memory_id));
2603        }
2604        let conn = self
2605            .conn
2606            .lock()
2607            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2608        conn.execute(
2609            "UPDATE memories SET content=?1, what=?2, why=?3, context=?4, learned=?5, is_encrypted=0, encrypted_for=NULL, updated_at=?6 WHERE id=?7",
2610            params![memory.content, memory.what, memory.why, memory.context, memory.learned, Utc::now().to_rfc3339(), memory_id.to_string()],
2611        )?;
2612        drop(conn);
2613        self.get(memory_id)?
2614            .ok_or(crate::error::MnemeError::NotFound(memory_id))
2615    }
2616
2617    // --- Passive Capture ---
2618
2619    /// Parsea texto de output de sesión y extrae memorias automáticamente.
2620    /// Busca secciones como ## Key Learnings, ## Decisions, ## Architecture, etc.
2621    pub fn capture_passive(
2622        &self,
2623        text: &str,
2624        project: &str,
2625        session_id: Option<Uuid>,
2626        engine: Option<std::sync::Arc<crate::embeddings::engine::EmbeddingEngine>>,
2627        embedding_store: Option<crate::embeddings::store::EmbeddingStore>,
2628    ) -> crate::error::Result<Vec<Memory>> {
2629        let mut saved = Vec::new();
2630
2631        // Parse sections from markdown-style headings
2632        let lines: Vec<&str> = text.lines().collect();
2633        let mut i = 0;
2634        while i < lines.len() {
2635            let line = lines[i].trim();
2636
2637            // Detect markdown headings with known section markers
2638            if let Some(section_type) = Self::detect_section_type(line) {
2639                let title = line.trim_start_matches('#').trim().to_string();
2640                let mut content_parts: Vec<String> = Vec::new();
2641                i += 1;
2642
2643                // Collect content until next heading or end
2644                while i < lines.len() && !lines[i].trim().starts_with('#') && !lines[i].trim().is_empty() {
2645                    content_parts.push(lines[i].to_string());
2646                    i += 1;
2647                }
2648
2649                if content_parts.is_empty() {
2650                    continue;
2651                }
2652
2653                let content = content_parts.join("\n").trim().to_string();
2654                if content.len() < 10 {
2655                    continue;
2656                }
2657
2658                let (memory_type, importance, what, why, context, learned, topic_key) = Self::section_to_metadata(section_type, &title, &content);
2659
2660                let input = CreateMemoryInput {
2661                    project: project.to_string(),
2662                    scope: Some(Scope::Project),
2663                    title: title.clone(),
2664                    content: content.clone(),
2665                    what: what.map(|s| s.to_string()),
2666                    why: why.map(|s| s.to_string()),
2667                    context: context.map(|s| s.to_string()),
2668                    learned: learned.map(|s| s.to_string()),
2669                    memory_type,
2670                    importance,
2671                    tags: Vec::new(),
2672                    topic_key,
2673                    capture_prompt: session_id.map(|_| true),
2674                    encrypt: false,
2675                    valid_from: None,
2676                    valid_until: None,
2677                    provenance: None,
2678                };
2679
2680                match self.save(input, engine.clone(), embedding_store.clone()) {
2681                    Ok(memory) => {
2682                        if let Some(sid) = session_id {
2683                            let session_store = SessionStore::new(self.conn.clone());
2684                            let _ = session_store.add_memory(sid, memory.id);
2685                        }
2686                        saved.push(memory);
2687                    }
2688                    Err(e) => {
2689                        tracing::warn!(section = %title, error = %e, "passive capture save failed");
2690                    }
2691                }
2692            } else {
2693                i += 1;
2694            }
2695        }
2696
2697        tracing::info!(captured = saved.len(), "passive capture complete");
2698        Ok(saved)
2699    }
2700
2701    /// Detecta el tipo de sección basado en el contenido del heading.
2702    fn detect_section_type(line: &str) -> Option<&'static str> {
2703        let lower = line.to_lowercase();
2704        let markers = [
2705            ("key learnings", "learning"),
2706            ("decisions", "decision"),
2707            ("architecture", "architecture"),
2708            ("bugfix", "bugfix"),
2709            ("bug fix", "bugfix"),
2710            ("bugs fixed", "bugfix"),
2711            ("patterns", "pattern"),
2712            ("conventions", "convention"),
2713            ("dependencies", "dependency"),
2714            ("discoveries", "discovery"),
2715            ("discovery", "discovery"),
2716            ("workflow", "workflow"),
2717            ("config changes", "config"),
2718            ("config", "config"),
2719            ("notes", "note"),
2720            ("note", "note"),
2721            ("summary", "note"),
2722        ];
2723        for (keyword, section_type) in &markers {
2724            if lower.contains(keyword) {
2725                return Some(section_type);
2726            }
2727        }
2728        None
2729    }
2730
2731    /// Convierte una sección parseada en metadatos de CreateMemoryInput.
2732    fn section_to_metadata(
2733        section_type: &str,
2734        title: &str,
2735        content: &str,
2736    ) -> (MemoryType, Importance, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>) {
2737        let memory_type = MemoryType::from_str(section_type).unwrap_or(MemoryType::Note);
2738        let importance = match section_type {
2739            "architecture" | "decision" => Importance::High,
2740            "bugfix" => Importance::Medium,
2741            "config" => Importance::Low,
2742            _ => Importance::Medium,
2743        };
2744
2745        // Extract structured fields from content
2746        let (what, why, context, learned) = Self::extract_structured_fields(content);
2747
2748        let topic_key = if !section_type.is_empty() && !title.is_empty() {
2749            let slug = title
2750                .to_lowercase()
2751                .replace(|c: char| !c.is_alphanumeric() && c != ' ', "")
2752                .split_whitespace()
2753                .take(3)
2754                .collect::<Vec<_>>()
2755                .join("-");
2756            Some(format!("{}/{}", section_type, slug))
2757        } else {
2758            None
2759        };
2760
2761        (memory_type, importance, what, why, context, learned, topic_key)
2762    }
2763
2764    /// Extrae campos What/Why/Context/Learned de contenido estructurado.
2765    fn extract_structured_fields(content: &str) -> (Option<String>, Option<String>, Option<String>, Option<String>) {
2766        let mut what = None;
2767        let mut why = None;
2768        let mut context = None;
2769        let mut learned = None;
2770
2771        for line in content.lines() {
2772            let trimmed = line.trim();
2773            if let Some(val) = trimmed.strip_prefix("**What:**").or_else(|| trimmed.strip_prefix("**What:** ")) {
2774                what = Some(val.trim().to_string());
2775            } else if let Some(val) = trimmed.strip_prefix("**Why:**").or_else(|| trimmed.strip_prefix("**Why:** ")) {
2776                why = Some(val.trim().to_string());
2777            } else if let Some(val) = trimmed.strip_prefix("**Context:**").or_else(|| trimmed.strip_prefix("**Context:** ")) {
2778                context = Some(val.trim().to_string());
2779            } else if let Some(val) = trimmed.strip_prefix("**Learned:**").or_else(|| trimmed.strip_prefix("**Learned:** ")) {
2780                learned = Some(val.trim().to_string());
2781            }
2782        }
2783
2784        (what, why, context, learned)
2785    }
2786
2787    // --- Conflict Detection ---
2788
2789    /// Detecta candidatos de conflicto para una memoria recién guardada.
2790    /// Busca por: topic_key compartido, título similar, y mismo project+type.
2791    pub fn detect_conflict_candidates(&self, memory: &Memory) -> crate::error::Result<Vec<ConflictCandidate>> {
2792        let conn = self
2793            .conn
2794            .lock()
2795            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2796        let mut candidates = Vec::new();
2797        let matcher = fuzzy_matcher::skim::SkimMatcherV2::default();
2798
2799        // 1. Check topic_key overlap
2800        if let Some(ref topic_key) = memory.topic_key {
2801            let mut stmt = conn.prepare(
2802                "SELECT id, title, memory_type FROM memories
2803                 WHERE project = ?1 AND topic_key = ?2 AND id != ?3
2804                 AND deleted_at IS NULL AND deprecated_at IS NULL
2805                 LIMIT 5"
2806            )?;
2807            let rows = stmt.query_map(params![memory.project, topic_key, memory.id.to_string()], |row| {
2808                Ok((
2809                    row.get::<_, String>(0)?,
2810                    row.get::<_, String>(1)?,
2811                    row.get::<_, String>(2)?,
2812                ))
2813            })?;
2814            for row in rows {
2815                let (id_str, _title, _type) = row?;
2816                if let Ok(target_id) = Uuid::parse_str(&id_str) {
2817                    candidates.push(ConflictCandidate {
2818                        id: 0,
2819                        source_id: memory.id,
2820                        target_id,
2821                        reason: format!("Same topic_key: '{}'", topic_key),
2822                        match_score: 0.7,
2823                        candidate_type: "topic_key".to_string(),
2824                        judgment_status: "pending".to_string(),
2825                        judged_relation: None,
2826                        judged_reason: None,
2827                        created_at: Utc::now(),
2828                    });
2829                }
2830            }
2831        }
2832
2833        // 2. Check title similarity (fuzzy match >= 80)
2834        {
2835            let mut stmt = conn.prepare(
2836                "SELECT id, title FROM memories
2837                 WHERE project = ?1 AND id != ?2
2838                 AND deleted_at IS NULL AND deprecated_at IS NULL
2839                 LIMIT 50"
2840            )?;
2841            let rows = stmt.query_map(params![memory.project, memory.id.to_string()], |row| {
2842                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2843            })?;
2844            for row in rows {
2845                let (id_str, title) = row?;
2846                if let (Ok(target_id), Some(score)) = (Uuid::parse_str(&id_str), matcher.fuzzy_match(&title, &memory.title)) {
2847                    let normalized = (score as f32).abs() / 100.0;
2848                    if normalized >= 0.80 {
2849                        candidates.push(ConflictCandidate {
2850                            id: 0,
2851                            source_id: memory.id,
2852                            target_id,
2853                            reason: format!("Similar title: '{}' vs '{}' (score: {:.2})", memory.title, title, normalized),
2854                            match_score: normalized,
2855                            candidate_type: "title".to_string(),
2856                            judgment_status: "pending".to_string(),
2857                            judged_relation: None,
2858                            judged_reason: None,
2859                            created_at: Utc::now(),
2860                        });
2861                    }
2862                }
2863            }
2864        }
2865
2866        // Deduplicate by (source_id, target_id, candidate_type)
2867        let mut seen = std::collections::HashSet::new();
2868        candidates.retain(|c| seen.insert((c.source_id, c.target_id, c.candidate_type.clone())));
2869
2870        // Save to DB
2871        for c in &candidates {
2872            conn.execute(
2873                "INSERT OR IGNORE INTO relation_candidates
2874                 (source_id, target_id, reason, match_score, candidate_type, judgment_status, created_at)
2875                 VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6)",
2876                params![
2877                    c.source_id.to_string(),
2878                    c.target_id.to_string(),
2879                    c.reason,
2880                    c.match_score,
2881                    c.candidate_type,
2882                    Utc::now().to_rfc3339(),
2883                ],
2884            )?;
2885        }
2886
2887        Ok(candidates)
2888    }
2889
2890    /// Lista candidatos de conflicto pendientes.
2891    pub fn list_conflict_candidates(
2892        &self,
2893        project: &str,
2894        status: Option<&str>,
2895        limit: u32,
2896    ) -> crate::error::Result<Vec<ConflictCandidate>> {
2897        let conn = self
2898            .conn
2899            .lock()
2900            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2901
2902        let status_filter = status.unwrap_or("pending");
2903        let limit_i64 = limit as i64;
2904
2905        let mut stmt = conn.prepare(
2906            "SELECT c.id, c.source_id, c.target_id, c.reason, c.match_score, c.candidate_type,
2907                    c.judgment_status, c.judged_relation, c.judged_reason, c.created_at
2908             FROM relation_candidates c
2909             JOIN memories m1 ON m1.id = c.source_id
2910             JOIN memories m2 ON m2.id = c.target_id
2911             WHERE c.judgment_status = ?1 AND m1.project = ?2 AND m1.deleted_at IS NULL AND m2.deleted_at IS NULL
2912             ORDER BY c.match_score DESC
2913             LIMIT ?3"
2914        )?;
2915
2916        let rows = stmt.query_map(params![status_filter, project, limit_i64], |row| {
2917            Ok(ConflictCandidate {
2918                id: row.get(0)?,
2919                source_id: Uuid::parse_str(&row.get::<_, String>(1)?).map_err(|e| {
2920                    rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, Box::new(e))
2921                })?,
2922                target_id: Uuid::parse_str(&row.get::<_, String>(2)?).map_err(|e| {
2923                    rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Text, Box::new(e))
2924                })?,
2925                reason: row.get(3)?,
2926                match_score: row.get(4)?,
2927                candidate_type: row.get(5)?,
2928                judgment_status: row.get(6)?,
2929                judged_relation: row.get(7)?,
2930                judged_reason: row.get(8)?,
2931                created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(9)?)
2932                    .map_err(|e| {
2933                        rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, Box::new(e))
2934                    })?
2935                    .with_timezone(&Utc),
2936            })
2937        })?;
2938
2939        let mut candidates = Vec::new();
2940        for row in rows {
2941            candidates.push(row?);
2942        }
2943        Ok(candidates)
2944    }
2945
2946    /// Registra el juicio de un LLM/agente sobre un candidato.
2947    /// Si el juicio es conflicts_with/supersedes, actualiza la relación automáticamente.
2948    pub fn judge_conflict(
2949        &self,
2950        candidate_id: i64,
2951        judged_relation: &str,
2952        reasoning: &str,
2953        judged_by: &str,
2954    ) -> crate::error::Result<ConflictJudgment> {
2955        let conn = self
2956            .conn
2957            .lock()
2958            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
2959
2960        // Get candidate details
2961        let (source_id_str, target_id_str): (String, String) = conn.query_row(
2962            "SELECT source_id, target_id FROM relation_candidates WHERE id = ?1",
2963            params![candidate_id],
2964            |row| Ok((row.get(0)?, row.get(1)?)),
2965        )?;
2966
2967        let source_id = Uuid::parse_str(&source_id_str)
2968            .map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
2969        let target_id = Uuid::parse_str(&target_id_str)
2970            .map_err(|e| crate::error::MnemeError::Config(e.to_string()))?;
2971        let now = Utc::now().to_rfc3339();
2972
2973        // Update candidate status
2974        conn.execute(
2975            "UPDATE relation_candidates SET judgment_status = 'judged', judged_relation = ?1, judged_reason = ?2, judged_at = ?3
2976             WHERE id = ?4",
2977            params![judged_relation, reasoning, now, candidate_id],
2978        )?;
2979
2980        // Record the judgment
2981        conn.execute(
2982            "INSERT INTO conflict_judgments (candidate_id, memory_id_a, memory_id_b, proposed_relation, confidence, reasoning, judged_by, created_at)
2983             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
2984            params![
2985                candidate_id,
2986                source_id_str,
2987                target_id_str,
2988                judged_relation,
2989                1.0,
2990                reasoning,
2991                judged_by,
2992                now,
2993            ],
2994        )?;
2995        let judgment_id = conn.last_insert_rowid();
2996
2997        // If relation is conflicts_with, supersedes, or extends — create the actual relation
2998        if judged_relation == "conflicts_with" || judged_relation == "supersedes" || judged_relation == "extends" {
2999            let relation_type = match judged_relation {
3000                "conflicts_with" => RelationType::ConflictsWith,
3001                "supersedes" => RelationType::Supersedes,
3002                "extends" => RelationType::Extends,
3003                _ => RelationType::ConflictsWith,
3004            };
3005
3006            // Check if relation already exists
3007            let existing: u32 = conn.query_row(
3008                "SELECT COUNT(*) FROM memory_relations WHERE source_id = ?1 AND target_id = ?2",
3009                params![source_id_str, target_id_str],
3010                |row| row.get(0),
3011            ).unwrap_or(0);
3012
3013            if existing == 0 {
3014                let rel_id = Uuid::new_v4();
3015                conn.execute(
3016                    "INSERT INTO memory_relations (id, sync_id, source_id, target_id, relation_type, confidence, judgment_status, reason, evidence, marked_by_actor, created_at, updated_at)
3017                     VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', ?7, ?8, ?9, ?10, ?11)",
3018                    params![
3019                        rel_id.to_string(),
3020                        rel_id.to_string(),
3021                        source_id_str,
3022                        target_id_str,
3023                        relation_type.to_string(),
3024                        1.0f32,
3025                        reasoning,
3026                        serde_json::to_string(&vec![reasoning])?,
3027                        judged_by,
3028                        now,
3029                        now,
3030                    ],
3031                )?;
3032            }
3033
3034            // If supersedes, mark old memory as deprecated
3035            if judged_relation == "supersedes" {
3036                conn.execute(
3037                    "UPDATE memories SET deprecated_at = ?1, deprecated_reason = ?2, supersedes_id = ?3, updated_at = ?4
3038                     WHERE id = ?5 AND deleted_at IS NULL AND deprecated_at IS NULL",
3039                    params![now, reasoning, source_id_str, now, target_id_str],
3040                )?;
3041            }
3042        }
3043
3044        Ok(ConflictJudgment {
3045            id: judgment_id,
3046            candidate_id,
3047            memory_id_a: source_id,
3048            memory_id_b: target_id,
3049            proposed_relation: judged_relation.to_string(),
3050            confidence: 1.0,
3051            reasoning: Some(reasoning.to_string()),
3052            evidence: None,
3053            judged_by: judged_by.to_string(),
3054            created_at: Utc::now(),
3055        })
3056    }
3057
3058    /// Obtiene contexto formateado para que un LLM juzgue un par de memorias.
3059    /// Obtiene las relaciones existentes entre dos memorias.
3060    pub fn get_existing_relations(&self, memory_id_a: Uuid, memory_id_b: Uuid) -> crate::error::Result<Vec<MemoryRelation>> {
3061        let conn = self
3062            .conn
3063            .lock()
3064            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
3065        let mut stmt = conn.prepare(
3066            "SELECT id, sync_id, source_id, target_id, relation_type, confidence, judgment_status,
3067                    reason, evidence, marked_by_actor, created_at, updated_at
3068             FROM memory_relations
3069             WHERE (source_id = ?1 AND target_id = ?2) OR (source_id = ?2 AND target_id = ?1)
3070             LIMIT 10"
3071        )?;
3072        let rows = stmt.query_map(params![memory_id_a.to_string(), memory_id_b.to_string()], |row| {
3073            Ok(MemoryRelation {
3074                id: Uuid::parse_str(&row.get::<_, String>(0)?).map_err(|e| {
3075                    rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
3076                })?,
3077                sync_id: row.get(1)?,
3078                source_id: Uuid::parse_str(&row.get::<_, String>(2)?).map_err(|e| {
3079                    rusqlite::Error::FromSqlConversionFailure(2, rusqlite::types::Type::Text, Box::new(e))
3080                })?,
3081                target_id: Uuid::parse_str(&row.get::<_, String>(3)?).map_err(|e| {
3082                    rusqlite::Error::FromSqlConversionFailure(3, rusqlite::types::Type::Text, Box::new(e))
3083                })?,
3084                relation_type: std::str::FromStr::from_str(&row.get::<_, String>(4)?).map_err(|e: crate::error::MnemeError| {
3085                    rusqlite::Error::FromSqlConversionFailure(4, rusqlite::types::Type::Text, Box::new(e))
3086                })?,
3087                confidence: row.get(5)?,
3088                judgment_status: row.get(6)?,
3089                reason: row.get(7)?,
3090                evidence: row.get(8)?,
3091                marked_by_actor: row.get(9)?,
3092                created_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(10)?)
3093                    .map_err(|e| {
3094                        rusqlite::Error::FromSqlConversionFailure(10, rusqlite::types::Type::Text, Box::new(e))
3095                    })?
3096                    .with_timezone(&Utc),
3097                updated_at: chrono::DateTime::parse_from_rfc3339(&row.get::<_, String>(11)?)
3098                    .map_err(|e| {
3099                        rusqlite::Error::FromSqlConversionFailure(11, rusqlite::types::Type::Text, Box::new(e))
3100                    })?
3101                    .with_timezone(&Utc),
3102            })
3103        })?;
3104        let mut relations = Vec::new();
3105        for row in rows {
3106            relations.push(row?);
3107        }
3108        Ok(relations)
3109    }
3110
3111    pub fn get_conflict_context(&self, source_id: Uuid, target_id: Uuid) -> crate::error::Result<String> {
3112        let source = self.get(source_id)?.ok_or_else(|| crate::error::MnemeError::NotFound(source_id))?;
3113        let target = self.get(target_id)?.ok_or_else(|| crate::error::MnemeError::NotFound(target_id))?;
3114
3115        Ok(format!(
3116            r#"## Memoria A (existente)
3117- **ID:** {}
3118- **Título:** {}
3119- **Tipo:** {}
3120- **Importancia:** {}
3121- **Contenido:** {}
3122
3123## Memoria B (nueva)
3124- **ID:** {}
3125- **Título:** {}
3126- **Tipo:** {}
3127- **Importancia:** {}
3128- **Contenido:** {}
3129
3130## Tarea
3131Analiza si la Memoria B **conflicta**, **extiende**, **reemplaza (supersedes)** o es **compatible** con la Memoria A.
3132Responde con una de estas relaciones: `compatible`, `conflicts_with`, `supersedes`, `extends`, `depends_on`
3133Provee una razón breve.
3134"#,
3135            source.id, source.title, source.memory_type, source.importance, &source.content[..source.content.len().min(300)],
3136            target.id, target.title, target.memory_type, target.importance, &target.content[..target.content.len().min(300)],
3137        ))
3138    }
3139}
3140
3141/// Candidato de conflicto detectado automáticamente.
3142#[derive(Debug, Clone, Serialize, Deserialize)]
3143pub struct ConflictCandidate {
3144    pub id: i64,
3145    pub source_id: Uuid,
3146    pub target_id: Uuid,
3147    pub reason: String,
3148    pub match_score: f32,
3149    pub candidate_type: String,
3150    pub judgment_status: String,
3151    pub judged_relation: Option<String>,
3152    pub judged_reason: Option<String>,
3153    pub created_at: DateTime<Utc>,
3154}
3155
3156/// Juicio de conflicto realizado por el LLM.
3157#[derive(Debug, Clone, Serialize, Deserialize)]
3158pub struct ConflictJudgment {
3159    pub id: i64,
3160    pub candidate_id: i64,
3161    pub memory_id_a: Uuid,
3162    pub memory_id_b: Uuid,
3163    pub proposed_relation: String,
3164    pub confidence: f32,
3165    pub reasoning: Option<String>,
3166    pub evidence: Option<String>,
3167    pub judged_by: String,
3168    pub created_at: DateTime<Utc>,
3169}
3170
3171/// Store para operaciones de sesión.
3172#[allow(dead_code)]
3173pub struct SessionStore {
3174    conn: Arc<Mutex<Connection>>,
3175}
3176
3177impl SessionStore {
3178    /// Crea un nuevo SessionStore.
3179    pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
3180        Self { conn }
3181    }
3182
3183    /// Start a new session.
3184    pub fn start(&self, project: &str, directory: Option<&str>) -> crate::error::Result<Session> {
3185        let id = Uuid::new_v4();
3186        let now = Utc::now();
3187        let conn = self
3188            .conn
3189            .lock()
3190            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
3191        conn.execute(
3192            "INSERT INTO sessions (id, project, directory, summary, memory_ids, started_at, ended_at, status)
3193             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
3194            params![
3195                id.to_string(),
3196                project,
3197                directory,
3198                Option::<String>::None,
3199                "[]",
3200                now.to_rfc3339(),
3201                Option::<String>::None,
3202                "active"
3203            ],
3204        )?;
3205
3206        tracing::info!("started session: {} for project: {}", id, project);
3207        Ok(Session {
3208            id,
3209            project: project.to_string(),
3210            directory: directory.map(|s| s.to_string()),
3211            summary: None,
3212            memory_ids: Vec::new(),
3213            started_at: now,
3214            ended_at: None,
3215            status: "active".to_string(),
3216        })
3217    }
3218
3219    /// End a session.
3220    pub fn end(&self, session_id: Uuid, summary: Option<&str>) -> crate::error::Result<Session> {
3221        let now = Utc::now();
3222        {
3223            let conn = self
3224                .conn
3225                .lock()
3226                .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
3227            conn.execute(
3228                "UPDATE sessions SET ended_at = ?1, summary = ?2, status = ?3 WHERE id = ?4",
3229                params![now.to_rfc3339(), summary, "ended", session_id.to_string()],
3230            )?;
3231        }
3232
3233        tracing::info!("ended session: {}", session_id);
3234        self.get(session_id)?
3235            .ok_or_else(|| crate::error::MnemeError::NotFound(session_id))
3236    }
3237
3238    /// Add a memory to a session.
3239    pub fn add_memory(&self, session_id: Uuid, memory_id: Uuid) -> crate::error::Result<()> {
3240        let conn = self
3241            .conn
3242            .lock()
3243            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
3244        let memory_ids_json: String = conn.query_row(
3245            "SELECT memory_ids FROM sessions WHERE id = ?1",
3246            params![session_id.to_string()],
3247            |row| row.get(0),
3248        )?;
3249
3250        let mut memory_ids: Vec<String> = serde_json::from_str(&memory_ids_json)?;
3251        memory_ids.push(memory_id.to_string());
3252        let updated = serde_json::to_string(&memory_ids)?;
3253
3254        conn.execute(
3255            "UPDATE sessions SET memory_ids = ?1 WHERE id = ?2",
3256            params![updated, session_id.to_string()],
3257        )?;
3258
3259        tracing::debug!("added memory {} to session {}", memory_id, session_id);
3260        Ok(())
3261    }
3262
3263    /// Get the active session for a project.
3264    pub fn get_active(&self, project: &str) -> crate::error::Result<Option<Session>> {
3265        let conn = self
3266            .conn
3267            .lock()
3268            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
3269        let result = conn.query_row(
3270            "SELECT id, project, directory, summary, memory_ids, started_at, ended_at, status
3271             FROM sessions WHERE project = ?1 AND status = 'active' ORDER BY started_at DESC LIMIT 1",
3272            params![project],
3273            |row| self.row_to_session(row),
3274        );
3275
3276        match result {
3277            Ok(session) => Ok(Some(session)),
3278            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
3279            Err(e) => Err(e.into()),
3280        }
3281    }
3282
3283    /// Get a session by ID.
3284    pub fn get(&self, session_id: Uuid) -> crate::error::Result<Option<Session>> {
3285        let conn = self
3286            .conn
3287            .lock()
3288            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
3289        let result = conn.query_row(
3290            "SELECT id, project, directory, summary, memory_ids, started_at, ended_at, status
3291             FROM sessions WHERE id = ?1",
3292            params![session_id.to_string()],
3293            |row| self.row_to_session(row),
3294        );
3295
3296        match result {
3297            Ok(session) => Ok(Some(session)),
3298            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
3299            Err(e) => Err(e.into()),
3300        }
3301    }
3302
3303    /// List sessions for a project.
3304    pub fn list(&self, project: &str, limit: u32) -> crate::error::Result<Vec<Session>> {
3305        let conn = self
3306            .conn
3307            .lock()
3308            .map_err(|_| crate::error::MnemeError::Config("mutex poisoned".into()))?;
3309        let mut stmt = conn.prepare(
3310            "SELECT id, project, directory, summary, memory_ids, started_at, ended_at, status
3311             FROM sessions WHERE project = ?1 ORDER BY started_at DESC LIMIT ?",
3312        )?;
3313        let limit_i64 = limit as i64;
3314        let rows = stmt.query_map(params![project, limit_i64], |row| self.row_to_session(row))?;
3315
3316        let mut sessions = Vec::new();
3317        for row in rows {
3318            sessions.push(row?);
3319        }
3320        Ok(sessions)
3321    }
3322
3323    fn row_to_session(&self, row: &rusqlite::Row) -> Result<Session, rusqlite::Error> {
3324        Ok(Session {
3325            id: Uuid::parse_str(&row.get::<_, String>(0)?).map_err(|e| {
3326                rusqlite::Error::FromSqlConversionFailure(
3327                    0,
3328                    rusqlite::types::Type::Text,
3329                    Box::new(e),
3330                )
3331            })?,
3332            project: row.get(1)?,
3333            directory: row.get(2)?,
3334            summary: row.get(3)?,
3335            memory_ids: serde_json::from_str(&row.get::<_, String>(4)?).unwrap_or_default(),
3336            started_at: DateTime::parse_from_rfc3339(&row.get::<_, String>(5)?)
3337                .map_err(|e| {
3338                    rusqlite::Error::FromSqlConversionFailure(
3339                        5,
3340                        rusqlite::types::Type::Text,
3341                        Box::new(e),
3342                    )
3343                })?
3344                .with_timezone(&Utc),
3345            ended_at: row
3346                .get::<_, Option<String>>(6)?
3347                .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
3348                .map(|d| d.with_timezone(&Utc)),
3349            status: row.get(7)?,
3350        })
3351    }
3352}