1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct KnowledgeWrite {
24 pub path: String,
26 pub content: String,
28 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct MemoryWrite {
48 pub content: String,
50 #[serde(rename = "type")]
52 pub memory_type: String,
53 pub importance: f32,
55 #[serde(default)]
57 pub tags: Vec<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct PersistencePlan {
63 pub memory: Vec<MemoryWrite>,
65 pub knowledge: Vec<KnowledgeWrite>,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct KnowledgeSaveRecord {
72 pub message_index: usize,
74 pub knowledge_path: String,
76 pub saved_at: String,
78 pub source: String,
80}
81
82pub 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 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 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 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 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 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 pub async fn execute_plan(
185 &self,
186 mut plan: PersistencePlan,
187 session_id: &str,
188 message_index: usize,
189 ) {
190 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 let now_iso = chrono::Utc::now().to_rfc3339();
231 for kw in &mut plan.knowledge {
232 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 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 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 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 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 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 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 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 let json_str = response.content.trim();
399 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 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 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; }
455 }
456 found
457 }
458}
459
460fn 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
472fn 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
485fn auto_save_path(directive: &Directive, content: &str) -> String {
487 let date = chrono::Local::now().format("%Y-%m-%d").to_string();
488
489 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 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 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}