oxios_kernel/kernel_handle/
knowledge_lens.rs1use std::collections::HashSet;
13use std::path::PathBuf;
14use std::sync::Arc;
15
16use anyhow::Result;
17use parking_lot::RwLock;
18use serde::{Deserialize, Serialize};
19use tokio::sync::mpsc;
20
21use crate::memory::{MemoryEntry, MemoryManager, MemoryType};
22
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct KnowledgeContext {
26 pub notes: Vec<KnowledgeNote>,
28 pub memories: Vec<MemoryNote>,
30 pub index_entries_used: usize,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct KnowledgeNote {
37 pub path: String,
39 pub name: String,
41 pub content: String,
43 pub backlink_count: usize,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct MemoryNote {
50 pub id: String,
52 pub source: String,
54 pub content: String,
56 pub importance: f32,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct CopilotResponse {
63 pub content: String,
65 pub referenced_notes: Vec<String>,
67 pub referenced_memories: Vec<String>,
69}
70
71pub struct KnowledgeLens {
76 kb: Arc<oxios_markdown::KnowledgeBase>,
78 memory: Arc<MemoryManager>,
80 agent_writes: Arc<RwLock<HashSet<String>>>,
82 #[allow(dead_code)]
84 callback_handle: Option<mpsc::Sender<oxios_markdown::knowledge::FileChange>>,
85}
86
87impl std::fmt::Debug for KnowledgeLens {
88 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 f.debug_struct("KnowledgeLens").finish()
90 }
91}
92
93impl KnowledgeLens {
94 pub fn new(
98 kb: Arc<oxios_markdown::KnowledgeBase>,
99 memory: Arc<MemoryManager>,
100 ) -> anyhow::Result<Self> {
101 let (tx, mut rx) = mpsc::channel::<oxios_markdown::knowledge::FileChange>(64);
102 let tx_for_cb = tx.clone();
103 kb.on_file_change(move |_path, event| {
104 let tx = tx.clone();
105 tokio::spawn(async move {
106 let _ = tx.send(event).await;
107 });
108 });
109
110 let lens = Self {
111 kb,
112 memory,
113 agent_writes: Arc::new(RwLock::new(HashSet::new())),
114 callback_handle: Some(tx_for_cb),
115 };
116
117 let memory = lens.memory.clone();
119 let kb = lens.kb.clone();
120 tokio::spawn(async move {
121 while let Some(event) = rx.recv().await {
122 lens_handle_event(kb.clone(), memory.clone(), event);
123 }
124 });
125
126 Ok(lens)
127 }
128
129 pub fn root(&self) -> PathBuf {
131 self.kb.root()
132 }
133
134 pub fn knowledge_base(&self) -> &Arc<oxios_markdown::KnowledgeBase> {
136 &self.kb
137 }
138
139 pub fn mark_agent_write(&self, path: &str) {
141 self.agent_writes.write().insert(path.to_string());
142 }
143
144 pub fn is_agent_write(&self, path: &str) -> bool {
146 self.agent_writes.read().contains(path)
147 }
148
149 pub fn clear_agent_write(&self, path: &str) {
151 self.agent_writes.write().remove(path);
152 }
153
154 pub async fn recall_for_context(&self, query: &str, limit: usize) -> Result<KnowledgeContext> {
159 let mem_entries = self
161 .memory
162 .search(query, None, limit)
163 .await
164 .unwrap_or_default();
165
166 let memories: Vec<MemoryNote> = mem_entries
167 .iter()
168 .map(|e| MemoryNote {
169 id: e.id.clone(),
170 source: e.source.clone(),
171 content: e.content.chars().take(300).collect(),
172 importance: e.importance,
173 })
174 .collect();
175
176 let note_hits = self.kb.search(query, limit)?;
178
179 let notes: Vec<KnowledgeNote> = note_hits
180 .into_iter()
181 .map(|h| {
182 let content = self
183 .kb
184 .note_read(&h.path)
185 .ok()
186 .flatten()
187 .map(|c| c.chars().take(500).collect::<String>())
188 .unwrap_or_default();
189 KnowledgeNote {
190 path: h.path,
191 name: h.name,
192 content,
193 backlink_count: h.backlink_count,
194 }
195 })
196 .collect();
197
198 Ok(KnowledgeContext {
199 notes,
200 memories,
201 index_entries_used: mem_entries.len(),
202 })
203 }
204
205 #[allow(clippy::unused_async)]
209 pub async fn copilot_chat(
210 &self,
211 engine_handle: Arc<crate::engine::EngineHandle>,
212 question: &str,
213 context_path: Option<&str>,
214 ) -> Result<CopilotResponse> {
215 let mut context_parts = Vec::new();
216 let mut referenced_notes = Vec::new();
217
218 if let Some(path) = context_path
220 && let Ok(Some(content)) = self.kb.note_read(path)
221 {
222 let snippet: String = content.chars().take(2000).collect();
223 context_parts.push(format!("## Current: {path}\n\n{snippet}"));
224 referenced_notes.push(path.to_string());
225 }
226
227 let hits = self.kb.search(question, 5).unwrap_or_default();
229 for hit in &hits {
230 if referenced_notes.contains(&hit.path) {
231 continue;
232 }
233 if let Ok(Some(content)) = self.kb.note_read(&hit.path) {
234 let snippet: String = content.chars().take(500).collect();
235 context_parts.push(format!("## Related: {}\n\n{}", hit.path, snippet));
236 referenced_notes.push(hit.path.clone());
237 }
238 }
239
240 let mut referenced_memories = Vec::new();
242 if let Ok(entries) = self.memory.search(question, None, 3).await {
243 for mem in &entries {
244 context_parts.push(format!(
245 "## Memory [{}]: {}",
246 mem.memory_type.label(),
247 mem.content.chars().take(200).collect::<String>()
248 ));
249 referenced_memories.push(mem.id.clone());
250 }
251 }
252
253 let system_prompt = format!(
255 "You are a knowledge assistant embedded in a markdown note-taking system.\n\
256 Answer questions about the user's notes using ONLY the provided context.\n\n\
257 ## Rules\n\
258 - Only answer based on the context below. If the context doesn't contain\n\
259 the answer, say \"I couldn't find relevant notes on that topic.\"\n\
260 - Cite which notes you're referencing by name.\n\
261 - Be concise — the user is in an editor, not a chat room.\n\
262 - Be concise — the user is in an editor, not a chat room.\n\n\
263 ## Available Notes\n\n{}",
264 context_parts.join("\n\n")
265 );
266
267 let resolved = engine_handle
272 .resolve_default()
273 .map_err(|e| anyhow::anyhow!("Model/provider: {e}"))?;
274
275 let mut ctx = oxi_sdk::Context::new();
276 ctx.set_system_prompt(&system_prompt);
277 ctx.add_message(oxi_sdk::Message::User(oxi_sdk::UserMessage::new(question)));
278
279 let stream = resolved
280 .provider
281 .stream(&resolved.model, &ctx, None)
282 .await
283 .map_err(|e| anyhow::anyhow!("Stream: {e}"))?;
284 let mut text = String::new();
285 use futures::StreamExt;
286 let mut pinned = std::pin::pin!(stream);
287 while let Some(event) = pinned.next().await {
288 match event {
289 oxi_sdk::ProviderEvent::TextDelta { delta, .. } => text.push_str(&delta),
290 oxi_sdk::ProviderEvent::Done { .. } => break,
291 oxi_sdk::ProviderEvent::Error { error, .. } => {
292 return Err(anyhow::anyhow!("AI: {error:?}"));
293 }
294 _ => {}
295 }
296 }
297
298 Ok(CopilotResponse {
299 content: text,
300 referenced_notes,
301 referenced_memories,
302 })
303 }
304}
305
306fn lens_handle_event(
309 kb: Arc<oxios_markdown::KnowledgeBase>,
310 memory: Arc<MemoryManager>,
311 event: oxios_markdown::knowledge::FileChange,
312) {
313 use oxios_markdown::knowledge::FileChange::*;
314 match event {
315 Created(path) | Updated(path) => {
316 if let Ok(Some(content)) = kb.note_read(&path) {
317 index_to_memory(&path, &content, &memory);
318 }
319 }
320 Deleted(path) => {
321 let id = format!("note-{}", path.replace('/', "-").trim_end_matches(".md"));
322 let rt = tokio::runtime::Handle::try_current();
323 if let Ok(handle) = rt {
324 let memory = memory.clone();
325 handle.spawn(async move {
326 let _ = memory.forget(&id, MemoryType::Knowledge).await;
327 });
328 }
329 }
330 Moved { old, new } => {
331 let id = format!("note-{}", old.replace('/', "-").trim_end_matches(".md"));
332 let rt = tokio::runtime::Handle::try_current();
333 if let Ok(handle) = rt {
334 let memory = memory.clone();
335 let kb = kb.clone();
336 let new_path = new.clone();
337 handle.spawn(async move {
338 let _ = memory.forget(&id, MemoryType::Knowledge).await;
339 if let Ok(Some(content)) = kb.note_read(&new_path) {
340 index_to_memory(&new_path, &content, &memory);
341 }
342 });
343 }
344 }
345 }
346}
347
348fn index_to_memory(path: &str, content: &str, memory: &Arc<MemoryManager>) {
349 let tags = oxios_markdown::parser::extract_headings(content)
350 .into_iter()
351 .take(5)
352 .collect::<Vec<_>>();
353 let now = chrono::Utc::now();
354 let importance = 0.5_f32.min(0.3 + (tags.len() as f32 * 0.05));
355
356 let entry = MemoryEntry {
357 id: format!("note-{}", path.replace('/', "-").trim_end_matches(".md")),
358 memory_type: MemoryType::Knowledge,
359 tier: crate::memory::MemoryTier::Warm,
360 content: content.to_string(),
361 content_hash: 0,
362 source: "knowledge:lens".to_string(),
363 session_id: None,
364 tags,
365 importance,
366 pinned: false,
367 protection: crate::memory::ProtectionLevel::None,
368 auto_classified: false,
369 session_appearances: 0,
370 user_corrected: false,
371 seen_in_sessions: vec![],
372 created_at: now,
373 accessed_at: now,
374 modified_at: now,
375 access_count: 0,
376 decay_score: 1.0,
377 compaction_level: 0,
378 compacted_from: vec![],
379 related_ids: vec![],
380 contradicts: None,
381 };
382
383 let rt = tokio::runtime::Handle::try_current();
384 if let Ok(handle) = rt {
385 let memory = memory.clone();
386 handle.spawn(async move {
387 let _ = memory.remember(entry).await;
388 });
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
397 fn test_knowledge_context_default() {
398 let ctx = KnowledgeContext::default();
399 assert!(ctx.notes.is_empty());
400 assert!(ctx.memories.is_empty());
401 assert_eq!(ctx.index_entries_used, 0);
402 }
403
404 #[test]
405 fn test_knowledge_note_serialization() {
406 let note = KnowledgeNote {
407 path: "notes/Rust.md".to_string(),
408 name: "Rust".to_string(),
409 content: "Rust is a systems language".to_string(),
410 backlink_count: 3,
411 };
412 let json = serde_json::to_string(¬e).unwrap();
413 let restored: KnowledgeNote = serde_json::from_str(&json).unwrap();
414 assert_eq!(restored.path, "notes/Rust.md");
415 assert_eq!(restored.backlink_count, 3);
416 }
417
418 #[test]
419 fn test_memory_note_serialization() {
420 let note = MemoryNote {
421 id: "mem-123".to_string(),
422 source: "session:abc".to_string(),
423 content: "User prefers dark mode".to_string(),
424 importance: 0.85,
425 };
426 let json = serde_json::to_string(¬e).unwrap();
427 let restored: MemoryNote = serde_json::from_str(&json).unwrap();
428 assert_eq!(restored.id, "mem-123");
429 assert!((restored.importance - 0.85).abs() < 0.01);
430 }
431
432 #[test]
433 fn test_copilot_response_serialization() {
434 let resp = CopilotResponse {
435 content: "The answer is 42".to_string(),
436 referenced_notes: vec!["notes/answer.md".to_string()],
437 referenced_memories: vec!["mem-1".to_string()],
438 };
439 let json = serde_json::to_string(&resp).unwrap();
440 let restored: CopilotResponse = serde_json::from_str(&json).unwrap();
441 assert_eq!(restored.content, "The answer is 42");
442 assert_eq!(restored.referenced_notes.len(), 1);
443 assert_eq!(restored.referenced_memories.len(), 1);
444 }
445
446 #[test]
447 fn test_knowledge_context_with_data() {
448 let ctx = KnowledgeContext {
449 notes: vec![KnowledgeNote {
450 path: "test.md".to_string(),
451 name: "Test".to_string(),
452 content: "Hello".to_string(),
453 backlink_count: 0,
454 }],
455 memories: vec![],
456 index_entries_used: 42,
457 };
458 let json = serde_json::to_string(&ctx).unwrap();
459 let restored: KnowledgeContext = serde_json::from_str(&json).unwrap();
460 assert_eq!(restored.notes.len(), 1);
461 assert_eq!(restored.index_entries_used, 42);
462 }
463}