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