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