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_restore(&self, path: &str, content: &str) -> Result<()> {
191 self.fs.read().write_path(path, content)?;
192 let mut backlinks = self.backlinks.write();
193 backlinks.remove_file(path);
194 backlinks.index_file(path, content);
195 Ok(())
197 }
198
199 pub fn note_move(&self, old_path: &str, new_path: &str) -> Result<()> {
201 self.fs.read().rename_path(old_path, new_path)?;
202 self.backlinks.write().remove_file(old_path);
203 if let Some(content) = self.note_read(new_path)? {
204 self.backlinks.write().index_file(new_path, &content);
205 }
206 self.notify_change(
207 old_path,
208 FileChange::Moved {
209 old: old_path.to_string(),
210 new: new_path.to_string(),
211 },
212 );
213 Ok(())
214 }
215
216 pub fn note_tree(&self, dir: &str) -> Result<Vec<FileEntry>> {
218 let fs = self.fs.read();
219 let dir = if dir.is_empty() || dir == "/" {
220 DIR_USER_ROOT
221 } else {
222 dir
223 };
224 Ok(fs.files_and_dirs(dir)?)
225 }
226
227 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<NoteHit>> {
234 let fs = self.fs.read();
235 let files = fs.search_files_by_name(query)?;
236
237 let hits: Vec<NoteHit> = files
238 .into_iter()
239 .take(limit)
240 .map(|f| {
241 let path = if f.parent_dir == DIR_USER_ROOT || f.parent_dir == "/" {
242 f.name.clone()
243 } else {
244 format!("{}/{}", f.parent_dir, f.name)
245 };
246 let name_sim = similar(&f.display_name, query) as i32;
247 let bl_count = self.backlinks.read().backlink_count(&path);
248 NoteHit {
249 path,
250 name: f.display_name,
251 snippet: String::new(),
252 backlink_count: bl_count,
253 name_similarity: name_sim,
254 }
255 })
256 .collect();
257
258 Ok(hits)
259 }
260
261 pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
265 self.backlinks.read().backlinks_for(path)
266 }
267
268 pub fn link_graph(&self) -> LinkGraph {
270 self.backlinks.read().link_graph()
271 }
272
273 pub fn index_all(&self) -> Result<usize> {
278 let fs = self.fs.read();
279 let entries = fs.files_and_dirs(DIR_USER_ROOT)?;
280 let mut count = 0;
281
282 for entry in &entries {
283 if entry.is_dir {
284 let sub = fs.files_and_dirs(&entry.name)?;
285 for sub_entry in &sub {
286 if !sub_entry.is_dir && sub_entry.name.ends_with(".md") {
287 let path = format!("{}/{}", entry.name, sub_entry.name);
288 if let Ok(content) = fs.read_path(&path) {
289 self.backlinks.write().index_file(&path, &content);
290 count += 1;
291 }
292 }
293 }
294 } else if entry.name.ends_with(".md") {
295 if let Ok(content) = fs.read_path(&entry.name) {
296 self.backlinks.write().index_file(&entry.name, &content);
297 count += 1;
298 }
299 }
300 }
301
302 tracing::info!(files = count, "Knowledge base indexed");
303 Ok(count)
304 }
305
306 pub fn chat_append(&self, message: &str) -> Result<()> {
310 let header = today_chat_header();
311 let timestamp = chrono::Local::now().format("`15:04`").to_string();
312 let entry = format!("{timestamp} {message}");
313
314 let mut content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
315 if !content.contains(&header) {
316 if !content.trim_end().ends_with('\n') {
317 content.push('\n');
318 }
319 content.push_str(&header);
320 content.push('\n');
321 }
322 content.push_str(&entry);
323 content.push('\n');
324 self.note_write(CHAT_FILENAME, &content)?;
325 Ok(())
326 }
327
328 pub fn chat_messages(&self) -> Result<Vec<String>> {
330 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
331 Ok(read_chat_msgs(&content))
332 }
333
334 pub fn chat_delete(&self, msg_hash: &str) -> Result<bool> {
336 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
337 match delete_chat_msg(&content, msg_hash) {
338 Ok(new_content) => {
339 self.note_write(CHAT_FILENAME, &new_content)?;
340 Ok(true)
341 }
342 Err(_) => Ok(false),
343 }
344 }
345
346 pub fn chat_rename(&self, msg_hash: &str, new_body: &str) -> Result<bool> {
348 let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
349 match rename_chat_msg(&content, msg_hash, new_body) {
350 Ok(new_content) => {
351 self.note_write(CHAT_FILENAME, &new_content)?;
352 Ok(true)
353 }
354 Err(_) => Ok(false),
355 }
356 }
357
358 pub fn chat_move_to(&self, msg_hash: &str, target_path: &str) -> Result<bool> {
360 let chat_content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
361 let target_content = self.note_read(target_path)?.unwrap_or_default();
362 let (new_chat, new_target) = move_from_chat(&chat_content, msg_hash, &target_content);
363 if new_chat != chat_content {
364 self.note_write(CHAT_FILENAME, &new_chat)?;
365 self.note_write(target_path, &new_target)?;
366 Ok(true)
367 } else {
368 Ok(false)
369 }
370 }
371
372 pub fn journal_add_record(&self, record: &str) -> Result<()> {
376 let fs = self.fs.read();
377 let tz = chrono::Local::now().offset().to_owned();
378 journal_add_record(&fs, record, tz)?;
379 Ok(())
380 }
381
382 pub fn journal_add_emoji(&self, emoji: &str) -> Result<()> {
384 let fs = self.fs.read();
385 let tz = chrono::Local::now().offset().to_owned();
386 journal_add_emoji(&fs, emoji, tz)?;
387 Ok(())
388 }
389
390 pub fn journal_today_path(&self) -> String {
392 let tz = chrono::Local::now().offset().to_owned();
393 today_journal_filename(tz)
394 }
395
396 pub fn habits(&self, year: i32) -> Result<Habits> {
400 let fs = self.fs.read();
401 Ok(habits(&fs, year)?)
402 }
403
404 pub fn habits_last_week(&self) -> Result<Habits> {
406 let fs = self.fs.read();
407 let tz = chrono::Local::now().offset().to_owned();
408 Ok(last_week_habits(&fs, tz)?)
409 }
410
411 pub fn habits_write(&self, year: i32, habits: &Habits) -> Result<()> {
413 let fs = self.fs.read();
414 write_habits(&fs, year, habits)?;
415 Ok(())
416 }
417
418 pub fn config(&self) -> Result<KnowledgeConfig> {
422 let fs = self.fs.read();
423 match fs.read_path("config.json") {
424 Ok(content) => Ok(serde_json::from_str(&content).unwrap_or_default()),
425 Err(_) => Ok(KnowledgeConfig::default()),
426 }
427 }
428
429 pub fn set_config(&self, config: &KnowledgeConfig) -> Result<()> {
431 let json = serde_json::to_string_pretty(config)?;
432 self.note_write("config.json", &json)?;
433 Ok(())
434 }
435
436 pub fn checklist_items(
440 &self,
441 path: &str,
442 ) -> Result<(Vec<String>, std::collections::HashMap<String, bool>)> {
443 let content = self.note_read(path)?.unwrap_or_default();
444 Ok(checklist_items(&content))
445 }
446
447 pub fn checklist_incomplete(&self, path: &str) -> Result<Vec<String>> {
449 let content = self.note_read(path)?.unwrap_or_default();
450 Ok(incomplete_checklist_items(&content))
451 }
452
453 pub fn checklist_add(&self, path: &str, item: &str, checked: bool) -> Result<()> {
455 let content = self.note_read(path)?.unwrap_or_default();
456 let updated = add_checklist_item(&content, item, checked);
457 self.note_write(path, &updated)
458 }
459
460 pub fn checklist_complete(&self, path: &str, item_hash: &str) -> Result<bool> {
462 let content = self.note_read(path)?.unwrap_or_default();
463 let (new_content, found) = complete_checklist_item(&content, item_hash);
464 if !found.is_empty() {
465 self.note_write(path, &new_content)?;
466 Ok(true)
467 } else {
468 Ok(false)
469 }
470 }
471
472 pub fn checklist_remove(&self, path: &str, item_or_hash: &str) -> Result<bool> {
474 let content = self.note_read(path)?.unwrap_or_default();
475 let (new_content, removed) = remove_checklist_item(&content, item_or_hash);
476 if !removed.is_empty() {
477 self.note_write(path, &new_content)?;
478 Ok(true)
479 } else {
480 Ok(false)
481 }
482 }
483
484 pub fn checklist_remove_completed(&self, path: &str) -> Result<(String, String)> {
486 let content = self.note_read(path)?.unwrap_or_default();
487 let (kept, removed) = remove_completed_checklist_items(&content);
488 if !removed.is_empty() {
489 self.note_write(path, &kept)?;
490 }
491 Ok((kept, removed))
492 }
493
494 pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
498 let fs = self.fs.read();
499 let config = self.config()?;
500 Ok(remove_completed_items(&fs, &config)?)
501 }
502
503 pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
505 let fs = self.fs.read();
506 let mut config = self.config()?;
507 let moved = move_due_tasks(&fs, &mut config)?;
508 if !moved.is_empty() {
509 self.set_config(&config)?;
510 }
511 Ok(moved)
512 }
513
514 pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
518 let fs = self.fs.read();
519 Ok(today_report(&fs)?)
520 }
521
522 pub fn done_today(&self) -> Result<Vec<FileEntry>> {
524 let fs = self.fs.read();
525 Ok(done_today(&fs)?)
526 }
527
528 pub fn markdown_to_html(&self, md: &str) -> String {
532 markdown_to_html(md)
533 }
534
535 pub fn auto_emoji(&self, text: &str) -> String {
537 emoji_for(text)
538 }
539
540 pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
542 world_clock_for_names(timezone_names)
543 }
544
545 pub fn mark_agent_write(&self, path: &str) {
549 self.agent_writes.lock().insert(path.to_string());
550 }
551
552 pub fn is_agent_write(&self, path: &str) -> bool {
554 self.agent_writes.lock().contains(path)
555 }
556
557 pub fn clear_agent_write(&self, path: &str) {
559 self.agent_writes.lock().remove(path);
560 }
561
562 pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
566 crate::tgtxt::extract_text_imgs_links(text)
567 }
568
569 pub fn extract_headings(&self, content: &str) -> Vec<String> {
573 extract_headings(content).into_iter().take(5).collect()
574 }
575}
576
577#[cfg(test)]
582mod tests {
583 use super::*;
584
585 fn make_test_kb() -> KnowledgeBase {
586 let dir = std::env::temp_dir().join(format!("test-kb-{}", uuid::Uuid::new_v4()));
587 KnowledgeBase::new(dir.join("kb")).expect("test knowledge base")
588 }
589
590 #[test]
591 fn test_note_write_and_read() {
592 let kb = make_test_kb();
593 kb.note_write("brain/Rust.md", "# Rust\n\nHello world")
594 .unwrap();
595 let content = kb.note_read("brain/Rust.md").unwrap();
596 assert_eq!(content, Some("# Rust\n\nHello world".to_string()));
597 }
598
599 #[test]
600 fn test_note_read_missing() {
601 let kb = make_test_kb();
602 assert_eq!(kb.note_read("nonexistent.md").unwrap(), None);
603 }
604
605 #[test]
606 fn test_note_delete() {
607 let kb = make_test_kb();
608 kb.note_write("del.md", "to delete").unwrap();
609 kb.note_delete("del.md").unwrap();
610 assert_eq!(kb.note_read("del.md").unwrap(), None);
611 }
612
613 #[test]
614 fn test_note_move() {
615 let kb = make_test_kb();
616 kb.note_write("old.md", "content").unwrap();
617 kb.note_move("old.md", "new.md").unwrap();
618 assert_eq!(kb.note_read("old.md").unwrap(), None);
619 assert_eq!(kb.note_read("new.md").unwrap(), Some("content".to_string()));
620 }
621
622 #[test]
623 fn test_backlinks() {
624 let kb = make_test_kb();
625 kb.note_write("brain/Rust.md", "See [Ownership](brain/Ownership.md)")
626 .unwrap();
627 let bl = kb.backlinks_for("brain/Ownership.md");
628 assert_eq!(bl.len(), 1);
629 assert_eq!(bl[0].source_path, "brain/Rust.md");
630 }
631
632 #[test]
633 fn test_note_tree() {
634 let kb = make_test_kb();
635 kb.note_write("brain/Rust.md", "Rust").unwrap();
636 let entries = kb.note_tree("brain").unwrap();
637 assert!(!entries.is_empty());
638 }
639
640 #[test]
641 fn test_search_by_name() {
642 let kb = make_test_kb();
643 kb.note_write("brain/Rust.md", "Rust content").unwrap();
644 let hits = kb.search("Rust", 10).unwrap();
645 assert!(!hits.is_empty());
646 }
647
648 #[test]
649 fn test_link_graph() {
650 let kb = make_test_kb();
651 kb.note_write("a.md", "[b](b.md)").unwrap();
652 let graph = kb.link_graph();
653 assert!(!graph.edges.is_empty());
654 }
655
656 #[test]
657 fn test_agent_write_tracking() {
658 let kb = make_test_kb();
659 assert!(!kb.is_agent_write("test.md"));
660 kb.mark_agent_write("test.md");
661 assert!(kb.is_agent_write("test.md"));
662 kb.clear_agent_write("test.md");
663 assert!(!kb.is_agent_write("test.md"));
664 }
665
666 #[test]
667 fn test_index_all() {
668 let kb = make_test_kb();
669 kb.note_write("brain/Rust.md", "Rust [Go](brain/Go.md)")
670 .unwrap();
671 kb.note_write("brain/Go.md", "Go language").unwrap();
672 kb.note_write("index.md", "Welcome").unwrap();
673 let count = kb.index_all().unwrap();
674 assert_eq!(count, 3);
675 let bl = kb.backlinks_for("brain/Go.md");
676 assert_eq!(bl.len(), 1);
677 }
678
679 #[test]
680 fn test_on_file_change_callback() {
681 let kb = make_test_kb();
682 let _called = std::sync::atomic::AtomicBool::new(false);
683 let path_clone: std::sync::Arc<std::sync::atomic::AtomicBool> =
684 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
685 let flag = path_clone.clone();
686
687 kb.on_file_change(move |path, change| {
688 let _ = path;
689 let _ = change;
690 flag.store(true, std::sync::atomic::Ordering::SeqCst);
691 });
692
693 kb.note_write("test.md", "hello").unwrap();
694 assert!(path_clone.load(std::sync::atomic::Ordering::SeqCst));
695 }
696
697 #[test]
698 fn test_chat_append() {
699 let kb = make_test_kb();
700 kb.chat_append("Test message").unwrap();
701 let messages = kb.chat_messages().unwrap();
702 assert!(!messages.is_empty());
703 }
704
705 #[test]
706 fn test_config() {
707 let kb = make_test_kb();
708 let cfg = kb.config().unwrap();
709 let cfg2 = kb.config().unwrap();
711 assert_eq!(cfg.language, cfg2.language);
712 }
713
714 #[test]
715 fn test_markdown_to_html() {
716 let kb = make_test_kb();
717 let html = kb.markdown_to_html("# Hello\n\n**world**");
718 assert!(html.contains("Hello"), "HTML should contain Hello: {html}");
720 assert!(html.contains("world"), "HTML should contain world: {html}");
721 }
722
723 #[test]
724 fn test_auto_emoji() {
725 let kb = make_test_kb();
726 let emoji = kb.auto_emoji("cooking pasta");
727 assert!(!emoji.is_empty());
728 }
729
730 #[test]
731 fn test_extract_headings() {
732 let kb = make_test_kb();
733 let headings = kb.extract_headings("# Title\n\n## Section\n\n### Subsection");
734 assert!(headings.len() >= 2);
735 }
736}