Skip to main content

oxios_markdown/
knowledge.rs

1//! KnowledgeBase — markdown knowledge base application layer.
2//!
3//! Integrates `VirtualFs`, `BacklinkIndex`, and all app-layer features
4//! (chat, journal, habits, checklist, etc.) into a single struct.
5//!
6//! **No kernel dependencies. No AI dependencies.**
7//! This crate can be used standalone by any channel (web, CLI, etc.)
8//! without going through the kernel.
9
10use std::collections::HashSet;
11use std::path::PathBuf;
12
13use anyhow::Result;
14use parking_lot::{Mutex as ParkingMutex, RwLock};
15
16/// Callback type for file change notifications.
17/// Used by [`KnowledgeLens`] to keep the semantic index in sync.
18pub 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::{
32    StemIndex, extract_headings, rewrite_link_targets, rewrite_wikilink_targets, similar,
33};
34use crate::plugins::world_clock_for_names;
35use crate::stats::{done_today, today_report};
36use crate::types::NoteMeta;
37use crate::types::{CHAT_FILENAME, DIR_USER_ROOT, FileEntry, Habits, KnowledgeConfig};
38#[cfg(test)]
39use crate::types::{NoteQuality, NoteSource};
40use crate::worker::{move_due_tasks, remove_completed_items};
41use crate::{today_chat_header, today_journal_filename};
42
43/// File change event emitted via `on_file_change` callbacks.
44#[derive(Debug, Clone)]
45pub enum FileChange {
46    /// A new file was created.
47    Created(String),
48    /// An existing file was updated.
49    Updated(String),
50    /// A file was deleted.
51    Deleted(String),
52    /// A file was moved or renamed.
53    Moved {
54        /// Original path before the move.
55        old: String,
56        /// New path after the move.
57        new: String,
58    },
59}
60
61/// Knowledge search hit (file-name based).
62#[derive(Debug, Clone)]
63pub struct NoteHit {
64    /// File path relative to knowledge root.
65    pub path: String,
66    /// Display name of the file.
67    pub name: String,
68    /// Content snippet.
69    pub snippet: String,
70    /// Number of backlinks pointing to this note.
71    pub backlink_count: usize,
72    /// Name similarity score (0–100).
73    pub name_similarity: i32,
74}
75
76/// Markdown knowledge base application layer.
77///
78/// Wraps [`VirtualFs`] for sandboxed file I/O, [`BacklinkIndex`] for
79/// link tracking, and provides all app-layer features (chat, journal,
80/// habits, checklist, etc.).
81///
82/// **No kernel dependencies.** Can be used standalone by any channel.
83pub struct KnowledgeBase {
84    /// Sandboxed filesystem.
85    fs: RwLock<VirtualFs>,
86    /// Bidirectional link index.
87    backlinks: RwLock<BacklinkIndex>,
88    /// Files written by agents (not by the user).
89    agent_writes: ParkingMutex<HashSet<String>>,
90    /// Callbacks invoked on file changes.
91    /// Used by [`KnowledgeLens`] to keep semantic index in sync.
92    on_change: RwLock<Vec<FileChangeCallback>>,
93}
94
95impl std::fmt::Debug for KnowledgeBase {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.debug_struct("KnowledgeBase")
98            .field("root", &self.fs.read().root())
99            .finish()
100    }
101}
102
103impl KnowledgeBase {
104    /// Create a new KnowledgeBase for the given root directory.
105    pub fn new(root: PathBuf) -> Result<Self> {
106        let fs = VirtualFs::new(root)?;
107        Ok(Self {
108            fs: RwLock::new(fs),
109            backlinks: RwLock::new(BacklinkIndex::new()),
110            agent_writes: ParkingMutex::new(HashSet::new()),
111            on_change: RwLock::new(Vec::new()),
112        })
113    }
114
115    /// Create a new KnowledgeBase scoped to a Space's subdirectory.
116    pub fn for_space(space_dir: &std::path::Path) -> Result<Self> {
117        Self::new(space_dir.join("knowledge"))
118    }
119
120    /// Get the root path of the knowledge base.
121    pub fn root(&self) -> PathBuf {
122        self.fs.read().root().to_path_buf()
123    }
124
125    /// Register a callback to be invoked on every file change.
126    ///
127    /// The callback receives `(path, FileChange)`.
128    /// Multiple callbacks can be registered.
129    pub fn on_file_change<F>(&self, f: F)
130    where
131        F: Fn(&str, FileChange) + Send + Sync + 'static,
132    {
133        self.on_change.write().push(Box::new(f));
134    }
135
136    /// Emit file change notifications to all registered callbacks.
137    fn notify_change(&self, path: &str, change: FileChange) {
138        for cb in self.on_change.read().iter() {
139            cb(path, change.clone());
140        }
141    }
142
143    // ── File I/O ───────────────────────────────────────────────────
144
145    /// Read a note's content.
146    pub fn note_read(&self, path: &str) -> Result<Option<String>> {
147        let fs = self.fs.read();
148        match fs.read_path(path) {
149            Ok(content) => Ok(Some(content)),
150            Err(_) => Ok(None),
151        }
152    }
153
154    /// Read a note's raw bytes — for binary assets (images, etc.) that aren't
155    /// valid UTF-8. Text notes should use [`note_read`].
156    pub fn note_read_bytes(&self, path: &str) -> Result<Option<Vec<u8>>> {
157        let fs = self.fs.read();
158        match fs.read_path_bytes(path) {
159            Ok(bytes) => Ok(Some(bytes)),
160            Err(_) => Ok(None),
161        }
162    }
163
164    /// Build a lowercase-stem → paths[] index over every `.md` file in the KB.
165    ///
166    /// Used by `resolve_wikilink` to canonicalize `[[bare-stem]]` targets
167    /// during indexing and (transitively) to decide which wikilinks are
168    /// safe to rewrite on rename. Walks the whole tree; cheap for the
169    /// documented personal-KB scale (hundreds of files).
170    ///
171    /// MUST be called BEFORE acquiring the backlinks write lock — it takes
172    /// the fs read lock, and we never nest the two.
173    fn build_stem_index(&self) -> StemIndex {
174        let mut index: StemIndex = StemIndex::new();
175        let files = match self.list_all_md_files() {
176            Ok(f) => f,
177            Err(e) => {
178                tracing::warn!(error = %e, "stem_index walk failed; wikilinks stay unresolved");
179                return index;
180            }
181        };
182        for (path, _size) in files {
183            let stem = match path.rsplit('/').next() {
184                Some(b) => b.trim_end_matches(".md"),
185                None => path.as_str().trim_end_matches(".md"),
186            }
187            .to_lowercase();
188            index.entry(stem).or_default().push(path);
189        }
190        index
191    }
192    /// Write a note — creates or overwrites.
193    ///
194    /// Writes the `.md` file via VirtualFs, updates the backlink index,
195    /// and notifies registered `on_file_change` callbacks.
196    pub fn note_write(&self, path: &str, content: &str) -> Result<()> {
197        // Hold the write lock across the read-check + write so concurrent
198        // writers cannot interleave their write_all calls (F1). Drop the
199        // lock before notifying callbacks to avoid reentrancy deadlocks.
200        let is_new = {
201            let fs = self.fs.write();
202            let is_new = fs.read_path(path).is_err();
203            fs.write_path(path, content)?;
204            is_new
205        };
206        // Build the stem index BEFORE taking the backlinks write lock
207        // (fs read lock nests under nothing here).
208        let stem_index = self.build_stem_index();
209        {
210            let mut backlinks = self.backlinks.write();
211            backlinks.remove_file(path);
212            backlinks.index_file_with(path, content, &stem_index);
213        }
214
215        self.notify_change(
216            path,
217            if is_new {
218                FileChange::Created(path.to_string())
219            } else {
220                FileChange::Updated(path.to_string())
221            },
222        );
223        Ok(())
224    }
225
226    /// Write a note with provenance metadata (RFC-022).
227    ///
228    /// Prepends a YAML frontmatter block with `oxios:` metadata,
229    /// then delegates to `note_write`. If the file already has an
230    /// `oxios:` frontmatter block, it is merged (preserving `saved_at`,
231    /// updating `quality`/`source`). If the file has non-Oxios
232    /// frontmatter (e.g., Obsidian tags), it is left intact and
233    /// the note is treated as user-authored — no metadata is added.
234    pub fn note_write_with_meta(&self, path: &str, content: &str, meta: &NoteMeta) -> Result<bool> {
235        // Check existing content for frontmatter
236        let existing = self.note_read(path).ok().flatten();
237        let final_content = match existing {
238            Some(ref existing_content) => {
239                let (existing_meta, body) = parse_note_meta(existing_content);
240                match existing_meta {
241                    // Has Oxios frontmatter — merge
242                    Some(old_meta) => {
243                        let merged = NoteMeta {
244                            saved_at: old_meta.saved_at.or(meta.saved_at.clone()),
245                            ..meta.clone()
246                        };
247                        format_frontmatter(&merged, if body.is_empty() { content } else { &body })
248                    }
249                    // No Oxios frontmatter — user-authored or foreign frontmatter.
250                    // Don't touch it. Return Ok without writing.
251                    None => {
252                        tracing::debug!(
253                            path,
254                            "Skipping note_write_with_meta on user-authored note"
255                        );
256                        return Ok(false);
257                    }
258                }
259            }
260            None => format_frontmatter(meta, content),
261        };
262        self.note_write(path, &final_content).map(|_| true)
263    }
264
265    /// List notes that need Dream review (RFC-022).
266    ///
267    /// Scans the vault for `.md` files with `needs_review: true` in their
268    /// Oxios frontmatter. Reads only the frontmatter block (stops at the
269    /// closing `---`) for efficiency.
270    pub fn notes_needing_review(&self) -> Result<Vec<(String, NoteMeta)>> {
271        let fs = self.fs.read();
272        let mut result = Vec::new();
273
274        let files = fs.all_md_files()?;
275        for (path, _size) in &files {
276            if let Ok(content) = fs.read_path(path) {
277                let (meta, _body) = parse_note_meta(&content);
278                if let Some(m) = meta
279                    && m.needs_review
280                {
281                    result.push((path.clone(), m));
282                }
283            }
284        }
285
286        // Oldest first — they've been raw the longest
287        result.sort_by(|a, b| {
288            a.1.saved_at
289                .as_deref()
290                .unwrap_or("")
291                .cmp(b.1.saved_at.as_deref().unwrap_or(""))
292        });
293
294        Ok(result)
295    }
296    /// Delete the note at `path`, removing it from the filesystem and
297    /// dropping any recorded backlinks for that file.
298    pub fn note_delete(&self, path: &str) -> Result<()> {
299        {
300            let fs = self.fs.write();
301            fs.delete_path(path)?;
302        }
303        self.backlinks.write().remove_file(path);
304        self.notify_change(path, FileChange::Deleted(path.to_string()));
305        Ok(())
306    }
307
308    /// Restore a note's content without triggering file-change callbacks.
309    ///
310    /// Used when reverting to a previous git version — writes the file
311    /// and updates the backlink index, but does **not** fire `on_file_change`
312    /// callbacks. This prevents an infinite loop where restore → write →
313    /// callback → git commit → ... repeats.
314    pub fn note_restore(&self, path: &str, content: &str) -> Result<()> {
315        {
316            let fs = self.fs.write();
317            fs.write_path(path, content)?;
318        }
319        let stem_index = self.build_stem_index();
320        let mut backlinks = self.backlinks.write();
321        backlinks.remove_file(path);
322        backlinks.index_file_with(path, content, &stem_index);
323        // Intentionally skip notify_change()
324        Ok(())
325    }
326    /// Move/rename a note.
327    ///
328    /// In addition to the filesystem rename and backlink reindex, this
329    /// rewrites every `[text](old_path)]` reference in **other** notes
330    /// (and any self-reference in the moved note) to point at `new_path`,
331    /// AND every `[[target]]` wikilink that resolves to old_path (with
332    /// ambiguity guard for bare stems). Without this, renaming a note
333    /// that other notes link to would silently orphan those links — a
334    /// latent bug that affected both the F2 sidebar rename and the
335    /// H1-driven rename.
336    pub fn note_move(&self, old_path: &str, new_path: &str) -> Result<()> {
337        // 0. Build the stem index BEFORE renaming. The bare-stem ambiguity
338        //    check in `rewrite_wikilink_targets` needs old_path still
339        //    present in the tree; after the rename, old_path is gone and
340        //    the stem count would undercount. This is the rewrite-time
341        //    index; step 5 builds a second (post-rename) one for reindex.
342        let pre_stem_index = self.build_stem_index();
343
344        // 1. Rename on disk + read the moved file's content under the fs lock.
345        let new_content = {
346            let fs = self.fs.write();
347            fs.rename_path(old_path, new_path)?;
348            fs.read_path(new_path).ok()
349        };
350
351        // 2. Snapshot the set of files that link to old_path BEFORE we
352        //    tear down the index entry. Done under a read lock; the
353        //    actual rewrites happen outside the lock to keep the critical
354        //    section short.
355        let sources: HashSet<String> = {
356            let backlinks = self.backlinks.read();
357            backlinks.sources_for(old_path)
358        };
359
360        // 3. Rewrite self-references in the moved note (a note can link
361        //    to itself by its old name). This is what gets indexed and
362        //    persisted.
363        let indexed_content = match &new_content {
364            Some(c) => {
365                let (md_done, _) = rewrite_link_targets(c, old_path, new_path);
366                let (wiki_done, _) =
367                    rewrite_wikilink_targets(&md_done, old_path, new_path, Some(&pre_stem_index));
368                if &wiki_done != c {
369                    // Persist the self-reference fix.
370                    let _ = self.fs.write().write_path(new_path, &wiki_done);
371                }
372                wiki_done
373            }
374            None => String::new(),
375        };
376
377        // 4. Rewrite references in every other note that linked to the
378        //    old path. Collect (path, new_content) pairs to write + reindex.
379        let mut touched: Vec<(String, String)> = Vec::with_capacity(sources.len());
380        for src in &sources {
381            if src == old_path || src == new_path {
382                // Self-links already handled above; skip the moved file.
383                continue;
384            }
385            if let Ok(content) = self.fs.read().read_path(src) {
386                let (md_done, n_md) = rewrite_link_targets(&content, old_path, new_path);
387                let (final_done, n_wiki) =
388                    rewrite_wikilink_targets(&md_done, old_path, new_path, Some(&pre_stem_index));
389                if (n_md > 0 || n_wiki > 0) && final_done != content {
390                    touched.push((src.clone(), final_done));
391                }
392            }
393        }
394
395        // 5. Apply reindex: drop old, index new (with rewritten content),
396        //    and reindex every touched source. Build the post-rename stem
397        //    index so wikilinks in the reindexed notes re-resolve against
398        //    the now-current tree.
399        let post_stem_index = self.build_stem_index();
400        {
401            let mut backlinks = self.backlinks.write();
402            backlinks.remove_file(old_path);
403            if !indexed_content.is_empty() {
404                backlinks.index_file_with(new_path, &indexed_content, &post_stem_index);
405            }
406            for (src, content) in &touched {
407                backlinks.index_file_with(src, content, &post_stem_index);
408            }
409        }
410
411        // 6. Persist the rewritten sources. Done AFTER reindexing so a
412        //    crash between write and reindex leaves the index pointing at
413        //    the on-disk content (idempotent on next scan).
414        if !touched.is_empty() {
415            let fs = self.fs.write();
416            for (src, content) in &touched {
417                let _ = fs.write_path(src, content);
418            }
419        }
420
421        self.notify_change(
422            old_path,
423            FileChange::Moved {
424                old: old_path.to_string(),
425                new: new_path.to_string(),
426            },
427        );
428        Ok(())
429    }
430
431    /// List notes in a directory.
432    pub fn note_tree(&self, dir: &str) -> Result<Vec<FileEntry>> {
433        let fs = self.fs.read();
434        let dir = if dir.is_empty() || dir == "/" {
435            DIR_USER_ROOT
436        } else {
437            dir
438        };
439        Ok(fs.files_and_dirs(dir)?)
440    }
441
442    /// List all markdown files in the knowledge base (path, size).
443    /// Used by startup git reconciliation to detect post-crash drift.
444    pub fn list_all_md_files(&self) -> Result<Vec<(String, i64)>> {
445        let fs = self.fs.read();
446        Ok(fs.all_md_files()?)
447    }
448
449    // ── Search (file-name based only) ────────────────────────────
450
451    /// Search notes by file name fuzzy matching.
452    ///
453    /// **Note:** Semantic search is handled by `KnowledgeLens`,
454    /// not by this method.
455    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<NoteHit>> {
456        let fs = self.fs.read();
457        let files = fs.search_files_by_name(query)?;
458
459        let hits: Vec<NoteHit> = files
460            .into_iter()
461            .take(limit)
462            .map(|f| {
463                let path = if f.parent_dir == DIR_USER_ROOT || f.parent_dir == "/" {
464                    f.name.clone()
465                } else {
466                    format!("{}/{}", f.parent_dir, f.name)
467                };
468                let name_sim = similar(&f.display_name, query) as i32;
469                let bl_count = self.backlinks.read().backlink_count(&path);
470                NoteHit {
471                    path,
472                    name: f.display_name,
473                    snippet: String::new(),
474                    backlink_count: bl_count,
475                    name_similarity: name_sim,
476                }
477            })
478            .collect();
479
480        Ok(hits)
481    }
482
483    // ── Backlinks & Graph ─────────────────────────────────────────
484
485    /// Get backlinks for a note.
486    pub fn backlinks_for(&self, path: &str) -> Vec<Backlink> {
487        self.backlinks.read().backlinks_for(path)
488    }
489
490    /// Get the full link graph for visualization.
491    pub fn link_graph(&self) -> LinkGraph {
492        self.backlinks.read().link_graph()
493    }
494
495    /// Index all markdown files in the knowledge base.
496    ///
497    /// Walks the entire directory tree (at any depth) and builds the
498    /// backlink index, including wikilink targets resolved against a
499    /// stem index built from the same walk. Returns the number of files
500    /// indexed.
501    pub fn index_all(&self) -> Result<usize> {
502        // Read every file's content under the fs read lock first; we need
503        // the contents anyway and this avoids re-acquiring per file.
504        let (paths_contents, stem_index) = {
505            let fs = self.fs.read();
506            let all = fs.all_md_files()?;
507            let stem_index = {
508                let mut idx: StemIndex = StemIndex::new();
509                for (path, _size) in &all {
510                    let stem = path
511                        .rsplit('/')
512                        .next()
513                        .unwrap_or(path.as_str())
514                        .trim_end_matches(".md")
515                        .to_lowercase();
516                    idx.entry(stem).or_default().push(path.clone());
517                }
518                idx
519            };
520            let mut paths_contents: Vec<(String, String)> = Vec::with_capacity(all.len());
521            for (path, _size) in &all {
522                if let Ok(content) = fs.read_path(path) {
523                    paths_contents.push((path.clone(), content));
524                }
525            }
526            (paths_contents, stem_index)
527        };
528
529        let mut count = 0;
530        {
531            let mut backlinks = self.backlinks.write();
532            backlinks.clear();
533            for (path, content) in &paths_contents {
534                backlinks.index_file_with(path, content, &stem_index);
535                count += 1;
536            }
537        }
538
539        tracing::info!(files = count, "Knowledge base indexed");
540        Ok(count)
541    }
542
543    // ── Chat / Inbox ───────────────────────────────────────────────
544
545    /// Append a timestamped message to Chat.md.
546    pub fn chat_append(&self, message: &str) -> Result<()> {
547        let header = today_chat_header();
548        let timestamp = chrono::Local::now().format("`15:04`").to_string();
549        let entry = format!("- [ ] {timestamp} {message}");
550
551        let mut content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
552        if !content.contains(&header) {
553            if !content.trim_end().ends_with('\n') {
554                content.push('\n');
555            }
556            content.push_str(&header);
557            content.push('\n');
558        }
559        content.push_str(&entry);
560        content.push('\n');
561        self.note_write(CHAT_FILENAME, &content)?;
562        Ok(())
563    }
564
565    /// Parse Chat.md into structured message blocks.
566    pub fn chat_messages(&self) -> Result<Vec<String>> {
567        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
568        Ok(read_chat_msgs(&content))
569    }
570
571    /// Delete a specific chat message by its content hash.
572    pub fn chat_delete(&self, msg_hash: &str) -> Result<bool> {
573        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
574        match delete_chat_msg(&content, msg_hash) {
575            Ok(new_content) => {
576                self.note_write(CHAT_FILENAME, &new_content)?;
577                Ok(true)
578            }
579            Err(_) => Ok(false),
580        }
581    }
582
583    /// Rename a specific chat message by its content hash.
584    pub fn chat_rename(&self, msg_hash: &str, new_body: &str) -> Result<bool> {
585        let content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
586        match rename_chat_msg(&content, msg_hash, new_body) {
587            Ok(new_content) => {
588                self.note_write(CHAT_FILENAME, &new_content)?;
589                Ok(true)
590            }
591            Err(_) => Ok(false),
592        }
593    }
594
595    /// Move a chat message to a target file as a checklist item.
596    pub fn chat_move_to(&self, msg_hash: &str, target_path: &str) -> Result<bool> {
597        let chat_content = self.note_read(CHAT_FILENAME)?.unwrap_or_default();
598        let target_content = self.note_read(target_path)?.unwrap_or_default();
599        let (new_chat, new_target) = move_from_chat(&chat_content, msg_hash, &target_content);
600        if new_chat != chat_content {
601            self.note_write(CHAT_FILENAME, &new_chat)?;
602            self.note_write(target_path, &new_target)?;
603            Ok(true)
604        } else {
605            Ok(false)
606        }
607    }
608
609    // ── Journal ───────────────────────────────────────────────────
610
611    /// Add a timestamped record to today's journal entry.
612    pub fn journal_add_record(&self, record: &str) -> Result<()> {
613        let fs = self.fs.write();
614        let tz = chrono::Local::now().offset().to_owned();
615        journal_add_record(&fs, record, tz)?;
616        Ok(())
617    }
618
619    /// Add an emoji to today's journal header.
620    pub fn journal_add_emoji(&self, emoji: &str) -> Result<()> {
621        let fs = self.fs.write();
622        let tz = chrono::Local::now().offset().to_owned();
623        journal_add_emoji(&fs, emoji, tz)?;
624        Ok(())
625    }
626
627    /// Get today's journal file path (e.g., "journal/2026.05 May.md").
628    pub fn journal_today_path(&self) -> String {
629        let tz = chrono::Local::now().offset().to_owned();
630        today_journal_filename(tz)
631    }
632
633    // ── Habits ───────────────────────────────────────────────────
634
635    /// Read habit tracking data for a given year.
636    pub fn habits(&self, year: i32) -> Result<Habits> {
637        let fs = self.fs.read();
638        Ok(habits(&fs, year)?)
639    }
640
641    /// Get last week's habit data.
642    pub fn habits_last_week(&self) -> Result<Habits> {
643        let fs = self.fs.read();
644        let tz = chrono::Local::now().offset().to_owned();
645        Ok(last_week_habits(&fs, tz)?)
646    }
647
648    /// Write habit data for a year.
649    pub fn habits_write(&self, year: i32, habits: &Habits) -> Result<()> {
650        let fs = self.fs.write();
651        write_habits(&fs, year, habits)?;
652        Ok(())
653    }
654
655    // ── Config ────────────────────────────────────────────────────
656
657    /// Read the knowledge base config (config.json).
658    pub fn config(&self) -> Result<KnowledgeConfig> {
659        let fs = self.fs.read();
660        match fs.read_path("config.json") {
661            Ok(content) => Ok(serde_json::from_str(&content).unwrap_or_default()),
662            Err(_) => Ok(KnowledgeConfig::default()),
663        }
664    }
665
666    /// Write the knowledge base config.
667    pub fn set_config(&self, config: &KnowledgeConfig) -> Result<()> {
668        let json = serde_json::to_string_pretty(config)?;
669        self.note_write("config.json", &json)?;
670        Ok(())
671    }
672
673    // ── Checklist ────────────────────────────────────────────────
674
675    /// Parse checklist items from a file.
676    pub fn checklist_items(
677        &self,
678        path: &str,
679    ) -> Result<(Vec<String>, std::collections::HashMap<String, bool>)> {
680        let content = self.note_read(path)?.unwrap_or_default();
681        Ok(checklist_items(&content))
682    }
683
684    /// Get incomplete checklist items from a file.
685    pub fn checklist_incomplete(&self, path: &str) -> Result<Vec<String>> {
686        let content = self.note_read(path)?.unwrap_or_default();
687        Ok(incomplete_checklist_items(&content))
688    }
689
690    /// Add a checklist item to a file.
691    pub fn checklist_add(&self, path: &str, item: &str, checked: bool) -> Result<()> {
692        let content = self.note_read(path)?.unwrap_or_default();
693        let updated = add_checklist_item(&content, item, checked);
694        self.note_write(path, &updated)
695    }
696
697    /// Complete a checklist item by hash.
698    pub fn checklist_complete(&self, path: &str, item_hash: &str) -> Result<bool> {
699        let content = self.note_read(path)?.unwrap_or_default();
700        let (new_content, found) = complete_checklist_item(&content, item_hash);
701        if !found.is_empty() {
702            self.note_write(path, &new_content)?;
703            Ok(true)
704        } else {
705            Ok(false)
706        }
707    }
708
709    /// Remove a checklist item by text or hash.
710    pub fn checklist_remove(&self, path: &str, item_or_hash: &str) -> Result<bool> {
711        let content = self.note_read(path)?.unwrap_or_default();
712        let (new_content, removed) = remove_checklist_item(&content, item_or_hash);
713        if !removed.is_empty() {
714            self.note_write(path, &new_content)?;
715            Ok(true)
716        } else {
717            Ok(false)
718        }
719    }
720
721    /// Remove all completed checklist items.
722    pub fn checklist_remove_completed(&self, path: &str) -> Result<(String, String)> {
723        let content = self.note_read(path)?.unwrap_or_default();
724        let (kept, removed) = remove_completed_checklist_items(&content);
725        if !removed.is_empty() {
726            self.note_write(path, &kept)?;
727        }
728        Ok((kept, removed))
729    }
730
731    // ── Worker ────────────────────────────────────────────────────
732
733    /// Run nightly cleanup.
734    pub fn run_nightly_cleanup(&self) -> Result<crate::worker::NightlyReport> {
735        // Read config before acquiring the write lock — config() takes
736        // a read lock and would otherwise deadlock against our write guard.
737        let config = self.config()?;
738        let fs = self.fs.write();
739        Ok(remove_completed_items(&fs, &config)?)
740    }
741
742    /// Move due scheduled tasks to Chat.
743    pub fn run_scheduled_tasks(&self) -> Result<Vec<String>> {
744        // Read config first, take the write lock only for the worker pass,
745        // then release it before set_config() (which calls note_write and
746        // would re-acquire the lock).
747        let mut config = self.config()?;
748        let moved = {
749            let fs = self.fs.write();
750            move_due_tasks(&fs, &mut config)?
751        };
752        if !moved.is_empty() {
753            self.set_config(&config)?;
754        }
755        Ok(moved)
756    }
757
758    // ── Stats ────────────────────────────────────────────────────
759
760    /// Get today's completion report.
761    pub fn today_report(&self) -> Result<crate::stats::TodayReport> {
762        let fs = self.fs.read();
763        Ok(today_report(&fs)?)
764    }
765
766    /// Get list of files completed today.
767    pub fn done_today(&self) -> Result<Vec<FileEntry>> {
768        let fs = self.fs.read();
769        Ok(done_today(&fs)?)
770    }
771
772    // ── Utilities ───────────────────────────────────────────────
773
774    /// Convert markdown to HTML.
775    pub fn markdown_to_html(&self, md: &str) -> String {
776        markdown_to_html(md)
777    }
778
779    /// Find an emoji for a keyword.
780    pub fn auto_emoji(&self, text: &str) -> String {
781        emoji_for(text)
782    }
783
784    /// Generate world clock report for given timezone names.
785    pub fn world_clock(&self, timezone_names: &[&str]) -> Vec<crate::plugins::TimezoneEntry> {
786        world_clock_for_names(timezone_names)
787    }
788
789    // ── Agent Write Tracking ──────────────────────────────────────
790
791    /// Mark a file as having been written by an agent.
792    pub fn mark_agent_write(&self, path: &str) {
793        self.agent_writes.lock().insert(path.to_string());
794    }
795
796    /// Check if a file was written by an agent.
797    pub fn is_agent_write(&self, path: &str) -> bool {
798        self.agent_writes.lock().contains(path)
799    }
800
801    /// Clear the agent-write marker for a file.
802    pub fn clear_agent_write(&self, path: &str) {
803        self.agent_writes.lock().remove(path);
804    }
805
806    // ── Text extraction ──────────────────────────────────────────
807
808    /// Extract text, images, and links from markdown content.
809    pub fn extract_text_imgs_links(&self, text: &str) -> crate::tgtxt::ExtractResult {
810        crate::tgtxt::extract_text_imgs_links(text)
811    }
812
813    // ── Headings (for tag extraction) ─────────────────────────────
814
815    /// Extract headings from content for tag generation.
816    pub fn extract_headings(&self, content: &str) -> Vec<String> {
817        extract_headings(content).into_iter().take(5).collect()
818    }
819}
820
821// ---------------------------------------------------------------------------
822// Frontmatter helpers (RFC-022)
823// ---------------------------------------------------------------------------
824
825/// Parse Oxios frontmatter from a note's content.
826///
827/// Returns `(Some(NoteMeta), body)` if the `oxios:` key is present in the
828/// frontmatter. Returns `(None, original_content)` if there is no frontmatter
829/// or the frontmatter does not contain the `oxios:` key (e.g., user-written
830/// Obsidian frontmatter). In the latter case, the full original content
831/// (including any user frontmatter) is returned as the body.
832pub fn parse_note_meta(content: &str) -> (Option<NoteMeta>, String) {
833    let trimmed = content.trim_start();
834    if !trimmed.starts_with("---") {
835        return (None, content.to_string());
836    }
837
838    // Find the closing ---
839    let after_first = &trimmed[3..];
840    let rest = after_first.trim_start_matches(['-', '\n', '\r']);
841    if let Some(end_offset) = rest.find("\n---") {
842        let yaml_block = &rest[..end_offset];
843        let body_start = end_offset + 4; // skip \n---
844        let body = rest[body_start..].trim_start().to_string();
845
846        // Parse YAML looking for the `oxios:` key
847        if !yaml_block.contains("oxios:") {
848            // User frontmatter, not ours
849            return (None, content.to_string());
850        }
851
852        #[derive(serde::Deserialize)]
853        struct FrontmatterWrapper {
854            oxios: NoteMeta,
855        }
856
857        match serde_yaml::from_str::<FrontmatterWrapper>(yaml_block) {
858            Ok(wrapper) => (Some(wrapper.oxios), body),
859            Err(_) => (None, content.to_string()),
860        }
861    } else {
862        (None, content.to_string())
863    }
864}
865
866/// Format a NoteMeta as YAML frontmatter prepended to content.
867///
868/// `serde_yaml::to_string` produces flat YAML like `author: agent\nsource: Hook\n`.
869/// We must indent each line with 2 spaces so they become children of the
870/// `oxios:` mapping key.
871fn format_frontmatter(meta: &NoteMeta, body: &str) -> String {
872    let yaml = serde_yaml::to_string(meta).unwrap_or_default();
873    let indented: String = yaml
874        .lines()
875        .filter(|l| !l.is_empty())
876        .map(|l| format!("  {l}"))
877        .collect::<Vec<_>>()
878        .join("\n");
879    format!("---\noxios:\n{}\n---\n\n{}", indented, body)
880}
881
882// ---------------------------------------------------------------------------
883// Tests
884// ---------------------------------------------------------------------------
885
886#[cfg(test)]
887mod tests {
888    use super::*;
889
890    fn make_test_kb() -> KnowledgeBase {
891        let dir = std::env::temp_dir().join(format!("test-kb-{}", uuid::Uuid::new_v4()));
892        KnowledgeBase::new(dir.join("kb")).expect("test knowledge base")
893    }
894
895    #[test]
896    fn test_note_write_and_read() {
897        let kb = make_test_kb();
898        kb.note_write("brain/Rust.md", "# Rust\n\nHello world")
899            .unwrap();
900        let content = kb.note_read("brain/Rust.md").unwrap();
901        assert_eq!(content, Some("# Rust\n\nHello world".to_string()));
902    }
903
904    #[test]
905    fn test_note_read_missing() {
906        let kb = make_test_kb();
907        assert_eq!(kb.note_read("nonexistent.md").unwrap(), None);
908    }
909
910    #[test]
911    fn test_note_delete() {
912        let kb = make_test_kb();
913        kb.note_write("del.md", "to delete").unwrap();
914        kb.note_delete("del.md").unwrap();
915        assert_eq!(kb.note_read("del.md").unwrap(), None);
916    }
917
918    #[test]
919    fn test_note_move() {
920        let kb = make_test_kb();
921        kb.note_write("old.md", "content").unwrap();
922        kb.note_move("old.md", "new.md").unwrap();
923        assert_eq!(kb.note_read("old.md").unwrap(), None);
924        assert_eq!(kb.note_read("new.md").unwrap(), Some("content".to_string()));
925    }
926
927    #[test]
928    fn test_note_move_rewrites_inbound_links() {
929        let kb = make_test_kb();
930        // Two notes link to the target by its old name.
931        kb.note_write("a.md", "See [target](target.md) and [again](target.md).")
932            .unwrap();
933        kb.note_write("b.md", "Ref [target](target.md).").unwrap();
934        kb.note_write("target.md", "# Target\n\nbody").unwrap();
935        // Re-resolve: a.md/b.md were indexed before target.md existed, so
936        // the markdown links are exact-path matches (work regardless), but
937        // a fresh index keeps the test self-consistent.
938        kb.index_all().unwrap();
939
940        kb.note_move("target.md", "renamed.md").unwrap();
941
942        // Moved file content preserved.
943        assert_eq!(kb.note_read("target.md").unwrap(), None);
944        assert_eq!(
945            kb.note_read("renamed.md").unwrap(),
946            Some("# Target\n\nbody".to_string())
947        );
948
949        // Inbound links rewritten on disk.
950        assert_eq!(
951            kb.note_read("a.md").unwrap().as_deref(),
952            Some("See [target](renamed.md) and [again](renamed.md).")
953        );
954        assert_eq!(
955            kb.note_read("b.md").unwrap().as_deref(),
956            Some("Ref [target](renamed.md).")
957        );
958
959        // Backlink index resolves links under the new name.
960        let bl: HashSet<String> = kb
961            .backlinks_for("renamed.md")
962            .into_iter()
963            .map(|b| b.source_path)
964            .collect();
965        assert_eq!(bl, HashSet::from(["a.md".to_string(), "b.md".to_string()]));
966        assert_eq!(kb.backlinks_for("target.md").len(), 0);
967    }
968
969    #[test]
970    fn test_note_move_rewrites_wikilinks() {
971        let kb = make_test_kb();
972        // Source references the target via every supported wikilink form.
973        kb.note_write(
974            "src.md",
975            "Bare [[Target]] path [[dir/Target]] full [[dir/Target.md]] alias [[Target|T]].",
976        )
977        .unwrap();
978        kb.note_write("dir/Target.md", "# Target\n\nbody").unwrap();
979        // src.md was indexed before dir/Target.md existed; rebuild so its
980        // wikilinks resolve against the now-complete tree.
981        kb.index_all().unwrap();
982
983        kb.note_move("dir/Target.md", "dir/Renamed.md").unwrap();
984
985        // Every form rewrites to the new path; alias is preserved.
986        assert_eq!(
987            kb.note_read("src.md").unwrap().as_deref(),
988            Some(
989                "Bare [[Renamed]] path [[dir/Renamed]] full [[dir/Renamed.md]] alias [[Renamed|T]]."
990            ),
991        );
992        // Backlinks now resolve under the new canonical path.
993        assert_eq!(kb.backlinks_for("dir/Renamed.md").len(), 1);
994        assert_eq!(kb.backlinks_for("dir/Target.md").len(), 0);
995    }
996
997    #[test]
998    fn test_note_move_skips_ambiguous_bare_wikilink() {
999        // Two files share the stem "Dup": the bare [[Dup]] in src is
1000        // ambiguous and must NOT be indexed → not rewritten when EITHER
1001        // Dup renames. The path-style [[a/Dup]] IS unambiguous and rewrites.
1002        let kb = make_test_kb();
1003        kb.note_write("src.md", "ambig [[Dup]] explicit [[a/Dup]]")
1004            .unwrap();
1005        kb.note_write("a/Dup.md", "# A").unwrap();
1006        kb.note_write("b/Dup.md", "# B").unwrap();
1007        // src.md was indexed before both Dups existed — rebuild so the
1008        // bare stem is now (correctly) ambiguous and dropped from the index.
1009        kb.index_all().unwrap();
1010
1011        kb.note_move("a/Dup.md", "a/Moved.md").unwrap();
1012
1013        let src = kb.note_read("src.md").unwrap().unwrap_or_default();
1014        // Bare link untouched (ambiguous); path-style link rewritten.
1015        assert!(
1016            src.contains("[[Dup]]"),
1017            "ambiguous bare link must be left alone: {src}"
1018        );
1019        assert!(
1020            src.contains("[[a/Moved]]"),
1021            "explicit path link must be rewritten: {src}"
1022        );
1023    }
1024
1025    #[test]
1026    fn test_backlinks_track_wikilinks() {
1027        let kb = make_test_kb();
1028        kb.note_write("brain/Rust.md", "See [[Ownership]] and [[brain/Go]]")
1029            .unwrap();
1030        kb.note_write("brain/Ownership.md", "# Ownership").unwrap();
1031        kb.note_write("brain/Go.md", "# Go").unwrap();
1032        // Rust.md was indexed before Ownership/Go existed; rebuild so its
1033        // wikilinks resolve against the now-complete tree.
1034        kb.index_all().unwrap();
1035
1036        // Both wikilinks resolve and appear as backlinks on their targets.
1037        let owners_of_ownership: HashSet<String> = kb
1038            .backlinks_for("brain/Ownership.md")
1039            .into_iter()
1040            .map(|b| b.source_path)
1041            .collect();
1042        assert!(owners_of_ownership.contains("brain/Rust.md"));
1043        let owners_of_go: HashSet<String> = kb
1044            .backlinks_for("brain/Go.md")
1045            .into_iter()
1046            .map(|b| b.source_path)
1047            .collect();
1048        assert!(owners_of_go.contains("brain/Rust.md"));
1049    }
1050
1051    #[test]
1052    fn test_backlinks() {
1053        let kb = make_test_kb();
1054        kb.note_write("brain/Rust.md", "See [Ownership](brain/Ownership.md)")
1055            .unwrap();
1056        let bl = kb.backlinks_for("brain/Ownership.md");
1057        assert_eq!(bl.len(), 1);
1058        assert_eq!(bl[0].source_path, "brain/Rust.md");
1059    }
1060
1061    #[test]
1062    fn test_note_tree() {
1063        let kb = make_test_kb();
1064        kb.note_write("brain/Rust.md", "Rust").unwrap();
1065        let entries = kb.note_tree("brain").unwrap();
1066        assert!(!entries.is_empty());
1067    }
1068
1069    #[test]
1070    fn test_search_by_name() {
1071        let kb = make_test_kb();
1072        kb.note_write("brain/Rust.md", "Rust content").unwrap();
1073        let hits = kb.search("Rust", 10).unwrap();
1074        assert!(!hits.is_empty());
1075    }
1076
1077    #[test]
1078    fn test_link_graph() {
1079        let kb = make_test_kb();
1080        kb.note_write("a.md", "[b](b.md)").unwrap();
1081        let graph = kb.link_graph();
1082        assert!(!graph.edges.is_empty());
1083    }
1084
1085    #[test]
1086    fn test_agent_write_tracking() {
1087        let kb = make_test_kb();
1088        assert!(!kb.is_agent_write("test.md"));
1089        kb.mark_agent_write("test.md");
1090        assert!(kb.is_agent_write("test.md"));
1091        kb.clear_agent_write("test.md");
1092        assert!(!kb.is_agent_write("test.md"));
1093    }
1094
1095    #[test]
1096    fn test_index_all() {
1097        let kb = make_test_kb();
1098        kb.note_write("brain/Rust.md", "Rust [Go](brain/Go.md)")
1099            .unwrap();
1100        kb.note_write("brain/Go.md", "Go language").unwrap();
1101        kb.note_write("index.md", "Welcome").unwrap();
1102        let count = kb.index_all().unwrap();
1103        assert_eq!(count, 3);
1104        let bl = kb.backlinks_for("brain/Go.md");
1105        assert_eq!(bl.len(), 1);
1106    }
1107
1108    #[test]
1109    fn test_on_file_change_callback() {
1110        let kb = make_test_kb();
1111        let _called = std::sync::atomic::AtomicBool::new(false);
1112        let path_clone: std::sync::Arc<std::sync::atomic::AtomicBool> =
1113            std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1114        let flag = path_clone.clone();
1115
1116        kb.on_file_change(move |path, change| {
1117            let _ = path;
1118            let _ = change;
1119            flag.store(true, std::sync::atomic::Ordering::SeqCst);
1120        });
1121
1122        kb.note_write("test.md", "hello").unwrap();
1123        assert!(path_clone.load(std::sync::atomic::Ordering::SeqCst));
1124    }
1125
1126    #[test]
1127    fn test_chat_append() {
1128        let kb = make_test_kb();
1129        kb.chat_append("Test message").unwrap();
1130        let messages = kb.chat_messages().unwrap();
1131        // The captured message must be a parseable marker block (- [ ] `HH:MM` text),
1132        // not merged into the date header. chat_append must emit the `- [ ]` prefix
1133        // that read_chat_msgs splits on.
1134        assert!(
1135            messages
1136                .iter()
1137                .any(|m| m.starts_with("- [") && m.contains("Test message")),
1138            "captured message should be a parseable marker block: {messages:?}"
1139        );
1140    }
1141
1142    #[test]
1143    fn test_config() {
1144        let kb = make_test_kb();
1145        let cfg = kb.config().unwrap();
1146        // Should return default for non-existent config
1147        let cfg2 = kb.config().unwrap();
1148        assert_eq!(cfg.language, cfg2.language);
1149    }
1150
1151    #[test]
1152    fn test_markdown_to_html() {
1153        let kb = make_test_kb();
1154        let html = kb.markdown_to_html("# Hello\n\n**world**");
1155        // markdown_to_html wraps content in a <p> tag by default, check for content
1156        assert!(html.contains("Hello"), "HTML should contain Hello: {html}");
1157        assert!(html.contains("world"), "HTML should contain world: {html}");
1158    }
1159
1160    #[test]
1161    fn test_auto_emoji() {
1162        let kb = make_test_kb();
1163        let emoji = kb.auto_emoji("cooking pasta");
1164        assert!(!emoji.is_empty());
1165    }
1166
1167    #[test]
1168    fn test_extract_headings() {
1169        let kb = make_test_kb();
1170        let headings = kb.extract_headings("# Title\n\n## Section\n\n### Subsection");
1171        assert!(headings.len() >= 2);
1172    }
1173
1174    #[test]
1175    fn test_frontmatter_roundtrip() {
1176        let meta = NoteMeta {
1177            author: "agent".to_string(),
1178            source: NoteSource::Hook,
1179            quality: NoteQuality::Raw,
1180            needs_review: true,
1181            session_id: Some("abc123".to_string()),
1182            message_index: Some(3),
1183            saved_at: Some("2026-06-13T00:00:00Z".to_string()),
1184        };
1185        let body = "## Test\n\nContent here.";
1186        let formatted = format_frontmatter(&meta, body);
1187        assert!(formatted.starts_with("---\noxios:\n"));
1188        let (parsed_meta, parsed_body) = parse_note_meta(&formatted);
1189        assert!(
1190            parsed_meta.is_some(),
1191            "Failed to parse round-tripped frontmatter"
1192        );
1193        let pm = parsed_meta.unwrap();
1194        assert_eq!(pm.author, "agent");
1195        assert_eq!(pm.session_id.as_deref(), Some("abc123"));
1196        assert_eq!(pm.message_index, Some(3));
1197        assert_eq!(parsed_body.trim(), body.trim());
1198    }
1199
1200    #[test]
1201    fn test_parse_user_frontmatter_ignored() {
1202        let content = "---\ntags: [rust, design]\n---\n\n## My Note\nContent.";
1203        let (meta, body) = parse_note_meta(content);
1204        assert!(
1205            meta.is_none(),
1206            "User frontmatter should not be parsed as NoteMeta"
1207        );
1208        assert!(
1209            body.contains("tags: [rust, design]"),
1210            "User frontmatter preserved"
1211        );
1212    }
1213
1214    #[test]
1215    fn test_parse_no_frontmatter() {
1216        let content = "# Just a note\nSome content.";
1217        let (meta, body) = parse_note_meta(content);
1218        assert!(meta.is_none());
1219        assert_eq!(body, content);
1220    }
1221}