1use crate::error::Result;
14use crate::fts::{is_generic_topic, prepare_search_query, tokenize_query};
15use crate::hubs::{is_planning_intent, is_release_intent, HUB_BACKLOG, HUB_CHANGELOG, HUB_README, HUB_ROADMAP};
16use crate::query::{QueryOptions, RankedHit};
17use crate::storage::Database;
18use crate::types::{ContextBundle, ContextNode, ContextRole, Node, NodeType};
19use serde::{Deserialize, Serialize};
20use std::collections::{HashMap, HashSet};
21use std::path::Path;
22use std::time::Instant;
23
24const MAX_EXCERPT_CHARS: usize = 900;
26const MAX_SYMBOL_EXCERPT_CHARS: usize = 280;
28const MAX_PACKED_SYMBOL_NEIGHBORS: usize = 8;
30
31#[derive(Debug, Clone)]
33pub struct ContextOptions {
34 pub max_tokens: usize,
36 pub hop_depth: usize,
38 pub max_seeds: usize,
40 pub max_nodes: usize,
42 pub hop_decay: f32,
44 pub no_symbols: bool,
46 pub include_types: Vec<crate::types::NodeType>,
48 pub exclude_types: Vec<crate::types::NodeType>,
50 pub hop_to_symbols: bool,
53 pub hop_from_docs_only: bool,
55 pub include_excerpts: bool,
57 pub scope: Option<String>,
59 pub main_scope: String,
61 pub scope_main: crate::query::ScopeMainInclude,
63}
64
65impl Default for ContextOptions {
66 fn default() -> Self {
67 Self {
68 max_tokens: 2048,
69 hop_depth: 1,
70 max_seeds: 8,
71 max_nodes: 24,
72 hop_decay: 0.65,
73 no_symbols: true,
75 include_types: Vec::new(),
76 exclude_types: Vec::new(),
77 hop_to_symbols: true,
78 hop_from_docs_only: true,
79 include_excerpts: true,
80 scope: None,
81 main_scope: crate::scopes::MAIN_SCOPE.to_string(),
82 scope_main: crate::query::ScopeMainInclude::HubsOnly,
83 }
84 }
85}
86
87impl ContextOptions {
88 pub fn agent() -> Self {
90 Self::default()
91 }
92
93 pub fn with_symbols(mut self) -> Self {
95 self.no_symbols = false;
96 self
97 }
98
99 fn seed_query_opts(&self) -> QueryOptions {
100 QueryOptions {
101 limit: self.max_seeds.saturating_mul(2).max(8),
102 no_symbols: self.no_symbols,
103 include_types: self.include_types.clone(),
104 exclude_types: self.exclude_types.clone(),
105 scope: self.scope.clone(),
106 main_scope: self.main_scope.clone(),
107 scope_main: self.scope_main,
108 ..QueryOptions::default()
109 }
110 }
111
112 fn allows_pack(&self, ty: &crate::types::NodeType, role: ContextRole) -> bool {
113 use crate::types::NodeType;
114 if !self.include_types.is_empty() && !self.include_types.contains(ty) {
115 if !(self.hop_to_symbols && role == ContextRole::Neighbor && *ty == NodeType::Symbol) {
117 return false;
118 }
119 }
120 if self.exclude_types.contains(ty) {
121 return false;
122 }
123 if *ty == NodeType::Symbol {
124 if role == ContextRole::Seed && self.no_symbols {
125 return false;
126 }
127 if role == ContextRole::Neighbor && self.no_symbols && !self.hop_to_symbols {
128 return false;
129 }
130 }
131 true
132 }
133}
134
135#[derive(Debug, Clone)]
137struct Candidate {
138 node: Node,
139 score: f32,
140 role: ContextRole,
141 hop: u8,
142 excerpt: Option<String>,
143}
144
145pub fn assemble_context(
155 db: &Database,
156 brain_dir: &Path,
157 prompt: &str,
158 opts: &ContextOptions,
159) -> Result<ContextBundle> {
160 let start = Instant::now();
161 let char_budget = opts.max_tokens.saturating_mul(4).max(256);
162 let query_tokens = prepare_search_query(prompt)
163 .map(|p| p.tokens)
164 .unwrap_or_else(|_| tokenize_query(prompt));
165
166 let qopts = opts.seed_query_opts();
167 let mut seeds = db.search_ranked(prompt, &qopts)?;
168 let generic = is_generic_topic(&query_tokens);
169 let release_intent = is_release_intent(&query_tokens);
170 let planning_intent = is_planning_intent(&query_tokens);
171 let cold_start = seeds.is_empty() || generic;
172 if cold_start || release_intent || planning_intent {
174 inject_hub_seeds(
175 db,
176 &mut seeds,
177 opts,
178 release_intent,
179 planning_intent,
180 cold_start,
181 )?;
182 }
183
184 let mut candidates: Vec<Candidate> = Vec::new();
185 let mut seed_ids: HashSet<String> = HashSet::new();
186
187 for (i, hit) in seeds.into_iter().take(opts.max_seeds).enumerate() {
188 if !opts.allows_pack(&hit.node.node_type, ContextRole::Seed) {
189 continue;
190 }
191 if is_template_stub(&hit.node) {
193 continue;
194 }
195 seed_ids.insert(hit.node.id.clone());
196 let mut score = hit.score * (1.0 / (1.0 + i as f32 * 0.05));
198 if is_generated_path(&hit.node) {
200 score *= 0.85;
201 }
202 if hit.node.node_type == NodeType::Adr {
203 score *= 1.08;
204 }
205 let excerpt = if opts.include_excerpts {
206 load_excerpt(db, &hit.node, MAX_EXCERPT_CHARS)
207 } else {
208 None
209 };
210 candidates.push(Candidate {
211 node: hit.node,
212 score,
213 role: ContextRole::Seed,
214 hop: 0,
215 excerpt,
216 });
217 }
218
219 if candidates.is_empty() {
221 let mut hub_hits = Vec::new();
222 inject_hub_seeds(db, &mut hub_hits, opts, true, true, true)?;
223 for hit in hub_hits.into_iter().take(6) {
224 if !opts.allows_pack(&hit.node.node_type, ContextRole::Seed) {
225 continue;
226 }
227 if seed_ids.contains(&hit.node.id) {
228 continue;
229 }
230 seed_ids.insert(hit.node.id.clone());
231 let excerpt = if opts.include_excerpts {
232 load_excerpt(db, &hit.node, MAX_EXCERPT_CHARS)
233 } else {
234 None
235 };
236 candidates.push(Candidate {
237 node: hit.node,
238 score: hit.score,
239 role: ContextRole::Seed,
240 hop: 0,
241 excerpt,
242 });
243 }
244 }
245
246 let mut graph_nodes = 0usize;
247 let mut graph_edges = 0usize;
248 let mut neighbor_ids: Vec<String> = Vec::new();
249
250 #[cfg(feature = "mmap")]
251 if opts.hop_depth > 0 {
252 let mmap_path = brain_dir.join("graph.mmap");
253 if mmap_path.exists() {
254 if let Ok(graph) = crate::mmap::CsrMmapGraph::open(&mmap_path) {
255 graph_nodes = graph.node_count;
256 graph_edges = graph.edge_count;
257
258 let mut best_neighbor: HashMap<String, (f32, u8)> = HashMap::new();
259
260 for cand in candidates.iter().filter(|c| c.role == ContextRole::Seed) {
261 if opts.hop_from_docs_only && cand.node.node_type == NodeType::Symbol {
262 continue;
263 }
264 if let Some(idx) = graph.index_of(&cand.node.id) {
265 for (nidx, edge_w) in
266 graph.k_hop_neighborhood(idx as usize, opts.hop_depth)
267 {
268 if let Some(id) = graph.node_id(nidx as usize) {
269 if seed_ids.contains(id) {
270 continue;
271 }
272 let hop: u8 = if opts.hop_depth <= 1 || edge_w >= 0.85 {
273 1
274 } else {
275 2
276 };
277 let nscore = cand.score * edge_w * opts.hop_decay.powi(hop as i32);
278 best_neighbor
279 .entry(id.to_string())
280 .and_modify(|(s, h)| {
281 if nscore > *s {
282 *s = nscore;
283 *h = hop;
284 }
285 })
286 .or_insert((nscore, hop));
287 }
288 }
289 }
290 }
291
292 let mut neigh_sorted: Vec<_> = best_neighbor.into_iter().collect();
293 neigh_sorted.sort_by(|a, b| {
294 b.1 .0
295 .partial_cmp(&a.1 .0)
296 .unwrap_or(std::cmp::Ordering::Equal)
297 });
298
299 for (id, (score, hop)) in neigh_sorted {
300 neighbor_ids.push(id.clone());
301 if let Some(node) = db.get_node(&id)? {
302 if !opts.allows_pack(&node.node_type, ContextRole::Neighbor) {
303 continue;
304 }
305 let mut nscore = score;
306 if node.node_type == NodeType::Symbol {
307 match symbol_neighbor_quality(&node, &query_tokens) {
308 SymbolQuality::Drop => continue,
309 SymbolQuality::Keep { boost } => nscore *= boost,
310 }
311 }
312 let max_ex = if node.node_type == NodeType::Symbol {
313 MAX_SYMBOL_EXCERPT_CHARS
314 } else {
315 MAX_EXCERPT_CHARS
316 };
317 let excerpt = if opts.include_excerpts {
318 load_excerpt(db, &node, max_ex)
319 } else {
320 None
321 };
322 candidates.push(Candidate {
323 node,
324 score: nscore,
325 role: ContextRole::Neighbor,
326 hop,
327 excerpt,
328 });
329 }
330 }
331 }
332 }
333 }
334
335 candidates.sort_by(|a, b| {
338 pack_rank(b)
339 .partial_cmp(&pack_rank(a))
340 .unwrap_or(std::cmp::Ordering::Equal)
341 });
342
343 let mut packed: Vec<ContextNode> = Vec::new();
344 let mut used_chars = estimate_overhead(prompt);
345 let mut seen = HashSet::new();
346 let mut symbol_neighbors = 0usize;
347 let mut packed_excerpts: Vec<String> = Vec::new();
348
349 for cand in candidates {
350 if packed.len() >= opts.max_nodes {
351 break;
352 }
353 if !seen.insert(cand.node.id.clone()) {
354 continue;
355 }
356 if cand.role == ContextRole::Neighbor
357 && cand.node.node_type == NodeType::Symbol
358 && symbol_neighbors >= MAX_PACKED_SYMBOL_NEIGHBORS
359 {
360 continue;
361 }
362 if is_readme_family(&cand.node.id)
364 && packed.iter().any(|n| is_readme_family(&n.id))
365 {
366 continue;
367 }
368 if cand.node.id == HUB_CHANGELOG
370 && packed.iter().any(|n| n.id == HUB_CHANGELOG)
371 {
372 continue;
373 }
374 if let Some(ex) = &cand.excerpt {
376 if packed_excerpts
377 .iter()
378 .any(|prev| excerpt_jaccard(prev, ex) > 0.55)
379 {
380 continue;
381 }
382 }
383 let ctx_node = to_context_node(&cand);
384 let cost = estimate_node_chars(&ctx_node);
385 if used_chars + cost > char_budget && !packed.is_empty() {
386 if cand.role == ContextRole::Neighbor {
388 continue;
389 }
390 if packed.iter().any(|n| n.role == ContextRole::Seed) {
391 continue;
392 }
393 }
394 if used_chars + cost > char_budget && !packed.is_empty() {
395 break;
396 }
397 used_chars += cost;
398 if cand.role == ContextRole::Neighbor && cand.node.node_type == NodeType::Symbol {
399 symbol_neighbors += 1;
400 }
401 if let Some(ex) = &ctx_node.excerpt {
402 packed_excerpts.push(ex.clone());
403 }
404 packed.push(ctx_node);
405 }
406
407 neighbor_ids.retain(|id| !seed_ids.contains(id));
408 neighbor_ids.truncate(48);
409
410 let latency_us = start.elapsed().as_micros() as u64;
411 let tokens_used = used_chars.div_ceil(4);
412
413 Ok(ContextBundle {
414 prompt: prompt.to_string(),
415 max_tokens: opts.max_tokens,
416 tokens_used,
417 nodes: packed,
418 neighbor_ids,
419 latency_us,
420 graph_nodes,
421 graph_edges,
422 })
423}
424
425enum SymbolQuality {
426 Drop,
427 Keep { boost: f32 },
428}
429
430fn symbol_neighbor_quality(node: &Node, query_tokens: &[String]) -> SymbolQuality {
432 let title = node.title.to_lowercase();
433 let id = node.id.to_lowercase();
434 let leaf = id.rsplit('/').next().unwrap_or(&id);
435
436 for t in query_tokens {
438 if title.contains(t.as_str()) || id.contains(t.as_str()) {
439 return SymbolQuality::Keep { boost: 1.8 };
440 }
441 }
442
443 if title.contains("::") || leaf.contains("::") {
445 return SymbolQuality::Keep { boost: 1.15 };
446 }
447
448 let name = title.split("::").last().unwrap_or(&title);
450 if name.len() <= 2 {
451 return SymbolQuality::Drop;
452 }
453 if name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && name.len() <= 12 {
454 return SymbolQuality::Drop;
456 }
457 if !name.contains('_') && !title.contains("::") && name.len() <= 8 {
459 let has_upper = name.chars().any(|c| c.is_ascii_uppercase());
461 let has_lower = name.chars().any(|c| c.is_ascii_lowercase());
462 if has_upper && has_lower {
463 return SymbolQuality::Keep { boost: 1.0 };
464 }
465 return SymbolQuality::Drop;
466 }
467
468 SymbolQuality::Keep { boost: 1.0 }
469}
470
471fn inject_hub_seeds(
473 db: &Database,
474 seeds: &mut Vec<RankedHit>,
475 opts: &ContextOptions,
476 release_intent: bool,
477 planning_intent: bool,
478 cold_start: bool,
479) -> Result<()> {
480 let existing: HashSet<String> = seeds.iter().map(|h| h.node.id.clone()).collect();
481 let mut hubs: Vec<(&str, f32)> = Vec::new();
482 if cold_start {
483 hubs.extend([
484 (HUB_README, 4.5_f32),
485 ("docs/goals/from-readme", 3.8),
486 ("docs/implementation/module-map.generated", 2.2),
487 ("docs/goals/readme", 1.5),
488 ]);
489 }
490 if release_intent || cold_start {
491 hubs.push((HUB_CHANGELOG, if release_intent { 5.0 } else { 3.2 }));
493 }
494 if planning_intent || cold_start {
495 hubs.push((HUB_ROADMAP, if planning_intent { 4.6 } else { 2.4 }));
496 hubs.push((HUB_BACKLOG, if planning_intent { 4.4 } else { 2.2 }));
497 }
498 let mut seen = HashSet::new();
499 hubs.retain(|(id, _)| seen.insert(*id));
500
501 for (id, score) in hubs {
502 if existing.contains(id) {
503 continue;
504 }
505 if let Some(node) = db.get_node(id)? {
506 if !opts.allows_pack(&node.node_type, ContextRole::Seed) {
507 continue;
508 }
509 if is_template_stub(&node) {
510 continue;
511 }
512 seeds.push(RankedHit {
513 node,
514 score,
515 reasons: vec![format!("hub-fallback:{id}")],
516 });
517 }
518 }
519 seeds.sort_by(|a, b| {
521 b.score
522 .partial_cmp(&a.score)
523 .unwrap_or(std::cmp::Ordering::Equal)
524 });
525 Ok(())
526}
527
528fn is_template_stub(node: &Node) -> bool {
529 let id = node.id.to_lowercase();
530 let path = node.file_path.as_deref().unwrap_or("").to_lowercase();
531 let title = node.title.to_lowercase();
532 id.contains("template")
533 || path.ends_with("template.md")
534 || title.contains("template")
535 || title == "adr template"
536}
537
538fn is_generated_path(node: &Node) -> bool {
539 let path = node.file_path.as_deref().unwrap_or("");
540 path.contains("from-readme")
541 || path.contains(".generated.")
542 || path.ends_with("module-map.generated.md")
543}
544
545fn is_readme_family(id: &str) -> bool {
546 id == "readme" || id.contains("from-readme")
547}
548
549fn excerpt_jaccard(a: &str, b: &str) -> f32 {
550 let ta: HashSet<&str> = a
551 .split_whitespace()
552 .filter(|w| w.len() > 2)
553 .collect();
554 let tb: HashSet<&str> = b
555 .split_whitespace()
556 .filter(|w| w.len() > 2)
557 .collect();
558 if ta.is_empty() || tb.is_empty() {
559 return 0.0;
560 }
561 let inter = ta.intersection(&tb).count() as f32;
562 let uni = ta.union(&tb).count() as f32;
563 if uni == 0.0 {
564 0.0
565 } else {
566 inter / uni
567 }
568}
569
570fn pack_rank(c: &Candidate) -> f32 {
571 let role = match c.role {
572 ContextRole::Seed => 3.0,
573 ContextRole::Neighbor => 0.0,
574 };
575 let ty = match c.node.node_type {
576 NodeType::Adr => 2.5,
577 NodeType::Goal => 2.0,
578 NodeType::Changelog => 1.95,
579 NodeType::Plan => 1.9,
580 NodeType::EdgeCase => 1.8,
581 NodeType::Analysis => 1.65,
582 NodeType::Concept => 1.4,
583 NodeType::Reference => 1.2,
584 NodeType::Alternative => 1.1,
585 NodeType::Symbol => 0.0,
586 };
587 c.score + role + ty
589}
590
591fn load_excerpt(db: &Database, node: &Node, max_chars: usize) -> Option<String> {
592 let raw = db
593 .get_fts_content(&node.id)
594 .ok()
595 .flatten()
596 .filter(|s| !s.trim().is_empty())
597 .or_else(|| node.summary.clone().filter(|s| !s.trim().is_empty()))?;
598 let cleaned = strip_yaml_frontmatter(&raw);
599 Some(truncate_excerpt(cleaned, max_chars))
600}
601
602fn strip_yaml_frontmatter(s: &str) -> &str {
604 let t = s.trim_start();
605 if !t.starts_with("---") {
606 return s.trim();
607 }
608 let after_open = &t[3..];
609 let body = after_open.strip_prefix('\n').unwrap_or(after_open);
611 if let Some(idx) = body.find("\n---") {
612 let rest = &body[idx + 4..];
613 return rest.trim_start_matches(['\r', '\n']).trim();
614 }
615 s.trim()
616}
617
618fn truncate_excerpt(s: &str, max_chars: usize) -> String {
619 let s = s.trim();
620 if s.chars().count() <= max_chars {
621 return s.to_string();
622 }
623 let mut out = String::new();
624 for (i, ch) in s.chars().enumerate() {
625 if i >= max_chars.saturating_sub(1) {
626 break;
627 }
628 out.push(ch);
629 }
630 out.push('…');
631 out
632}
633
634fn to_context_node(c: &Candidate) -> ContextNode {
635 ContextNode {
636 id: c.node.id.clone(),
637 node_type: c.node.node_type.clone(),
638 title: c.node.title.clone(),
639 summary: c.node.summary.clone(),
640 excerpt: c.excerpt.clone(),
641 file_path: c.node.file_path.clone(),
642 score_hint: c.score,
643 role: c.role,
644 hop: c.hop,
645 }
646}
647
648fn estimate_overhead(prompt: &str) -> usize {
649 120 + prompt.len()
650}
651
652fn estimate_node_chars(n: &ContextNode) -> usize {
653 let mut c = n.id.len() + n.title.len() + 48;
654 if let Some(s) = &n.summary {
655 c += s.len();
656 }
657 if let Some(ex) = &n.excerpt {
658 c += ex.len();
659 }
660 if let Some(p) = &n.file_path {
661 c += p.len();
662 }
663 c
664}
665
666pub fn hits_to_nodes(hits: Vec<RankedHit>) -> Vec<Node> {
668 hits.into_iter().map(|h| h.node).collect()
669}
670
671#[derive(Debug, Clone, Serialize, Deserialize)]
676pub struct WorkspaceHit {
677 pub workspace: String,
679 pub hit: RankedHit,
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686 use crate::brain::Brain;
687 use crate::indexer::WorkspaceIndexer;
688 use crate::storage::Database;
689 use tempfile::tempdir;
690
691 #[test]
692 fn context_includes_graph_neighbors_within_budget() {
693 let dir = tempdir().unwrap();
694 let docs = dir.path().join("docs");
695 std::fs::create_dir_all(&docs).unwrap();
696 std::fs::write(
698 docs.join("alpha.md"),
699 "---\ntags: [alphaunique]\nnode_type: concept\n---\n# AlphaUnique\nLinks [[betaunique]].\nalphaunique body.\n",
700 )
701 .unwrap();
702 std::fs::write(
703 docs.join("beta.md"),
704 "---\ntags: [other]\nnode_type: concept\n---\n# BetaUnique\nNo alphaunique here.\n",
705 )
706 .unwrap();
707
708 let mut brain = Brain::create(dir.path()).unwrap();
709 brain.sync().unwrap();
710
711 let opts = ContextOptions {
712 max_tokens: 2048,
713 hop_depth: 1,
714 max_seeds: 4,
715 max_nodes: 12,
716 hop_decay: 0.7,
717 ..ContextOptions::default()
718 };
719 let ctx = assemble_context(brain.database(), brain.brain_dir(), "alphaunique", &opts)
720 .unwrap();
721 assert!(!ctx.nodes.is_empty());
722 assert!(ctx.tokens_used > 0);
723 assert!(ctx.tokens_used <= opts.max_tokens + 64); let has_beta = ctx.nodes.iter().any(|n| n.id.contains("beta"))
727 || ctx.neighbor_ids.iter().any(|id| id.contains("beta"));
728 assert!(
729 has_beta,
730 "expected beta neighbor via graph; nodes={:?} neigh={:?}",
731 ctx.nodes.iter().map(|n| &n.id).collect::<Vec<_>>(),
732 ctx.neighbor_ids
733 );
734 }
735
736 #[test]
737 fn tight_budget_limits_nodes() {
738 let dir = tempdir().unwrap();
739 let docs = dir.path().join("docs");
740 std::fs::create_dir_all(&docs).unwrap();
741 for i in 0..8 {
742 std::fs::write(
743 docs.join(format!("n{i}.md")),
744 format!("---\ntags: [topic]\n---\n# Note {i}\nshared topic content {i}\n"),
745 )
746 .unwrap();
747 }
748 let mut brain = Brain::create(dir.path()).unwrap();
749 brain.sync().unwrap();
750 let opts = ContextOptions {
751 max_tokens: 40, hop_depth: 0,
753 max_seeds: 8,
754 max_nodes: 20,
755 hop_decay: 0.5,
756 ..ContextOptions::default()
757 };
758 let ctx =
759 assemble_context(brain.database(), brain.brain_dir(), "topic", &opts).unwrap();
760 assert!(!ctx.nodes.is_empty());
761 assert!(ctx.nodes.len() < 8, "budget should clip nodes");
762 }
763
764 #[test]
765 fn natural_language_prompt_seeds_and_packs_excerpt() {
766 let dir = tempdir().unwrap();
767 std::fs::write(
768 dir.path().join("README.md"),
769 "# demotool\n\nLightweight **egui** explorer powered by DuckDB.\n\
770 Inspired by Duckling but avoids Tauri/WebView.\n",
771 )
772 .unwrap();
773 let mut brain = Brain::create(dir.path()).unwrap();
774 brain.sync().unwrap();
775
776 let ctx = assemble_context(
777 brain.database(),
778 brain.brain_dir(),
779 "why egui not tauri",
780 &ContextOptions::default(),
781 )
782 .unwrap();
783 assert!(
784 !ctx.nodes.is_empty(),
785 "expected seeds for NL prompt; packed=0"
786 );
787 let has_excerpt = ctx.nodes.iter().any(|n| {
788 n.excerpt
789 .as_ref()
790 .map(|e| e.to_lowercase().contains("egui") || e.to_lowercase().contains("tauri"))
791 .unwrap_or(false)
792 });
793 assert!(
794 has_excerpt,
795 "expected body excerpt mentioning egui/tauri; nodes={:?}",
796 ctx.nodes
797 .iter()
798 .map(|n| (&n.id, &n.excerpt))
799 .collect::<Vec<_>>()
800 );
801 }
802
803 #[test]
804 fn release_prompt_prefers_changelog_hub() {
805 let dir = tempdir().unwrap();
806 std::fs::write(
807 dir.path().join("README.md"),
808 "# Demo\n\nShip notes live in the changelog.\n",
809 )
810 .unwrap();
811 std::fs::write(
812 dir.path().join("CHANGELOG.md"),
813 "# Changelog\n\n## [1.2.3] - 2026-01-01\n\n### Added\n- important feature alpha\n",
814 )
815 .unwrap();
816 let brain = dir.path().join(".brain");
817 std::fs::create_dir_all(&brain).unwrap();
818 let db = Database::open(brain.join("db.sqlite")).unwrap();
819 let indexer = WorkspaceIndexer::new(db, dir.path());
820 indexer.index_workspace().unwrap();
821 let db = Database::open(brain.join("db.sqlite")).unwrap();
822 let ctx = assemble_context(
823 &db,
824 &brain,
825 "what shipped in the changelog release",
826 &ContextOptions {
827 max_tokens: 800,
828 hop_depth: 0,
829 ..ContextOptions::default()
830 },
831 )
832 .unwrap();
833 assert!(
834 ctx.nodes.iter().any(|n| n.id == "changelog"),
835 "expected changelog hub packed; nodes={:?}",
836 ctx.nodes.iter().map(|n| &n.id).collect::<Vec<_>>()
837 );
838 }
839
840 #[test]
841 fn generic_overview_prompt_falls_back_to_hub() {
842 let dir = tempdir().unwrap();
843 std::fs::write(
844 dir.path().join("README.md"),
845 "# demotool\n\nNative egui + DuckDB CLI. Avoids Tauri.\n",
846 )
847 .unwrap();
848 let mut brain = Brain::create(dir.path()).unwrap();
849 brain.sync().unwrap();
850
851 for prompt in [
852 "summarize architecture",
853 "what is this project about",
854 "give an overview",
855 ] {
856 let ctx = assemble_context(
857 brain.database(),
858 brain.brain_dir(),
859 prompt,
860 &ContextOptions::default(),
861 )
862 .unwrap();
863 assert!(
864 !ctx.nodes.is_empty(),
865 "expected hub fallback for `{prompt}`; packed=0"
866 );
867 let has_hub = ctx.nodes.iter().any(|n| {
868 n.id == "readme"
869 || n.id.contains("from-readme")
870 || n.excerpt
871 .as_ref()
872 .map(|e| e.to_lowercase().contains("egui") || e.to_lowercase().contains("duckdb"))
873 .unwrap_or(false)
874 });
875 assert!(
876 has_hub,
877 "expected README hub content for `{prompt}`; nodes={:?}",
878 ctx.nodes.iter().map(|n| &n.id).collect::<Vec<_>>()
879 );
880 }
881 }
882
883 #[test]
884 fn strips_frontmatter_from_excerpts() {
885 let dir = tempdir().unwrap();
886 std::fs::create_dir_all(dir.path().join("docs")).unwrap();
887 std::fs::write(
888 dir.path().join("docs/a.md"),
889 "---\ntags: [x]\nnode_type: concept\n---\n# Alpha\n\nBody about egui.\n",
890 )
891 .unwrap();
892 let mut brain = Brain::create(dir.path()).unwrap();
893 brain.sync().unwrap();
894 let ctx = assemble_context(
895 brain.database(),
896 brain.brain_dir(),
897 "egui",
898 &ContextOptions {
899 hop_depth: 0,
900 ..ContextOptions::default()
901 },
902 )
903 .unwrap();
904 let ex = ctx
905 .nodes
906 .iter()
907 .find_map(|n| n.excerpt.as_ref())
908 .expect("excerpt");
909 assert!(
910 !ex.trim_start().starts_with("---"),
911 "frontmatter leaked into excerpt: {ex}"
912 );
913 assert!(ex.to_lowercase().contains("egui") || ex.contains("Alpha"));
914 }
915
916 #[test]
917 fn prefers_adr_seed_over_symbol_neighbor() {
918 let dir = tempdir().unwrap();
919 std::fs::create_dir_all(dir.path().join("docs/adr")).unwrap();
920 std::fs::create_dir_all(dir.path().join("src")).unwrap();
921 std::fs::write(
922 dir.path().join("Cargo.toml"),
923 "[package]\nname = \"demo\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
924 )
925 .unwrap();
926 std::fs::write(
927 dir.path().join("src/lib.rs"),
928 "/// Run SQL via duckdb CLI.\npub fn run_sql() {}\n",
929 )
930 .unwrap();
931 std::fs::write(
932 dir.path().join("docs/adr/sql.md"),
933 "---\nnode_type: adr\n---\n# SQL via duckdb\n\nUse symbol:run_sql for SQL execution via duckdb CLI.\n",
934 )
935 .unwrap();
936 let mut brain = Brain::create(dir.path()).unwrap();
937 brain.sync().unwrap();
938 let _ = brain.sync().unwrap(); let ctx = assemble_context(
940 brain.database(),
941 brain.brain_dir(),
942 "how does SQL execution work",
943 &ContextOptions::default(),
944 )
945 .unwrap();
946 assert!(!ctx.nodes.is_empty());
947 let first = &ctx.nodes[0];
948 assert_eq!(
949 first.node_type,
950 NodeType::Adr,
951 "expected ADR first, got {:?} id={}",
952 first.node_type,
953 first.id
954 );
955 }
956
957 #[test]
958 fn dedups_near_identical_excerpts() {
959 let dir = tempdir().unwrap();
960 let body = "Lightweight egui explorer with DuckDB CLI. Avoids Tauri completely.\n".repeat(3);
961 std::fs::write(
962 dir.path().join("README.md"),
963 format!("# demotool\n\n{body}"),
964 )
965 .unwrap();
966 std::fs::create_dir_all(dir.path().join("docs/goals")).unwrap();
967 std::fs::write(
968 dir.path().join("docs/goals/from-readme.md"),
969 format!(
970 "---\nnode_type: goal\n---\n# Goals harvested from README\n\n{body}"
971 ),
972 )
973 .unwrap();
974 let mut brain = Brain::create(dir.path()).unwrap();
975 brain.sync().unwrap();
976 let ctx = assemble_context(
977 brain.database(),
978 brain.brain_dir(),
979 "egui duckdb tauri",
980 &ContextOptions {
981 max_tokens: 900,
982 hop_depth: 0,
983 ..ContextOptions::default()
984 },
985 )
986 .unwrap();
987 let ids: Vec<_> = ctx.nodes.iter().map(|n| n.id.as_str()).collect();
989 let both = ids.contains(&"readme") && ids.iter().any(|i| i.contains("from-readme"));
990 assert!(
991 !both || ctx.nodes.len() == 1,
992 "expected dedup of near-identical hubs; packed={ids:?}"
993 );
994 }
995}