1use 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
17pub struct KnowledgeTool {
24 kb: Arc<KnowledgeBase>,
25}
26
27impl KnowledgeTool {
28 pub fn from_kernel(kernel: &KernelHandle) -> Self {
30 Self {
31 kb: kernel.knowledge.clone(),
32 }
33 }
34
35 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 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_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_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_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" => {
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 "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_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 "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 "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}