Skip to main content

oxios_kernel/tools/kernel/
knowledge_tool.rs

1//! Knowledge tool — agent-facing tool for markdown note management.
2//!
3//! Provides a single `knowledge` tool with action-based dispatch, following
4//! the same pattern as `memory_tools.rs`. Actions: read, write, delete,
5//! move, tree, search, backlinks.
6
7use std::sync::Arc;
8
9use async_trait::async_trait;
10use chrono::Datelike;
11use oxi_sdk::{AgentTool as OxiAgentTool, AgentToolResult, ToolContext};
12use serde_json::{json, Value};
13
14use crate::KernelHandle;
15use oxios_markdown::KnowledgeBase;
16
17/// Tool for reading, writing, and managing markdown knowledge notes.
18///
19/// Uses action-based dispatch: `read`, `write`, `delete`, `move`, `tree`,
20/// `search`, `backlinks`.
21///
22/// Delegates directly to [`KnowledgeBase`] for all operations.
23pub struct KnowledgeTool {
24    kb: Arc<KnowledgeBase>,
25}
26
27impl KnowledgeTool {
28    /// Create from a [`KernelHandle`], extracting KnowledgeBase directly.
29    pub fn from_kernel(kernel: &KernelHandle) -> Self {
30        Self {
31            kb: kernel.knowledge.clone(),
32        }
33    }
34
35    /// Create with explicit KnowledgeBase (for testing).
36    pub fn new(kb: Arc<KnowledgeBase>) -> Self {
37        Self { kb }
38    }
39}
40
41impl std::fmt::Debug for KnowledgeTool {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        f.debug_struct("KnowledgeTool").finish()
44    }
45}
46
47#[async_trait]
48impl OxiAgentTool for KnowledgeTool {
49    fn name(&self) -> &str {
50        "knowledge"
51    }
52
53    fn label(&self) -> &str {
54        "Knowledge"
55    }
56
57    fn description(&self) -> &'static str {
58        "Manage markdown knowledge notes. Actions: read, write, delete, move, tree, search, backlinks, checklist_items, checklist_add, checklist_complete, checklist_remove, chat_append, chat_messages, chat_delete, chat_move, journal_add, journal_emoji, journal_today, habits, habits_last_week, today_report, done_today, config_read, config_write, nightly_cleanup, run_scheduled, markdown_to_html, auto_emoji."
59    }
60
61    fn parameters_schema(&self) -> Value {
62        json!({
63            "type": "object",
64            "properties": {
65                "action": {
66                    "type": "string",
67                    "enum": [
68                    "read", "write", "delete", "move", "tree", "search", "backlinks",
69                    "checklist_items", "checklist_add", "checklist_complete", "checklist_remove",
70                    "chat_append", "chat_messages", "chat_delete", "chat_move",
71                    "journal_add", "journal_emoji", "journal_today",
72                    "habits", "habits_last_week",
73                    "today_report", "done_today",
74                    "config_read", "config_write",
75                    "nightly_cleanup", "run_scheduled",
76                    "markdown_to_html", "auto_emoji"
77                ],
78                    "description": "The action to perform"
79                },
80                "path": {
81                    "type": "string",
82                    "description": "Note path (e.g., 'brain/Rust.md' or 'Chat.md')"
83                },
84                "content": {
85                    "type": "string",
86                    "description": "Content for write action"
87                },
88                "old_path": {
89                    "type": "string",
90                    "description": "Old path for move action"
91                },
92                "new_path": {
93                    "type": "string",
94                    "description": "New path for move action"
95                },
96                "dir": {
97                    "type": "string",
98                    "description": "Directory for tree action (default: root)"
99                },
100                "query": {
101                    "type": "string",
102                    "description": "Search query for search action"
103                },
104                "limit": {
105                    "type": "integer",
106                    "description": "Max results for search/tree (default: 20)"
107                },
108                "item": {
109                    "type": "string",
110                    "description": "Checklist item text (for checklist_add)"
111                },
112                "checked": {
113                    "type": "boolean",
114                    "description": "Whether the checklist item is checked (for checklist_add, default: false)"
115                },
116                "item_hash": {
117                    "type": "string",
118                    "description": "Hash identifying a checklist or chat item (for checklist_complete, chat_delete, chat_move)"
119                },
120                "item_or_hash": {
121                    "type": "string",
122                    "description": "Checklist item text or hash (for checklist_remove)"
123                },
124                "message": {
125                    "type": "string",
126                    "description": "Chat message text (for chat_append)"
127                },
128                "msg_hash": {
129                    "type": "string",
130                    "description": "Hash identifying a chat message (for chat_delete, chat_move)"
131                },
132                "target_path": {
133                    "type": "string",
134                    "description": "Target note path (for chat_move)"
135                },
136                "record": {
137                    "type": "string",
138                    "description": "Journal record text (for journal_add)"
139                },
140                "emoji": {
141                    "type": "string",
142                    "description": "Emoji string (for journal_emoji)"
143                },
144                "year": {
145                    "type": "integer",
146                    "description": "Year number (for habits action)"
147                },
148                "config": {
149                    "type": "object",
150                    "description": "KnowledgeConfig JSON object (for config_write)"
151                },
152                "md": {
153                    "type": "string",
154                    "description": "Markdown text to convert (for markdown_to_html)"
155                },
156                "text": {
157                    "type": "string",
158                    "description": "Text to find emoji for (for auto_emoji)"
159                }
160            },
161            "required": ["action"]
162        })
163    }
164
165    async fn execute(
166        &self,
167        _tool_call_id: &str,
168        params: Value,
169        _signal: Option<tokio::sync::oneshot::Receiver<()>>,
170        _ctx: &ToolContext,
171    ) -> Result<AgentToolResult, oxi_sdk::ToolError> {
172        let action = params["action"].as_str().unwrap_or("");
173        if action.is_empty() {
174            return Ok(AgentToolResult::error("action is required"));
175        }
176
177        match action {
178            "read" => {
179                let path = params["path"].as_str().unwrap_or("");
180                if path.is_empty() {
181                    return Ok(AgentToolResult::error("path is required for read"));
182                }
183                match self.kb.note_read(path) {
184                    Ok(Some(content)) => Ok(AgentToolResult::success(&content)),
185                    Ok(None) => Ok(AgentToolResult::error(format!("Note '{}' not found", path))),
186                    Err(e) => Ok(AgentToolResult::error(format!("Failed to read note: {e}"))),
187                }
188            }
189            "write" => {
190                let path = params["path"].as_str().unwrap_or("");
191                let content = params["content"].as_str().unwrap_or("");
192                if path.is_empty() {
193                    return Ok(AgentToolResult::error("path is required for write"));
194                }
195                if content.is_empty() {
196                    return Ok(AgentToolResult::error("content is required for write"));
197                }
198                match self.kb.note_write(path, content) {
199                    Ok(()) => Ok(AgentToolResult::success(format!(
200                        "Note '{}' written successfully",
201                        path
202                    ))),
203                    Err(e) => Ok(AgentToolResult::error(format!("Failed to write note: {e}"))),
204                }
205            }
206            "delete" => {
207                let path = params["path"].as_str().unwrap_or("");
208                if path.is_empty() {
209                    return Ok(AgentToolResult::error("path is required for delete"));
210                }
211                match self.kb.note_delete(path) {
212                    Ok(()) => Ok(AgentToolResult::success(format!("Note '{}' deleted", path))),
213                    Err(e) => Ok(AgentToolResult::error(format!("Failed to delete note: {e}"))),
214                }
215            }
216            "move" => {
217                let old_path = params["old_path"]
218                    .as_str()
219                    .or_else(|| {
220                        // Also accept "path" as old_path if old_path not provided
221                        if params["new_path"].as_str().is_some() {
222                            params["path"].as_str()
223                        } else {
224                            None
225                        }
226                    })
227                    .unwrap_or("");
228                let new_path = params["new_path"].as_str().unwrap_or("");
229                if old_path.is_empty() || new_path.is_empty() {
230                    return Ok(AgentToolResult::error(
231                        "old_path and new_path are required for move",
232                    ));
233                }
234                match self.kb.note_move(old_path, new_path) {
235                    Ok(()) => Ok(AgentToolResult::success(format!(
236                        "Note moved from '{}' to '{}'",
237                        old_path, new_path
238                    ))),
239                    Err(e) => Ok(AgentToolResult::error(format!("Failed to move note: {e}"))),
240                }
241            }
242            "tree" => {
243                let dir = params["dir"].as_str().unwrap_or("/");
244                let limit = params["limit"].as_u64().unwrap_or(50) as usize;
245                match self.kb.note_tree(dir) {
246                    Ok(entries) => {
247                        let count = entries.len();
248                        let entries: Vec<_> = entries.into_iter().take(limit).collect();
249                        if entries.is_empty() {
250                            return Ok(AgentToolResult::success("Directory is empty"));
251                        }
252                        let mut output = format!(
253                            "Found {} entries (showing {}):\n\n",
254                            count,
255                            entries.len()
256                        );
257                        for entry in &entries {
258                            let kind = if entry.is_dir { "📁" } else { "📄" };
259                            output.push_str(&format!(
260                                "{} {} ({})\n",
261                                kind, entry.display_name, entry.name
262                            ));
263                        }
264                        Ok(AgentToolResult::success(&output))
265                    }
266                    Err(e) => Ok(AgentToolResult::error(format!("Failed to list notes: {e}"))),
267                }
268            }
269            "search" => {
270                let query = params["query"].as_str().unwrap_or("");
271                if query.is_empty() {
272                    return Ok(AgentToolResult::error("query is required for search"));
273                }
274                let limit = params["limit"].as_u64().unwrap_or(10) as usize;
275                match self.kb.search(query, limit) {
276                    Ok(hits) => {
277                        if hits.is_empty() {
278                            return Ok(AgentToolResult::success("No matching notes found"));
279                        }
280                        let mut output = format!("Found {} matching notes:\n\n", hits.len());
281                        for hit in &hits {
282                            output.push_str(&format!(
283                                "- {} (path: {}, backlinks: {}, name_sim: {}%)\n",
284                                hit.name,
285                                hit.path,
286                                hit.backlink_count,
287                                hit.name_similarity,
288                            ));
289                        }
290                        Ok(AgentToolResult::success(&output))
291                    }
292                    Err(e) => {
293                        Ok(AgentToolResult::error(format!("Failed to search notes: {e}")))
294                    }
295                }
296            }
297            "backlinks" => {
298                let path = params["path"].as_str().unwrap_or("");
299                if path.is_empty() {
300                    return Ok(AgentToolResult::error("path is required for backlinks"));
301                }
302                let backlinks = self.kb.backlinks_for(path);
303                if backlinks.is_empty() {
304                    return Ok(AgentToolResult::success(format!(
305                        "No backlinks for '{}'",
306                        path
307                    )));
308                }
309                let mut output = format!("Backlinks for '{}' ({}):\n\n", path, backlinks.len());
310                for bl in &backlinks {
311                    output.push_str(&format!(
312                        "- {} → {} (line {})\n",
313                        bl.source_path, bl.target_path, bl.line_number
314                    ));
315                }
316                Ok(AgentToolResult::success(&output))
317            }
318            // ── Checklist ─────────────────────────────────────────
319
320            "checklist_items" => {
321                let path = params["path"].as_str().unwrap_or("");
322                if path.is_empty() {
323                    return Ok(AgentToolResult::error("path is required for checklist_items"));
324                }
325                match self.kb.checklist_items(path) {
326                    Ok((items, checked_map)) => {
327                        if items.is_empty() {
328                            return Ok(AgentToolResult::success("No checklist items found"));
329                        }
330                        let mut output = format!("Checklist items for '{}' ({}):\n\n", path, items.len());
331                        for item in &items {
332                            let status = checked_map.get(item).map(|b| if *b { "✅" } else { "⬜" }).unwrap_or("⬜");
333                            output.push_str(&format!("{} {}\n", status, item));
334                        }
335                        Ok(AgentToolResult::success(&output))
336                    }
337                    Err(e) => Ok(AgentToolResult::error(format!("Failed to get checklist items: {e}"))),
338                }
339            }
340
341            "checklist_add" => {
342                let path = params["path"].as_str().unwrap_or("");
343                let item = params["item"].as_str().unwrap_or("");
344                let checked = params["checked"].as_bool().unwrap_or(false);
345                if path.is_empty() {
346                    return Ok(AgentToolResult::error("path is required for checklist_add"));
347                }
348                if item.is_empty() {
349                    return Ok(AgentToolResult::error("item is required for checklist_add"));
350                }
351                match self.kb.checklist_add(path, item, checked) {
352                    Ok(()) => Ok(AgentToolResult::success(format!(
353                        "Checklist item added to '{}'", path
354                    ))),
355                    Err(e) => Ok(AgentToolResult::error(format!("Failed to add checklist item: {e}"))),
356                }
357            }
358
359            "checklist_complete" => {
360                let path = params["path"].as_str().unwrap_or("");
361                let item_hash = params["item_hash"].as_str().unwrap_or("");
362                if path.is_empty() {
363                    return Ok(AgentToolResult::error("path is required for checklist_complete"));
364                }
365                if item_hash.is_empty() {
366                    return Ok(AgentToolResult::error("item_hash is required for checklist_complete"));
367                }
368                match self.kb.checklist_complete(path, item_hash) {
369                    Ok(true) => Ok(AgentToolResult::success(format!(
370                        "Checklist item completed in '{}'", path
371                    ))),
372                    Ok(false) => Ok(AgentToolResult::error(format!(
373                        "Checklist item '{}' not found in '{}'", item_hash, path
374                    ))),
375                    Err(e) => Ok(AgentToolResult::error(format!("Failed to complete checklist item: {e}"))),
376                }
377            }
378
379            "checklist_remove" => {
380                let path = params["path"].as_str().unwrap_or("");
381                let item_or_hash = params["item_or_hash"].as_str().unwrap_or("");
382                if path.is_empty() {
383                    return Ok(AgentToolResult::error("path is required for checklist_remove"));
384                }
385                if item_or_hash.is_empty() {
386                    return Ok(AgentToolResult::error("item_or_hash is required for checklist_remove"));
387                }
388                match self.kb.checklist_remove(path, item_or_hash) {
389                    Ok(true) => Ok(AgentToolResult::success(format!(
390                        "Checklist item removed from '{}'", path
391                    ))),
392                    Ok(false) => Ok(AgentToolResult::error(format!(
393                        "Checklist item '{}' not found in '{}'", item_or_hash, path
394                    ))),
395                    Err(e) => Ok(AgentToolResult::error(format!("Failed to remove checklist item: {e}"))),
396                }
397            }
398
399            // ── Chat ────────────────────────────────────────────────
400
401            "chat_append" => {
402                let message = params["message"].as_str().unwrap_or("");
403                if message.is_empty() {
404                    return Ok(AgentToolResult::error("message is required for chat_append"));
405                }
406                match self.kb.chat_append(message) {
407                    Ok(()) => Ok(AgentToolResult::success("Message appended to chat")),
408                    Err(e) => Ok(AgentToolResult::error(format!("Failed to append chat message: {e}"))),
409                }
410            }
411
412            "chat_messages" => {
413                match self.kb.chat_messages() {
414                    Ok(messages) => {
415                        if messages.is_empty() {
416                            return Ok(AgentToolResult::success("No chat messages found"));
417                        }
418                        let mut output = format!("Chat messages ({}):\n\n", messages.len());
419                        for (i, msg) in messages.iter().enumerate() {
420                            output.push_str(&format!("{}. {}\n", i + 1, msg));
421                        }
422                        Ok(AgentToolResult::success(&output))
423                    }
424                    Err(e) => Ok(AgentToolResult::error(format!("Failed to get chat messages: {e}"))),
425                }
426            }
427
428            "chat_delete" => {
429                let msg_hash = params["msg_hash"].as_str().unwrap_or("");
430                if msg_hash.is_empty() {
431                    return Ok(AgentToolResult::error("msg_hash is required for chat_delete"));
432                }
433                match self.kb.chat_delete(msg_hash) {
434                    Ok(true) => Ok(AgentToolResult::success(format!(
435                        "Chat message '{}' deleted", msg_hash
436                    ))),
437                    Ok(false) => Ok(AgentToolResult::error(format!(
438                        "Chat message '{}' not found", msg_hash
439                    ))),
440                    Err(e) => Ok(AgentToolResult::error(format!("Failed to delete chat message: {e}"))),
441                }
442            }
443
444            "chat_move" => {
445                let msg_hash = params["msg_hash"].as_str().unwrap_or("");
446                let target_path = params["target_path"].as_str().unwrap_or("");
447                if msg_hash.is_empty() {
448                    return Ok(AgentToolResult::error("msg_hash is required for chat_move"));
449                }
450                if target_path.is_empty() {
451                    return Ok(AgentToolResult::error("target_path is required for chat_move"));
452                }
453                match self.kb.chat_move_to(msg_hash, target_path) {
454                    Ok(true) => Ok(AgentToolResult::success(format!(
455                        "Chat message moved to '{}'", target_path
456                    ))),
457                    Ok(false) => Ok(AgentToolResult::error(format!(
458                        "Chat message '{}' not found", msg_hash
459                    ))),
460                    Err(e) => Ok(AgentToolResult::error(format!("Failed to move chat message: {e}"))),
461                }
462            }
463
464            // ── Journal ─────────────────────────────────────────────
465
466            "journal_add" => {
467                let record = params["record"].as_str().unwrap_or("");
468                if record.is_empty() {
469                    return Ok(AgentToolResult::error("record is required for journal_add"));
470                }
471                match self.kb.journal_add_record(record) {
472                    Ok(()) => Ok(AgentToolResult::success("Journal record added")),
473                    Err(e) => Ok(AgentToolResult::error(format!("Failed to add journal record: {e}"))),
474                }
475            }
476
477            "journal_emoji" => {
478                let emoji = params["emoji"].as_str().unwrap_or("");
479                if emoji.is_empty() {
480                    return Ok(AgentToolResult::error("emoji is required for journal_emoji"));
481                }
482                match self.kb.journal_add_emoji(emoji) {
483                    Ok(()) => Ok(AgentToolResult::success(format!("Journal emoji set to '{}'", emoji))),
484                    Err(e) => Ok(AgentToolResult::error(format!("Failed to set journal emoji: {e}"))),
485                }
486            }
487
488            "journal_today" => {
489                let path = self.kb.journal_today_path();
490                Ok(AgentToolResult::success(&path))
491            }
492
493            // ── Habits ──────────────────────────────────────────────
494
495            "habits" => {
496                let year = params["year"].as_i64().unwrap_or_else(|| {
497                    chrono::Local::now().year() as i64
498                }) as i32;
499                match self.kb.habits(year) {
500                    Ok(habits) => {
501                        let json = serde_json::to_string_pretty(&habits)
502                            .unwrap_or_else(|_| "{}".to_string());
503                        Ok(AgentToolResult::success(&json))
504                    }
505                    Err(e) => Ok(AgentToolResult::error(format!("Failed to get habits: {e}"))),
506                }
507            }
508
509            "habits_last_week" => {
510                match self.kb.habits_last_week() {
511                    Ok(habits) => {
512                        let json = serde_json::to_string_pretty(&habits)
513                            .unwrap_or_else(|_| "{}".to_string());
514                        Ok(AgentToolResult::success(&json))
515                    }
516                    Err(e) => Ok(AgentToolResult::error(format!("Failed to get last week habits: {e}"))),
517                }
518            }
519
520            // ── Stats ───────────────────────────────────────────────
521
522            "today_report" => {
523                match self.kb.today_report() {
524                    Ok(report) => {
525                        let json = serde_json::to_string_pretty(&report)
526                            .unwrap_or_else(|_| "{}".to_string());
527                        Ok(AgentToolResult::success(&json))
528                    }
529                    Err(e) => Ok(AgentToolResult::error(format!("Failed to get today report: {e}"))),
530                }
531            }
532
533            "done_today" => {
534                match self.kb.done_today() {
535                    Ok(entries) => {
536                        if entries.is_empty() {
537                            return Ok(AgentToolResult::success("No completed items today"));
538                        }
539                        let mut output = format!("Done today ({}):\n\n", entries.len());
540                        for entry in &entries {
541                            let kind = if entry.is_dir { "📁" } else { "📄" };
542                            output.push_str(&format!(
543                                "{} {} ({})\n",
544                                kind, entry.display_name, entry.name
545                            ));
546                        }
547                        Ok(AgentToolResult::success(&output))
548                    }
549                    Err(e) => Ok(AgentToolResult::error(format!("Failed to get done today: {e}"))),
550                }
551            }
552
553            // ── Config ──────────────────────────────────────────────
554
555            "config_read" => {
556                match self.kb.config() {
557                    Ok(config) => {
558                        let json = serde_json::to_string_pretty(&config)
559                            .unwrap_or_else(|_| "{}".to_string());
560                        Ok(AgentToolResult::success(&json))
561                    }
562                    Err(e) => Ok(AgentToolResult::error(format!("Failed to read config: {e}"))),
563                }
564            }
565
566            "config_write" => {
567                let config_val = params.get("config").cloned().unwrap_or(json!({}));
568                match serde_json::from_value::<oxios_markdown::types::KnowledgeConfig>(config_val) {
569                    Ok(config) => {
570                        match self.kb.set_config(&config) {
571                            Ok(()) => Ok(AgentToolResult::success("Config updated successfully")),
572                            Err(e) => Ok(AgentToolResult::error(format!("Failed to write config: {e}"))),
573                        }
574                    }
575                    Err(e) => Ok(AgentToolResult::error(format!("Invalid config object: {e}"))),
576                }
577            }
578
579            // ── Automation ──────────────────────────────────────────
580
581            "nightly_cleanup" => {
582                match self.kb.run_nightly_cleanup() {
583                    Ok(report) => {
584                        let json = serde_json::to_string_pretty(&report)
585                            .unwrap_or_else(|_| "{}".to_string());
586                        Ok(AgentToolResult::success(&json))
587                    }
588                    Err(e) => Ok(AgentToolResult::error(format!("Failed to run nightly cleanup: {e}"))),
589                }
590            }
591
592            "run_scheduled" => {
593                match self.kb.run_scheduled_tasks() {
594                    Ok(moved) => {
595                        if moved.is_empty() {
596                            Ok(AgentToolResult::success("No scheduled tasks due"))
597                        } else {
598                            let mut output = format!("Moved {} scheduled tasks to chat:\n\n", moved.len());
599                            for task in &moved {
600                                output.push_str(&format!("- {}\n", task));
601                            }
602                            Ok(AgentToolResult::success(&output))
603                        }
604                    }
605                    Err(e) => Ok(AgentToolResult::error(format!("Failed to run scheduled tasks: {e}"))),
606                }
607            }
608
609            // ── Utils ───────────────────────────────────────────────
610
611            "markdown_to_html" => {
612                let md = params["md"].as_str().unwrap_or("");
613                if md.is_empty() {
614                    return Ok(AgentToolResult::error("md is required for markdown_to_html"));
615                }
616                let html = self.kb.markdown_to_html(md);
617                Ok(AgentToolResult::success(&html))
618            }
619
620            "auto_emoji" => {
621                let text = params["text"].as_str().unwrap_or("");
622                if text.is_empty() {
623                    return Ok(AgentToolResult::error("text is required for auto_emoji"));
624                }
625                let emoji = self.kb.auto_emoji(text);
626                Ok(AgentToolResult::success(&emoji))
627            }
628
629            _ => Ok(AgentToolResult::error(format!(
630                "Unknown action '{}'. Must be one of: read, write, delete, move, tree, search, backlinks, \
631                 checklist_items, checklist_add, checklist_complete, checklist_remove, \
632                 chat_append, chat_messages, chat_delete, chat_move, \
633                 journal_add, journal_emoji, journal_today, \
634                 habits, habits_last_week, today_report, done_today, \
635                 config_read, config_write, nightly_cleanup, run_scheduled, \
636                 markdown_to_html, auto_emoji",
637                action
638            ))),
639        }
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    #[test]
648    fn test_knowledge_tool_schema() {
649        let dir = std::env::temp_dir().join(format!("test-kb-tool-{}", uuid::Uuid::new_v4()));
650        let kb = Arc::new(oxios_markdown::KnowledgeBase::new(dir).unwrap());
651        let tool = KnowledgeTool::new(kb);
652        assert_eq!(tool.name(), "knowledge");
653        let schema = tool.parameters_schema();
654        assert!(schema["required"].is_array());
655        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
656        assert_eq!(actions.len(), 28);
657    }
658}