1use rmcp::{
6 ErrorData as McpError,
7 handler::server::wrapper::Parameters,
8 model::{PromptMessage, PromptMessageRole},
9 prompt, prompt_router, schemars,
10};
11use serde::Deserialize;
12
13use super::KimunHandler;
14
15#[derive(Debug, Deserialize, schemars::JsonSchema)]
20pub struct DailyReviewParams {
21 pub date: Option<String>,
23}
24
25#[derive(Debug, Deserialize, schemars::JsonSchema)]
26pub struct FindConnectionsParams {
27 pub path: String,
29}
30
31#[derive(Debug, Deserialize, schemars::JsonSchema)]
32pub struct ResearchNoteParams {
33 pub path: String,
35 pub max_results: Option<u32>,
37}
38
39#[derive(Debug, Deserialize, schemars::JsonSchema)]
40pub struct BrainstormParams {
41 pub topic: String,
43 pub max_results: Option<u32>,
45}
46
47#[derive(Debug, Deserialize, schemars::JsonSchema)]
48pub struct WeeklyReviewParams {
49 pub date: Option<String>,
51}
52
53#[derive(Debug, Deserialize, schemars::JsonSchema)]
54pub struct LinkSuggestionsParams {
55 pub path: String,
57 pub max_results: Option<u32>,
59}
60
61#[derive(Debug, Deserialize, schemars::JsonSchema)]
62pub struct ResearchTopicParams {
63 pub topic: String,
65 pub max_results: Option<u32>,
67}
68
69#[derive(Debug, Deserialize, schemars::JsonSchema)]
70pub struct TriageInboxParams {
71 pub max_notes: Option<u32>,
73 pub max_context: Option<u32>,
75}
76
77impl KimunHandler {
82 async fn extract_leaf_headings(
86 &self,
87 path: &kimun_core::nfs::VaultPath,
88 ) -> Result<Vec<String>, McpError> {
89 use std::collections::HashSet;
90
91 let chunks_map = self
92 .vault
93 .get_note_chunks(path)
94 .await
95 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
96
97 let mut seen: HashSet<String> = HashSet::new();
98 let mut topics: Vec<String> = Vec::new();
99 for chunks in chunks_map.values() {
100 for chunk in chunks {
101 if let Some(leaf) = chunk.breadcrumb_last() {
102 let t = leaf.trim().to_string();
103 if !t.is_empty() && seen.insert(t.clone()) {
104 topics.push(t);
105 }
106 }
107 }
108 }
109 Ok(topics)
110 }
111}
112
113#[prompt_router(vis = "pub")]
118impl KimunHandler {
119 #[prompt(
120 description = "Load today's journal entry and ask the LLM to review the day: summarise accomplishments, identify action items, and note recurring themes."
121 )]
122 async fn daily_review(
123 &self,
124 Parameters(p): Parameters<DailyReviewParams>,
125 ) -> Result<Vec<PromptMessage>, McpError> {
126 use kimun_core::error::{FSError, VaultError};
127
128 let date_str = match p.date.as_deref() {
129 None => chrono::Utc::now().format("%Y-%m-%d").to_string(),
130 Some(d) => {
131 if chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d").is_err() {
132 return Err(McpError::invalid_params(
133 format!("Invalid date '{}' — expected YYYY-MM-DD.", d),
134 None,
135 ));
136 }
137 d.to_string()
138 }
139 };
140
141 let journal_path = self
142 .vault
143 .journal_path()
144 .append(&kimun_core::nfs::VaultPath::note_path_from(&date_str))
145 .absolute();
146
147 let journal_text = match self.vault.get_note_text(&journal_path).await {
148 Ok(t) => t,
149 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
150 return Ok(vec![PromptMessage::new_text(
151 PromptMessageRole::User,
152 format!("No journal entry found for {}.", date_str),
153 )]);
154 }
155 Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
156 };
157
158 let message = format!(
159 "Here is my journal entry for {date_str}:\n\n---\n{journal_text}\n---\n\n\
160 Please review this journal entry:\n\
161 1. Summarize what was accomplished\n\
162 2. Identify any action items or follow-ups\n\
163 3. Note any open questions or concerns that need follow-up"
164 );
165
166 Ok(vec![PromptMessage::new_text(
167 PromptMessageRole::User,
168 message,
169 )])
170 }
171
172 #[prompt(
173 description = "Load a note and its backlink list, then ask the LLM to identify non-obvious conceptual connections to the rest of the vault."
174 )]
175 async fn find_connections(
176 &self,
177 Parameters(p): Parameters<FindConnectionsParams>,
178 ) -> Result<Vec<PromptMessage>, McpError> {
179 use kimun_core::error::{FSError, VaultError};
180 use kimun_core::nfs::VaultPath;
181
182 let vault_path = VaultPath::note_path_from(&p.path);
183
184 let note_text = match self.vault.get_note_text(&vault_path).await {
185 Ok(t) => t,
186 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
187 return Ok(vec![PromptMessage::new_text(
188 PromptMessageRole::User,
189 format!("Note not found: {}", vault_path),
190 )]);
191 }
192 Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
193 };
194
195 let backlinks = self
196 .vault
197 .get_backlinks(&vault_path)
198 .await
199 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
200
201 let backlinks_section = if backlinks.is_empty() {
202 String::new()
203 } else {
204 let paths: Vec<String> = backlinks
205 .iter()
206 .map(|(entry, _)| format!("- {}", entry.path))
207 .collect();
208 format!("\nNotes that link to this note:\n{}\n", paths.join("\n"))
209 };
210
211 let message = format!(
212 "Here is the note at \"{path}\":\n\n---\n{note_text}\n---\n{backlinks_section}\n\
213 Identify non-obvious conceptual connections between this note and the rest of the vault. \
214 What themes link them? What ideas are worth exploring further?\n\
215 (You can use the available vault tools to read any linked note in full.)",
216 path = vault_path,
217 );
218
219 Ok(vec![PromptMessage::new_text(
220 PromptMessageRole::User,
221 message,
222 )])
223 }
224
225 #[prompt(
226 description = "Search the vault using a note's section headings as queries, then ask the LLM to synthesise what is captured and identify gaps."
227 )]
228 async fn research_note(
229 &self,
230 Parameters(p): Parameters<ResearchNoteParams>,
231 ) -> Result<Vec<PromptMessage>, McpError> {
232 use kimun_core::error::{FSError, VaultError};
233 use kimun_core::nfs::VaultPath;
234 use std::collections::HashSet;
235
236 let vault_path = VaultPath::note_path_from(&p.path);
237 let max = p.max_results.unwrap_or(5) as usize;
238
239 let note_text = match self.vault.get_note_text(&vault_path).await {
240 Ok(t) => t,
241 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
242 return Ok(vec![PromptMessage::new_text(
243 PromptMessageRole::User,
244 format!("Note not found: {}", vault_path),
245 )]);
246 }
247 Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
248 };
249
250 let topics = self.extract_leaf_headings(&vault_path).await?;
251
252 let norm = |p: &VaultPath| p.flatten().absolute().to_string();
256 let mut seen: HashSet<String> = HashSet::new();
257 seen.insert(norm(&vault_path));
258
259 let mut related_sections: Vec<String> = Vec::new();
260
261 'outer: for topic in &topics {
262 let results = self
263 .vault
264 .search_notes(topic)
265 .await
266 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
267 for (entry, _) in results {
268 let path_str = norm(&entry.path);
269 if seen.contains(&path_str) {
270 continue;
271 }
272 seen.insert(path_str.clone());
273 let text = self
274 .vault
275 .get_note_text(&entry.path)
276 .await
277 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
278 related_sections.push(format!("=== {} ===\n{}", entry.path, text));
279 if related_sections.len() >= max {
280 break 'outer;
281 }
282 }
283 }
284
285 let topics_list = if topics.is_empty() {
286 "(no sections found)".to_string()
287 } else {
288 topics.join(", ")
289 };
290
291 let related_block = if related_sections.is_empty() {
292 "No related notes found in the vault.".to_string()
293 } else {
294 related_sections.join("\n\n")
295 };
296
297 let message = format!(
298 "Here is the note at \"{path}\":\n\n---\n{note_text}\n---\n\n\
299 Related notes found by searching section topics ({topics_list}):\n\n\
300 {related_block}\n\n\
301 For each of the section topics ({topics_list}), synthesize what the vault captures \
302 and identify what is missing or unexplored. What key ideas are captured? \
303 What gaps exist? What questions remain unanswered?",
304 path = vault_path,
305 );
306
307 Ok(vec![PromptMessage::new_text(
308 PromptMessageRole::User,
309 message,
310 )])
311 }
312
313 #[prompt(
314 description = "Search the vault for a topic and ask the LLM to generate new ideas that build on existing notes, with a suggested note to append them to."
315 )]
316 async fn brainstorm(
317 &self,
318 Parameters(p): Parameters<BrainstormParams>,
319 ) -> Result<Vec<PromptMessage>, McpError> {
320 let results = self
321 .vault
322 .search_notes(&p.topic)
323 .await
324 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
325
326 let max = p.max_results.unwrap_or(5) as usize;
327 let top: Vec<_> = results.into_iter().take(max).collect();
328 let suggested_path = top.first().map(|(entry, _)| entry.path.to_string());
329
330 let mut vault_sections: Vec<String> = Vec::new();
331 for (entry, _) in &top {
332 let text = self
333 .vault
334 .get_note_text(&entry.path)
335 .await
336 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
337 vault_sections.push(format!("=== {} ===\n{}", entry.path, text));
338 }
339
340 let vault_block = if vault_sections.is_empty() {
341 String::new()
342 } else {
343 format!(
344 "Here is relevant content from my vault:\n\n{}\n\n",
345 vault_sections.join("\n\n")
346 )
347 };
348
349 let suggestion_line = match &suggested_path {
350 Some(path) => format!("3. Suggested note to append new ideas to: {}\n", path),
351 None => String::new(),
352 };
353
354 let message = format!(
355 "I want to brainstorm ideas about: \"{topic}\"\n\n\
356 {vault_block}\
357 Based on my existing notes:\n\
358 1. Generate 5–10 new ideas related to \"{topic}\" that build on what's already captured\n\
359 2. For each new idea, identify which existing note it connects to and suggest where it could be appended or linked\n\
360 {suggestion_line}",
361 topic = p.topic,
362 );
363
364 Ok(vec![PromptMessage::new_text(
365 PromptMessageRole::User,
366 message,
367 )])
368 }
369
370 #[prompt(
371 description = "Load a full week of journal entries and ask the LLM to synthesise themes, accomplishments, and carry-overs."
372 )]
373 async fn weekly_review(
374 &self,
375 Parameters(p): Parameters<WeeklyReviewParams>,
376 ) -> Result<Vec<PromptMessage>, McpError> {
377 use chrono::{Datelike, Duration, NaiveDate, Utc};
378 use kimun_core::error::{FSError, VaultError};
379 use kimun_core::nfs::VaultPath;
380
381 let anchor: NaiveDate = match p.date.as_deref() {
383 None => Utc::now().date_naive(),
384 Some(d) => match NaiveDate::parse_from_str(d, "%Y-%m-%d") {
385 Ok(date) => date,
386 Err(_) => {
387 return Err(McpError::invalid_params(
388 format!("Invalid date '{}' — expected YYYY-MM-DD.", d),
389 None,
390 ));
391 }
392 },
393 };
394
395 let days_from_monday = anchor.weekday().num_days_from_monday();
397 let monday = anchor - Duration::days(days_from_monday as i64);
398 let sunday = monday + Duration::days(6);
399
400 let day_names = [
402 "Monday",
403 "Tuesday",
404 "Wednesday",
405 "Thursday",
406 "Friday",
407 "Saturday",
408 "Sunday",
409 ];
410
411 let mut days_text = String::new();
412 for i in 0..7 {
413 let day = monday + Duration::days(i);
414 let date_str = day.format("%Y-%m-%d").to_string();
415 let journal_path = self
416 .vault
417 .journal_path()
418 .append(&VaultPath::note_path_from(&date_str))
419 .absolute();
420
421 let content = match self.vault.get_note_text(&journal_path).await {
422 Ok(text) => text,
423 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
424 "(no entry)".to_string()
425 }
426 Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
427 };
428
429 days_text.push_str(&format!(
430 "{} {}:\n---\n{}\n---\n\n",
431 day_names[i as usize], date_str, content
432 ));
433 }
434
435 let message = format!(
436 "Week of {} – {}\n\n{}\
437 Please review this week:\n\
438 1. What were the main themes and accomplishments?\n\
439 2. What carried over unfinished from day to day?\n\
440 3. What patterns are worth paying attention to?\n\
441 4. What should be prioritised next week?",
442 monday.format("%Y-%m-%d"),
443 sunday.format("%Y-%m-%d"),
444 days_text
445 );
446
447 Ok(vec![PromptMessage::new_text(
448 PromptMessageRole::User,
449 message,
450 )])
451 }
452
453 #[prompt(
454 description = "Search the vault for a topic, expand the search via backlinks and related headings from the results, then ask the LLM for a comprehensive overview of the topic and everything connected to it."
455 )]
456 async fn research_topic(
457 &self,
458 Parameters(p): Parameters<ResearchTopicParams>,
459 ) -> Result<Vec<PromptMessage>, McpError> {
460 use kimun_core::nfs::VaultPath;
461 use std::collections::HashSet;
462
463 let max = p.max_results.unwrap_or(10) as usize;
464 let mut seen: HashSet<String> = HashSet::new();
465
466 let initial_results = self
468 .vault
469 .search_notes(&p.topic)
470 .await
471 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
472
473 let mut direct_notes: Vec<(VaultPath, String)> = Vec::new();
474 let mut backlink_candidates: Vec<VaultPath> = Vec::new();
475 let mut secondary_topics: Vec<String> = Vec::new();
477 let mut secondary_topics_lower: std::collections::HashSet<String> =
478 std::collections::HashSet::new();
479
480 for (entry, _) in initial_results {
481 if direct_notes.len() >= max {
482 break;
483 }
484 let path_str = entry.path.to_string();
485 if seen.contains(&path_str) {
486 continue;
487 }
488 seen.insert(path_str);
489
490 let text = self
491 .vault
492 .get_note_text(&entry.path)
493 .await
494 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
495 direct_notes.push((entry.path.clone(), text));
496
497 let backlinks = self
499 .vault
500 .get_backlinks(&entry.path)
501 .await
502 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
503 for (bl_entry, _) in backlinks {
504 let bl_str = bl_entry.path.to_string();
505 if !seen.contains(&bl_str) {
506 seen.insert(bl_str);
507 backlink_candidates.push(bl_entry.path);
508 }
509 }
510
511 let headings = self.extract_leaf_headings(&entry.path).await?;
513 for t in headings {
514 let t_lower = t.to_lowercase();
515 if t_lower != p.topic.to_lowercase() && secondary_topics_lower.insert(t_lower) {
516 secondary_topics.push(t);
517 }
518 }
519 }
520
521 let mut backlink_notes: Vec<(VaultPath, String)> = Vec::new();
523 for path in &backlink_candidates {
524 if direct_notes.len() + backlink_notes.len() >= max {
525 break;
526 }
527 let text = self
528 .vault
529 .get_note_text(path)
530 .await
531 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
532 backlink_notes.push((path.clone(), text));
533 }
534
535 let mut related_notes: Vec<(VaultPath, String)> = Vec::new();
537 let mut contributing_topics: Vec<String> = Vec::new();
538 'outer: for topic in &secondary_topics {
539 let results = self
540 .vault
541 .search_notes(topic)
542 .await
543 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
544 let before = related_notes.len();
545 for (entry, _) in results {
546 if direct_notes.len() + backlink_notes.len() + related_notes.len() >= max {
547 break 'outer;
548 }
549 let path_str = entry.path.to_string();
550 if seen.contains(&path_str) {
551 continue;
552 }
553 seen.insert(path_str);
554 let text = self
555 .vault
556 .get_note_text(&entry.path)
557 .await
558 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
559 related_notes.push((entry.path, text));
560 }
561 if related_notes.len() > before {
562 contributing_topics.push(topic.clone());
563 }
564 }
565
566 if direct_notes.is_empty() && backlink_notes.is_empty() && related_notes.is_empty() {
567 return Ok(vec![PromptMessage::new_text(
568 PromptMessageRole::User,
569 format!("No notes found in the vault related to \"{}\".", p.topic),
570 )]);
571 }
572
573 let mut blocks: Vec<String> = Vec::new();
574
575 if !direct_notes.is_empty() {
576 let section = direct_notes
577 .iter()
578 .map(|(path, text)| format!("=== {} ===\n{}", path, text))
579 .collect::<Vec<_>>()
580 .join("\n\n");
581 blocks.push(format!(
582 "### Notes matching \"{}\":\n\n{}",
583 p.topic, section
584 ));
585 }
586
587 if !backlink_notes.is_empty() {
588 let section = backlink_notes
589 .iter()
590 .map(|(path, text)| format!("=== {} ===\n{}", path, text))
591 .collect::<Vec<_>>()
592 .join("\n\n");
593 blocks.push(format!("### Notes linking to the above:\n\n{}", section));
594 }
595
596 if !related_notes.is_empty() {
597 let section = related_notes
598 .iter()
599 .map(|(path, text)| format!("=== {} ===\n{}", path, text))
600 .collect::<Vec<_>>()
601 .join("\n\n");
602 let header = if contributing_topics.is_empty() {
603 "### Notes on related subtopics:".to_string()
604 } else {
605 let label = contributing_topics
606 .iter()
607 .take(5)
608 .cloned()
609 .collect::<Vec<_>>()
610 .join(", ");
611 format!("### Notes on related subtopics ({label}):")
612 };
613 blocks.push(format!("{header}\n\n{section}"));
614 }
615
616 let content_block = blocks.join("\n\n");
617
618 let message = format!(
619 "Research topic: \"{topic}\"\n\n\
620 {content_block}\n\n\
621 Using the vault content above, provide a comprehensive overview of \"{topic}\":\n\
622 1. What does the vault capture about this topic?\n\
623 2. What are the key ideas, patterns, or recurring themes?\n\
624 3. How do the related notes connect to the topic?\n\
625 4. What gaps or unexplored angles exist?",
626 topic = p.topic,
627 );
628
629 Ok(vec![PromptMessage::new_text(
630 PromptMessageRole::User,
631 message,
632 )])
633 }
634
635 #[prompt(
636 description = "Find vault notes topically related to the given note but not yet linked, and ask the LLM to evaluate which connections are worth formalising."
637 )]
638 async fn link_suggestions(
639 &self,
640 Parameters(p): Parameters<LinkSuggestionsParams>,
641 ) -> Result<Vec<PromptMessage>, McpError> {
642 use kimun_core::error::{FSError, VaultError};
643 use kimun_core::nfs::VaultPath;
644 use kimun_core::note::LinkType;
645 use std::collections::HashSet;
646
647 let vault_path = VaultPath::note_path_from(&p.path);
648 let max = p.max_results.unwrap_or(5) as usize;
649
650 let note_text = match self.vault.get_note_text(&vault_path).await {
652 Ok(t) => t,
653 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
654 return Ok(vec![PromptMessage::new_text(
655 PromptMessageRole::User,
656 format!("Note not found: {}", vault_path),
657 )]);
658 }
659 Err(e) => return Err(McpError::internal_error(e.to_string(), None)),
660 };
661
662 let topics = self.extract_leaf_headings(&vault_path).await?;
663
664 let norm = |p: &VaultPath| p.flatten().absolute().to_string();
669 let mut excluded: HashSet<String> = HashSet::new();
670 excluded.insert(norm(&vault_path));
671
672 let md_note = self
673 .vault
674 .get_markdown_and_links(&vault_path)
675 .await
676 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
677 for link in md_note.links {
678 if let LinkType::Note(linked_path) = link.ltype {
679 excluded.insert(norm(&linked_path));
680 }
681 }
682
683 let backlinks = self
684 .vault
685 .get_backlinks(&vault_path)
686 .await
687 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
688 for (entry, _) in &backlinks {
689 excluded.insert(norm(&entry.path));
690 }
691
692 let mut candidates: Vec<(VaultPath, String)> = Vec::new();
694 let mut seen: HashSet<String> = excluded.clone();
695
696 'outer: for topic in &topics {
697 let results = self
698 .vault
699 .search_notes(topic)
700 .await
701 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
702 for (entry, _) in results {
703 let path_str = norm(&entry.path);
704 if seen.contains(&path_str) {
705 continue;
706 }
707 seen.insert(path_str);
708 let text = self
709 .vault
710 .get_note_text(&entry.path)
711 .await
712 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
713 candidates.push((entry.path, text));
714 if candidates.len() >= max {
715 break 'outer;
716 }
717 }
718 }
719
720 if candidates.is_empty() {
721 return Ok(vec![PromptMessage::new_text(
722 PromptMessageRole::User,
723 format!(
724 "Here is the note at \"{}\":\n\n---\n{}\n---\n\nNo unlinked related notes found in the vault.",
725 vault_path, note_text
726 ),
727 )]);
728 }
729
730 let candidates_block: String = candidates
731 .iter()
732 .map(|(path, text)| format!("=== {} ===\n{}", path, text))
733 .collect::<Vec<_>>()
734 .join("\n\n");
735
736 let message = format!(
737 "Here is the note at \"{path}\":\n\n---\n{note_text}\n---\n\n\
738 Candidate notes not yet linked to or from this note:\n\n\
739 {candidates_block}\n\n\
740 For each candidate:\n\
741 1. Assess whether a meaningful conceptual connection exists.\n\
742 2. If yes, suggest the exact [[wikilink]] syntax to add and where in the note it fits.\n\
743 3. If no clear connection, explain briefly why it was surfaced.",
744 path = vault_path,
745 );
746
747 Ok(vec![PromptMessage::new_text(
748 PromptMessageRole::User,
749 message,
750 )])
751 }
752
753 #[prompt(
754 description = "Review inbox notes and suggest how to organize them: move to journal, promote to a proper note with related context, or keep in inbox for later."
755 )]
756 async fn triage_inbox(
757 &self,
758 Parameters(p): Parameters<TriageInboxParams>,
759 ) -> Result<Vec<PromptMessage>, McpError> {
760 let max_notes = p.max_notes.unwrap_or(20) as usize;
761 let max_context = p.max_context.unwrap_or(3) as usize;
762
763 let all_inbox = self
764 .vault
765 .get_notes(self.vault.inbox_path(), false)
766 .await
767 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
768
769 let inbox_notes: Vec<_> = all_inbox.into_iter().take(max_notes).collect();
770
771 if inbox_notes.is_empty() {
772 return Ok(vec![PromptMessage::new_text(
773 PromptMessageRole::User,
774 "The inbox is empty — no notes to triage.".to_string(),
775 )]);
776 }
777
778 let mut sections = Vec::new();
779
780 for (entry, _content_data) in &inbox_notes {
781 let content = match self.vault.get_note_text(&entry.path).await {
782 Ok(t) => t,
783 Err(_) => continue,
784 };
785
786 let search_terms: String = content
787 .split_whitespace()
788 .take(15)
789 .collect::<Vec<_>>()
790 .join(" ");
791
792 let mut related_section = String::new();
793 if !search_terms.is_empty()
794 && let Ok(results) = self.vault.search_notes(&search_terms).await
795 {
796 let related: Vec<_> = results
797 .iter()
798 .filter(|(e, _)| e.path != entry.path)
799 .take(max_context)
800 .collect();
801 if !related.is_empty() {
802 related_section.push_str("\nRelated notes:\n");
803 for (rel_entry, rel_content) in &related {
804 let preview: String = rel_content.title.chars().take(200).collect();
805 related_section
806 .push_str(&format!("- {} — \"{}\"\n", rel_entry.path, preview));
807 }
808 }
809 }
810
811 let filename = entry.path.get_clean_name();
812 sections.push(format!(
813 "---\n## {path} (filename: {filename})\n\n{content}\n{related}\n",
814 path = entry.path,
815 content = content,
816 related = related_section,
817 ));
818 }
819
820 let message = format!(
821 "Here are the notes in the inbox ({count} total):\n\n\
822 {sections}\
823 ---\n\n\
824 For each inbox note, suggest what to do:\n\
825 1. **Journal** — append the content to the journal entry for the date in the filename \
826 (use `append_note` on the journal path `/journal/YYYY-MM-DD`, then delete the inbox note with `move_note` or inform the user)\n\
827 2. **Promote** — create a proper note with a descriptive name in an appropriate vault directory \
828 (use `create_note` with the enriched content, linking to related notes if helpful, then delete the inbox note)\n\
829 3. **Keep** — leave it in the inbox if it needs more thought\n\n\
830 Process one note at a time. Use the available tools to execute your suggestions.",
831 count = inbox_notes.len(),
832 sections = sections.join(""),
833 );
834
835 Ok(vec![PromptMessage::new_text(
836 PromptMessageRole::User,
837 message,
838 )])
839 }
840}
841
842#[cfg(test)]
843mod tests {
844 use super::super::*;
845 use super::*;
846 use kimun_core::{NoteVault, VaultConfig};
847 use tempfile::TempDir;
848
849 async fn make_handler() -> (KimunHandler, TempDir) {
850 let dir = TempDir::new().unwrap();
851 let vault = NoteVault::new(VaultConfig::new(dir.path())).await.unwrap();
852 vault.validate_and_init().await.unwrap();
853 let handler = KimunHandler::new(vault);
854 (handler, dir)
855 }
856
857 fn first_text(msgs: &[PromptMessage]) -> String {
859 match msgs.first().map(|m| &m.content) {
860 Some(PromptMessageContent::Text { text }) => text.clone(),
861 _ => String::new(),
862 }
863 }
864
865 #[tokio::test]
866 async fn test_daily_review_no_entry_returns_graceful_message() {
867 let (handler, _dir) = make_handler().await;
868 let msgs = handler
869 .daily_review(Parameters(DailyReviewParams { date: None }))
870 .await
871 .unwrap();
872 assert!(!msgs.is_empty());
873 let text = first_text(&msgs);
874 assert!(
875 text.contains("No journal entry"),
876 "expected graceful message, got: {}",
877 text
878 );
879 }
880
881 #[tokio::test]
882 async fn test_daily_review_with_entry_includes_content() {
883 let (handler, _dir) = make_handler().await;
884 handler
886 .journal(Parameters(JournalParams {
887 text: "worked on unique_daily_review_content_xyz".to_string(),
888 date: None,
889 }))
890 .await
891 .unwrap();
892 let msgs = handler
893 .daily_review(Parameters(DailyReviewParams { date: None }))
894 .await
895 .unwrap();
896 assert!(!msgs.is_empty());
897 let text = first_text(&msgs);
898 assert!(
899 text.contains("unique_daily_review_content_xyz"),
900 "expected journal content in prompt: {}",
901 text
902 );
903 }
904
905 #[tokio::test]
906 async fn test_daily_review_specific_date() {
907 let (handler, _dir) = make_handler().await;
908 handler
909 .journal(Parameters(JournalParams {
910 text: "specific date entry content".to_string(),
911 date: Some("2026-01-15".to_string()),
912 }))
913 .await
914 .unwrap();
915 let msgs = handler
916 .daily_review(Parameters(DailyReviewParams {
917 date: Some("2026-01-15".to_string()),
918 }))
919 .await
920 .unwrap();
921 assert!(!msgs.is_empty());
922 let text = first_text(&msgs);
923 assert!(
924 text.contains("specific date entry content"),
925 "expected entry in prompt: {}",
926 text
927 );
928 }
929
930 #[tokio::test]
931 async fn test_daily_review_invalid_date_returns_error() {
932 let (handler, _dir) = make_handler().await;
933 let result = handler
934 .daily_review(Parameters(DailyReviewParams {
935 date: Some("not-a-date".to_string()),
936 }))
937 .await;
938 assert!(result.is_err(), "expected Err for invalid date");
939 let err = result.unwrap_err();
940 assert!(
941 err.message.contains("Invalid date"),
942 "expected error message to mention invalid date: {:?}",
943 err
944 );
945 }
946
947 #[tokio::test]
948 async fn test_find_connections_includes_note_content() {
949 let (handler, _dir) = make_handler().await;
950 handler
951 .create_note(Parameters(CreateNoteParams {
952 path: "my/note".to_string(),
953 content: "# My Note\n\nunique_connections_content_abc".to_string(),
954 }))
955 .await
956 .unwrap();
957 let msgs = handler
958 .find_connections(Parameters(FindConnectionsParams {
959 path: "my/note".to_string(),
960 }))
961 .await
962 .unwrap();
963 assert!(!msgs.is_empty());
964 let text = first_text(&msgs);
965 assert!(
966 text.contains("unique_connections_content_abc"),
967 "expected note content in prompt: {}",
968 text
969 );
970 }
971
972 #[tokio::test]
973 async fn test_find_connections_lists_backlinks() {
974 let (handler, _dir) = make_handler().await;
975 handler
976 .create_note(Parameters(CreateNoteParams {
977 path: "target".to_string(),
978 content: "# Target".to_string(),
979 }))
980 .await
981 .unwrap();
982 handler
983 .create_note(Parameters(CreateNoteParams {
984 path: "source".to_string(),
985 content: "see [[target]] for details".to_string(),
986 }))
987 .await
988 .unwrap();
989 let msgs = handler
990 .find_connections(Parameters(FindConnectionsParams {
991 path: "target".to_string(),
992 }))
993 .await
994 .unwrap();
995 let text = first_text(&msgs);
996 assert!(
997 text.contains("source"),
998 "expected backlink 'source' in prompt: {}",
999 text
1000 );
1001 }
1002
1003 #[tokio::test]
1004 async fn test_find_connections_no_backlinks_omits_section() {
1005 let (handler, _dir) = make_handler().await;
1006 handler
1007 .create_note(Parameters(CreateNoteParams {
1008 path: "lone/note".to_string(),
1009 content: "# Lone\n\nno links to here".to_string(),
1010 }))
1011 .await
1012 .unwrap();
1013 let msgs = handler
1014 .find_connections(Parameters(FindConnectionsParams {
1015 path: "lone/note".to_string(),
1016 }))
1017 .await
1018 .unwrap();
1019 assert!(!msgs.is_empty());
1020 let text = first_text(&msgs);
1021 assert!(text.contains("Lone"), "expected note content: {}", text);
1023 assert!(
1024 !text.contains("Notes that link"),
1025 "should not have backlinks section: {}",
1026 text
1027 );
1028 }
1029
1030 #[tokio::test]
1031 async fn test_find_connections_note_not_found() {
1032 let (handler, _dir) = make_handler().await;
1033 let msgs = handler
1034 .find_connections(Parameters(FindConnectionsParams {
1035 path: "missing/note".to_string(),
1036 }))
1037 .await
1038 .unwrap();
1039 assert!(!msgs.is_empty());
1040 let text = first_text(&msgs);
1041 assert!(
1042 text.contains("not found"),
1043 "expected not-found message: {}",
1044 text
1045 );
1046 }
1047
1048 #[tokio::test]
1049 async fn test_research_note_includes_source_note() {
1050 let (handler, _dir) = make_handler().await;
1051 handler
1052 .create_note(Parameters(CreateNoteParams {
1053 path: "research/topic".to_string(),
1054 content: "# Topic\n\n## Background\n\nunique_research_source_xyz\n\n## Open Questions\n\nwhat next?".to_string(),
1055 }))
1056 .await
1057 .unwrap();
1058 let msgs = handler
1059 .research_note(Parameters(ResearchNoteParams {
1060 path: "research/topic".to_string(),
1061 max_results: Some(3),
1062 }))
1063 .await
1064 .unwrap();
1065 assert!(!msgs.is_empty());
1066 let text = first_text(&msgs);
1067 assert!(
1068 text.contains("unique_research_source_xyz"),
1069 "expected source note content: {}",
1070 text
1071 );
1072 }
1073
1074 #[tokio::test]
1075 async fn test_research_note_includes_related_notes() {
1076 let (handler, _dir) = make_handler().await;
1077 handler
1078 .create_note(Parameters(CreateNoteParams {
1079 path: "research/main".to_string(),
1080 content: "# Main\n\n## Rust Programming\n\nabout rust".to_string(),
1081 }))
1082 .await
1083 .unwrap();
1084 handler
1085 .create_note(Parameters(CreateNoteParams {
1086 path: "research/related".to_string(),
1087 content: "# Related\n\nRust Programming is great".to_string(),
1088 }))
1089 .await
1090 .unwrap();
1091 let msgs = handler
1092 .research_note(Parameters(ResearchNoteParams {
1093 path: "research/main".to_string(),
1094 max_results: Some(5),
1095 }))
1096 .await
1097 .unwrap();
1098 let text = first_text(&msgs);
1099 assert!(
1100 text.contains("research/related"),
1101 "expected related note in prompt: {}",
1102 text
1103 );
1104 }
1105
1106 #[tokio::test]
1107 async fn test_research_note_not_found() {
1108 let (handler, _dir) = make_handler().await;
1109 let msgs = handler
1110 .research_note(Parameters(ResearchNoteParams {
1111 path: "missing/note".to_string(),
1112 max_results: None,
1113 }))
1114 .await
1115 .unwrap();
1116 assert!(!msgs.is_empty());
1117 let text = first_text(&msgs);
1118 assert!(
1119 text.contains("not found"),
1120 "expected not-found message: {}",
1121 text
1122 );
1123 }
1124
1125 #[tokio::test]
1126 async fn test_brainstorm_includes_vault_content() {
1127 let (handler, _dir) = make_handler().await;
1128 handler
1129 .create_note(Parameters(CreateNoteParams {
1130 path: "ideas/rust".to_string(),
1131 content: "# Rust Ideas\n\nunique_brainstorm_rust_content_xyz".to_string(),
1132 }))
1133 .await
1134 .unwrap();
1135 let msgs = handler
1136 .brainstorm(Parameters(BrainstormParams {
1137 topic: "unique_brainstorm_rust_content_xyz".to_string(),
1138 max_results: None,
1139 }))
1140 .await
1141 .unwrap();
1142 assert!(!msgs.is_empty());
1143 let text = first_text(&msgs);
1144 assert!(
1145 text.contains("unique_brainstorm_rust_content_xyz"),
1146 "expected vault content in prompt: {}",
1147 text
1148 );
1149 }
1150
1151 #[tokio::test]
1152 async fn test_brainstorm_suggests_note_to_append() {
1153 let (handler, _dir) = make_handler().await;
1154 handler
1155 .create_note(Parameters(CreateNoteParams {
1156 path: "ideas/brainstorm_target".to_string(),
1157 content: "# Brainstorm Target\n\nunique_suggest_xyz_content".to_string(),
1158 }))
1159 .await
1160 .unwrap();
1161 let msgs = handler
1162 .brainstorm(Parameters(BrainstormParams {
1163 topic: "unique_suggest_xyz_content".to_string(),
1164 max_results: None,
1165 }))
1166 .await
1167 .unwrap();
1168 let text = first_text(&msgs);
1169 assert!(
1170 text.contains("ideas/brainstorm_target"),
1171 "expected suggested note path: {}",
1172 text
1173 );
1174 }
1175
1176 #[tokio::test]
1177 async fn test_brainstorm_no_vault_content_still_returns_prompt() {
1178 let (handler, _dir) = make_handler().await;
1179 let msgs = handler
1180 .brainstorm(Parameters(BrainstormParams {
1181 topic: "completely_nonexistent_topic_zzz_999".to_string(),
1182 max_results: None,
1183 }))
1184 .await
1185 .unwrap();
1186 assert!(!msgs.is_empty());
1187 let text = first_text(&msgs);
1188 assert!(
1189 text.contains("completely_nonexistent_topic_zzz_999"),
1190 "expected topic in prompt: {}",
1191 text
1192 );
1193 assert!(
1195 !text.contains("Suggested note"),
1196 "should not suggest a note when no results: {}",
1197 text
1198 );
1199 }
1200
1201 #[tokio::test]
1202 async fn test_weekly_review_includes_entries_and_marks_missing() {
1203 let (handler, _dir) = make_handler().await;
1204 handler
1206 .journal(Parameters(JournalParams {
1207 text: "monday content unique_weekly_mon_xyz".to_string(),
1208 date: Some("2026-03-02".to_string()),
1209 }))
1210 .await
1211 .unwrap();
1212 handler
1213 .journal(Parameters(JournalParams {
1214 text: "wednesday content unique_weekly_wed_xyz".to_string(),
1215 date: Some("2026-03-04".to_string()),
1216 }))
1217 .await
1218 .unwrap();
1219 let msgs = handler
1220 .weekly_review(Parameters(WeeklyReviewParams {
1221 date: Some("2026-03-02".to_string()),
1222 }))
1223 .await
1224 .unwrap();
1225 assert!(!msgs.is_empty());
1226 let text = first_text(&msgs);
1227 assert!(
1228 text.contains("unique_weekly_mon_xyz"),
1229 "monday entry: {}",
1230 text
1231 );
1232 assert!(
1233 text.contains("unique_weekly_wed_xyz"),
1234 "wednesday entry: {}",
1235 text
1236 );
1237 assert!(text.contains("(no entry)"), "missing days: {}", text);
1239 }
1240
1241 #[tokio::test]
1242 async fn test_weekly_review_date_in_middle_of_week_uses_correct_range() {
1243 let (handler, _dir) = make_handler().await;
1244 let msgs = handler
1246 .weekly_review(Parameters(WeeklyReviewParams {
1247 date: Some("2026-03-04".to_string()),
1248 }))
1249 .await
1250 .unwrap();
1251 let text = first_text(&msgs);
1252 assert!(
1253 text.contains("2026-03-02") && text.contains("2026-03-08"),
1254 "expected Mon 2026-03-02 – Sun 2026-03-08 in: {}",
1255 text
1256 );
1257 }
1258
1259 #[tokio::test]
1260 async fn test_weekly_review_invalid_date_returns_error() {
1261 let (handler, _dir) = make_handler().await;
1262 let result = handler
1263 .weekly_review(Parameters(WeeklyReviewParams {
1264 date: Some("not-a-date".to_string()),
1265 }))
1266 .await;
1267 assert!(result.is_err(), "expected Err for invalid date");
1268 let err = result.unwrap_err();
1269 assert!(
1270 err.message.contains("Invalid date"),
1271 "expected error message to mention invalid date: {:?}",
1272 err
1273 );
1274 }
1275
1276 #[tokio::test]
1277 async fn test_link_suggestions_returns_unlinked_candidates() {
1278 let (handler, _dir) = make_handler().await;
1279 handler
1280 .create_note(Parameters(CreateNoteParams {
1281 path: "source".to_string(),
1282 content: "# Source\n\n## Rust Programming\n\nsome rust content".to_string(),
1283 }))
1284 .await
1285 .unwrap();
1286 handler
1287 .create_note(Parameters(CreateNoteParams {
1288 path: "candidate".to_string(),
1289 content: "# Candidate\n\nRust Programming is great".to_string(),
1290 }))
1291 .await
1292 .unwrap();
1293 let msgs = handler
1294 .link_suggestions(Parameters(LinkSuggestionsParams {
1295 path: "source".to_string(),
1296 max_results: Some(5),
1297 }))
1298 .await
1299 .unwrap();
1300 assert!(!msgs.is_empty());
1301 let text = first_text(&msgs);
1302 assert!(
1303 text.contains("candidate"),
1304 "expected candidate note in prompt: {}",
1305 text
1306 );
1307 }
1308
1309 #[tokio::test]
1310 async fn test_link_suggestions_excludes_already_linked_notes() {
1311 let (handler, _dir) = make_handler().await;
1312 handler
1313 .create_note(Parameters(CreateNoteParams {
1314 path: "source".to_string(),
1315 content: "# Source\n\n## Rust Programming\n\nsee [[linked-note]]".to_string(),
1316 }))
1317 .await
1318 .unwrap();
1319 handler
1320 .create_note(Parameters(CreateNoteParams {
1321 path: "linked-note".to_string(),
1322 content: "# Linked Note\n\nRust Programming is great".to_string(),
1323 }))
1324 .await
1325 .unwrap();
1326 let msgs = handler
1327 .link_suggestions(Parameters(LinkSuggestionsParams {
1328 path: "source".to_string(),
1329 max_results: Some(5),
1330 }))
1331 .await
1332 .unwrap();
1333 let text = first_text(&msgs);
1334 assert!(
1336 !text.contains("=== /linked-note") && !text.contains("=== linked-note"),
1337 "linked-note should be excluded from candidates: {}",
1338 text
1339 );
1340 }
1341
1342 #[tokio::test]
1343 async fn test_link_suggestions_empty_vault_returns_graceful_message() {
1344 let (handler, _dir) = make_handler().await;
1345 handler
1346 .create_note(Parameters(CreateNoteParams {
1347 path: "lonely".to_string(),
1348 content: "# Lonely\n\n## Some Topic\n\nalone".to_string(),
1349 }))
1350 .await
1351 .unwrap();
1352 let msgs = handler
1353 .link_suggestions(Parameters(LinkSuggestionsParams {
1354 path: "lonely".to_string(),
1355 max_results: Some(5),
1356 }))
1357 .await
1358 .unwrap();
1359 assert!(!msgs.is_empty());
1360 let text = first_text(&msgs);
1361 assert!(
1362 text.contains("No unlinked related notes"),
1363 "expected graceful no-results message: {}",
1364 text
1365 );
1366 }
1367
1368 #[tokio::test]
1371 async fn test_research_topic_no_results_returns_graceful_message() {
1372 let (handler, _dir) = make_handler().await;
1373 let msgs = handler
1374 .research_topic(Parameters(ResearchTopicParams {
1375 topic: "completely_nonexistent_topic_zzz_123".to_string(),
1376 max_results: None,
1377 }))
1378 .await
1379 .unwrap();
1380 assert!(!msgs.is_empty());
1381 let text = first_text(&msgs);
1382 assert!(
1383 text.contains("No notes found"),
1384 "expected graceful no-results message: {}",
1385 text
1386 );
1387 }
1388
1389 #[tokio::test]
1390 async fn test_research_topic_includes_direct_search_results() {
1391 let (handler, _dir) = make_handler().await;
1392 handler
1393 .create_note(Parameters(CreateNoteParams {
1394 path: "science/quantum".to_string(),
1395 content: "# Quantum Physics\n\nunique_quantum_direct_xyz".to_string(),
1396 }))
1397 .await
1398 .unwrap();
1399 let msgs = handler
1400 .research_topic(Parameters(ResearchTopicParams {
1401 topic: "unique_quantum_direct_xyz".to_string(),
1402 max_results: None,
1403 }))
1404 .await
1405 .unwrap();
1406 assert!(!msgs.is_empty());
1407 let text = first_text(&msgs);
1408 assert!(
1409 text.contains("unique_quantum_direct_xyz"),
1410 "expected direct result content in prompt: {}",
1411 text
1412 );
1413 assert!(
1414 text.contains("Notes matching"),
1415 "expected direct-results section header: {}",
1416 text
1417 );
1418 }
1419
1420 #[tokio::test]
1421 async fn test_research_topic_includes_backlinks() {
1422 let (handler, _dir) = make_handler().await;
1423 handler
1425 .create_note(Parameters(CreateNoteParams {
1426 path: "topics/target".to_string(),
1427 content: "# Target\n\nunique_backlink_target_xyz".to_string(),
1428 }))
1429 .await
1430 .unwrap();
1431 handler
1434 .create_note(Parameters(CreateNoteParams {
1435 path: "topics/linker".to_string(),
1436 content: "# Linker\n\nSee [[topics/target]] for more detail".to_string(),
1437 }))
1438 .await
1439 .unwrap();
1440 let msgs = handler
1441 .research_topic(Parameters(ResearchTopicParams {
1442 topic: "unique_backlink_target_xyz".to_string(),
1443 max_results: Some(10),
1444 }))
1445 .await
1446 .unwrap();
1447 let text = first_text(&msgs);
1448 assert!(
1449 text.contains("topics/linker"),
1450 "expected linker note to appear somewhere in the prompt: {}",
1451 text
1452 );
1453 }
1454
1455 #[tokio::test]
1456 async fn test_research_topic_includes_related_via_headings() {
1457 let (handler, _dir) = make_handler().await;
1458 handler
1460 .create_note(Parameters(CreateNoteParams {
1461 path: "topics/main".to_string(),
1462 content: "# Main\n\n## Async Runtime\n\nunique_heading_research_abc".to_string(),
1463 }))
1464 .await
1465 .unwrap();
1466 handler
1468 .create_note(Parameters(CreateNoteParams {
1469 path: "topics/related".to_string(),
1470 content: "# Related\n\nAsync Runtime is fundamental in Rust".to_string(),
1471 }))
1472 .await
1473 .unwrap();
1474 let msgs = handler
1475 .research_topic(Parameters(ResearchTopicParams {
1476 topic: "unique_heading_research_abc".to_string(),
1477 max_results: Some(10),
1478 }))
1479 .await
1480 .unwrap();
1481 let text = first_text(&msgs);
1482 assert!(
1483 text.contains("topics/related"),
1484 "expected related note via heading search: {}",
1485 text
1486 );
1487 assert!(
1488 text.contains("Notes on related subtopics"),
1489 "expected subtopics section header: {}",
1490 text
1491 );
1492 }
1493
1494 #[tokio::test]
1495 async fn test_research_topic_deduplicates_notes() {
1496 let (handler, _dir) = make_handler().await;
1497 handler
1499 .create_note(Parameters(CreateNoteParams {
1500 path: "dedup/alpha".to_string(),
1501 content: "# Alpha\n\nunique_dedup_topic_xyz\n\n## Subtopic\n\nunique_dedup_sub_xyz"
1502 .to_string(),
1503 }))
1504 .await
1505 .unwrap();
1506 handler
1508 .create_note(Parameters(CreateNoteParams {
1509 path: "dedup/beta".to_string(),
1510 content: "# Beta\n\nunique_dedup_sub_xyz and more".to_string(),
1511 }))
1512 .await
1513 .unwrap();
1514 let msgs = handler
1515 .research_topic(Parameters(ResearchTopicParams {
1516 topic: "unique_dedup_topic_xyz".to_string(),
1517 max_results: Some(10),
1518 }))
1519 .await
1520 .unwrap();
1521 let text = first_text(&msgs);
1522 let count = text.matches("dedup/alpha").count();
1524 assert!(
1525 count >= 1,
1526 "expected dedup/alpha to appear at least once: {}",
1527 text
1528 );
1529 let header_count = text.matches("/dedup/alpha").count();
1531 assert!(
1532 header_count <= 2, "dedup/alpha appeared too many times ({}), suggesting duplicate inclusion: {}",
1534 header_count,
1535 text
1536 );
1537 }
1538
1539 #[tokio::test]
1540 async fn test_research_topic_respects_max_results() {
1541 let (handler, _dir) = make_handler().await;
1542 for i in 0..5 {
1544 handler
1545 .create_note(Parameters(CreateNoteParams {
1546 path: format!("limit/note{}", i),
1547 content: format!("# Note {}\n\nunique_limit_topic_xyz note number {}", i, i),
1548 }))
1549 .await
1550 .unwrap();
1551 }
1552 let msgs = handler
1553 .research_topic(Parameters(ResearchTopicParams {
1554 topic: "unique_limit_topic_xyz".to_string(),
1555 max_results: Some(2),
1556 }))
1557 .await
1558 .unwrap();
1559 let text = first_text(&msgs);
1560 let section_count = (0..5)
1562 .filter(|i| text.contains(&format!("limit/note{}", i)))
1563 .count();
1564 assert!(
1565 section_count <= 2,
1566 "expected at most 2 notes with max_results=2, found {} in: {}",
1567 section_count,
1568 text
1569 );
1570 }
1571}