1use crate::error::Result;
14use crate::fts::{prepare_search_query, tokenize_query};
15use crate::query::{QueryOptions, RankedHit};
16use crate::storage::Database;
17use crate::types::{ContextBundle, ContextNode, ContextRole, Node, NodeType};
18use serde::{Deserialize, Serialize};
19use std::collections::{HashMap, HashSet};
20use std::path::Path;
21use std::time::Instant;
22
23const MAX_EXCERPT_CHARS: usize = 900;
25const MAX_SYMBOL_EXCERPT_CHARS: usize = 280;
27const MAX_PACKED_SYMBOL_NEIGHBORS: usize = 8;
29
30#[derive(Debug, Clone)]
32pub struct ContextOptions {
33 pub max_tokens: usize,
35 pub hop_depth: usize,
37 pub max_seeds: usize,
39 pub max_nodes: usize,
41 pub hop_decay: f32,
43 pub no_symbols: bool,
45 pub include_types: Vec<crate::types::NodeType>,
47 pub exclude_types: Vec<crate::types::NodeType>,
49 pub hop_to_symbols: bool,
52 pub hop_from_docs_only: bool,
54 pub include_excerpts: bool,
56}
57
58impl Default for ContextOptions {
59 fn default() -> Self {
60 Self {
61 max_tokens: 2048,
62 hop_depth: 1,
63 max_seeds: 8,
64 max_nodes: 24,
65 hop_decay: 0.65,
66 no_symbols: true,
68 include_types: Vec::new(),
69 exclude_types: Vec::new(),
70 hop_to_symbols: true,
71 hop_from_docs_only: true,
72 include_excerpts: true,
73 }
74 }
75}
76
77impl ContextOptions {
78 pub fn agent() -> Self {
80 Self::default()
81 }
82
83 pub fn with_symbols(mut self) -> Self {
85 self.no_symbols = false;
86 self
87 }
88
89 fn seed_query_opts(&self) -> QueryOptions {
90 QueryOptions {
91 limit: self.max_seeds.saturating_mul(2).max(8),
92 no_symbols: self.no_symbols,
93 include_types: self.include_types.clone(),
94 exclude_types: self.exclude_types.clone(),
95 ..QueryOptions::default()
96 }
97 }
98
99 fn allows_pack(&self, ty: &crate::types::NodeType, role: ContextRole) -> bool {
100 use crate::types::NodeType;
101 if !self.include_types.is_empty() && !self.include_types.contains(ty) {
102 if !(self.hop_to_symbols && role == ContextRole::Neighbor && *ty == NodeType::Symbol) {
104 return false;
105 }
106 }
107 if self.exclude_types.contains(ty) {
108 return false;
109 }
110 if *ty == NodeType::Symbol {
111 if role == ContextRole::Seed && self.no_symbols {
112 return false;
113 }
114 if role == ContextRole::Neighbor && self.no_symbols && !self.hop_to_symbols {
115 return false;
116 }
117 }
118 true
119 }
120}
121
122#[derive(Debug, Clone)]
124struct Candidate {
125 node: Node,
126 score: f32,
127 role: ContextRole,
128 hop: u8,
129 excerpt: Option<String>,
130}
131
132pub fn assemble_context(
142 db: &Database,
143 brain_dir: &Path,
144 prompt: &str,
145 opts: &ContextOptions,
146) -> Result<ContextBundle> {
147 let start = Instant::now();
148 let char_budget = opts.max_tokens.saturating_mul(4).max(256);
149 let query_tokens = prepare_search_query(prompt)
150 .map(|p| p.tokens)
151 .unwrap_or_else(|_| tokenize_query(prompt));
152
153 let qopts = opts.seed_query_opts();
154 let seeds = db.search_ranked(prompt, &qopts)?;
155
156 let mut candidates: Vec<Candidate> = Vec::new();
157 let mut seed_ids: HashSet<String> = HashSet::new();
158
159 for (i, hit) in seeds.into_iter().take(opts.max_seeds).enumerate() {
160 if !opts.allows_pack(&hit.node.node_type, ContextRole::Seed) {
161 continue;
162 }
163 seed_ids.insert(hit.node.id.clone());
164 let score = hit.score * (1.0 / (1.0 + i as f32 * 0.05));
166 let excerpt = if opts.include_excerpts {
167 load_excerpt(db, &hit.node, MAX_EXCERPT_CHARS)
168 } else {
169 None
170 };
171 candidates.push(Candidate {
172 node: hit.node,
173 score,
174 role: ContextRole::Seed,
175 hop: 0,
176 excerpt,
177 });
178 }
179
180 let mut graph_nodes = 0usize;
181 let mut graph_edges = 0usize;
182 let mut neighbor_ids: Vec<String> = Vec::new();
183
184 #[cfg(feature = "mmap")]
185 if opts.hop_depth > 0 {
186 let mmap_path = brain_dir.join("graph.mmap");
187 if mmap_path.exists() {
188 if let Ok(graph) = crate::mmap::CsrMmapGraph::open(&mmap_path) {
189 graph_nodes = graph.node_count;
190 graph_edges = graph.edge_count;
191
192 let mut best_neighbor: HashMap<String, (f32, u8)> = HashMap::new();
193
194 for cand in candidates.iter().filter(|c| c.role == ContextRole::Seed) {
195 if opts.hop_from_docs_only && cand.node.node_type == NodeType::Symbol {
196 continue;
197 }
198 if let Some(idx) = graph.index_of(&cand.node.id) {
199 for (nidx, edge_w) in
200 graph.k_hop_neighborhood(idx as usize, opts.hop_depth)
201 {
202 if let Some(id) = graph.node_id(nidx as usize) {
203 if seed_ids.contains(id) {
204 continue;
205 }
206 let hop: u8 = if opts.hop_depth <= 1 || edge_w >= 0.85 {
207 1
208 } else {
209 2
210 };
211 let nscore = cand.score * edge_w * opts.hop_decay.powi(hop as i32);
212 best_neighbor
213 .entry(id.to_string())
214 .and_modify(|(s, h)| {
215 if nscore > *s {
216 *s = nscore;
217 *h = hop;
218 }
219 })
220 .or_insert((nscore, hop));
221 }
222 }
223 }
224 }
225
226 let mut neigh_sorted: Vec<_> = best_neighbor.into_iter().collect();
227 neigh_sorted.sort_by(|a, b| {
228 b.1 .0
229 .partial_cmp(&a.1 .0)
230 .unwrap_or(std::cmp::Ordering::Equal)
231 });
232
233 for (id, (score, hop)) in neigh_sorted {
234 neighbor_ids.push(id.clone());
235 if let Some(node) = db.get_node(&id)? {
236 if !opts.allows_pack(&node.node_type, ContextRole::Neighbor) {
237 continue;
238 }
239 let mut nscore = score;
240 if node.node_type == NodeType::Symbol {
241 match symbol_neighbor_quality(&node, &query_tokens) {
242 SymbolQuality::Drop => continue,
243 SymbolQuality::Keep { boost } => nscore *= boost,
244 }
245 }
246 let max_ex = if node.node_type == NodeType::Symbol {
247 MAX_SYMBOL_EXCERPT_CHARS
248 } else {
249 MAX_EXCERPT_CHARS
250 };
251 let excerpt = if opts.include_excerpts {
252 load_excerpt(db, &node, max_ex)
253 } else {
254 None
255 };
256 candidates.push(Candidate {
257 node,
258 score: nscore,
259 role: ContextRole::Neighbor,
260 hop,
261 excerpt,
262 });
263 }
264 }
265 }
266 }
267 }
268
269 candidates.sort_by(|a, b| {
271 let a_bonus = if a.node.node_type == NodeType::Symbol {
273 0.0
274 } else {
275 0.05
276 };
277 let b_bonus = if b.node.node_type == NodeType::Symbol {
278 0.0
279 } else {
280 0.05
281 };
282 (b.score + b_bonus)
283 .partial_cmp(&(a.score + a_bonus))
284 .unwrap_or(std::cmp::Ordering::Equal)
285 });
286
287 let mut packed: Vec<ContextNode> = Vec::new();
288 let mut used_chars = estimate_overhead(prompt);
289 let mut seen = HashSet::new();
290 let mut symbol_neighbors = 0usize;
291
292 for cand in candidates {
293 if packed.len() >= opts.max_nodes {
294 break;
295 }
296 if !seen.insert(cand.node.id.clone()) {
297 continue;
298 }
299 if cand.role == ContextRole::Neighbor
300 && cand.node.node_type == NodeType::Symbol
301 && symbol_neighbors >= MAX_PACKED_SYMBOL_NEIGHBORS
302 {
303 continue;
304 }
305 let ctx_node = to_context_node(&cand);
306 let cost = estimate_node_chars(&ctx_node);
307 if used_chars + cost > char_budget && !packed.is_empty() {
308 if cand.role == ContextRole::Neighbor {
310 continue;
311 }
312 if packed.iter().any(|n| n.role == ContextRole::Seed) {
313 continue;
314 }
315 }
316 if used_chars + cost > char_budget && !packed.is_empty() {
317 break;
318 }
319 used_chars += cost;
320 if cand.role == ContextRole::Neighbor && cand.node.node_type == NodeType::Symbol {
321 symbol_neighbors += 1;
322 }
323 packed.push(ctx_node);
324 }
325
326 neighbor_ids.retain(|id| !seed_ids.contains(id));
327 neighbor_ids.truncate(48);
328
329 let latency_us = start.elapsed().as_micros() as u64;
330 let tokens_used = used_chars.div_ceil(4);
331
332 Ok(ContextBundle {
333 prompt: prompt.to_string(),
334 max_tokens: opts.max_tokens,
335 tokens_used,
336 nodes: packed,
337 neighbor_ids,
338 latency_us,
339 graph_nodes,
340 graph_edges,
341 })
342}
343
344enum SymbolQuality {
345 Drop,
346 Keep { boost: f32 },
347}
348
349fn symbol_neighbor_quality(node: &Node, query_tokens: &[String]) -> SymbolQuality {
351 let title = node.title.to_lowercase();
352 let id = node.id.to_lowercase();
353 let leaf = id.rsplit('/').next().unwrap_or(&id);
354
355 for t in query_tokens {
357 if title.contains(t.as_str()) || id.contains(t.as_str()) {
358 return SymbolQuality::Keep { boost: 1.8 };
359 }
360 }
361
362 if title.contains("::") || leaf.contains("::") {
364 return SymbolQuality::Keep { boost: 1.15 };
365 }
366
367 let name = title.split("::").last().unwrap_or(&title);
369 if name.len() <= 2 {
370 return SymbolQuality::Drop;
371 }
372 if name.chars().all(|c| c.is_ascii_uppercase() || c == '_') && name.len() <= 12 {
373 return SymbolQuality::Drop;
375 }
376 if !name.contains('_') && !title.contains("::") && name.len() <= 8 {
378 let has_upper = name.chars().any(|c| c.is_ascii_uppercase());
380 let has_lower = name.chars().any(|c| c.is_ascii_lowercase());
381 if has_upper && has_lower {
382 return SymbolQuality::Keep { boost: 1.0 };
383 }
384 return SymbolQuality::Drop;
385 }
386
387 SymbolQuality::Keep { boost: 1.0 }
388}
389
390fn load_excerpt(db: &Database, node: &Node, max_chars: usize) -> Option<String> {
391 let raw = db
392 .get_fts_content(&node.id)
393 .ok()
394 .flatten()
395 .filter(|s| !s.trim().is_empty())
396 .or_else(|| node.summary.clone().filter(|s| !s.trim().is_empty()))?;
397 Some(truncate_excerpt(&raw, max_chars))
398}
399
400fn truncate_excerpt(s: &str, max_chars: usize) -> String {
401 let s = s.trim();
402 if s.chars().count() <= max_chars {
403 return s.to_string();
404 }
405 let mut out = String::new();
406 for (i, ch) in s.chars().enumerate() {
407 if i >= max_chars.saturating_sub(1) {
408 break;
409 }
410 out.push(ch);
411 }
412 out.push('…');
413 out
414}
415
416fn to_context_node(c: &Candidate) -> ContextNode {
417 ContextNode {
418 id: c.node.id.clone(),
419 node_type: c.node.node_type.clone(),
420 title: c.node.title.clone(),
421 summary: c.node.summary.clone(),
422 excerpt: c.excerpt.clone(),
423 file_path: c.node.file_path.clone(),
424 score_hint: c.score,
425 role: c.role,
426 hop: c.hop,
427 }
428}
429
430fn estimate_overhead(prompt: &str) -> usize {
431 120 + prompt.len()
432}
433
434fn estimate_node_chars(n: &ContextNode) -> usize {
435 let mut c = n.id.len() + n.title.len() + 48;
436 if let Some(s) = &n.summary {
437 c += s.len();
438 }
439 if let Some(ex) = &n.excerpt {
440 c += ex.len();
441 }
442 if let Some(p) = &n.file_path {
443 c += p.len();
444 }
445 c
446}
447
448pub fn hits_to_nodes(hits: Vec<RankedHit>) -> Vec<Node> {
450 hits.into_iter().map(|h| h.node).collect()
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct WorkspaceHit {
459 pub workspace: String,
461 pub hit: RankedHit,
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468 use crate::brain::Brain;
469 use tempfile::tempdir;
470
471 #[test]
472 fn context_includes_graph_neighbors_within_budget() {
473 let dir = tempdir().unwrap();
474 let docs = dir.path().join("docs");
475 std::fs::create_dir_all(&docs).unwrap();
476 std::fs::write(
478 docs.join("alpha.md"),
479 "---\ntags: [alphaunique]\nnode_type: concept\n---\n# AlphaUnique\nLinks [[betaunique]].\nalphaunique body.\n",
480 )
481 .unwrap();
482 std::fs::write(
483 docs.join("beta.md"),
484 "---\ntags: [other]\nnode_type: concept\n---\n# BetaUnique\nNo alphaunique here.\n",
485 )
486 .unwrap();
487
488 let mut brain = Brain::create(dir.path()).unwrap();
489 brain.sync().unwrap();
490
491 let opts = ContextOptions {
492 max_tokens: 2048,
493 hop_depth: 1,
494 max_seeds: 4,
495 max_nodes: 12,
496 hop_decay: 0.7,
497 ..ContextOptions::default()
498 };
499 let ctx = assemble_context(brain.database(), brain.brain_dir(), "alphaunique", &opts)
500 .unwrap();
501 assert!(!ctx.nodes.is_empty());
502 assert!(ctx.tokens_used > 0);
503 assert!(ctx.tokens_used <= opts.max_tokens + 64); let has_beta = ctx.nodes.iter().any(|n| n.id.contains("beta"))
507 || ctx.neighbor_ids.iter().any(|id| id.contains("beta"));
508 assert!(
509 has_beta,
510 "expected beta neighbor via graph; nodes={:?} neigh={:?}",
511 ctx.nodes.iter().map(|n| &n.id).collect::<Vec<_>>(),
512 ctx.neighbor_ids
513 );
514 }
515
516 #[test]
517 fn tight_budget_limits_nodes() {
518 let dir = tempdir().unwrap();
519 let docs = dir.path().join("docs");
520 std::fs::create_dir_all(&docs).unwrap();
521 for i in 0..8 {
522 std::fs::write(
523 docs.join(format!("n{i}.md")),
524 format!("---\ntags: [topic]\n---\n# Note {i}\nshared topic content {i}\n"),
525 )
526 .unwrap();
527 }
528 let mut brain = Brain::create(dir.path()).unwrap();
529 brain.sync().unwrap();
530 let opts = ContextOptions {
531 max_tokens: 40, hop_depth: 0,
533 max_seeds: 8,
534 max_nodes: 20,
535 hop_decay: 0.5,
536 ..ContextOptions::default()
537 };
538 let ctx =
539 assemble_context(brain.database(), brain.brain_dir(), "topic", &opts).unwrap();
540 assert!(!ctx.nodes.is_empty());
541 assert!(ctx.nodes.len() < 8, "budget should clip nodes");
542 }
543
544 #[test]
545 fn natural_language_prompt_seeds_and_packs_excerpt() {
546 let dir = tempdir().unwrap();
547 std::fs::write(
548 dir.path().join("README.md"),
549 "# demotool\n\nLightweight **egui** explorer powered by DuckDB.\n\
550 Inspired by Duckling but avoids Tauri/WebView.\n",
551 )
552 .unwrap();
553 let mut brain = Brain::create(dir.path()).unwrap();
554 brain.sync().unwrap();
555
556 let ctx = assemble_context(
557 brain.database(),
558 brain.brain_dir(),
559 "why egui not tauri",
560 &ContextOptions::default(),
561 )
562 .unwrap();
563 assert!(
564 !ctx.nodes.is_empty(),
565 "expected seeds for NL prompt; packed=0"
566 );
567 let has_excerpt = ctx.nodes.iter().any(|n| {
568 n.excerpt
569 .as_ref()
570 .map(|e| e.to_lowercase().contains("egui") || e.to_lowercase().contains("tauri"))
571 .unwrap_or(false)
572 });
573 assert!(
574 has_excerpt,
575 "expected body excerpt mentioning egui/tauri; nodes={:?}",
576 ctx.nodes
577 .iter()
578 .map(|n| (&n.id, &n.excerpt))
579 .collect::<Vec<_>>()
580 );
581 }
582}