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