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::Directive;
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        directive: &Directive,
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(directive, 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, with heuristic fallback when empty.
152        let knowledge_already_handled = !plan.knowledge.is_empty();
153        let reflection_plan = self
154            .reflect(directive, trajectory, output, knowledge_already_handled)
155            .await;
156        match reflection_plan {
157            Ok(reflected) => {
158                plan.memory.extend(reflected.memory);
159                if !already_saved_knowledge {
160                    plan.knowledge.extend(reflected.knowledge);
161                }
162                // LLM produced nothing for memory — fall back to user-input scan.
163                if plan.memory.is_empty() {
164                    let heuristic = Self::heuristic_fallback(directive, output);
165                    if !heuristic.is_empty() {
166                        tracing::info!(
167                            count = heuristic.len(),
168                            "PersistenceHook heuristic filled empty reflection"
169                        );
170                        plan.memory.extend(heuristic);
171                    }
172                }
173            }
174            Err(e) => {
175                tracing::warn!(error = %e, "PersistenceHook reflection failed; trying heuristic fallback");
176                let heuristic = Self::heuristic_fallback(directive, output);
177                plan.memory.extend(heuristic);
178            }
179        }
180        Ok(plan)
181    }
182
183    /// Execute a persistence plan (fire-and-forget style, but still awaits I/O).
184    pub async fn execute_plan(
185        &self,
186        mut plan: PersistencePlan,
187        session_id: &str,
188        message_index: usize,
189    ) {
190        // Memory writes
191        for mw in &plan.memory {
192            let memory_type = match mw.memory_type.as_str() {
193                "episode" => MemoryType::Episode,
194                _ => MemoryType::Fact,
195            };
196            let now = chrono::Utc::now();
197            let entry = MemoryEntry {
198                id: uuid::Uuid::new_v4().to_string(),
199                memory_type,
200                tier: memory_type.initial_tier(),
201                content: mw.content.clone(),
202                content_hash: content_hash(&mw.content),
203                tags: mw.tags.clone(),
204                source: "persistence-hook".to_string(),
205                session_id: Some(session_id.to_string()),
206                importance: mw.importance.clamp(0.0, 1.0),
207                pinned: false,
208                protection: crate::memory::ProtectionLevel::None,
209                auto_classified: true,
210                session_appearances: 0,
211                user_corrected: false,
212                seen_in_sessions: vec![],
213                created_at: now,
214                accessed_at: now,
215                modified_at: now,
216                access_count: 0,
217                decay_score: 1.0,
218                compaction_level: 0,
219                compacted_from: vec![],
220                related_ids: vec![],
221                contradicts: None,
222            };
223            match self.memory_manager.remember(entry).await {
224                Ok(_id) => tracing::debug!(session = session_id, "Hook saved memory entry"),
225                Err(e) => tracing::warn!(error = %e, "Hook failed to save memory"),
226            }
227        }
228
229        // Knowledge writes
230        let now_iso = chrono::Utc::now().to_rfc3339();
231        for kw in &mut plan.knowledge {
232            // Backfill session context into meta (reflection path leaves these empty)
233            if kw.meta.session_id.is_none() {
234                kw.meta.session_id = Some(session_id.to_string());
235            }
236            if kw.meta.message_index.is_none() {
237                kw.meta.message_index = Some(message_index);
238            }
239            if kw.meta.saved_at.is_none() {
240                kw.meta.saved_at = Some(now_iso.clone());
241            }
242        }
243        for kw in &plan.knowledge {
244            match self
245                .knowledge_base
246                .note_write_with_meta(&kw.path, &kw.content, &kw.meta)
247            {
248                Ok(true) => {
249                    tracing::info!(
250                        path = %kw.path,
251                        session = session_id,
252                        "Hook saved knowledge note"
253                    );
254                    // Record the save mapping
255                    let record = KnowledgeSaveRecord {
256                        message_index,
257                        knowledge_path: kw.path.clone(),
258                        saved_at: chrono::Utc::now().to_rfc3339(),
259                        source: "hook".to_string(),
260                    };
261                    self.record_save(session_id, &record).await;
262                    // Publish event
263                    let _ = self.event_bus.publish(KernelEvent::KnowledgePersisted {
264                        session_id: session_id.to_string(),
265                        message_index,
266                        path: kw.path.clone(),
267                        source: "hook".to_string(),
268                    });
269                }
270                Ok(false) => {
271                    tracing::warn!(
272                        path = %kw.path,
273                        "Hook skipped knowledge save: path is a user-authored note"
274                    );
275                }
276                Err(e) => {
277                    tracing::warn!(error = %e, path = %kw.path, "Hook failed to save knowledge")
278                }
279            }
280        }
281    }
282
283    /// Record a knowledge save to StateStore.
284    async fn record_save(&self, session_id: &str, record: &KnowledgeSaveRecord) {
285        let saves: Vec<KnowledgeSaveRecord> = self
286            .state_store
287            .load_json("knowledge-saves", session_id)
288            .await
289            .ok()
290            .flatten()
291            .unwrap_or_default();
292        // Note: we load, push, save — not append. This is fine for the
293        // low-throughput knowledge-save path. If contention becomes an
294        // issue, switch to append-only log + compaction.
295        let mut saves = saves;
296        saves.push(record.clone());
297        if let Err(e) = self
298            .state_store
299            .save_json("knowledge-saves", session_id, &saves)
300            .await
301        {
302            tracing::warn!(error = %e, "Failed to record knowledge save");
303        }
304    }
305
306    /// LLM reflection — ask the model what to persist.
307    async fn reflect(
308        &self,
309        directive: &Directive,
310        trajectory: &[TrajectoryStep],
311        output: &str,
312        knowledge_already_handled: bool,
313    ) -> Result<PersistencePlan> {
314        let trajectory_summary: Vec<String> = trajectory
315            .iter()
316            .take(20)
317            .map(|s| {
318                let out_preview = if s.output.len() > 100 {
319                    // Char-boundary safe: multibyte UTF-8 (Korean, emoji)
320                    // would panic on a raw byte slice.
321                    let mut end = 100;
322                    while end > 0 && !s.output.is_char_boundary(end) {
323                        end -= 1;
324                    }
325                    format!("{}...", &s.output[..end])
326                } else {
327                    s.output.clone()
328                };
329                format!("- {} → {}", s.input, out_preview)
330            })
331            .collect();
332        let result_snippet = if output.len() > 500 {
333            let mut end = 500;
334            while end > 0 && !output.is_char_boundary(end) {
335                end -= 1;
336            }
337            format!("{}...", &output[..end])
338        } else {
339            output.to_string()
340        };
341
342        let knowledge_hint = if knowledge_already_handled {
343            "Knowledge has already been saved by the agent — do NOT include a knowledge array."
344        } else {
345            "You may also save knowledge (documents, reference material) if the output contains substantive content worth keeping."
346        };
347
348        let prompt = format!(
349            "Review this agent execution. Decide what to persist.\n\n\
350             Goal: {}\n\
351             Request: {}\n\
352             Steps:\n{}\n\
353             Result: {}\n\n\
354             Two stores:\n\
355             - Memory: facts about the user, preference corrections, project context. Not visible to the user. Agent's own learning.\n\
356             - Knowledge: documents, research, reference material the user would want later. Visible via Web UI.\n\
357             {knowledge_hint}\n\
358             Look especially hard at the user's REQUEST — user preferences and facts live there, not in the agent's output.\n\
359             \n\
360             When saving to knowledge, strip conversational wrapping: greetings, sign-offs, questions to the user, hedging. Extract only substantive content.\n\
361             JSON only:\n\
362             {{\"memory\":[{{\"content\":\"...\",\"type\":\"fact|episode\",\"importance\":0.0-1.0}}],\"knowledge\":[{{\"path\":\"cat/file.md\",\"content\":\"...\"}}]}}",
363            directive.goal,
364            directive.original_request,
365            trajectory_summary.join("\n"),
366            result_snippet,
367        );
368
369        // Build a lightweight agent via EngineHandle → Oxi → AgentBuilder
370        let engine = self.engine_handle.get();
371        let model_id = engine.default_model_id().to_string();
372        let agent_config = oxi_sdk::AgentConfig {
373            description: Some("Persistence reflection".into()),
374            model_id: model_id.clone(),
375            system_prompt: Some("You output JSON only. No explanation.".to_string()),
376            max_tokens: Some(512),
377            temperature: Some(0.3),
378            ..Default::default()
379        };
380
381        tracing::info!(
382            model = %model_id,
383            prompt_len = prompt.len(),
384            request_len = directive.original_request.len(),
385            output_len = output.len(),
386            "PersistenceHook reflection starting"
387        );
388
389        let agent = engine.oxi().agent(agent_config).build()?;
390        let (response, _events) = agent.run(prompt).await?;
391
392        tracing::info!(
393            response_len = response.content.len(),
394            "PersistenceHook reflection response received"
395        );
396
397        // Parse JSON from response
398        let json_str = response.content.trim();
399        // Strip markdown code fences if present
400        let json_str = json_str
401            .strip_prefix("```json\n")
402            .or_else(|| json_str.strip_prefix("```\n"))
403            .unwrap_or(json_str);
404        let json_str = json_str.strip_suffix("```").unwrap_or(json_str);
405
406        let plan: PersistencePlan = serde_json::from_str(json_str.trim())?;
407        tracing::info!(
408            memory_count = plan.memory.len(),
409            knowledge_count = plan.knowledge.len(),
410            "PersistenceHook reflection plan parsed"
411        );
412        Ok(plan)
413    }
414
415    /// Heuristic fallback: when the LLM reflection fails or returns an empty
416    /// memory plan, scan the user's request (not the agent output) for
417    /// preference/fact patterns. The user's stated facts live in the request,
418    /// not in the agent's response.
419    fn heuristic_fallback(directive: &Directive, output: &str) -> Vec<MemoryWrite> {
420        let mut found = Vec::new();
421        let request = &directive.original_request;
422        if request.is_empty() {
423            return found;
424        }
425        // Preference indicators (Korean + English).
426        let preference_patterns = [
427            "I prefer",
428            "I like",
429            "I always",
430            "I never",
431            "remember that",
432            "기억해",
433            "항상 ",
434            "절대 ",
435            "내가 ",
436            "나는 ",
437            "저는 ",
438            "선호",
439        ];
440        for pat in &preference_patterns {
441            if request.contains(pat) || output.contains(pat) {
442                let content = if request.contains(pat) {
443                    truncate(request, 200)
444                } else {
445                    truncate(output, 200)
446                };
447                found.push(MemoryWrite {
448                    content,
449                    memory_type: "fact".to_string(),
450                    importance: 0.6,
451                    tags: vec!["persistence-hook-heuristic".to_string()],
452                });
453                break; // one heuristic hit per execution is enough
454            }
455        }
456        found
457    }
458}
459
460/// Truncate a string for error messages without panicking on UTF-8 boundaries.
461fn truncate(s: &str, max_bytes: usize) -> String {
462    if s.len() <= max_bytes {
463        return s.to_string();
464    }
465    let mut end = max_bytes;
466    while end > 0 && !s.is_char_boundary(end) {
467        end -= 1;
468    }
469    format!("{}…", &s[..end])
470}
471
472/// Check if content looks like a structured markdown document.
473fn looks_like_document(content: &str) -> bool {
474    if content.len() < 300 {
475        return false;
476    }
477    let has_headers = content.contains("## ") || content.contains("# ");
478    let has_structure = content.contains("- ")
479        || content.contains("* ")
480        || content.contains("```")
481        || content.contains("| ");
482    has_headers && has_structure
483}
484
485/// Generate an auto-save path from the directive goal and content.
486fn auto_save_path(directive: &Directive, content: &str) -> String {
487    let date = chrono::Local::now().format("%Y-%m-%d").to_string();
488
489    // Try to extract a meaningful name from the first ## heading
490    let heading = content
491        .lines()
492        .find(|l| l.starts_with("## ") || l.starts_with("# "))
493        .map(|l| l.trim_start_matches('#').trim().to_string())
494        .filter(|h| !h.is_empty())
495        .unwrap_or_else(|| {
496            directive
497                .goal
498                .split_whitespace()
499                .take(5)
500                .collect::<Vec<_>>()
501                .join("-")
502        });
503
504    // Slugify
505    let slug: String = heading
506        .to_lowercase()
507        .chars()
508        .map(|c| {
509            if c.is_alphanumeric() || c == '-' || c == '_' {
510                c
511            } else {
512                '-'
513            }
514        })
515        .collect();
516    let slug = slug
517        .split('-')
518        .filter(|s| !s.is_empty())
519        .collect::<Vec<_>>()
520        .join("-");
521    let slug = if slug.len() > 60 {
522        // Slug is ASCII-only (slugified above), but guard the slice for
523        // safety and consistency with the rest of the codebase.
524        let mut end = 60;
525        while end > 0 && !slug.is_char_boundary(end) {
526            end -= 1;
527        }
528        slug[..end].to_string()
529    } else {
530        slug
531    };
532
533    format!("notes/{slug}-{date}.md")
534}
535
536#[cfg(test)]
537mod tests {
538    use super::*;
539
540    #[test]
541    fn test_looks_like_document_short() {
542        assert!(!looks_like_document("short text"));
543    }
544
545    #[test]
546    fn test_looks_like_document_structured() {
547        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.";
548        assert!(looks_like_document(content));
549    }
550
551    #[test]
552    fn test_looks_like_document_no_structure() {
553        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.";
554        assert!(!looks_like_document(content));
555    }
556
557    #[test]
558    fn test_looks_like_document_has_list() {
559        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";
560        assert!(looks_like_document(content));
561    }
562
563    #[test]
564    fn test_auto_save_path() {
565        let directive = Directive {
566            goal: "Write a Rust design document".to_string(),
567            ..Default::default()
568        };
569        let content = "## Rust Ownership Design\n\nContent here...";
570        let path = auto_save_path(&directive, content);
571        assert!(path.starts_with("notes/"));
572        assert!(path.ends_with(".md"));
573        assert!(path.contains("rust"));
574    }
575    #[test]
576    fn test_auto_save_path_from_goal() {
577        let directive = Directive {
578            goal: "Fetch hacker news".to_string(),
579            ..Default::default()
580        };
581        let content = "Plain text without headings but we still need a path.";
582        let path = auto_save_path(&directive, content);
583        assert!(path.starts_with("notes/"));
584        assert!(path.contains("fetch"));
585    }
586}