Skip to main content

microscope_memory/
pipeline.rs

1//! Canonical memory store pipeline.
2//!
3//! One unified path for ALL memory store entrypoints:
4//!   - Microscope CLI
5//!   - Hope CLI
6//!   - Hope server
7//!   - Bridge
8//!   - Future agent calls
9//!
10//! Pipeline steps:
11//!   1. Normalize input
12//!   2. Structural emotion extraction
13//!   3. Persistence (append log + layer file)
14//!   4. Timeline log
15//!   5. Emotion log
16//!   6. Auto-rebuild check
17//!
18//! This eliminates the architectural drift where different entrypoints
19//! had different store semantics (some with emotion, some without).
20
21use crate::config::Config;
22use crate::emotion_extraction::extract_emotion;
23use crate::reader::store_memory_with_status;
24
25/// Canonical store pipeline result.
26#[derive(Debug, Clone)]
27pub struct StoreResult {
28    pub stored: bool,
29    pub emotion_extracted: bool,
30    pub emotion_event: String,
31    pub message: String,
32}
33
34/// The single canonical memory store pipeline.
35///
36/// All entrypoints MUST use this function instead of calling
37/// `store_memory` or `store_memory_with_emotion` directly.
38///
39/// This ensures:
40/// - Emotion extraction always runs
41/// - Persistence semantics are identical
42/// - Timeline logging is consistent
43/// - No silent omission of emotion data
44pub fn store_memory_pipeline(
45    config: &Config,
46    text: &str,
47    layer: &str,
48    importance: u8,
49) -> Result<StoreResult, String> {
50    // 1. Normalize: trim, ensure non-empty
51    let normalized = text.trim();
52    if normalized.is_empty() {
53        return Err("Cannot store empty text".to_string());
54    }
55
56    let imp = importance.clamp(1, 10);
57
58    // 2. Structural emotion extraction
59    let extraction = extract_emotion(normalized);
60    let emotion_vector = extraction.pad.to_21d();
61    let emotion_event = format!("{:?}", extraction.event);
62
63    // 3-6. Persistence + timeline + emotion log + auto-rebuild
64    store_memory_with_status(config, normalized, layer, imp, None, Some(emotion_vector))?;
65
66    Ok(StoreResult {
67        stored: true,
68        emotion_extracted: true,
69        emotion_event: emotion_event.clone(),
70        message: format!(
71            "STORED | layer: {} | importance: {} | emotion: {}",
72            layer, imp, emotion_event
73        ),
74    })
75}
76
77/// Pipeline with status (for open loops, resolved, archived).
78pub fn store_memory_pipeline_with_status(
79    config: &Config,
80    text: &str,
81    layer: &str,
82    importance: u8,
83    status: Option<&str>,
84) -> Result<StoreResult, String> {
85    let normalized = text.trim();
86    if normalized.is_empty() {
87        return Err("Cannot store empty text".to_string());
88    }
89
90    let imp = importance.clamp(1, 10);
91
92    let extraction = extract_emotion(normalized);
93    let emotion_vector = extraction.pad.to_21d();
94    let emotion_event = format!("{:?}", extraction.event);
95
96    store_memory_with_status(config, normalized, layer, imp, status, Some(emotion_vector))?;
97
98    Ok(StoreResult {
99        stored: true,
100        emotion_extracted: true,
101        emotion_event: emotion_event.clone(),
102        message: format!(
103            "STORED | layer: {} | importance: {} | status: {} | emotion: {}",
104            layer,
105            imp,
106            status.unwrap_or("normal"),
107            emotion_event
108        ),
109    })
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    fn test_config() -> Config {
117        let dir = std::env::temp_dir().join("hope-pipeline-test");
118        std::fs::create_dir_all(&dir).ok();
119        let layers = dir.join("layers");
120        std::fs::create_dir_all(&layers).ok();
121        let mut cfg = Config::default();
122        cfg.paths.output_dir = dir.to_string_lossy().to_string();
123        cfg.paths.temp_dir = dir.join("tmp").to_string_lossy().to_string();
124        // layers_dir must be redirected too. The store path resolves the layer
125        // file as layers_dir/<layer>.txt, and the default is "./layers"
126        // relative to the process CWD -- which during `cargo test` is the
127        // repository root. Without this, running the suite appended test
128        // strings to the real layers/session.txt on every run, silently
129        // polluting the committed corpus.
130        cfg.paths.layers_dir = layers.to_string_lossy().to_string();
131        cfg
132    }
133
134    #[test]
135    fn test_pipeline_stores_with_emotion() {
136        let cfg = test_config();
137        let result = store_memory_pipeline(
138            &cfg,
139            "Sikeresen lefordult a projekt, minden teszt zöld!",
140            "session",
141            6,
142        );
143        assert!(result.is_ok());
144        let r = result.unwrap();
145        assert!(r.stored);
146        assert!(r.emotion_extracted);
147        assert!(!r.emotion_event.is_empty());
148    }
149
150    #[test]
151    fn test_pipeline_rejects_empty() {
152        let cfg = test_config();
153        assert!(store_memory_pipeline(&cfg, "", "session", 5).is_err());
154        assert!(store_memory_pipeline(&cfg, "   ", "session", 5).is_err());
155    }
156
157    #[test]
158    fn test_pipeline_clamps_importance() {
159        let cfg = test_config();
160        let r = store_memory_pipeline(&cfg, "test text", "session", 0).unwrap();
161        assert!(r.message.contains("importance: 1"));
162        let r2 = store_memory_pipeline(&cfg, "test text 2", "session", 255).unwrap();
163        assert!(r2.message.contains("importance: 10"));
164    }
165}