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