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