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 '{}' 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 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_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_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_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" => {
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 "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_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 "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 "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}