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#[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 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#[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 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#[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#[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#[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 pub valid_from: Option<DateTime<Utc>>,
241 pub valid_until: Option<DateTime<Utc>>,
243 pub provenance: Option<String>,
245}
246
247#[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#[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#[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#[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#[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#[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#[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 pub valid_from: Option<DateTime<Utc>>,
342 pub valid_until: Option<DateTime<Utc>>,
344 pub provenance: Option<String>,
346}
347
348#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct ReindexStats {
409 pub total: u32,
411 pub indexed: u32,
413 pub skipped: u32,
415 pub failed: u32,
417 pub duration_ms: u64,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct AuditReport {
424 pub stale_memories: Vec<Memory>,
426 pub untagged_memories: Vec<Memory>,
428 pub short_memories: Vec<Memory>,
430 pub type_distribution: HashMap<String, u32>,
432 pub average_revisions: f64,
434 pub duplicate_groups: u32,
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct DuplicateGroup {
441 pub memory_ids: Vec<String>,
443 pub titles: Vec<String>,
445 pub cosine_score: f32,
447}
448
449#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct GraphNode {
452 pub id: String,
454 pub title: String,
456 pub memory_type: String,
458 pub importance: String,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize)]
464pub struct GraphEdge {
465 pub source: String,
467 pub target: String,
469 pub relation_type: String,
471 pub confidence: f32,
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct GraphData {
478 pub nodes: Vec<GraphNode>,
480 pub edges: Vec<GraphEdge>,
482}
483
484#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct SummaryResult {
487 pub summary: String,
489 pub memory_count: u32,
491 pub by_type: HashMap<String, u32>,
493}
494
495#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct HealthReport {
498 pub db_size_mb: f64,
500 pub total_memories: u32,
502 pub orphaned_memories: u32,
504 pub unindexed_embeddings: u32,
506 pub last_sync: Option<String>,
508 pub embedding_model: String,
510 pub version: String,
512}
513
514#[derive(Debug, Clone, Serialize, Deserialize)]
516pub struct KnowledgeGap {
517 pub area: String,
519 pub count: u32,
521 pub suggestion: String,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize)]
527pub struct KnowledgeGapsReport {
528 pub gaps: Vec<KnowledgeGap>,
530 pub coverage_score: f64,
532}
533
534#[allow(dead_code)]
536pub struct MemoryStore {
537 conn: Arc<Mutex<Connection>>,
538 crypto: Option<Arc<Mutex<crate::crypto::CryptoEngine>>>,
539}
540
541impl MemoryStore {
542 pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
544 Self { conn, crypto: None }
545 }
546
547 pub fn with_crypto(mut self, crypto: Arc<Mutex<crate::crypto::CryptoEngine>>) -> Self {
549 self.crypto = Some(crypto);
550 self
551 }
552
553 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 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 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 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 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 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()), input.valid_until.map(|d| d.to_rfc3339()), input.provenance, ],
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 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 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 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 if input.valid_from.is_some() {
915 }
917
918 tracing::info!("saved new memory: {}", id);
919 self.get(id)?
920 .ok_or_else(|| crate::error::MnemeError::NotFound(id))
921 }
922
923 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 {
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 {
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 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 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 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 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 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 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 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 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 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 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 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#[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#[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#[allow(dead_code)]
3173pub struct SessionStore {
3174 conn: Arc<Mutex<Connection>>,
3175}
3176
3177impl SessionStore {
3178 pub fn new(conn: Arc<Mutex<Connection>>) -> Self {
3180 Self { conn }
3181 }
3182
3183 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 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 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 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 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 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}