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(crate::test_support::sys(dir.path())))
852 .await
853 .unwrap();
854 vault.validate_and_init().await.unwrap();
855 let handler = KimunHandler::new(vault);
856 (handler, dir)
857 }
858
859 fn first_text(msgs: &[PromptMessage]) -> String {
861 match msgs.first().map(|m| &m.content) {
862 Some(PromptMessageContent::Text { text }) => text.clone(),
863 _ => String::new(),
864 }
865 }
866
867 #[tokio::test]
868 async fn test_daily_review_no_entry_returns_graceful_message() {
869 let (handler, _dir) = make_handler().await;
870 let msgs = handler
871 .daily_review(Parameters(DailyReviewParams { date: None }))
872 .await
873 .unwrap();
874 assert!(!msgs.is_empty());
875 let text = first_text(&msgs);
876 assert!(
877 text.contains("No journal entry"),
878 "expected graceful message, got: {}",
879 text
880 );
881 }
882
883 #[tokio::test]
884 async fn test_daily_review_with_entry_includes_content() {
885 let (handler, _dir) = make_handler().await;
886 handler
888 .journal(Parameters(JournalParams {
889 text: "worked on unique_daily_review_content_xyz".to_string(),
890 date: None,
891 }))
892 .await
893 .unwrap();
894 let msgs = handler
895 .daily_review(Parameters(DailyReviewParams { date: None }))
896 .await
897 .unwrap();
898 assert!(!msgs.is_empty());
899 let text = first_text(&msgs);
900 assert!(
901 text.contains("unique_daily_review_content_xyz"),
902 "expected journal content in prompt: {}",
903 text
904 );
905 }
906
907 #[tokio::test]
908 async fn test_daily_review_specific_date() {
909 let (handler, _dir) = make_handler().await;
910 handler
911 .journal(Parameters(JournalParams {
912 text: "specific date entry content".to_string(),
913 date: Some("2026-01-15".to_string()),
914 }))
915 .await
916 .unwrap();
917 let msgs = handler
918 .daily_review(Parameters(DailyReviewParams {
919 date: Some("2026-01-15".to_string()),
920 }))
921 .await
922 .unwrap();
923 assert!(!msgs.is_empty());
924 let text = first_text(&msgs);
925 assert!(
926 text.contains("specific date entry content"),
927 "expected entry in prompt: {}",
928 text
929 );
930 }
931
932 #[tokio::test]
933 async fn test_daily_review_invalid_date_returns_error() {
934 let (handler, _dir) = make_handler().await;
935 let result = handler
936 .daily_review(Parameters(DailyReviewParams {
937 date: Some("not-a-date".to_string()),
938 }))
939 .await;
940 assert!(result.is_err(), "expected Err for invalid date");
941 let err = result.unwrap_err();
942 assert!(
943 err.message.contains("Invalid date"),
944 "expected error message to mention invalid date: {:?}",
945 err
946 );
947 }
948
949 #[tokio::test]
950 async fn test_find_connections_includes_note_content() {
951 let (handler, _dir) = make_handler().await;
952 handler
953 .create_note(Parameters(CreateNoteParams {
954 path: "my/note".to_string(),
955 content: "# My Note\n\nunique_connections_content_abc".to_string(),
956 }))
957 .await
958 .unwrap();
959 let msgs = handler
960 .find_connections(Parameters(FindConnectionsParams {
961 path: "my/note".to_string(),
962 }))
963 .await
964 .unwrap();
965 assert!(!msgs.is_empty());
966 let text = first_text(&msgs);
967 assert!(
968 text.contains("unique_connections_content_abc"),
969 "expected note content in prompt: {}",
970 text
971 );
972 }
973
974 #[tokio::test]
975 async fn test_find_connections_lists_backlinks() {
976 let (handler, _dir) = make_handler().await;
977 handler
978 .create_note(Parameters(CreateNoteParams {
979 path: "target".to_string(),
980 content: "# Target".to_string(),
981 }))
982 .await
983 .unwrap();
984 handler
985 .create_note(Parameters(CreateNoteParams {
986 path: "source".to_string(),
987 content: "see [[target]] for details".to_string(),
988 }))
989 .await
990 .unwrap();
991 let msgs = handler
992 .find_connections(Parameters(FindConnectionsParams {
993 path: "target".to_string(),
994 }))
995 .await
996 .unwrap();
997 let text = first_text(&msgs);
998 assert!(
999 text.contains("source"),
1000 "expected backlink 'source' in prompt: {}",
1001 text
1002 );
1003 }
1004
1005 #[tokio::test]
1006 async fn test_find_connections_no_backlinks_omits_section() {
1007 let (handler, _dir) = make_handler().await;
1008 handler
1009 .create_note(Parameters(CreateNoteParams {
1010 path: "lone/note".to_string(),
1011 content: "# Lone\n\nno links to here".to_string(),
1012 }))
1013 .await
1014 .unwrap();
1015 let msgs = handler
1016 .find_connections(Parameters(FindConnectionsParams {
1017 path: "lone/note".to_string(),
1018 }))
1019 .await
1020 .unwrap();
1021 assert!(!msgs.is_empty());
1022 let text = first_text(&msgs);
1023 assert!(text.contains("Lone"), "expected note content: {}", text);
1025 assert!(
1026 !text.contains("Notes that link"),
1027 "should not have backlinks section: {}",
1028 text
1029 );
1030 }
1031
1032 #[tokio::test]
1033 async fn test_find_connections_note_not_found() {
1034 let (handler, _dir) = make_handler().await;
1035 let msgs = handler
1036 .find_connections(Parameters(FindConnectionsParams {
1037 path: "missing/note".to_string(),
1038 }))
1039 .await
1040 .unwrap();
1041 assert!(!msgs.is_empty());
1042 let text = first_text(&msgs);
1043 assert!(
1044 text.contains("not found"),
1045 "expected not-found message: {}",
1046 text
1047 );
1048 }
1049
1050 #[tokio::test]
1051 async fn test_research_note_includes_source_note() {
1052 let (handler, _dir) = make_handler().await;
1053 handler
1054 .create_note(Parameters(CreateNoteParams {
1055 path: "research/topic".to_string(),
1056 content: "# Topic\n\n## Background\n\nunique_research_source_xyz\n\n## Open Questions\n\nwhat next?".to_string(),
1057 }))
1058 .await
1059 .unwrap();
1060 let msgs = handler
1061 .research_note(Parameters(ResearchNoteParams {
1062 path: "research/topic".to_string(),
1063 max_results: Some(3),
1064 }))
1065 .await
1066 .unwrap();
1067 assert!(!msgs.is_empty());
1068 let text = first_text(&msgs);
1069 assert!(
1070 text.contains("unique_research_source_xyz"),
1071 "expected source note content: {}",
1072 text
1073 );
1074 }
1075
1076 #[tokio::test]
1077 async fn test_research_note_includes_related_notes() {
1078 let (handler, _dir) = make_handler().await;
1079 handler
1080 .create_note(Parameters(CreateNoteParams {
1081 path: "research/main".to_string(),
1082 content: "# Main\n\n## Rust Programming\n\nabout rust".to_string(),
1083 }))
1084 .await
1085 .unwrap();
1086 handler
1087 .create_note(Parameters(CreateNoteParams {
1088 path: "research/related".to_string(),
1089 content: "# Related\n\nRust Programming is great".to_string(),
1090 }))
1091 .await
1092 .unwrap();
1093 let msgs = handler
1094 .research_note(Parameters(ResearchNoteParams {
1095 path: "research/main".to_string(),
1096 max_results: Some(5),
1097 }))
1098 .await
1099 .unwrap();
1100 let text = first_text(&msgs);
1101 assert!(
1102 text.contains("research/related"),
1103 "expected related note in prompt: {}",
1104 text
1105 );
1106 }
1107
1108 #[tokio::test]
1109 async fn test_research_note_not_found() {
1110 let (handler, _dir) = make_handler().await;
1111 let msgs = handler
1112 .research_note(Parameters(ResearchNoteParams {
1113 path: "missing/note".to_string(),
1114 max_results: None,
1115 }))
1116 .await
1117 .unwrap();
1118 assert!(!msgs.is_empty());
1119 let text = first_text(&msgs);
1120 assert!(
1121 text.contains("not found"),
1122 "expected not-found message: {}",
1123 text
1124 );
1125 }
1126
1127 #[tokio::test]
1128 async fn test_brainstorm_includes_vault_content() {
1129 let (handler, _dir) = make_handler().await;
1130 handler
1131 .create_note(Parameters(CreateNoteParams {
1132 path: "ideas/rust".to_string(),
1133 content: "# Rust Ideas\n\nunique_brainstorm_rust_content_xyz".to_string(),
1134 }))
1135 .await
1136 .unwrap();
1137 let msgs = handler
1138 .brainstorm(Parameters(BrainstormParams {
1139 topic: "unique_brainstorm_rust_content_xyz".to_string(),
1140 max_results: None,
1141 }))
1142 .await
1143 .unwrap();
1144 assert!(!msgs.is_empty());
1145 let text = first_text(&msgs);
1146 assert!(
1147 text.contains("unique_brainstorm_rust_content_xyz"),
1148 "expected vault content in prompt: {}",
1149 text
1150 );
1151 }
1152
1153 #[tokio::test]
1154 async fn test_brainstorm_suggests_note_to_append() {
1155 let (handler, _dir) = make_handler().await;
1156 handler
1157 .create_note(Parameters(CreateNoteParams {
1158 path: "ideas/brainstorm_target".to_string(),
1159 content: "# Brainstorm Target\n\nunique_suggest_xyz_content".to_string(),
1160 }))
1161 .await
1162 .unwrap();
1163 let msgs = handler
1164 .brainstorm(Parameters(BrainstormParams {
1165 topic: "unique_suggest_xyz_content".to_string(),
1166 max_results: None,
1167 }))
1168 .await
1169 .unwrap();
1170 let text = first_text(&msgs);
1171 assert!(
1172 text.contains("ideas/brainstorm_target"),
1173 "expected suggested note path: {}",
1174 text
1175 );
1176 }
1177
1178 #[tokio::test]
1179 async fn test_brainstorm_no_vault_content_still_returns_prompt() {
1180 let (handler, _dir) = make_handler().await;
1181 let msgs = handler
1182 .brainstorm(Parameters(BrainstormParams {
1183 topic: "completely_nonexistent_topic_zzz_999".to_string(),
1184 max_results: None,
1185 }))
1186 .await
1187 .unwrap();
1188 assert!(!msgs.is_empty());
1189 let text = first_text(&msgs);
1190 assert!(
1191 text.contains("completely_nonexistent_topic_zzz_999"),
1192 "expected topic in prompt: {}",
1193 text
1194 );
1195 assert!(
1197 !text.contains("Suggested note"),
1198 "should not suggest a note when no results: {}",
1199 text
1200 );
1201 }
1202
1203 #[tokio::test]
1204 async fn test_weekly_review_includes_entries_and_marks_missing() {
1205 let (handler, _dir) = make_handler().await;
1206 handler
1208 .journal(Parameters(JournalParams {
1209 text: "monday content unique_weekly_mon_xyz".to_string(),
1210 date: Some("2026-03-02".to_string()),
1211 }))
1212 .await
1213 .unwrap();
1214 handler
1215 .journal(Parameters(JournalParams {
1216 text: "wednesday content unique_weekly_wed_xyz".to_string(),
1217 date: Some("2026-03-04".to_string()),
1218 }))
1219 .await
1220 .unwrap();
1221 let msgs = handler
1222 .weekly_review(Parameters(WeeklyReviewParams {
1223 date: Some("2026-03-02".to_string()),
1224 }))
1225 .await
1226 .unwrap();
1227 assert!(!msgs.is_empty());
1228 let text = first_text(&msgs);
1229 assert!(
1230 text.contains("unique_weekly_mon_xyz"),
1231 "monday entry: {}",
1232 text
1233 );
1234 assert!(
1235 text.contains("unique_weekly_wed_xyz"),
1236 "wednesday entry: {}",
1237 text
1238 );
1239 assert!(text.contains("(no entry)"), "missing days: {}", text);
1241 }
1242
1243 #[tokio::test]
1244 async fn test_weekly_review_date_in_middle_of_week_uses_correct_range() {
1245 let (handler, _dir) = make_handler().await;
1246 let msgs = handler
1248 .weekly_review(Parameters(WeeklyReviewParams {
1249 date: Some("2026-03-04".to_string()),
1250 }))
1251 .await
1252 .unwrap();
1253 let text = first_text(&msgs);
1254 assert!(
1255 text.contains("2026-03-02") && text.contains("2026-03-08"),
1256 "expected Mon 2026-03-02 – Sun 2026-03-08 in: {}",
1257 text
1258 );
1259 }
1260
1261 #[tokio::test]
1262 async fn test_weekly_review_invalid_date_returns_error() {
1263 let (handler, _dir) = make_handler().await;
1264 let result = handler
1265 .weekly_review(Parameters(WeeklyReviewParams {
1266 date: Some("not-a-date".to_string()),
1267 }))
1268 .await;
1269 assert!(result.is_err(), "expected Err for invalid date");
1270 let err = result.unwrap_err();
1271 assert!(
1272 err.message.contains("Invalid date"),
1273 "expected error message to mention invalid date: {:?}",
1274 err
1275 );
1276 }
1277
1278 #[tokio::test]
1279 async fn test_link_suggestions_returns_unlinked_candidates() {
1280 let (handler, _dir) = make_handler().await;
1281 handler
1282 .create_note(Parameters(CreateNoteParams {
1283 path: "source".to_string(),
1284 content: "# Source\n\n## Rust Programming\n\nsome rust content".to_string(),
1285 }))
1286 .await
1287 .unwrap();
1288 handler
1289 .create_note(Parameters(CreateNoteParams {
1290 path: "candidate".to_string(),
1291 content: "# Candidate\n\nRust Programming is great".to_string(),
1292 }))
1293 .await
1294 .unwrap();
1295 let msgs = handler
1296 .link_suggestions(Parameters(LinkSuggestionsParams {
1297 path: "source".to_string(),
1298 max_results: Some(5),
1299 }))
1300 .await
1301 .unwrap();
1302 assert!(!msgs.is_empty());
1303 let text = first_text(&msgs);
1304 assert!(
1305 text.contains("candidate"),
1306 "expected candidate note in prompt: {}",
1307 text
1308 );
1309 }
1310
1311 #[tokio::test]
1312 async fn test_link_suggestions_excludes_already_linked_notes() {
1313 let (handler, _dir) = make_handler().await;
1314 handler
1315 .create_note(Parameters(CreateNoteParams {
1316 path: "source".to_string(),
1317 content: "# Source\n\n## Rust Programming\n\nsee [[linked-note]]".to_string(),
1318 }))
1319 .await
1320 .unwrap();
1321 handler
1322 .create_note(Parameters(CreateNoteParams {
1323 path: "linked-note".to_string(),
1324 content: "# Linked Note\n\nRust Programming is great".to_string(),
1325 }))
1326 .await
1327 .unwrap();
1328 let msgs = handler
1329 .link_suggestions(Parameters(LinkSuggestionsParams {
1330 path: "source".to_string(),
1331 max_results: Some(5),
1332 }))
1333 .await
1334 .unwrap();
1335 let text = first_text(&msgs);
1336 assert!(
1338 !text.contains("=== /linked-note") && !text.contains("=== linked-note"),
1339 "linked-note should be excluded from candidates: {}",
1340 text
1341 );
1342 }
1343
1344 #[tokio::test]
1345 async fn test_link_suggestions_empty_vault_returns_graceful_message() {
1346 let (handler, _dir) = make_handler().await;
1347 handler
1348 .create_note(Parameters(CreateNoteParams {
1349 path: "lonely".to_string(),
1350 content: "# Lonely\n\n## Some Topic\n\nalone".to_string(),
1351 }))
1352 .await
1353 .unwrap();
1354 let msgs = handler
1355 .link_suggestions(Parameters(LinkSuggestionsParams {
1356 path: "lonely".to_string(),
1357 max_results: Some(5),
1358 }))
1359 .await
1360 .unwrap();
1361 assert!(!msgs.is_empty());
1362 let text = first_text(&msgs);
1363 assert!(
1364 text.contains("No unlinked related notes"),
1365 "expected graceful no-results message: {}",
1366 text
1367 );
1368 }
1369
1370 #[tokio::test]
1373 async fn test_research_topic_no_results_returns_graceful_message() {
1374 let (handler, _dir) = make_handler().await;
1375 let msgs = handler
1376 .research_topic(Parameters(ResearchTopicParams {
1377 topic: "completely_nonexistent_topic_zzz_123".to_string(),
1378 max_results: None,
1379 }))
1380 .await
1381 .unwrap();
1382 assert!(!msgs.is_empty());
1383 let text = first_text(&msgs);
1384 assert!(
1385 text.contains("No notes found"),
1386 "expected graceful no-results message: {}",
1387 text
1388 );
1389 }
1390
1391 #[tokio::test]
1392 async fn test_research_topic_includes_direct_search_results() {
1393 let (handler, _dir) = make_handler().await;
1394 handler
1395 .create_note(Parameters(CreateNoteParams {
1396 path: "science/quantum".to_string(),
1397 content: "# Quantum Physics\n\nunique_quantum_direct_xyz".to_string(),
1398 }))
1399 .await
1400 .unwrap();
1401 let msgs = handler
1402 .research_topic(Parameters(ResearchTopicParams {
1403 topic: "unique_quantum_direct_xyz".to_string(),
1404 max_results: None,
1405 }))
1406 .await
1407 .unwrap();
1408 assert!(!msgs.is_empty());
1409 let text = first_text(&msgs);
1410 assert!(
1411 text.contains("unique_quantum_direct_xyz"),
1412 "expected direct result content in prompt: {}",
1413 text
1414 );
1415 assert!(
1416 text.contains("Notes matching"),
1417 "expected direct-results section header: {}",
1418 text
1419 );
1420 }
1421
1422 #[tokio::test]
1423 async fn test_research_topic_includes_backlinks() {
1424 let (handler, _dir) = make_handler().await;
1425 handler
1427 .create_note(Parameters(CreateNoteParams {
1428 path: "topics/target".to_string(),
1429 content: "# Target\n\nunique_backlink_target_xyz".to_string(),
1430 }))
1431 .await
1432 .unwrap();
1433 handler
1436 .create_note(Parameters(CreateNoteParams {
1437 path: "topics/linker".to_string(),
1438 content: "# Linker\n\nSee [[topics/target]] for more detail".to_string(),
1439 }))
1440 .await
1441 .unwrap();
1442 let msgs = handler
1443 .research_topic(Parameters(ResearchTopicParams {
1444 topic: "unique_backlink_target_xyz".to_string(),
1445 max_results: Some(10),
1446 }))
1447 .await
1448 .unwrap();
1449 let text = first_text(&msgs);
1450 assert!(
1451 text.contains("topics/linker"),
1452 "expected linker note to appear somewhere in the prompt: {}",
1453 text
1454 );
1455 }
1456
1457 #[tokio::test]
1458 async fn test_research_topic_includes_related_via_headings() {
1459 let (handler, _dir) = make_handler().await;
1460 handler
1462 .create_note(Parameters(CreateNoteParams {
1463 path: "topics/main".to_string(),
1464 content: "# Main\n\n## Async Runtime\n\nunique_heading_research_abc".to_string(),
1465 }))
1466 .await
1467 .unwrap();
1468 handler
1470 .create_note(Parameters(CreateNoteParams {
1471 path: "topics/related".to_string(),
1472 content: "# Related\n\nAsync Runtime is fundamental in Rust".to_string(),
1473 }))
1474 .await
1475 .unwrap();
1476 let msgs = handler
1477 .research_topic(Parameters(ResearchTopicParams {
1478 topic: "unique_heading_research_abc".to_string(),
1479 max_results: Some(10),
1480 }))
1481 .await
1482 .unwrap();
1483 let text = first_text(&msgs);
1484 assert!(
1485 text.contains("topics/related"),
1486 "expected related note via heading search: {}",
1487 text
1488 );
1489 assert!(
1490 text.contains("Notes on related subtopics"),
1491 "expected subtopics section header: {}",
1492 text
1493 );
1494 }
1495
1496 #[tokio::test]
1497 async fn test_research_topic_deduplicates_notes() {
1498 let (handler, _dir) = make_handler().await;
1499 handler
1501 .create_note(Parameters(CreateNoteParams {
1502 path: "dedup/alpha".to_string(),
1503 content: "# Alpha\n\nunique_dedup_topic_xyz\n\n## Subtopic\n\nunique_dedup_sub_xyz"
1504 .to_string(),
1505 }))
1506 .await
1507 .unwrap();
1508 handler
1510 .create_note(Parameters(CreateNoteParams {
1511 path: "dedup/beta".to_string(),
1512 content: "# Beta\n\nunique_dedup_sub_xyz and more".to_string(),
1513 }))
1514 .await
1515 .unwrap();
1516 let msgs = handler
1517 .research_topic(Parameters(ResearchTopicParams {
1518 topic: "unique_dedup_topic_xyz".to_string(),
1519 max_results: Some(10),
1520 }))
1521 .await
1522 .unwrap();
1523 let text = first_text(&msgs);
1524 let count = text.matches("dedup/alpha").count();
1526 assert!(
1527 count >= 1,
1528 "expected dedup/alpha to appear at least once: {}",
1529 text
1530 );
1531 let header_count = text.matches("/dedup/alpha").count();
1533 assert!(
1534 header_count <= 2, "dedup/alpha appeared too many times ({}), suggesting duplicate inclusion: {}",
1536 header_count,
1537 text
1538 );
1539 }
1540
1541 #[tokio::test]
1542 async fn test_research_topic_respects_max_results() {
1543 let (handler, _dir) = make_handler().await;
1544 for i in 0..5 {
1546 handler
1547 .create_note(Parameters(CreateNoteParams {
1548 path: format!("limit/note{}", i),
1549 content: format!("# Note {}\n\nunique_limit_topic_xyz note number {}", i, i),
1550 }))
1551 .await
1552 .unwrap();
1553 }
1554 let msgs = handler
1555 .research_topic(Parameters(ResearchTopicParams {
1556 topic: "unique_limit_topic_xyz".to_string(),
1557 max_results: Some(2),
1558 }))
1559 .await
1560 .unwrap();
1561 let text = first_text(&msgs);
1562 let section_count = (0..5)
1564 .filter(|i| text.contains(&format!("limit/note{}", i)))
1565 .count();
1566 assert!(
1567 section_count <= 2,
1568 "expected at most 2 notes with max_results=2, found {} in: {}",
1569 section_count,
1570 text
1571 );
1572 }
1573}