1pub mod project_matcher;
2pub mod scenario_matcher;
3pub mod scorer;
4pub mod selector;
5
6#[cfg(feature = "bm25")]
7pub mod bm25;
8
9#[cfg(feature = "embedding")]
10pub mod embedding;
11
12use crate::config::{AppConfig, ProjectConfig};
13use crate::domain::{
14 ContextBundle, DebugTrace, LifecycleCandidate, MemoryRecord, Note, RouteInput, RouteResult,
15};
16use std::collections::HashMap;
17
18pub fn build_context(
19 config: &AppConfig,
20 notes: &[Note],
21 input: RouteInput,
22 debug: DebugTrace,
23) -> ContextBundle {
24 build_context_with_lifecycle(config, notes, &[], input, debug)
25}
26
27pub fn build_context_with_lifecycle(
28 config: &AppConfig,
29 notes: &[Note],
30 lifecycle_records: &[(String, MemoryRecord)],
31 input: RouteInput,
32 debug: DebugTrace,
33) -> ContextBundle {
34 build_context_with_lifecycle_and_refs(config, notes, lifecycle_records, input, debug, None)
35}
36
37pub fn build_context_with_lifecycle_and_refs(
38 config: &AppConfig,
39 notes: &[Note],
40 lifecycle_records: &[(String, MemoryRecord)],
41 input: RouteInput,
42 debug: DebugTrace,
43 reference_map: Option<&crate::reference_tracker::ReferenceMap>,
44) -> ContextBundle {
45 let project = project_matcher::match_project(config, &input.cwd);
46 let project_config = project.as_ref().and_then(|matched| {
47 config
48 .projects
49 .iter()
50 .find(|project| project.id == matched.id)
51 });
52 let modules = project_config
53 .map(|project| scenario_matcher::match_modules(project, &input))
54 .unwrap_or_default();
55 let scenes = scenario_matcher::match_scenes(config, &input);
56 let scored_notes = selector::select_scored_notes(
57 project_config,
58 project.as_ref(),
59 &modules,
60 &scenes,
61 notes,
62 &input,
63 config.output.max_notes,
64 );
65 let mut excluded_record_ids = selector::excluded_record_ids_from_scored(&scored_notes);
66 excluded_record_ids.extend(selector::superseded_record_ids(lifecycle_records));
68 let candidates: Vec<crate::domain::CandidateNote> = scored_notes
69 .iter()
70 .map(crate::domain::CandidateNote::from)
71 .collect();
72
73 let lifecycle_candidates = select_lifecycle_with_available_signals(
74 config,
75 project.as_ref(),
76 lifecycle_records,
77 &input,
78 &excluded_record_ids,
79 reference_map,
80 );
81
82 let crystallize_hint = detect_crystallize_hint(&lifecycle_candidates, lifecycle_records);
83
84 let sources = candidates
85 .iter()
86 .map(|candidate| candidate.relative_path.clone())
87 .collect();
88
89 ContextBundle {
90 input,
91 route: RouteResult {
92 project,
93 modules,
94 scenes,
95 candidates,
96 lifecycle_candidates,
97 sources,
98 debug,
99 crystallize_hint,
100 },
101 }
102}
103
104fn select_lifecycle_with_available_signals(
105 config: &AppConfig,
106 project: Option<&crate::domain::MatchedProject>,
107 lifecycle_records: &[(String, MemoryRecord)],
108 input: &RouteInput,
109 excluded_record_ids: &std::collections::HashSet<String>,
110 reference_map: Option<&crate::reference_tracker::ReferenceMap>,
111) -> Vec<crate::domain::LifecycleCandidate> {
112 let limit = config.output.max_lifecycle;
113
114 #[cfg(feature = "embedding")]
115 {
116 let embedding_results = try_embedding_search(config, &input.task, limit * 2);
117 if !embedding_results.is_empty() {
118 return selector::select_lifecycle_candidates_fused(
119 project,
120 lifecycle_records,
121 input,
122 limit,
123 excluded_record_ids,
124 reference_map,
125 #[cfg(feature = "bm25")]
126 None, &embedding_results,
128 );
129 }
130 }
131
132 selector::select_lifecycle_candidates(
133 project,
134 lifecycle_records,
135 input,
136 limit,
137 excluded_record_ids,
138 reference_map,
139 )
140}
141
142#[cfg(feature = "embedding")]
143fn try_embedding_search(config: &AppConfig, query: &str, limit: usize) -> Vec<(String, f32)> {
144 if !config.embedding.enabled {
145 return Vec::new();
146 }
147 let index_path = config.embedding.resolved_index_path();
148 if !index_path.exists() {
149 return Vec::new();
150 }
151 let index = match embedding::EmbeddingIndex::load(&index_path) {
152 Ok(idx) => idx,
153 Err(_) => return Vec::new(),
154 };
155 let Some(model) = embedding::cached_model_for(config.embedding.model_id.as_deref()) else {
156 return Vec::new();
157 };
158 match embedding::EmbeddingIndex::embed_query(model, query) {
159 Ok(query_emb) => index.search(&query_emb, limit),
160 Err(_) => Vec::new(),
161 }
162}
163
164pub fn project_config_for_input<'a>(
165 config: &'a AppConfig,
166 cwd: &std::path::Path,
167) -> Option<&'a ProjectConfig> {
168 project_matcher::match_project(config, cwd)
169 .as_ref()
170 .and_then(|matched| {
171 config
172 .projects
173 .iter()
174 .find(|project| project.id == matched.id)
175 })
176}
177
178const CRYSTALLIZE_THRESHOLD: usize = 3;
180
181fn detect_crystallize_hint(
185 candidates: &[LifecycleCandidate],
186 records: &[(String, MemoryRecord)],
187) -> Option<String> {
188 if candidates.len() < CRYSTALLIZE_THRESHOLD {
189 return None;
190 }
191
192 let mut entity_freq: HashMap<String, usize> = HashMap::new();
194 let mut tag_freq: HashMap<String, usize> = HashMap::new();
195
196 for candidate in candidates {
197 if let Some((_, record)) = records.iter().find(|(id, _)| id == &candidate.record_id) {
198 for entity in &record.entities {
199 let key = entity.to_lowercase();
200 *entity_freq.entry(key).or_insert(0) += 1;
201 }
202 for tag in &record.tags {
203 let key = tag.to_lowercase();
204 *tag_freq.entry(key).or_insert(0) += 1;
205 }
206 }
207 }
208
209 let best_entity = entity_freq
211 .iter()
212 .filter(|(_, count)| **count >= CRYSTALLIZE_THRESHOLD)
213 .max_by_key(|(_, count)| *count);
214
215 let best_tag = tag_freq
216 .iter()
217 .filter(|(_, count)| **count >= CRYSTALLIZE_THRESHOLD)
218 .max_by_key(|(_, count)| *count);
219
220 let (topic, count) = match (best_entity, best_tag) {
222 (Some((entity, e_count)), Some((tag, t_count))) => {
223 if e_count >= t_count {
224 (entity.clone(), *e_count)
225 } else {
226 (tag.clone(), *t_count)
227 }
228 }
229 (Some((entity, e_count)), None) => (entity.clone(), *e_count),
230 (None, Some((tag, t_count))) => (tag.clone(), *t_count),
231 (None, None) => return None,
232 };
233
234 Some(format!(
235 "{count} fragments share topic \"{topic}\" — consider running `memory consolidate` to crystallize them into a knowledge page"
236 ))
237}