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