diff --git a/crates/contrib/grizzly/src/lib.rs b/crates/contrib/grizzly/src/lib.rs index f6563330..628fef98 100644 --- a/crates/contrib/grizzly/src/lib.rs +++ b/crates/contrib/grizzly/src/lib.rs @@ -10,7 +10,7 @@ pub mod tag; pub use db::BearDb; pub use error::Error; pub use note::Note; -pub use search::{SearchMatch, SearchMode, SearchParams}; +pub use search::{SearchMatch, SearchMode, SearchParams, Snippet}; pub use tag::Tag; type Result = std::result::Result; diff --git a/crates/contrib/grizzly/src/search.rs b/crates/contrib/grizzly/src/search.rs index 02c1f94d..93ab821c 100644 --- a/crates/contrib/grizzly/src/search.rs +++ b/crates/contrib/grizzly/src/search.rs @@ -1,5 +1,5 @@ use std::{ - collections::{BTreeSet, HashSet}, + collections::{HashMap, HashSet}, rc::Rc, }; @@ -32,14 +32,25 @@ pub struct SearchParams { /// Only search notes with these IDs. pub ids: Vec, - /// Number of context lines around each match. - pub context: usize, - /// Maximum number of notes to return (default: 50). pub limit: usize, /// Search backend to use. pub mode: SearchMode, + + /// Maximum characters in each result's snippet text (default: 200). + /// + /// Longer matching lines are truncated with `…` and centered on the + /// match. Callers should use `note_get` with the returned line numbers + /// to fetch full content when the snippet is insufficient. + pub snippet_chars: usize, + + /// Maximum number of line numbers reported in `SearchMatch::line_hits` + /// (default: 20). + /// + /// `SearchMatch::total_hits` always reports the true count, so callers + /// know when this cap kicked in. + pub max_line_hits: usize, } impl Default for SearchParams { @@ -48,9 +59,10 @@ impl Default for SearchParams { queries: vec![], tags: vec![], ids: vec![], - context: 3, limit: 50, mode: SearchMode::default(), + snippet_chars: 200, + max_line_hits: 20, } } } @@ -72,14 +84,20 @@ impl SearchParams { .collect(), tags: self.tags.clone(), ids: self.ids.clone(), - context: self.context, limit: self.limit, mode: self.mode, + snippet_chars: self.snippet_chars, + max_line_hits: self.max_line_hits, } } } -/// A search result with matching lines from a note. +/// A bounded summary of a matching note. +/// +/// Carries metadata plus a short snippet showing why the note matched. To +/// read full content, the caller should follow up with `note_get`, passing +/// the values from `line_hits` (or ranges around them) via its `lines` +/// parameter. pub struct SearchMatch { /// The note's unique identifier. pub note_id: String, @@ -87,37 +105,74 @@ pub struct SearchMatch { /// The note's title (as stored by Bear). pub title: String, - /// Groups of line numbers and their content. - /// Groups are separated by gaps (non-consecutive lines). - pub groups: Vec, + /// The note's tags. + pub tags: Vec, + + /// When the note was last modified, if known. + pub updated_at: Option, + + /// 1-indexed line numbers in the note's content where the query matched. + /// + /// Capped at `SearchParams::max_line_hits`; check `total_hits` for the + /// true count. Empty for title-only matches. + pub line_hits: Vec, + + /// Total number of content lines that matched the query. + /// + /// May exceed `line_hits.len()` when capped. + pub total_hits: usize, + + /// A short excerpt centered on the first match. + /// + /// `None` only when the note has no content at all. + pub snippet: Option, } -/// A contiguous group of matching/context lines. -pub struct MatchGroup { - pub lines: Vec<(usize, String)>, +/// A short text excerpt with the line it came from. +pub struct Snippet { + /// 1-indexed source line. + pub line: usize, + + /// Excerpt text. Prefixed/suffixed with `…` when truncated. + pub text: String, } impl SearchMatch { /// Format as pseudo-XML for LLM consumption. + /// + /// The output is intentionally compact and size-bounded. To read full + /// content, the caller should follow up with `note_get`, passing + /// `line_hits` (or a range around them) via its `lines` parameter. #[must_use] pub fn to_xml(&self) -> String { let mut out = format!( - "", - self.note_id, + "", + xml_escape(&self.note_id), xml_escape(&self.title), + xml_escape(&self.tags.join(" ")), + xml_escape(self.updated_at.as_deref().unwrap_or("unknown")), + self.total_hits, ); - for (idx, group) in self.groups.iter().enumerate() { - if idx > 0 { - out.push_str("\n..."); - } - out.push('\n'); - for (line_num, text) in &group.lines { - out.push_str(&format!("{line_num:03}: {text}\n")); - } + if let Some(snippet) = &self.snippet { + out.push_str(&format!( + "\n {}", + snippet.line, + xml_escape(&snippet.text), + )); + } + + if !self.line_hits.is_empty() { + let hits = self + .line_hits + .iter() + .map(usize::to_string) + .collect::>() + .join(", "); + out.push_str(&format!("\n {hits}")); } - out.push_str(""); + out.push_str("\n"); out } } @@ -192,16 +247,29 @@ fn execute_fts(conn: &Connection, cte: &str, params: &SearchParams) -> Result = fts_results.iter().map(|r| r.note_id.clone()).collect(); + let meta = fetch_metadata(conn, cte, ¬e_ids)?; + Ok(fts_results .into_iter() .map(|r| { let content = r.content.unwrap_or_default(); - let groups = extract_matching_lines(&content, ¶ms.queries, params.context); - + let (line_hits, total_hits, snippet) = extract_hits_and_snippet( + &content, + ¶ms.queries, + params.max_line_hits, + params.snippet_chars, + ); + + let m = meta.get(&r.note_id); SearchMatch { note_id: r.note_id, title: r.title, - groups, + tags: m.map(|m| m.tags.clone()).unwrap_or_default(), + updated_at: m.and_then(|m| m.updated_at.clone()), + line_hits, + total_hits, + snippet, } }) .collect()) @@ -376,89 +444,205 @@ fn execute_like(conn: &Connection, cte: &str, params: &SearchParams) -> Result, _>>()?; + let note_ids: Vec = scored_notes.iter().map(|n| n.id.clone()).collect(); + let meta = fetch_metadata(conn, cte, ¬e_ids)?; + let mut matches = vec![]; for note in scored_notes { let content = note.content.unwrap_or_default(); - let groups = extract_matching_lines(&content, ¶ms.queries, params.context); + let (line_hits, total_hits, snippet) = extract_hits_and_snippet( + &content, + ¶ms.queries, + params.max_line_hits, + params.snippet_chars, + ); + let m = meta.get(¬e.id); matches.push(SearchMatch { note_id: note.id, title: note.title, - groups, + tags: m.map(|m| m.tags.clone()).unwrap_or_default(), + updated_at: m.and_then(|m| m.updated_at.clone()), + line_hits, + total_hits, + snippet, }); } // Secondary sort: within the same SQL score tier, notes with more - // content-level line hits come first. + // content-level hits come first. // (SQL already orders by score DESC, so this is a stable tiebreaker.) - matches.sort_by(|a, b| { - let a_lines: usize = a.groups.iter().map(|g| g.lines.len()).sum(); - let b_lines: usize = b.groups.iter().map(|g| g.lines.len()).sum(); - b_lines.cmp(&a_lines) - }); + matches.sort_by_key(|m| std::cmp::Reverse(m.total_hits)); Ok(matches) } -/// Find matching lines in content and group them with context. -fn extract_matching_lines(content: &str, queries: &[String], context: usize) -> Vec { +/// Find matching lines and produce a snippet showing the best match. +/// +/// Returns `(line_hits, total_hits, snippet)`. `line_hits` is truncated to +/// `max_line_hits`; `total_hits` is always the full count. When no content +/// line matches the query (title-only match), `line_hits` is empty and the +/// snippet previews the first non-empty content line. `snippet` is `None` +/// only when the note has no content at all. +fn extract_hits_and_snippet( + content: &str, + queries: &[String], + max_line_hits: usize, + snippet_chars: usize, +) -> (Vec, usize, Option) { let lines: Vec<&str> = content.lines().collect(); + let lowered_queries: Vec = queries + .iter() + .filter(|q| !q.trim().is_empty()) + .map(|q| q.to_lowercase()) + .collect(); + + let mut hits: Vec = vec![]; + let mut first_hit: Option<(usize, usize)> = None; // (line_idx, byte_pos) - // Find lines matching any query - let mut hit_lines = BTreeSet::new(); - for query in queries { - let lower_query = query.to_lowercase(); + if !lowered_queries.is_empty() { for (idx, line) in lines.iter().enumerate() { - if line.to_lowercase().contains(&lower_query) { - hit_lines.insert(idx); + let lowered = line.to_lowercase(); + let mut earliest: Option = None; + for q in &lowered_queries { + if let Some(pos) = lowered.find(q) { + earliest = Some(earliest.map_or(pos, |p| p.min(pos))); + } + } + + if let Some(pos) = earliest { + hits.push(idx + 1); // 1-indexed + if first_hit.is_none() { + first_hit = Some((idx, pos)); + } } } } - if hit_lines.is_empty() { - // Title-only match; show first few lines as preview - let end = lines.len().min(context * 2 + 1); - if end == 0 { - return vec![]; - } - let group_lines = (0..end).map(|i| (i + 1, lines[i].to_string())).collect(); - return vec![MatchGroup { lines: group_lines }]; + let total_hits = hits.len(); + + let snippet = if let Some((line_idx, match_pos)) = first_hit { + Some(Snippet { + line: line_idx + 1, + text: make_snippet(lines[line_idx], match_pos, snippet_chars), + }) + } else { + // Title-only match (or empty query): preview the first non-empty line. + lines + .iter() + .enumerate() + .find(|(_, l)| !l.trim().is_empty()) + .map(|(idx, line)| Snippet { + line: idx + 1, + text: make_snippet(line, 0, snippet_chars), + }) + }; + + if hits.len() > max_line_hits { + hits.truncate(max_line_hits); } - // Expand hits with context - let mut visible = BTreeSet::new(); - for &hit in &hit_lines { - let start = hit.saturating_sub(context); - let end = (hit + context + 1).min(lines.len()); - for i in start..end { - visible.insert(i); - } + (hits, total_hits, snippet) +} + +/// Truncate `line` to roughly `max_chars` characters, centered on +/// `match_byte_pos`. Prefixes and/or suffixes the result with `…` when +/// truncation actually happened. +/// +/// `match_byte_pos` is a hint and is clamped to the line's byte length, so +/// callers can safely pass approximate positions derived from a lower-cased +/// copy of the line. +fn make_snippet(line: &str, match_byte_pos: usize, max_chars: usize) -> String { + let char_count = line.chars().count(); + if char_count <= max_chars { + return line.to_string(); } - // Group consecutive lines - let mut groups = vec![]; - let mut current_group: Vec<(usize, String)> = vec![]; - let mut prev: Option = None; + let half = max_chars / 2; + let clamped_pos = match_byte_pos.min(line.len()); + let match_char_pos = line[..clamped_pos].chars().count(); - for &idx in &visible { - if let Some(p) = prev - && idx != p + 1 - && !current_group.is_empty() - { - groups.push(MatchGroup { - lines: std::mem::take(&mut current_group), - }); - } - current_group.push((idx + 1, lines[idx].to_string())); // 1-indexed - prev = Some(idx); + let mut start_char = match_char_pos.saturating_sub(half); + let end_char = (start_char + max_chars).min(char_count); + start_char = end_char.saturating_sub(max_chars); + + let start_byte = line.char_indices().nth(start_char).map_or(0, |(b, _)| b); + let end_byte = line + .char_indices() + .nth(end_char) + .map_or(line.len(), |(b, _)| b); + + let mut out = String::with_capacity(end_byte - start_byte + 8); + if start_char > 0 { + out.push('…'); + } + out.push_str(&line[start_byte..end_byte]); + if end_char < char_count { + out.push('…'); + } + out +} + +struct NoteMeta { + tags: Vec, + updated_at: Option, +} + +/// Fetch tags and `updated_at` for a batch of note IDs in one query. +fn fetch_metadata( + conn: &Connection, + cte: &str, + note_ids: &[String], +) -> Result> { + if note_ids.is_empty() { + return Ok(HashMap::new()); } - if !current_group.is_empty() { - groups.push(MatchGroup { - lines: current_group, + + rusqlite::vtab::array::load_module(conn)?; + + let values = Rc::new( + note_ids + .iter() + .cloned() + .map(Value::from) + .collect::>(), + ); + let bind: Vec> = vec![Box::new(values)]; + let refs: Vec<&dyn rusqlite::types::ToSql> = bind.iter().map(AsRef::as_ref).collect(); + + let sql = format!( + "{cte} + SELECT n.id, n.updated_at, t.name + FROM notes n + LEFT JOIN note_tags nt ON nt.note_id = n.id + LEFT JOIN tags t ON t.id = nt.tag_id + WHERE n.id IN rarray(?1) + ORDER BY n.id, t.name" + ); + + let mut stmt = conn.prepare(&sql)?; + let mut out: HashMap = HashMap::new(); + + let rows = stmt.query_map(refs.as_slice(), |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + + for row in rows { + let (id, updated_at, tag) = row?; + let entry = out.entry(id).or_insert(NoteMeta { + tags: vec![], + updated_at, }); + if let Some(t) = tag { + entry.tags.push(t); + } } - groups + Ok(out) } fn xml_escape(s: &str) -> String { diff --git a/crates/contrib/grizzly/src/search_tests.rs b/crates/contrib/grizzly/src/search_tests.rs index ade2bbb6..292dad23 100644 --- a/crates/contrib/grizzly/src/search_tests.rs +++ b/crates/contrib/grizzly/src/search_tests.rs @@ -4,7 +4,6 @@ use crate::BearDb; fn search(db: &BearDb, queries: Vec<&str>) -> Vec { db.search(&SearchParams { queries: queries.into_iter().map(Into::into).collect(), - context: 1, ..Default::default() }) .unwrap() @@ -30,7 +29,6 @@ fn search_with_tag_filter() { let results = search_with(&db, &SearchParams { queries: vec!["25-minute".into()], tags: vec!["productivity".into()], - context: 1, ..Default::default() }); @@ -44,7 +42,6 @@ fn search_with_tag_filter_no_match() { let results = search_with(&db, &SearchParams { queries: vec!["productivity".into()], tags: vec!["personal".into()], - context: 1, ..Default::default() }); @@ -52,19 +49,18 @@ fn search_with_tag_filter_no_match() { } #[test] -fn search_context_lines() { +fn line_hits_reports_matching_line_numbers() { let db = BearDb::in_memory().unwrap(); let results = search_with(&db, &SearchParams { queries: vec!["capturing".into()], - context: 0, ..Default::default() }); // "capturing" is on line 2 of note-1 assert_eq!(results.len(), 1); - assert_eq!(results[0].groups.len(), 1); - assert_eq!(results[0].groups[0].lines.len(), 1); - assert_eq!(results[0].groups[0].lines[0].0, 2); // line 2 (1-indexed) + assert_eq!(results[0].line_hits, vec![2]); + assert_eq!(results[0].total_hits, 1); + assert_eq!(results[0].snippet.as_ref().unwrap().line, 2); } #[test] @@ -112,7 +108,6 @@ fn result_limit() { let results = search_with(&db, &SearchParams { queries: vec!["e".into()], // broad query, matches multiple notes limit: 1, - context: 1, ..Default::default() }); @@ -124,14 +119,24 @@ fn title_in_xml_output() { let m = SearchMatch { note_id: "abc-123".into(), title: "My Note".into(), - groups: vec![MatchGroup { - lines: vec![(1, "first line".into())], - }], + tags: vec!["work".into()], + updated_at: Some("2024-01-01 00:00:00".into()), + line_hits: vec![1], + total_hits: 1, + snippet: Some(Snippet { + line: 1, + text: "first line".into(), + }), }; let xml = m.to_xml(); assert!(xml.contains(r#"note-id="abc-123""#)); assert!(xml.contains(r#"title="My Note""#)); + assert!(xml.contains(r#"tags="work""#)); + assert!(xml.contains(r#"updated-at="2024-01-01 00:00:00""#)); + assert!(xml.contains(r#"total-hits="1""#)); + assert!(xml.contains(r#"first line"#)); + assert!(xml.contains("1")); } #[test] @@ -139,7 +144,11 @@ fn title_xml_escaping() { let m = SearchMatch { note_id: "x".into(), title: r#"Notes & "Quotes" "#.into(), - groups: vec![], + tags: vec![], + updated_at: None, + line_hits: vec![], + total_hits: 0, + snippet: None, }; let xml = m.to_xml(); @@ -147,24 +156,23 @@ fn title_xml_escaping() { } #[test] -fn match_to_xml_groups() { +fn xml_lists_all_line_hits() { let m = SearchMatch { note_id: "abc-123".into(), title: "Test".into(), - groups: vec![ - MatchGroup { - lines: vec![(10, "line ten".into()), (11, "line eleven".into())], - }, - MatchGroup { - lines: vec![(50, "line fifty".into())], - }, - ], + tags: vec![], + updated_at: None, + line_hits: vec![10, 11, 50], + total_hits: 3, + snippet: Some(Snippet { + line: 10, + text: "line ten".into(), + }), }; let xml = m.to_xml(); - assert!(xml.contains("010: line ten")); - assert!(xml.contains("...")); - assert!(xml.contains("050: line fifty")); + assert!(xml.contains("10, 11, 50")); + assert!(xml.contains(r#"line ten"#)); } #[test] @@ -173,7 +181,6 @@ fn fts_mode_word_search() { let results = search_with(&db, &SearchParams { queries: vec!["productivity".into()], mode: SearchMode::Fts, - context: 1, ..Default::default() }); assert_eq!(results.len(), 1); @@ -187,7 +194,6 @@ fn like_mode_substring() { let results = search_with(&db, &SearchParams { queries: vec!["prod".into()], mode: SearchMode::Like, - context: 1, ..Default::default() }); assert_eq!(results.len(), 1); @@ -201,7 +207,6 @@ fn fts_mode_with_tag_filter() { queries: vec!["intervals".into()], tags: vec!["productivity".into()], mode: SearchMode::Fts, - context: 1, ..Default::default() }); assert_eq!(results.len(), 1); @@ -216,7 +221,6 @@ fn fts_mode_tag_filter_excludes() { queries: vec!["productivity".into()], tags: vec!["personal".into()], mode: SearchMode::Fts, - context: 1, ..Default::default() }); assert!(results.is_empty()); @@ -229,7 +233,6 @@ fn auto_falls_back_to_like_for_short_queries() { // and too short for trigram (< 3 chars), so Auto falls back to LIKE let results = search_with(&db, &SearchParams { queries: vec!["pr".into()], - context: 1, ..Default::default() }); assert_eq!(results.len(), 1); @@ -242,7 +245,6 @@ fn auto_uses_fts_when_available() { // "productivity" is a full word — FTS5 should handle it directly let results = search_with(&db, &SearchParams { queries: vec!["productivity".into()], - context: 1, ..Default::default() }); assert_eq!(results.len(), 1); @@ -255,7 +257,6 @@ fn wildcard_query_with_tag_filter() { let results = search_with(&db, &SearchParams { queries: vec!["*".into()], tags: vec!["productivity".into()], - context: 1, ..Default::default() }); assert_eq!(results.len(), 2); @@ -271,7 +272,6 @@ fn wildcard_query_with_nested_tag_filter() { let results = search_with(&db, &SearchParams { queries: vec!["*".into()], tags: vec!["projects/jp".into()], - context: 1, ..Default::default() }); assert_eq!(results.len(), 1); @@ -284,7 +284,6 @@ fn nested_tag_filter_with_content_query() { let results = search_with(&db, &SearchParams { queries: vec!["capturing".into()], tags: vec!["projects/jp".into()], - context: 1, ..Default::default() }); assert_eq!(results.len(), 1); @@ -298,8 +297,114 @@ fn nested_tag_filter_excludes_untagged() { let results = search_with(&db, &SearchParams { queries: vec!["intervals".into()], tags: vec!["projects/jp".into()], - context: 1, ..Default::default() }); assert!(results.is_empty()); } + +#[test] +fn result_carries_tags_and_updated_at() { + let db = BearDb::in_memory().unwrap(); + let results = search(&db, vec!["productivity"]); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].note_id, "note-1"); + assert_eq!(results[0].tags, vec!["productivity", "projects/jp"]); + assert!(results[0].updated_at.is_some()); +} + +#[test] +fn title_only_match_previews_first_content_line() { + // "Pomodoro" appears in the title of note-2 but not in its content. + // Bear notes typically embed the title in the first content line too, + // so test data: query for "Shopping" which matches note-3 title; first + // content line is "Eggs". + let db = BearDb::in_memory().unwrap(); + let results = search(&db, vec!["Shopping"]); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].note_id, "note-3"); + assert_eq!(results[0].total_hits, 0); + assert!(results[0].line_hits.is_empty()); + let snippet = results[0] + .snippet + .as_ref() + .expect("snippet for content note"); + assert_eq!(snippet.line, 1); + assert_eq!(snippet.text, "Eggs"); +} + +#[test] +fn extract_caps_line_hits_but_reports_full_total() { + let content = (1..=100) + .map(|n| format!("line {n} target")) + .collect::>() + .join("\n"); + + let (line_hits, total_hits, snippet) = + extract_hits_and_snippet(&content, &["target".into()], 5, 200); + + assert_eq!(line_hits.len(), 5); + assert_eq!(line_hits, vec![1, 2, 3, 4, 5]); + assert_eq!(total_hits, 100); + assert_eq!(snippet.unwrap().line, 1); +} + +#[test] +fn extract_returns_no_snippet_for_empty_content() { + let (line_hits, total_hits, snippet) = + extract_hits_and_snippet("", &["anything".into()], 20, 200); + + assert!(line_hits.is_empty()); + assert_eq!(total_hits, 0); + assert!(snippet.is_none()); +} + +#[test] +fn make_snippet_short_line_returned_unchanged() { + let line = "hello world"; + assert_eq!(make_snippet(line, 0, 200), "hello world"); +} + +#[test] +fn make_snippet_truncates_long_line_with_ellipses() { + // 1000 'a' chars with the match at position 500. + let line: String = "a".repeat(1000); + let snippet = make_snippet(&line, 500, 50); + + // Leading + trailing ellipsis, ~50 chars in between. + assert!(snippet.starts_with('\u{2026}')); + assert!(snippet.ends_with('\u{2026}')); + assert_eq!(snippet.chars().count(), 52); // 50 + 2 ellipses +} + +#[test] +fn make_snippet_at_line_start_no_leading_ellipsis() { + let line: String = "a".repeat(1000); + let snippet = make_snippet(&line, 0, 50); + + assert!(!snippet.starts_with('\u{2026}')); + assert!(snippet.ends_with('\u{2026}')); +} + +#[test] +fn make_snippet_at_line_end_no_trailing_ellipsis() { + let line: String = "a".repeat(1000); + let snippet = make_snippet(&line, 1000, 50); + + assert!(snippet.starts_with('\u{2026}')); + assert!(!snippet.ends_with('\u{2026}')); +} + +#[test] +fn make_snippet_handles_multibyte_chars() { + // 1000 é chars (2 bytes each). Each is one char but two bytes. + let line: String = "é".repeat(1000); + let snippet = make_snippet(&line, line.len() / 2, 50); + + // Result must still be valid UTF-8 and contain ~50 é characters. + assert!(snippet.starts_with('\u{2026}')); + assert!(snippet.ends_with('\u{2026}')); + let inner = snippet.trim_matches('\u{2026}'); + assert!(inner.chars().all(|c| c == 'é')); +} diff --git a/crates/contrib/grizzly/src/server.rs b/crates/contrib/grizzly/src/server.rs index 3455ebfb..4e443ab4 100644 --- a/crates/contrib/grizzly/src/server.rs +++ b/crates/contrib/grizzly/src/server.rs @@ -83,10 +83,6 @@ pub struct NoteSearchRequest { #[serde(deserialize_with = "deserialize_string_or_vec")] pub queries: Vec, - /// Number of context lines around each match (default: 3). - #[serde(default = "default_context")] - pub context: usize, - /// Filter: only search notes with ALL of these tags. #[serde(default)] pub tags: Vec, @@ -110,10 +106,6 @@ pub struct NoteCreateRequest { pub content: String, } -fn default_context() -> usize { - 3 -} - fn deserialize_string_or_vec<'de, D>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -179,8 +171,11 @@ impl GrizzlyService { } #[tool( - description = "Search Bear notes by content. Returns matching lines with surrounding \ - context. Results are formatted with line numbers." + description = "Search Bear notes by content. Returns metadata (id, title, tags, \ + updated_at), a short snippet showing the match, and the line numbers where \ + the query matched. The response is size-bounded by design. To read full \ + content, follow up with `note_get`, passing the returned line numbers via \ + its `lines` parameter." )] async fn note_search( &self, @@ -192,7 +187,6 @@ impl GrizzlyService { queries: req.queries, tags: req.tags, ids: req.ids, - context: req.context, ..Default::default() }; diff --git a/crates/jp_attachment_bear_note/src/lib.rs b/crates/jp_attachment_bear_note/src/lib.rs index b3250a75..51ee8f78 100644 --- a/crates/jp_attachment_bear_note/src/lib.rs +++ b/crates/jp_attachment_bear_note/src/lib.rs @@ -212,7 +212,6 @@ fn get_notes(query: &Query, db: &BearDb) -> Result, Box