1use std::collections::HashSet;
11use std::path::PathBuf;
12
13use anyhow::Result;
14use parking_lot::{Mutex as ParkingMutex, RwLock};
15
16pub type FileChangeCallback = Box<dyn Fn(&str, FileChange) + Send + Sync>;
19
20use crate::backlinks::{Backlink, BacklinkIndex, LinkGraph};
21use crate::chat::{delete_chat_msg, move_from_chat, read_chat_msgs, rename_chat_msg};
22use crate::checklist::{
23 add_checklist_item, checklist_items, complete_checklist_item, incomplete_checklist_items,
24 remove_checklist_item, remove_completed_checklist_items,
25};
26use crate::fs::VirtualFs;
27use crate::habits::{habits, last_week_habits, write_habits};
28use crate::html::markdown_to_html;
29use crate::i18n::emoji_for;
30use crate::journal::{add_emoji as journal_add_emoji, add_record as journal_add_record};
31use crate::parser::{extract_headings, similar};
32use crate::plugins::world_clock_for_names;
33use crate::stats::{done_today, today_report};
34use crate::types::{FileEntry, Habits, KnowledgeConfig, CHAT_FILENAME, DIR_USER_ROOT};
35use crate::worker::{move_due_tasks, remove_completed_items};
36use crate::{today_chat_header, today_journal_filename};
37
38#[derive(Debug, Clone)]
40pub enum FileChange {
41 Created(String),
43 Updated(String),
45 Deleted(String),
47 Moved {
49 old: String,
51 new: String,
53 },
54}
55
56#[derive(Debug, Clone)]
58pub struct NoteHit {
59 pub path: String,
61 pub name: String,
63 pub snippet: String,
65 pub backlink_count: usize,
67 pub name_similarity: i32,
69}
70
71pub struct KnowledgeBase {
79 fs: RwLock<VirtualFs>,
81 backlinks: RwLock<BacklinkIndex>,
83 agent_writes: ParkingMutex<HashSet<String>>,
85 on_change: RwLock<Vec<FileChangeCallback>>,
88}
89
90impl std::fmt::Debug for KnowledgeBase {
91 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92 f.debug_struct("KnowledgeBase")
93 .field("root", &self.fs.read().root())
94 .finish()
95 }
96}
97
98impl KnowledgeBase {
99 pub fn new(root: PathBuf) -> Result<Self> {
101 let fs = VirtualFs::new(root)?;
102 Ok(Self {
103 fs: RwLock::new(fs),
104 backlinks: RwLock::new(BacklinkIndex::new()),
105 agent_writes: ParkingMutex::new(HashSet::new()),
106 on_change: RwLock::new(Vec::new()),
107 })
108 }
109
110 pub fn for_space(space_dir: &std::path::Path) -> Result<Self> {
112 Self::new(space_dir.join("knowledge"))
113 }
114
115 pub fn root(&self) -> PathBuf {
117 self.fs.read().root().to_path_buf()
118 }
119
120 pub fn on_file_change<F>(&self, f: F)
125 where
126 F: Fn(&str, FileChange) + Send + Sync + 'static,
127 {
128 self.on_change.write().push(Box::new(f));
129 }
130
131 fn notify_change(&self, path: &str, change: FileChange) {
133 for cb in self.on_change.read().iter() {
134 cb(path, change.clone());
135 }
136 }
137
138 pub fn note_read(&self, path: &str) -> Result<Option<String>> {
142 let fs = self.fs.read();
143 match fs.read_path(path) {
144 Ok(content) => Ok(Some(content)),
145 Err(_) => Ok(None),
146 }
147 }
148
149 pub fn note_write(&self, path: &str, content: &str) -> Result<()> {
154 let fs = self.fs.read();
155 let is_new = fs.read_path(path).is_err();
156
157 fs.write_path(path, content)?;
158
159 {
160 let mut backlinks = self.backlinks.write();
161 backlinks.remove_file(path);
162 backlinks.index_file(path, content);
163 }
164
165 self.notify_change(
166 path,
167 if is_new {
168 FileChange::Created(path.to_string())
169 } else {
170 FileChange::Updated(path.to_string())
171 },
172 );
173 Ok(())
174 }
175
176 pub fn note_delete(&self, path: &str) -> Result<()> {
178 self.fs.read().delete_path(path)?;
179 self.backlinks.write().remove_file(path);
180 self.notify_change(path, FileChange::Deleted(path.to_string()));
181 Ok(())
182 }
183
184 pub fn note_move(&self, old_path: &str, new_path: &str) -> Result<()> {
186 self.fs.read().rename_path(old_path, new_path)?;
187 self.backlinks.write().remove_file(old_path);
188 if let Some(content) = self.note_read(new_path)? {
189 self.backlinks.write().index_file(new_path, &content);
190 }
191 self.notify_change(
192 old_path,
193 FileChange::Moved {
194 old: old_path.to_string(),
195 new: new_path.to_string(),
196 },
197 );
198 Ok(())
199 }
200
201 pub fn note_tree(&self, dir: &str) -> Result<Vec<FileEntry>> {
203 let fs = self.fs.read();
204 let dir = if dir.is_empty() || dir == "/" {
205 DIR_USER_ROOT
206 } else {
207 dir
208 };
209 Ok(fs.files_and_dirs(dir)?)
210 }
211
212 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<NoteHit>> {
219 let fs = self.fs.read();
220 let files = fs.search_files_by_name(query)?;
221
222 let hits: Vec<NoteHit> = files
223 .into_iter()
224 .take(limit)
225 .map(|f| {
226 let path = if f.parent_dir == DIR_USER_ROOT || f.parent_dir == "/" {
227 f.name.clone()
228 } else {
229 format!("{}/{}", f.parent_dir, f.name)
230 };
231 let name_sim = similar(&f.display_name, query) as i32;
232 let bl_count = self.backlinks.read().backlink_count(&path);
233 NoteHit {
234 path,
235 name: f.display_name,
236 snippet: String::new(),
237 backlink_count: bl_count,
238 name_similarity: name_sim,
239 }
240 })
241 .collect();
242
243 Ok(hits)
244 }
245
246 pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
250 self.backlinks.read().backlinks_for(path)
251 }
252
253 pub fn link_graph(&self) -> LinkGraph {
255 self.backlinks.read().link_graph()
256 }
257
258 pub fn index_all(&self) -> Result<usize> {
263 let fs = self.fs.read();
264 let entries = fs.files_and_dirs(DIR_USER_ROOT)?;
265 let mut count = 0;
266
267 for entry in &entries {
268 if entry.is_dir {
269 let sub = fs.files_and_dirs(&entry.name)?;
270 for sub_entry in &sub {
271 if !sub_entry.is_dir && sub_entry.name.ends_with(".md") {
272 let path = format!("{}/{}", entry.name, sub_entry.name);
273 if let Ok(content) = fs.read_path(&path) {
274 self.backlinks.write().index_file(&path, &content);
275 count += 1;
276 }
277 }
278 }
279 } else if entry.name.ends_with(".md") {
280 if let Ok(content) = fs.read_path(&entry.name) {
281 self.backlinks.write().index_file(&entry.name, &content);
282 count += 1;
283 }
284 }
285 }
286
287 tracing::info!(files = count, "Knowledge base indexed");
288 Ok(count)
289 }
290
291 pub fn chat_append(&self, message: &str) -> Result<()> {
295 let header = today_chat_header();
296 let timestamp = chrono::Local::now().format("`15:04`").to_string();
297 let entry = format!("{} {}", timestamp, message);
298
299 let mut content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
300 if !content.contains(&header) {
301 if !content.trim_end().ends_with('\n') {
302 content.push('\n');
303 }
304 content.push_str(&header);
305 content.push('\n');
306 }
307 content.push_str(&entry);
308 content.push('\n');
309 self.note_write(CHAT_FILENAME, &content)?;
310 Ok(())
311 }
312
313 pub fn chat_messages(&self) -> Result<Vec<String>> {
315 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
316 Ok(read_chat_msgs(&content))
317 }
318
319 pub fn chat_delete(&self, msg_hash: &str) -> Result<bool> {
321 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
322 match delete_chat_msg(&content, msg_hash) {
323 Ok(new_content) => {
324 self.note_write(CHAT_FILENAME, &new_content)?;
325 Ok(true)
326 }
327 Err(_) => Ok(false),
328 }
329 }
330
331 pub fn chat_rename(&self, msg_hash: &str, new_body: &str) -> Result<bool> {
333 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
334 match rename_chat_msg(&content, msg_hash, new_body) {
335 Ok(new_content) => {
336 self.note_write(CHAT_FILENAME, &new_content)?;
337 Ok(true)
338 }
339 Err(_) => Ok(false),
340 }
341 }
342
343 pub fn chat_move_to(&self, msg_hash: &str, target_path: &str) -> Result<bool> {
345 let chat_content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
346 let target_content = self.note_read(target_path)?.unwrap_or_default();
347 let (new_chat, new_target) = move_from_chat(&chat_content, msg_hash, &target_content);
348 if new_chat != chat_content {
349 self.note_write(CHAT_FILENAME, &new_chat)?;
350 self.note_write(target_path, &new_target)?;
351 Ok(true)
352 } else {
353 Ok(false)
354 }
355 }
356
357 pub fn journal_add_record(&self, record: &str) -> Result<()> {
361 let fs = self.fs.read();
362 let tz = chrono::Local::now().offset().to_owned();
363 journal_add_record(&fs, record, tz)?;
364 Ok(())
365 }
366
367 pub fn journal_add_emoji(&self, emoji: &str) -> Result<()> {
369 let fs = self.fs.read();
370 let tz = chrono::Local::now().offset().to_owned();
371 journal_add_emoji(&fs, emoji, tz)?;
372 Ok(())
373 }
374
375 pub fn journal_today_path(&self) -> String {
377 let tz = chrono::Local::now().offset().to_owned();
378 today_journal_filename(tz)
379 }
380
381 pub fn habits(&self, year: i32) -> Result<Habits> {
385 let fs = self.fs.read();
386 Ok(habits(&fs, year)?)
387 }
388
389 pub fn habits_last_week(&self) -> Result<Habits> {
391 let fs = self.fs.read();
392 let tz = chrono::Local::now().offset().to_owned();
393 Ok(last_week_habits(&fs, tz)?)
394 }
395
396 pub fn habits_write(&self, year: i32, habits: &Habits) -> Result<()> {
398 let fs = self.fs.read();
399 write_habits(&fs, year, habits)?;
400 Ok(())
401 }
402
403 pub fn config(&self) -> Result<KnowledgeConfig> {
407 let fs = self.fs.read();
408 match fs.read_path("config.json") {
409 Ok(content) => Ok(serde_json::from_str(&content).unwrap_or_default()),
410 Err(_) => Ok(KnowledgeConfig::default()),
411 }
412 }
413
414 pub fn set_config(&self, config: &KnowledgeConfig) -> Result<()> {
416 let json = serde_json::to_string_pretty(config)?;
417 self.note_write("config.json", &json)?;
418 Ok(())
419 }
420
421 pub fn checklist_items(
425 &self,
426 path: &str,
427 ) -> Result<(Vec<String>, std::collections::HashMap<String, bool>)> {
428 let content = self.note_read(path)?.unwrap_or_default();
429 Ok(checklist_items(&content))
430 }
431
432 pub fn checklist_incomplete(&self, path: &str) -> Result<Vec<String>> {
434 let content = self.note_read(path)?.unwrap_or_default();
435 Ok(incomplete_checklist_items(&content))
436 }
437
438 pub fn checklist_add(&self, path: &str, item: &str, checked: bool) -> Result<()> {
440 let content = self.note_read(path)?.unwrap_or_default();
441 let updated = add_checklist_item(&content, item, checked);
442 self.note_write(path, &updated)
443 }
444
445 pub fn checklist_complete(&self, path: &str, item_hash: &str) -> Result<bool> {
447 let content = self.note_read(path)?.unwrap_or_default();
448 let (new_content, found) = complete_checklist_item(&content, item_hash);
449 if !found.is_empty() {
450 self.note_write(path, &new_content)?;
451 Ok(true)
452 } else {
453 Ok(false)
454 }
455 }
456
457 pub fn checklist_remove(&self, path: &str, item_or_hash: &str) -> Result<bool> {
459 let content = self.note_read(path)?.unwrap_or_default();
460 let (new_content, removed) = remove_checklist_item(&content, item_or_hash);
461 if !removed.is_empty() {
462 self.note_write(path, &new_content)?;
463 Ok(true)
464 } else {
465 Ok(false)
466 }
467 }
468
469 pub fn checklist_remove_completed(&self, path: &str) -> Result<(String, String)> {
471 let content = self.note_read(path)?.unwrap_or_default();
472 let (kept, removed) = remove_completed_checklist_items(&content);
473 if !removed.is_empty() {
474 self.note_write(path, &kept)?;
475 }
476 Ok((kept, removed))
477 }
478
479 pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
483 let fs = self.fs.read();
484 let config = self.config()?;
485 Ok(remove_completed_items(&fs, &config)?)
486 }
487
488 pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
490 let fs = self.fs.read();
491 let mut config = self.config()?;
492 let moved = move_due_tasks(&fs, &mut config)?;
493 if !moved.is_empty() {
494 self.set_config(&config)?;
495 }
496 Ok(moved)
497 }
498
499 pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
503 let fs = self.fs.read();
504 Ok(today_report(&fs)?)
505 }
506
507 pub fn done_today(&self) -> Result<Vec<FileEntry>> {
509 let fs = self.fs.read();
510 Ok(done_today(&fs)?)
511 }
512
513 pub fn markdown_to_html(&self, md: &str) -> String {
517 markdown_to_html(md)
518 }
519
520 pub fn auto_emoji(&self, text: &str) -> String {
522 emoji_for(text)
523 }
524
525 pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
527 world_clock_for_names(timezone_names)
528 }
529
530 pub fn mark_agent_write(&self, path: &str) {
534 self.agent_writes.lock().insert(path.to_string());
535 }
536
537 pub fn is_agent_write(&self, path: &str) -> bool {
539 self.agent_writes.lock().contains(path)
540 }
541
542 pub fn clear_agent_write(&self, path: &str) {
544 self.agent_writes.lock().remove(path);
545 }
546
547 pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
551 crate::tgtxt::extract_text_imgs_links(text)
552 }
553
554 pub fn extract_headings(&self, content: &str) -> Vec<String> {
558 extract_headings(content).into_iter().take(5).collect()
559 }
560}
561
562#[cfg(test)]
567mod tests {
568 use super::*;
569
570 fn make_test_kb() -> KnowledgeBase {
571 let dir = std::env::temp_dir().join(format!("test-kb-{}", uuid::Uuid::new_v4()));
572 KnowledgeBase::new(dir.join("kb")).expect("test knowledge base")
573 }
574
575 #[test]
576 fn test_note_write_and_read() {
577 let kb = make_test_kb();
578 kb.note_write("brain/Rust.md", "# Rust\n\nHello world")
579 .unwrap();
580 let content = kb.note_read("brain/Rust.md").unwrap();
581 assert_eq!(content, Some("# Rust\n\nHello world".to_string()));
582 }
583
584 #[test]
585 fn test_note_read_missing() {
586 let kb = make_test_kb();
587 assert_eq!(kb.note_read("nonexistent.md").unwrap(), None);
588 }
589
590 #[test]
591 fn test_note_delete() {
592 let kb = make_test_kb();
593 kb.note_write("del.md", "to delete").unwrap();
594 kb.note_delete("del.md").unwrap();
595 assert_eq!(kb.note_read("del.md").unwrap(), None);
596 }
597
598 #[test]
599 fn test_note_move() {
600 let kb = make_test_kb();
601 kb.note_write("old.md", "content").unwrap();
602 kb.note_move("old.md", "new.md").unwrap();
603 assert_eq!(kb.note_read("old.md").unwrap(), None);
604 assert_eq!(kb.note_read("new.md").unwrap(), Some("content".to_string()));
605 }
606
607 #[test]
608 fn test_backlinks() {
609 let kb = make_test_kb();
610 kb.note_write("brain/Rust.md", "See [Ownership](brain/Ownership.md)")
611 .unwrap();
612 let bl = kb.backlinks_for("brain/Ownership.md");
613 assert_eq!(bl.len(), 1);
614 assert_eq!(bl[0].source_path, "brain/Rust.md");
615 }
616
617 #[test]
618 fn test_note_tree() {
619 let kb = make_test_kb();
620 kb.note_write("brain/Rust.md", "Rust").unwrap();
621 let entries = kb.note_tree("brain").unwrap();
622 assert!(!entries.is_empty());
623 }
624
625 #[test]
626 fn test_search_by_name() {
627 let kb = make_test_kb();
628 kb.note_write("brain/Rust.md", "Rust content").unwrap();
629 let hits = kb.search("Rust", 10).unwrap();
630 assert!(!hits.is_empty());
631 }
632
633 #[test]
634 fn test_link_graph() {
635 let kb = make_test_kb();
636 kb.note_write("a.md", "[b](b.md)").unwrap();
637 let graph = kb.link_graph();
638 assert!(!graph.edges.is_empty());
639 }
640
641 #[test]
642 fn test_agent_write_tracking() {
643 let kb = make_test_kb();
644 assert!(!kb.is_agent_write("test.md"));
645 kb.mark_agent_write("test.md");
646 assert!(kb.is_agent_write("test.md"));
647 kb.clear_agent_write("test.md");
648 assert!(!kb.is_agent_write("test.md"));
649 }
650
651 #[test]
652 fn test_index_all() {
653 let kb = make_test_kb();
654 kb.note_write("brain/Rust.md", "Rust [Go](brain/Go.md)")
655 .unwrap();
656 kb.note_write("brain/Go.md", "Go language").unwrap();
657 kb.note_write("index.md", "Welcome").unwrap();
658 let count = kb.index_all().unwrap();
659 assert_eq!(count, 3);
660 let bl = kb.backlinks_for("brain/Go.md");
661 assert_eq!(bl.len(), 1);
662 }
663
664 #[test]
665 fn test_on_file_change_callback() {
666 let kb = make_test_kb();
667 let _called = std::sync::atomic::AtomicBool::new(false);
668 let path_clone: std::sync::Arc<std::sync::atomic::AtomicBool> =
669 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
670 let flag = path_clone.clone();
671
672 kb.on_file_change(move |path, change| {
673 let _ = path;
674 let _ = change;
675 flag.store(true, std::sync::atomic::Ordering::SeqCst);
676 });
677
678 kb.note_write("test.md", "hello").unwrap();
679 assert!(path_clone.load(std::sync::atomic::Ordering::SeqCst));
680 }
681
682 #[test]
683 fn test_chat_append() {
684 let kb = make_test_kb();
685 kb.chat_append("Test message").unwrap();
686 let messages = kb.chat_messages().unwrap();
687 assert!(!messages.is_empty());
688 }
689
690 #[test]
691 fn test_config() {
692 let kb = make_test_kb();
693 let cfg = kb.config().unwrap();
694 let cfg2 = kb.config().unwrap();
696 assert_eq!(cfg.language, cfg2.language);
697 }
698
699 #[test]
700 fn test_markdown_to_html() {
701 let kb = make_test_kb();
702 let html = kb.markdown_to_html("# Hello\n\n**world**");
703 assert!(html.contains("Hello"), "HTML should contain Hello: {html}");
705 assert!(html.contains("world"), "HTML should contain world: {html}");
706 }
707
708 #[test]
709 fn test_auto_emoji() {
710 let kb = make_test_kb();
711 let emoji = kb.auto_emoji("cooking pasta");
712 assert!(!emoji.is_empty());
713 }
714
715 #[test]
716 fn test_extract_headings() {
717 let kb = make_test_kb();
718 let headings = kb.extract_headings("# Title\n\n## Section\n\n### Subsection");
719 assert!(headings.len() >= 2);
720 }
721}