Skip to main content

kimun_notes/cli/commands/mcp/
prompts.rs

1// tui/src/cli/commands/mcp/prompts.rs
2//
3// MCP prompt templates — provide vault-enriched context to LLM clients.
4
5use 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// ---------------------------------------------------------------------------
16// Parameter structs
17// ---------------------------------------------------------------------------
18
19#[derive(Debug, Deserialize, schemars::JsonSchema)]
20pub struct DailyReviewParams {
21    /// Date in YYYY-MM-DD format; defaults to today
22    pub date: Option<String>,
23}
24
25#[derive(Debug, Deserialize, schemars::JsonSchema)]
26pub struct FindConnectionsParams {
27    /// Vault-relative path to the note, e.g. "projects/my-note"
28    pub path: String,
29}
30
31#[derive(Debug, Deserialize, schemars::JsonSchema)]
32pub struct ResearchNoteParams {
33    /// Vault-relative path to the note
34    pub path: String,
35    /// Maximum number of related notes to include (default 5)
36    pub max_results: Option<u32>,
37}
38
39#[derive(Debug, Deserialize, schemars::JsonSchema)]
40pub struct BrainstormParams {
41    /// Topic to brainstorm ideas about
42    pub topic: String,
43    /// Maximum number of vault notes to include as context (default 5)
44    pub max_results: Option<u32>,
45}
46
47#[derive(Debug, Deserialize, schemars::JsonSchema)]
48pub struct WeeklyReviewParams {
49    /// Any date within the target week in YYYY-MM-DD format; defaults to today
50    pub date: Option<String>,
51}
52
53#[derive(Debug, Deserialize, schemars::JsonSchema)]
54pub struct LinkSuggestionsParams {
55    /// Vault-relative path to the note
56    pub path: String,
57    /// Maximum number of candidate notes to include (default 5)
58    pub max_results: Option<u32>,
59}
60
61#[derive(Debug, Deserialize, schemars::JsonSchema)]
62pub struct ResearchTopicParams {
63    /// Topic or keyword to research across the vault
64    pub topic: String,
65    /// Maximum total number of notes to include (default 10)
66    pub max_results: Option<u32>,
67}
68
69#[derive(Debug, Deserialize, schemars::JsonSchema)]
70pub struct TriageInboxParams {
71    /// Maximum number of inbox notes to include (default 20)
72    pub max_notes: Option<u32>,
73    /// Maximum number of related notes to include per inbox note (default 3)
74    pub max_context: Option<u32>,
75}
76
77// ---------------------------------------------------------------------------
78// Helpers
79// ---------------------------------------------------------------------------
80
81impl KimunHandler {
82    /// Return the unique leaf heading strings from a note's chunk tree, in
83    /// insertion order.  Used by several prompts to derive secondary search
84    /// terms from a note's section outline.
85    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// ---------------------------------------------------------------------------
114// Prompt implementations
115// ---------------------------------------------------------------------------
116
117#[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        // Search each topic; deduplicate results; cap at max. Normalize to the
253        // canonical (vault-absolute) form the index returns so the
254        // relative source path excludes itself from its own related list.
255        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        // Parse or default to today
382        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        // Compute Monday and Sunday of the week
396        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        // Day names for formatting
401        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        // Step 1: Direct search for the topic
467        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        // secondary_topics: insertion-ordered, deduplicated case-insensitively
476        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            // Step 2a: Collect backlinks for this note
498            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            // Step 2b: Extract leaf headings for secondary search (case-insensitive dedup)
512            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        // Step 3: Load backlink notes (within remaining budget)
522        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        // Step 4: Secondary search using headings extracted from the initial results
536        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        // Load source note
651        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        // Build exclusion set: outlinks + backlinks + source itself. Normalize
665        // every path to the canonical (vault-absolute) form the index search
666        // returns — self and outlinks arrive relative, backlinks
667        // absolute, so a raw string mix would never match the candidates.
668        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        // Search each heading; collect, deduplicate, filter, cap
693        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    /// Extract the text from the first PromptMessage's content.
860    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        // Create today's entry via the journal tool
887        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        // Note content should be present; backlinks section should be absent
1024        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        // No suggestion line when no results
1196        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        // Create entries for Monday and Wednesday of a known week (2026-03-02 is a Monday)
1207        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        // Days without entries should show (no entry)
1240        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        // 2026-03-04 is a Wednesday — should resolve to Mon 2026-03-02 – Sun 2026-03-08
1247        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        // The already-linked note should not appear as a candidate
1337        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    // ── research_topic tests ────────────────────────────────────────────────
1371
1372    #[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        // Note that will be a direct search hit
1426        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        // Note that links to target — should appear somewhere in the output (via backlinks
1434        // if the index is warm, or via heading-based secondary search otherwise)
1435        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        // Direct hit with a heading that becomes a secondary search term
1461        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        // Note that matches the heading "Async Runtime"
1469        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        // Note matches both direct search and would be a backlink from itself — must appear once
1500        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        // Second note that matches on the subtopic heading
1509        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        // Count occurrences of "dedup/alpha" — should appear exactly once
1525        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        // The prompt message should only contain one === /dedup/alpha === block
1532        let header_count = text.matches("/dedup/alpha").count();
1533        assert!(
1534            header_count <= 2, // path may appear in section header and content
1535            "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        // Create 5 notes all matching the same topic
1545        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        // At most 2 note sections should be present
1563        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}