Skip to main content

oxios_kernel/tools/builtin/
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 '{path}' not found"))),
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 '{path}' written successfully"
201                    ))),
202                    Err(e) => Ok(AgentToolResult::error(format!("Failed to write note: {e}"))),
203                }
204            }
205            "delete" => {
206                let path = params["path"].as_str().unwrap_or("");
207                if path.is_empty() {
208                    return Ok(AgentToolResult::error("path is required for delete"));
209                }
210                match self.kb.note_delete(path) {
211                    Ok(()) => Ok(AgentToolResult::success(format!("Note '{path}' deleted"))),
212                    Err(e) => Ok(AgentToolResult::error(format!("Failed to delete note: {e}"))),
213                }
214            }
215            "move" => {
216                let old_path = params["old_path"]
217                    .as_str()
218                    .or_else(|| {
219                        // Also accept "path" as old_path if old_path not provided
220                        if params["new_path"].as_str().is_some() {
221                            params["path"].as_str()
222                        } else {
223                            None
224                        }
225                    })
226                    .unwrap_or("");
227                let new_path = params["new_path"].as_str().unwrap_or("");
228                if old_path.is_empty() || new_path.is_empty() {
229                    return Ok(AgentToolResult::error(
230                        "old_path and new_path are required for move",
231                    ));
232                }
233                match self.kb.note_move(old_path, new_path) {
234                    Ok(()) => Ok(AgentToolResult::success(format!(
235                        "Note moved from '{old_path}' to '{new_path}'"
236                    ))),
237                    Err(e) => Ok(AgentToolResult::error(format!("Failed to move note: {e}"))),
238                }
239            }
240            "tree" => {
241                let dir = params["dir"].as_str().unwrap_or("/");
242                let limit = params["limit"].as_u64().unwrap_or(50) as usize;
243                match self.kb.note_tree(dir) {
244                    Ok(entries) => {
245                        let count = entries.len();
246                        let entries: Vec<_> = entries.into_iter().take(limit).collect();
247                        if entries.is_empty() {
248                            return Ok(AgentToolResult::success("Directory is empty"));
249                        }
250                        let mut output = format!(
251                            "Found {} entries (showing {}):\n\n",
252                            count,
253                            entries.len()
254                        );
255                        for entry in &entries {
256                            let kind = if entry.is_dir { "📁" } else { "📄" };
257                            output.push_str(&format!(
258                                "{} {} ({})\n",
259                                kind, entry.display_name, entry.name
260                            ));
261                        }
262                        Ok(AgentToolResult::success(&output))
263                    }
264                    Err(e) => Ok(AgentToolResult::error(format!("Failed to list notes: {e}"))),
265                }
266            }
267            "search" => {
268                let query = params["query"].as_str().unwrap_or("");
269                if query.is_empty() {
270                    return Ok(AgentToolResult::error("query is required for search"));
271                }
272                let limit = params["limit"].as_u64().unwrap_or(10) as usize;
273                match self.kb.search(query, limit) {
274                    Ok(hits) => {
275                        if hits.is_empty() {
276                            return Ok(AgentToolResult::success("No matching notes found"));
277                        }
278                        let mut output = format!("Found {} matching notes:\n\n", hits.len());
279                        for hit in &hits {
280                            output.push_str(&format!(
281                                "- {} (path: {}, backlinks: {}, name_sim: {}%)\n",
282                                hit.name,
283                                hit.path,
284                                hit.backlink_count,
285                                hit.name_similarity,
286                            ));
287                        }
288                        Ok(AgentToolResult::success(&output))
289                    }
290                    Err(e) => {
291                        Ok(AgentToolResult::error(format!("Failed to search notes: {e}")))
292                    }
293                }
294            }
295            "backlinks" => {
296                let path = params["path"].as_str().unwrap_or("");
297                if path.is_empty() {
298                    return Ok(AgentToolResult::error("path is required for backlinks"));
299                }
300                let backlinks = self.kb.backlinks_for(path);
301                if backlinks.is_empty() {
302                    return Ok(AgentToolResult::success(format!(
303                        "No backlinks for '{path}'"
304                    )));
305                }
306                let mut output = format!("Backlinks for '{}' ({}):\n\n", path, backlinks.len());
307                for bl in &backlinks {
308                    output.push_str(&format!(
309                        "- {} → {} (line {})\n",
310                        bl.source_path, bl.target_path, bl.line_number
311                    ));
312                }
313                Ok(AgentToolResult::success(&output))
314            }
315            // ── Checklist ─────────────────────────────────────────
316
317            "checklist_items" => {
318                let path = params["path"].as_str().unwrap_or("");
319                if path.is_empty() {
320                    return Ok(AgentToolResult::error("path is required for checklist_items"));
321                }
322                match self.kb.checklist_items(path) {
323                    Ok((items, checked_map)) => {
324                        if items.is_empty() {
325                            return Ok(AgentToolResult::success("No checklist items found"));
326                        }
327                        let mut output = format!("Checklist items for '{}' ({}):\n\n", path, items.len());
328                        for item in &items {
329                            let status = checked_map.get(item).map(|b| if *b { "✅" } else { "⬜" }).unwrap_or("⬜");
330                            output.push_str(&format!("{status} {item}\n"));
331                        }
332                        Ok(AgentToolResult::success(&output))
333                    }
334                    Err(e) => Ok(AgentToolResult::error(format!("Failed to get checklist items: {e}"))),
335                }
336            }
337
338            "checklist_add" => {
339                let path = params["path"].as_str().unwrap_or("");
340                let item = params["item"].as_str().unwrap_or("");
341                let checked = params["checked"].as_bool().unwrap_or(false);
342                if path.is_empty() {
343                    return Ok(AgentToolResult::error("path is required for checklist_add"));
344                }
345                if item.is_empty() {
346                    return Ok(AgentToolResult::error("item is required for checklist_add"));
347                }
348                match self.kb.checklist_add(path, item, checked) {
349                    Ok(()) => Ok(AgentToolResult::success(format!(
350                        "Checklist item added to '{path}'"
351                    ))),
352                    Err(e) => Ok(AgentToolResult::error(format!("Failed to add checklist item: {e}"))),
353                }
354            }
355
356            "checklist_complete" => {
357                let path = params["path"].as_str().unwrap_or("");
358                let item_hash = params["item_hash"].as_str().unwrap_or("");
359                if path.is_empty() {
360                    return Ok(AgentToolResult::error("path is required for checklist_complete"));
361                }
362                if item_hash.is_empty() {
363                    return Ok(AgentToolResult::error("item_hash is required for checklist_complete"));
364                }
365                match self.kb.checklist_complete(path, item_hash) {
366                    Ok(true) => Ok(AgentToolResult::success(format!(
367                        "Checklist item completed in '{path}'"
368                    ))),
369                    Ok(false) => Ok(AgentToolResult::error(format!(
370                        "Checklist item '{item_hash}' not found in '{path}'"
371                    ))),
372                    Err(e) => Ok(AgentToolResult::error(format!("Failed to complete checklist item: {e}"))),
373                }
374            }
375
376            "checklist_remove" => {
377                let path = params["path"].as_str().unwrap_or("");
378                let item_or_hash = params["item_or_hash"].as_str().unwrap_or("");
379                if path.is_empty() {
380                    return Ok(AgentToolResult::error("path is required for checklist_remove"));
381                }
382                if item_or_hash.is_empty() {
383                    return Ok(AgentToolResult::error("item_or_hash is required for checklist_remove"));
384                }
385                match self.kb.checklist_remove(path, item_or_hash) {
386                    Ok(true) => Ok(AgentToolResult::success(format!(
387                        "Checklist item removed from '{path}'"
388                    ))),
389                    Ok(false) => Ok(AgentToolResult::error(format!(
390                        "Checklist item '{item_or_hash}' not found in '{path}'"
391                    ))),
392                    Err(e) => Ok(AgentToolResult::error(format!("Failed to remove checklist item: {e}"))),
393                }
394            }
395
396            // ── Chat ────────────────────────────────────────────────
397
398            "chat_append" => {
399                let message = params["message"].as_str().unwrap_or("");
400                if message.is_empty() {
401                    return Ok(AgentToolResult::error("message is required for chat_append"));
402                }
403                match self.kb.chat_append(message) {
404                    Ok(()) => Ok(AgentToolResult::success("Message appended to chat")),
405                    Err(e) => Ok(AgentToolResult::error(format!("Failed to append chat message: {e}"))),
406                }
407            }
408
409            "chat_messages" => {
410                match self.kb.chat_messages() {
411                    Ok(messages) => {
412                        if messages.is_empty() {
413                            return Ok(AgentToolResult::success("No chat messages found"));
414                        }
415                        let mut output = format!("Chat messages ({}):\n\n", messages.len());
416                        for (i, msg) in messages.iter().enumerate() {
417                            output.push_str(&format!("{}. {}\n", i + 1, msg));
418                        }
419                        Ok(AgentToolResult::success(&output))
420                    }
421                    Err(e) => Ok(AgentToolResult::error(format!("Failed to get chat messages: {e}"))),
422                }
423            }
424
425            "chat_delete" => {
426                let msg_hash = params["msg_hash"].as_str().unwrap_or("");
427                if msg_hash.is_empty() {
428                    return Ok(AgentToolResult::error("msg_hash is required for chat_delete"));
429                }
430                match self.kb.chat_delete(msg_hash) {
431                    Ok(true) => Ok(AgentToolResult::success(format!(
432                        "Chat message '{msg_hash}' deleted"
433                    ))),
434                    Ok(false) => Ok(AgentToolResult::error(format!(
435                        "Chat message '{msg_hash}' not found"
436                    ))),
437                    Err(e) => Ok(AgentToolResult::error(format!("Failed to delete chat message: {e}"))),
438                }
439            }
440
441            "chat_move" => {
442                let msg_hash = params["msg_hash"].as_str().unwrap_or("");
443                let target_path = params["target_path"].as_str().unwrap_or("");
444                if msg_hash.is_empty() {
445                    return Ok(AgentToolResult::error("msg_hash is required for chat_move"));
446                }
447                if target_path.is_empty() {
448                    return Ok(AgentToolResult::error("target_path is required for chat_move"));
449                }
450                match self.kb.chat_move_to(msg_hash, target_path) {
451                    Ok(true) => Ok(AgentToolResult::success(format!(
452                        "Chat message moved to '{target_path}'"
453                    ))),
454                    Ok(false) => Ok(AgentToolResult::error(format!(
455                        "Chat message '{msg_hash}' not found"
456                    ))),
457                    Err(e) => Ok(AgentToolResult::error(format!("Failed to move chat message: {e}"))),
458                }
459            }
460
461            // ── Journal ─────────────────────────────────────────────
462
463            "journal_add" => {
464                let record = params["record"].as_str().unwrap_or("");
465                if record.is_empty() {
466                    return Ok(AgentToolResult::error("record is required for journal_add"));
467                }
468                match self.kb.journal_add_record(record) {
469                    Ok(()) => Ok(AgentToolResult::success("Journal record added")),
470                    Err(e) => Ok(AgentToolResult::error(format!("Failed to add journal record: {e}"))),
471                }
472            }
473
474            "journal_emoji" => {
475                let emoji = params["emoji"].as_str().unwrap_or("");
476                if emoji.is_empty() {
477                    return Ok(AgentToolResult::error("emoji is required for journal_emoji"));
478                }
479                match self.kb.journal_add_emoji(emoji) {
480                    Ok(()) => Ok(AgentToolResult::success(format!("Journal emoji set to '{emoji}'"))),
481                    Err(e) => Ok(AgentToolResult::error(format!("Failed to set journal emoji: {e}"))),
482                }
483            }
484
485            "journal_today" => {
486                let path = self.kb.journal_today_path();
487                Ok(AgentToolResult::success(&path))
488            }
489
490            // ── Habits ──────────────────────────────────────────────
491
492            "habits" => {
493                let year = params["year"].as_i64().unwrap_or_else(|| {
494                    chrono::Local::now().year() as i64
495                }) as i32;
496                match self.kb.habits(year) {
497                    Ok(habits) => {
498                        let json = serde_json::to_string_pretty(&habits)
499                            .unwrap_or_else(|_| "{}".to_string());
500                        Ok(AgentToolResult::success(&json))
501                    }
502                    Err(e) => Ok(AgentToolResult::error(format!("Failed to get habits: {e}"))),
503                }
504            }
505
506            "habits_last_week" => {
507                match self.kb.habits_last_week() {
508                    Ok(habits) => {
509                        let json = serde_json::to_string_pretty(&habits)
510                            .unwrap_or_else(|_| "{}".to_string());
511                        Ok(AgentToolResult::success(&json))
512                    }
513                    Err(e) => Ok(AgentToolResult::error(format!("Failed to get last week habits: {e}"))),
514                }
515            }
516
517            // ── Stats ───────────────────────────────────────────────
518
519            "today_report" => {
520                match self.kb.today_report() {
521                    Ok(report) => {
522                        let json = serde_json::to_string_pretty(&report)
523                            .unwrap_or_else(|_| "{}".to_string());
524                        Ok(AgentToolResult::success(&json))
525                    }
526                    Err(e) => Ok(AgentToolResult::error(format!("Failed to get today report: {e}"))),
527                }
528            }
529
530            "done_today" => {
531                match self.kb.done_today() {
532                    Ok(entries) => {
533                        if entries.is_empty() {
534                            return Ok(AgentToolResult::success("No completed items today"));
535                        }
536                        let mut output = format!("Done today ({}):\n\n", entries.len());
537                        for entry in &entries {
538                            let kind = if entry.is_dir { "📁" } else { "📄" };
539                            output.push_str(&format!(
540                                "{} {} ({})\n",
541                                kind, entry.display_name, entry.name
542                            ));
543                        }
544                        Ok(AgentToolResult::success(&output))
545                    }
546                    Err(e) => Ok(AgentToolResult::error(format!("Failed to get done today: {e}"))),
547                }
548            }
549
550            // ── Config ──────────────────────────────────────────────
551
552            "config_read" => {
553                match self.kb.config() {
554                    Ok(config) => {
555                        let json = serde_json::to_string_pretty(&config)
556                            .unwrap_or_else(|_| "{}".to_string());
557                        Ok(AgentToolResult::success(&json))
558                    }
559                    Err(e) => Ok(AgentToolResult::error(format!("Failed to read config: {e}"))),
560                }
561            }
562
563            "config_write" => {
564                let config_val = params.get("config").cloned().unwrap_or(json!({}));
565                match serde_json::from_value::<oxios_markdown::types::KnowledgeConfig>(config_val) {
566                    Ok(config) => {
567                        match self.kb.set_config(&config) {
568                            Ok(()) => Ok(AgentToolResult::success("Config updated successfully")),
569                            Err(e) => Ok(AgentToolResult::error(format!("Failed to write config: {e}"))),
570                        }
571                    }
572                    Err(e) => Ok(AgentToolResult::error(format!("Invalid config object: {e}"))),
573                }
574            }
575
576            // ── Automation ──────────────────────────────────────────
577
578            "nightly_cleanup" => {
579                match self.kb.run_nightly_cleanup() {
580                    Ok(report) => {
581                        let json = serde_json::to_string_pretty(&report)
582                            .unwrap_or_else(|_| "{}".to_string());
583                        Ok(AgentToolResult::success(&json))
584                    }
585                    Err(e) => Ok(AgentToolResult::error(format!("Failed to run nightly cleanup: {e}"))),
586                }
587            }
588
589            "run_scheduled" => {
590                match self.kb.run_scheduled_tasks() {
591                    Ok(moved) => {
592                        if moved.is_empty() {
593                            Ok(AgentToolResult::success("No scheduled tasks due"))
594                        } else {
595                            let mut output = format!("Moved {} scheduled tasks to chat:\n\n", moved.len());
596                            for task in &moved {
597                                output.push_str(&format!("- {task}\n"));
598                            }
599                            Ok(AgentToolResult::success(&output))
600                        }
601                    }
602                    Err(e) => Ok(AgentToolResult::error(format!("Failed to run scheduled tasks: {e}"))),
603                }
604            }
605
606            // ── Utils ───────────────────────────────────────────────
607
608            "markdown_to_html" => {
609                let md = params["md"].as_str().unwrap_or("");
610                if md.is_empty() {
611                    return Ok(AgentToolResult::error("md is required for markdown_to_html"));
612                }
613                let html = self.kb.markdown_to_html(md);
614                Ok(AgentToolResult::success(&html))
615            }
616
617            "auto_emoji" => {
618                let text = params["text"].as_str().unwrap_or("");
619                if text.is_empty() {
620                    return Ok(AgentToolResult::error("text is required for auto_emoji"));
621                }
622                let emoji = self.kb.auto_emoji(text);
623                Ok(AgentToolResult::success(&emoji))
624            }
625
626            _ => Ok(AgentToolResult::error(format!(
627                "Unknown action '{action}'. Must be one of: read, write, delete, move, tree, search, backlinks, \
628                 checklist_items, checklist_add, checklist_complete, checklist_remove, \
629                 chat_append, chat_messages, chat_delete, chat_move, \
630                 journal_add, journal_emoji, journal_today, \
631                 habits, habits_last_week, today_report, done_today, \
632                 config_read, config_write, nightly_cleanup, run_scheduled, \
633                 markdown_to_html, auto_emoji"
634            ))),
635        }
636    }
637}
638
639#[cfg(test)]
640mod tests {
641    use super::*;
642
643    #[test]
644    fn test_knowledge_tool_schema() {
645        let dir = std::env::temp_dir().join(format!("test-kb-tool-{}", uuid::Uuid::new_v4()));
646        let kb = Arc::new(oxios_markdown::KnowledgeBase::new(dir).unwrap());
647        let tool = KnowledgeTool::new(kb);
648        assert_eq!(tool.name(), "knowledge");
649        let schema = tool.parameters_schema();
650        assert!(schema["required"].is_array());
651        let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
652        assert_eq!(actions.len(), 28);
653    }
654}