Skip to main content

oxios_kernel/kernel_handle/
knowledge_lens.rs

1//! KnowledgeLens — semantic search overlay for the markdown knowledge base.
2//!
3//! Wraps a [`KnowledgeBase`] and adds HNSW-based semantic vector search
4//! via the agent's [`MemoryManager`]. Provides `recall_for_context()` for
5//! injecting relevant knowledge into agent context windows.
6//!
7//! **RFC-003: Knowledge Base Independent Separation**
8//! - Semantic search lives in the kernel (AI layer), not oxios-markdown
9//! - `KnowledgeLens` subscribes to `KnowledgeBase.on_file_change()` to keep
10//!   the HNSW index in sync automatically
11
12use 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/// Knowledge context injected into agent prompts.
24#[derive(Debug, Clone, Default, Serialize, Deserialize)]
25pub struct KnowledgeContext {
26    /// Relevant knowledge notes for the query.
27    pub notes: Vec<KnowledgeNote>,
28    /// Memory entries from agent memory.
29    pub memories: Vec<MemoryNote>,
30    /// Number of HNSW index entries used.
31    pub index_entries_used: usize,
32}
33
34/// A knowledge note extracted from the markdown knowledge base.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct KnowledgeNote {
37    /// Relative path.
38    pub path: String,
39    /// Display name.
40    pub name: String,
41    /// Content snippet.
42    pub content: String,
43    /// Number of backlinks.
44    pub backlink_count: usize,
45}
46
47/// A memory entry from the agent's memory system.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct MemoryNote {
50    /// Memory ID.
51    pub id: String,
52    /// Source tag (e.g. "memory:agent", "session:...").
53    pub source: String,
54    /// Content snippet.
55    pub content: String,
56    /// Importance score (0-1).
57    pub importance: f32,
58}
59
60/// Copilot response (AI-powered chat about the knowledge base).
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct CopilotResponse {
63    /// AI-generated answer.
64    pub content: String,
65    /// Note paths referenced in the response.
66    pub referenced_notes: Vec<String>,
67    /// Memory IDs referenced in the response.
68    pub referenced_memories: Vec<String>,
69}
70
71/// KnowledgeLens — semantic overlay over KnowledgeBase.
72///
73/// Owns the HNSW index (via MemoryManager) and keeps it synchronized
74/// with the markdown knowledge base via file-change callbacks.
75pub struct KnowledgeLens {
76    /// The underlying knowledge base.
77    kb: Arc<oxios_markdown::KnowledgeBase>,
78    /// Agent memory manager (provides HNSW index + keyword search).
79    memory: Arc<MemoryManager>,
80    /// Tracks which files were written by agents.
81    agent_writes: Arc<RwLock<HashSet<String>>>,
82    /// Callback handle for file-change events.
83    #[allow(dead_code)]
84    callback_handle: Option<mpsc::Sender<oxios_markdown::knowledge::FileChange>>,
85    /// Default model ID for copilot chat.
86    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    /// Create a new KnowledgeLens wrapping the given knowledge base.
97    ///
98    /// Registers a file-change callback to keep the memory index in sync.
99    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        // Spawn background task to process file-change events
121        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    /// Get the root path of the knowledge base.
133    pub fn root(&self) -> PathBuf {
134        self.kb.root()
135    }
136
137    /// Get the underlying knowledge base (read-only access).
138    pub fn knowledge_base(&self) -> &Arc<oxios_markdown::KnowledgeBase> {
139        &self.kb
140    }
141
142    /// Get the default model ID used for copilot chat.
143    pub fn model_id(&self) -> &str {
144        &self.model_id
145    }
146
147    /// Mark a file as having been written by an agent.
148    pub fn mark_agent_write(&self, path: &str) {
149        self.agent_writes.write().insert(path.to_string());
150    }
151
152    /// Check if a file was written by an agent.
153    pub fn is_agent_write(&self, path: &str) -> bool {
154        self.agent_writes.read().contains(path)
155    }
156
157    /// Clear the agent-write marker for a file.
158    pub fn clear_agent_write(&self, path: &str) {
159        self.agent_writes.write().remove(path);
160    }
161
162    /// Recall relevant knowledge for a given context/query.
163    ///
164    /// Combines markdown note search (via KnowledgeBase) with agent memory
165    /// search (via MemoryManager). Returns notes ranked by relevance.
166    pub async fn recall_for_context(&self, query: &str, limit: usize) -> Result<KnowledgeContext> {
167        // Search agent memory for relevant entries
168        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        // Search knowledge notes
185        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    /// Copilot chat — AI-powered question answering about the knowledge base.
214    ///
215    /// This method is async (uses `provider.stream()` which is Send).
216    #[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        // 1. Current file context
228        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: {}\n\n{}", path, snippet));
232                referenced_notes.push(path.to_string());
233            }
234        }
235
236        // 2. Related notes
237        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        // 3. Memory context
250        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        // 4. AI call
263        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
318// ─── File change event handler ────────────────────────────────────────────────
319
320fn 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        content: content.to_string(),
372        source: "knowledge:lens".to_string(),
373        session_id: None,
374        tags,
375        importance,
376        created_at: now,
377        accessed_at: now,
378        access_count: 0,
379    };
380
381    let rt = tokio::runtime::Handle::try_current();
382    if let Ok(handle) = rt {
383        let memory = memory.clone();
384        handle.spawn(async move {
385            let _ = memory.remember(entry).await;
386        });
387    }
388}