use std::collections::HashSet;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Result;
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use crate::memory::{MemoryEntry, MemoryManager, MemoryType};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct KnowledgeContext {
pub notes: Vec<KnowledgeNote>,
pub memories: Vec<MemoryNote>,
pub index_entries_used: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeNote {
pub path: String,
pub name: String,
pub content: String,
pub backlink_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryNote {
pub id: String,
pub source: String,
pub content: String,
pub importance: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CopilotResponse {
pub content: String,
pub referenced_notes: Vec<String>,
pub referenced_memories: Vec<String>,
}
pub struct KnowledgeLens {
kb: Arc<oxios_markdown::KnowledgeBase>,
memory: Arc<MemoryManager>,
agent_writes: Arc<RwLock<HashSet<String>>>,
#[allow(dead_code)]
callback_handle: Option<mpsc::Sender<oxios_markdown::knowledge::FileChange>>,
model_id: String,
}
impl std::fmt::Debug for KnowledgeLens {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("KnowledgeLens").finish()
}
}
impl KnowledgeLens {
pub fn new(
kb: Arc<oxios_markdown::KnowledgeBase>,
memory: Arc<MemoryManager>,
) -> anyhow::Result<Self> {
let (tx, mut rx) = mpsc::channel::<oxios_markdown::knowledge::FileChange>(64);
let tx_for_cb = tx.clone();
kb.on_file_change(move |_path, event| {
let tx = tx.clone();
tokio::spawn(async move {
let _ = tx.send(event).await;
});
});
let lens = Self {
kb,
memory,
agent_writes: Arc::new(RwLock::new(HashSet::new())),
callback_handle: Some(tx_for_cb),
model_id: "anthropic/claude-sonnet-4".to_string(),
};
let memory = lens.memory.clone();
let kb = lens.kb.clone();
tokio::spawn(async move {
while let Some(event) = rx.recv().await {
lens_handle_event(kb.clone(), memory.clone(), event);
}
});
Ok(lens)
}
pub fn root(&self) -> PathBuf {
self.kb.root()
}
pub fn knowledge_base(&self) -> &Arc<oxios_markdown::KnowledgeBase> {
&self.kb
}
pub fn model_id(&self) -> &str {
&self.model_id
}
pub fn mark_agent_write(&self, path: &str) {
self.agent_writes.write().insert(path.to_string());
}
pub fn is_agent_write(&self, path: &str) -> bool {
self.agent_writes.read().contains(path)
}
pub fn clear_agent_write(&self, path: &str) {
self.agent_writes.write().remove(path);
}
pub async fn recall_for_context(&self, query: &str, limit: usize) -> Result<KnowledgeContext> {
let mem_entries = self
.memory
.search(query, None, limit)
.await
.unwrap_or_default();
let memories: Vec<MemoryNote> = mem_entries
.iter()
.map(|e| MemoryNote {
id: e.id.clone(),
source: e.source.clone(),
content: e.content.chars().take(300).collect(),
importance: e.importance,
})
.collect();
let note_hits = self.kb.search(query, limit)?;
let notes: Vec<KnowledgeNote> = note_hits
.into_iter()
.map(|h| {
let content = self
.kb
.note_read(&h.path)
.ok()
.flatten()
.map(|c| c.chars().take(500).collect::<String>())
.unwrap_or_default();
KnowledgeNote {
path: h.path,
name: h.name,
content,
backlink_count: h.backlink_count,
}
})
.collect();
Ok(KnowledgeContext {
notes,
memories,
index_entries_used: mem_entries.len(),
})
}
#[allow(clippy::unused_async)]
pub async fn copilot_chat(
&self,
engine: Arc<dyn crate::engine::EngineProvider>,
model_id: &str,
question: &str,
context_path: Option<&str>,
) -> Result<CopilotResponse> {
let mut context_parts = Vec::new();
let mut referenced_notes = Vec::new();
if let Some(path) = context_path {
if let Ok(Some(content)) = self.kb.note_read(path) {
let snippet: String = content.chars().take(2000).collect();
context_parts.push(format!("## Current: {}\n\n{}", path, snippet));
referenced_notes.push(path.to_string());
}
}
let hits = self.kb.search(question, 5).unwrap_or_default();
for hit in &hits {
if referenced_notes.contains(&hit.path) {
continue;
}
if let Ok(Some(content)) = self.kb.note_read(&hit.path) {
let snippet: String = content.chars().take(500).collect();
context_parts.push(format!("## Related: {}\n\n{}", hit.path, snippet));
referenced_notes.push(hit.path.clone());
}
}
let mut referenced_memories = Vec::new();
if let Ok(entries) = self.memory.search(question, None, 3).await {
for mem in &entries {
context_parts.push(format!(
"## Memory [{}]: {}",
mem.memory_type.label(),
mem.content.chars().take(200).collect::<String>()
));
referenced_memories.push(mem.id.clone());
}
}
let system_prompt = format!(
"You are a knowledge assistant embedded in a markdown editor.\
Answer questions about the user's notes using the provided context.\
Be concise. Respond in the same language as the question.\n\n\
## Context:\n\n{}",
context_parts.join("\n\n")
);
let provider_name = model_id
.split_once('/')
.map(|(p, _)| p)
.unwrap_or("anthropic");
let provider = engine
.create_provider(provider_name)
.map_err(|e| anyhow::anyhow!("Provider: {e}"))?;
let model = engine
.resolve_model(model_id)
.map_err(|e| anyhow::anyhow!("Model: {e}"))?;
let mut ctx = oxi_sdk::Context::new();
ctx.set_system_prompt(&system_prompt);
ctx.add_message(oxi_sdk::Message::User(oxi_sdk::UserMessage::new(question)));
let stream = provider
.stream(&model, &ctx, None)
.await
.map_err(|e| anyhow::anyhow!("Stream: {e}"))?;
let mut text = String::new();
use futures::StreamExt;
let mut pinned = std::pin::pin!(stream);
while let Some(event) = pinned.next().await {
match event {
oxi_sdk::ProviderEvent::TextDelta { delta, .. } => text.push_str(&delta),
oxi_sdk::ProviderEvent::Done { .. } => break,
oxi_sdk::ProviderEvent::Error { error, .. } => {
return Err(anyhow::anyhow!("AI: {:?}", error));
}
_ => {}
}
}
Ok(CopilotResponse {
content: text,
referenced_notes,
referenced_memories,
})
}
}
fn lens_handle_event(
kb: Arc<oxios_markdown::KnowledgeBase>,
memory: Arc<MemoryManager>,
event: oxios_markdown::knowledge::FileChange,
) {
use oxios_markdown::knowledge::FileChange::*;
match event {
Created(path) | Updated(path) => {
if let Ok(Some(content)) = kb.note_read(&path) {
index_to_memory(&path, &content, &memory);
}
}
Deleted(path) => {
let id = format!("note-{}", path.replace('/', "-").trim_end_matches(".md"));
let rt = tokio::runtime::Handle::try_current();
if let Ok(handle) = rt {
let memory = memory.clone();
handle.spawn(async move {
let _ = memory.forget(&id, MemoryType::Knowledge).await;
});
}
}
Moved { old, new } => {
let id = format!("note-{}", old.replace('/', "-").trim_end_matches(".md"));
let rt = tokio::runtime::Handle::try_current();
if let Ok(handle) = rt {
let memory = memory.clone();
let kb = kb.clone();
let new_path = new.clone();
handle.spawn(async move {
let _ = memory.forget(&id, MemoryType::Knowledge).await;
if let Ok(Some(content)) = kb.note_read(&new_path) {
index_to_memory(&new_path, &content, &memory);
}
});
}
}
}
}
fn index_to_memory(path: &str, content: &str, memory: &Arc<MemoryManager>) {
let tags = oxios_markdown::parser::extract_headings(content)
.into_iter()
.take(5)
.collect::<Vec<_>>();
let now = chrono::Utc::now();
let importance = 0.5_f32.min(0.3 + (tags.len() as f32 * 0.05));
let entry = MemoryEntry {
id: format!("note-{}", path.replace('/', "-").trim_end_matches(".md")),
memory_type: MemoryType::Knowledge,
content: content.to_string(),
source: "knowledge:lens".to_string(),
session_id: None,
tags,
importance,
created_at: now,
accessed_at: now,
access_count: 0,
};
let rt = tokio::runtime::Handle::try_current();
if let Ok(handle) = rt {
let memory = memory.clone();
handle.spawn(async move {
let _ = memory.remember(entry).await;
});
}
}