Skip to main content

oxios_kernel/
persistence_hook.rs

1//! PersistenceHook — autonomous persistence after agent execution.
2//!
3//! Two-layer evaluation:
4//! 1. Heuristic: detect markdown documents → auto-save to knowledge (no LLM call)
5//! 2. LLM Reflection: extract facts/preferences → memory, detect missed knowledge saves
6
7use std::sync::Arc;
8
9use anyhow::Result;
10use serde::{Deserialize, Serialize};
11
12use crate::engine::EngineHandle;
13use crate::event_bus::{EventBus, KernelEvent};
14use crate::memory::{MemoryEntry, MemoryManager, MemoryType, content_hash};
15use crate::state_store::StateStore;
16use oxios_markdown::KnowledgeBase;
17use oxios_markdown::types::{NoteMeta, NoteQuality, NoteSource};
18use oxios_memory::memory::sona::TrajectoryStep;
19use oxios_ouroboros::Seed;
20
21/// A planned write to the knowledge vault.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct KnowledgeWrite {
24    /// Path within the knowledge base (e.g. "notes/rust-design.md").
25    pub path: String,
26    /// Markdown content to write.
27    pub content: String,
28    /// Provenance metadata (RFC-022).
29    #[serde(default = "default_knowledge_meta")]
30    pub meta: NoteMeta,
31}
32
33fn default_knowledge_meta() -> NoteMeta {
34    NoteMeta {
35        author: "agent".to_string(),
36        source: NoteSource::Hook,
37        quality: NoteQuality::Raw,
38        needs_review: true,
39        session_id: None,
40        message_index: None,
41        saved_at: None,
42    }
43}
44
45/// A planned write to agent memory.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct MemoryWrite {
48    /// Memory content.
49    pub content: String,
50    /// Memory type: "fact" or "episode".
51    #[serde(rename = "type")]
52    pub memory_type: String,
53    /// Importance score 0.0–1.0.
54    pub importance: f32,
55    /// Optional tags.
56    #[serde(default)]
57    pub tags: Vec<String>,
58}
59
60/// Result of evaluating an execution for persistence.
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct PersistencePlan {
63    /// Memory entries to persist.
64    pub memory: Vec<MemoryWrite>,
65    /// Knowledge notes to persist.
66    pub knowledge: Vec<KnowledgeWrite>,
67}
68
69/// Knowledge save record for a session message.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct KnowledgeSaveRecord {
72    /// Index of the message in the session.
73    pub message_index: usize,
74    /// Path within the knowledge base.
75    pub knowledge_path: String,
76    /// ISO 8601 timestamp.
77    pub saved_at: String,
78    /// How the save was triggered: "hook", "user", "tool".
79    pub source: String,
80}
81
82/// Autonomous persistence hook.
83///
84/// Evaluates agent output after execution and decides what to persist
85/// to memory and/or knowledge, using heuristic rules first and an
86/// optional LLM reflection pass for ambiguous cases.
87pub struct PersistenceHook {
88    memory_manager: Arc<MemoryManager>,
89    knowledge_base: Arc<KnowledgeBase>,
90    engine_handle: Arc<EngineHandle>,
91    state_store: Arc<StateStore>,
92    event_bus: EventBus,
93}
94
95impl PersistenceHook {
96    /// Create a new persistence hook.
97    ///
98    /// The reflection model is resolved live from `engine_handle` on each
99    /// reflection, so it tracks hot-swaps — same source of truth as interview
100    /// and execute.
101    pub fn new(
102        memory_manager: Arc<MemoryManager>,
103        knowledge_base: Arc<KnowledgeBase>,
104        engine_handle: Arc<EngineHandle>,
105        state_store: Arc<StateStore>,
106        event_bus: EventBus,
107    ) -> Self {
108        Self {
109            memory_manager,
110            knowledge_base,
111            engine_handle,
112            state_store,
113            event_bus,
114        }
115    }
116
117    /// Evaluate an execution and produce a persistence plan.
118    ///
119    /// `already_saved_knowledge` = true if tool-calling already did a knowledge write.
120    pub async fn evaluate(
121        &self,
122        seed: &Seed,
123        trajectory: &[TrajectoryStep],
124        output: &str,
125        already_saved_knowledge: bool,
126    ) -> Result<PersistencePlan> {
127        let mut plan = PersistencePlan {
128            memory: Vec::new(),
129            knowledge: Vec::new(),
130        };
131
132        // Layer 1: Heuristic — detect markdown documents
133        if !already_saved_knowledge && looks_like_document(output) {
134            let path = auto_save_path(seed, output);
135            let now = chrono::Utc::now().to_rfc3339();
136            plan.knowledge.push(KnowledgeWrite {
137                path,
138                content: output.to_string(),
139                meta: NoteMeta {
140                    author: "agent".to_string(),
141                    source: NoteSource::Hook,
142                    quality: NoteQuality::Raw,
143                    needs_review: true,
144                    session_id: None,
145                    message_index: None,
146                    saved_at: Some(now),
147                },
148            });
149        }
150
151        // Layer 2: LLM Reflection
152        let knowledge_already_handled = !plan.knowledge.is_empty();
153        let reflection_plan = self
154            .reflect(seed, trajectory, output, knowledge_already_handled)
155            .await;
156        match reflection_plan {
157            Ok(rp) => {
158                plan.memory.extend(rp.memory);
159                if !already_saved_knowledge {
160                    plan.knowledge.extend(rp.knowledge);
161                }
162            }
163            Err(e) => {
164                tracing::warn!(error = %e, "PersistenceHook reflection failed");
165            }
166        }
167
168        Ok(plan)
169    }
170
171    /// Execute a persistence plan (fire-and-forget style, but still awaits I/O).
172    pub async fn execute_plan(
173        &self,
174        mut plan: PersistencePlan,
175        session_id: &str,
176        message_index: usize,
177    ) {
178        // Memory writes
179        for mw in &plan.memory {
180            let memory_type = match mw.memory_type.as_str() {
181                "episode" => MemoryType::Episode,
182                _ => MemoryType::Fact,
183            };
184            let now = chrono::Utc::now();
185            let entry = MemoryEntry {
186                id: uuid::Uuid::new_v4().to_string(),
187                memory_type,
188                tier: memory_type.initial_tier(),
189                content: mw.content.clone(),
190                content_hash: content_hash(&mw.content),
191                tags: mw.tags.clone(),
192                source: "persistence-hook".to_string(),
193                session_id: Some(session_id.to_string()),
194                importance: mw.importance.clamp(0.0, 1.0),
195                pinned: false,
196                protection: crate::memory::ProtectionLevel::None,
197                auto_classified: true,
198                session_appearances: 0,
199                user_corrected: false,
200                seen_in_sessions: vec![],
201                created_at: now,
202                accessed_at: now,
203                modified_at: now,
204                access_count: 0,
205                decay_score: 1.0,
206                compaction_level: 0,
207                compacted_from: vec![],
208                related_ids: vec![],
209                contradicts: None,
210            };
211            match self.memory_manager.remember(entry).await {
212                Ok(_id) => tracing::debug!(session = session_id, "Hook saved memory entry"),
213                Err(e) => tracing::warn!(error = %e, "Hook failed to save memory"),
214            }
215        }
216
217        // Knowledge writes
218        let now_iso = chrono::Utc::now().to_rfc3339();
219        for kw in &mut plan.knowledge {
220            // Backfill session context into meta (reflection path leaves these empty)
221            if kw.meta.session_id.is_none() {
222                kw.meta.session_id = Some(session_id.to_string());
223            }
224            if kw.meta.message_index.is_none() {
225                kw.meta.message_index = Some(message_index);
226            }
227            if kw.meta.saved_at.is_none() {
228                kw.meta.saved_at = Some(now_iso.clone());
229            }
230        }
231        for kw in &plan.knowledge {
232            match self
233                .knowledge_base
234                .note_write_with_meta(&kw.path, &kw.content, &kw.meta)
235            {
236                Ok(true) => {
237                    tracing::info!(
238                        path = %kw.path,
239                        session = session_id,
240                        "Hook saved knowledge note"
241                    );
242                    // Record the save mapping
243                    let record = KnowledgeSaveRecord {
244                        message_index,
245                        knowledge_path: kw.path.clone(),
246                        saved_at: chrono::Utc::now().to_rfc3339(),
247                        source: "hook".to_string(),
248                    };
249                    self.record_save(session_id, &record).await;
250                    // Publish event
251                    let _ = self.event_bus.publish(KernelEvent::KnowledgePersisted {
252                        session_id: session_id.to_string(),
253                        message_index,
254                        path: kw.path.clone(),
255                        source: "hook".to_string(),
256                    });
257                }
258                Ok(false) => {
259                    tracing::warn!(
260                        path = %kw.path,
261                        "Hook skipped knowledge save: path is a user-authored note"
262                    );
263                }
264                Err(e) => {
265                    tracing::warn!(error = %e, path = %kw.path, "Hook failed to save knowledge")
266                }
267            }
268        }
269    }
270
271    /// Record a knowledge save to StateStore.
272    async fn record_save(&self, session_id: &str, record: &KnowledgeSaveRecord) {
273        let saves: Vec<KnowledgeSaveRecord> = self
274            .state_store
275            .load_json("knowledge-saves", session_id)
276            .await
277            .ok()
278            .flatten()
279            .unwrap_or_default();
280        // Note: we load, push, save — not append. This is fine for the
281        // low-throughput knowledge-save path. If contention becomes an
282        // issue, switch to append-only log + compaction.
283        let mut saves = saves;
284        saves.push(record.clone());
285        if let Err(e) = self
286            .state_store
287            .save_json("knowledge-saves", session_id, &saves)
288            .await
289        {
290            tracing::warn!(error = %e, "Failed to record knowledge save");
291        }
292    }
293
294    /// LLM reflection — ask the model what to persist.
295    async fn reflect(
296        &self,
297        seed: &Seed,
298        trajectory: &[TrajectoryStep],
299        output: &str,
300        knowledge_already_handled: bool,
301    ) -> Result<PersistencePlan> {
302        let trajectory_summary: Vec<String> = trajectory
303            .iter()
304            .take(20)
305            .map(|s| {
306                let out_preview = if s.output.len() > 100 {
307                    format!("{}...", &s.output[..100])
308                } else {
309                    s.output.clone()
310                };
311                format!("- {} → {}", s.input, out_preview)
312            })
313            .collect();
314
315        let result_snippet = if output.len() > 500 {
316            format!("{}...", &output[..500])
317        } else {
318            output.to_string()
319        };
320
321        let knowledge_section = if knowledge_already_handled {
322            String::new()
323        } else {
324            "- Knowledge: documents, research, reference material the user would want later. Visible via Web UI.\n"
325                .to_string()
326        };
327
328        let knowledge_field = if knowledge_already_handled {
329            String::new()
330        } else {
331            ",\"knowledge\":[{\"path\":\"cat/file.md\",\"content\":\"...\"}]".to_string()
332        };
333
334        let prompt = format!(
335            "Review this agent execution. Decide what to persist.\n\n\
336             Goal: {}\n\
337             Request: {}\n\
338             Steps:\n{}\n\
339             Result: {}\n\n\
340             Two stores:\n\
341             - Memory: facts about the user, preference corrections, project context. Not visible to the user. Agent's own learning.\n\
342             {knowledge_section}\
343             \n\
344             When saving to knowledge, strip conversational wrapping: greetings, sign-offs, questions to the user, hedging. Extract only substantive content.\n\
345             JSON only:\n\
346             {{\"memory\":[{{\"content\":\"...\",\"type\":\"fact|episode\",\"importance\":0.0-1.0}}]{knowledge_field}}}",
347            seed.goal,
348            seed.original_request,
349            trajectory_summary.join("\n"),
350            result_snippet,
351        );
352
353        // Build a lightweight agent via EngineHandle → Oxi → AgentBuilder
354        let engine = self.engine_handle.get();
355        let agent_config = oxi_sdk::AgentConfig {
356            description: Some("Persistence reflection".into()),
357            model_id: engine.default_model_id().to_string(),
358            system_prompt: Some("You output JSON only. No explanation.".to_string()),
359            max_tokens: Some(512),
360            temperature: Some(0.3),
361            ..Default::default()
362        };
363
364        let agent = engine.oxi().agent(agent_config).build()?;
365
366        let (response, _events) = agent.run(prompt).await?;
367
368        // Parse JSON from response
369        let json_str = response.content.trim();
370        // Strip markdown code fences if present
371        let json_str = json_str
372            .strip_prefix("```json\n")
373            .or_else(|| json_str.strip_prefix("```\n"))
374            .unwrap_or(json_str);
375        let json_str = json_str.strip_suffix("```").unwrap_or(json_str);
376
377        let plan: PersistencePlan = serde_json::from_str(json_str.trim())?;
378        Ok(plan)
379    }
380}
381
382/// Check if content looks like a structured markdown document.
383fn looks_like_document(content: &str) -> bool {
384    if content.len() < 300 {
385        return false;
386    }
387    let has_headers = content.contains("## ") || content.contains("# ");
388    let has_structure = content.contains("- ")
389        || content.contains("* ")
390        || content.contains("```")
391        || content.contains("| ");
392    has_headers && has_structure
393}
394
395/// Generate an auto-save path from the seed goal and content.
396fn auto_save_path(seed: &Seed, content: &str) -> String {
397    let date = chrono::Local::now().format("%Y-%m-%d").to_string();
398
399    // Try to extract a meaningful name from the first ## heading
400    let heading = content
401        .lines()
402        .find(|l| l.starts_with("## ") || l.starts_with("# "))
403        .map(|l| l.trim_start_matches('#').trim().to_string())
404        .filter(|h| !h.is_empty())
405        .unwrap_or_else(|| {
406            seed.goal
407                .split_whitespace()
408                .take(5)
409                .collect::<Vec<_>>()
410                .join("-")
411        });
412
413    // Slugify
414    let slug: String = heading
415        .to_lowercase()
416        .chars()
417        .map(|c| {
418            if c.is_alphanumeric() || c == '-' || c == '_' {
419                c
420            } else {
421                '-'
422            }
423        })
424        .collect();
425    let slug = slug
426        .split('-')
427        .filter(|s| !s.is_empty())
428        .collect::<Vec<_>>()
429        .join("-");
430    let slug = if slug.len() > 60 {
431        slug[..60].to_string()
432    } else {
433        slug
434    };
435
436    format!("notes/{slug}-{date}.md")
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn test_looks_like_document_short() {
445        assert!(!looks_like_document("short text"));
446    }
447
448    #[test]
449    fn test_looks_like_document_structured() {
450        let content = "# Title\n\nSome intro text here that makes this longer than three hundred characters. We need more text to reach the threshold. Adding some more content here. And even more text to be absolutely sure we cross the 300 character limit. Extra padding.\n\n## Section 1\n\n- Item 1\n- Item 2\n\n## Section 2\n\nSome content.";
451        assert!(looks_like_document(content));
452    }
453
454    #[test]
455    fn test_looks_like_document_no_structure() {
456        let content = "## Title\n\nJust plain text without any lists or code blocks. We need to make this longer than 300 characters to pass the length check. Let me add more text. And more text. And even more text to be sure.";
457        assert!(!looks_like_document(content));
458    }
459
460    #[test]
461    fn test_looks_like_document_has_list() {
462        let content = "## Title\n\nSome intro text here that makes this longer than three hundred characters. We need more text to reach the threshold. Adding some more content here. And even more text to be absolutely sure we cross the 300 character limit. Extra padding added. More text here too for good measure.\n\n- Item one\n- Item two";
463        assert!(looks_like_document(content));
464    }
465
466    #[test]
467    fn test_auto_save_path() {
468        let seed = Seed {
469            id: uuid::Uuid::new_v4(),
470            goal: "Write a Rust design document".to_string(),
471            constraints: vec![],
472            acceptance_criteria: vec![],
473            ontology: vec![],
474            created_at: chrono::Utc::now(),
475            generation: 0,
476            parent_seed_id: None,
477            cspace_hint: None,
478            original_request: String::new(),
479            output_schema: None,
480            project_id: None,
481            workspace_context: None,
482            mount_paths: Vec::new(),
483        };
484        let content = "## Rust Ownership Design\n\nContent here...";
485        let path = auto_save_path(&seed, content);
486        assert!(path.starts_with("notes/"));
487        assert!(path.ends_with(".md"));
488        assert!(path.contains("rust"));
489    }
490
491    #[test]
492    fn test_auto_save_path_from_goal() {
493        let seed = Seed {
494            id: uuid::Uuid::new_v4(),
495            goal: "Fetch hacker news".to_string(),
496            constraints: vec![],
497            acceptance_criteria: vec![],
498            ontology: vec![],
499            created_at: chrono::Utc::now(),
500            generation: 0,
501            parent_seed_id: None,
502            cspace_hint: None,
503            original_request: String::new(),
504            output_schema: None,
505            project_id: None,
506            workspace_context: None,
507            mount_paths: Vec::new(),
508        };
509        let content = "Plain text without headings but we still need a path.";
510        let path = auto_save_path(&seed, content);
511        assert!(path.starts_with("notes/"));
512        assert!(path.contains("fetch"));
513    }
514}