1use chrono::Utc;
2
3#[cfg(feature = "embeddings")]
4use crate::core::embeddings::EmbeddingEngine;
5
6use crate::core::knowledge::ProjectKnowledge;
7use crate::core::session::SessionState;
8
9#[allow(clippy::too_many_arguments)]
11pub fn handle(
12 project_root: &str,
13 action: &str,
14 category: Option<&str>,
15 key: Option<&str>,
16 value: Option<&str>,
17 query: Option<&str>,
18 session_id: &str,
19 pattern_type: Option<&str>,
20 examples: Option<Vec<String>>,
21 confidence: Option<f32>,
22) -> String {
23 match action {
24 "remember" => handle_remember(project_root, category, key, value, session_id, confidence),
25 "recall" => handle_recall(project_root, category, query, session_id),
26 "pattern" => handle_pattern(project_root, pattern_type, value, examples, session_id),
27 "status" => handle_status(project_root),
28 "remove" => handle_remove(project_root, category, key),
29 "export" => handle_export(project_root),
30 "consolidate" => handle_consolidate(project_root),
31 "timeline" => handle_timeline(project_root, category),
32 "rooms" => handle_rooms(project_root),
33 "search" => handle_search(query),
34 "wakeup" => handle_wakeup(project_root),
35 "embeddings_status" => handle_embeddings_status(project_root),
36 "embeddings_reset" => handle_embeddings_reset(project_root),
37 "embeddings_reindex" => handle_embeddings_reindex(project_root),
38 _ => format!(
39 "Unknown action: {action}. Use: remember, recall, pattern, status, remove, export, consolidate, timeline, rooms, search, wakeup, embeddings_status, embeddings_reset, embeddings_reindex"
40 ),
41 }
42}
43
44#[cfg(feature = "embeddings")]
45fn embeddings_auto_download_allowed() -> bool {
46 std::env::var("LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD")
47 .ok()
48 .is_some_and(|v| {
49 matches!(
50 v.trim().to_lowercase().as_str(),
51 "1" | "true" | "yes" | "on"
52 )
53 })
54}
55
56#[cfg(feature = "embeddings")]
57fn embedding_engine() -> Option<&'static EmbeddingEngine> {
58 use std::sync::OnceLock;
59
60 if !EmbeddingEngine::is_available() && !embeddings_auto_download_allowed() {
61 return None;
62 }
63
64 static ENGINE: OnceLock<anyhow::Result<EmbeddingEngine>> = OnceLock::new();
65 ENGINE
66 .get_or_init(EmbeddingEngine::load_default)
67 .as_ref()
68 .ok()
69}
70
71fn handle_embeddings_status(project_root: &str) -> String {
72 #[cfg(feature = "embeddings")]
73 {
74 let knowledge = ProjectKnowledge::load_or_create(project_root);
75 let model_available = EmbeddingEngine::is_available();
76 let auto = embeddings_auto_download_allowed();
77
78 let entries = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
79 &knowledge.project_hash,
80 )
81 .map_or(0, |i| i.entries.len());
82
83 let path = crate::core::data_dir::lean_ctx_data_dir()
84 .ok()
85 .map(|d| {
86 d.join("knowledge")
87 .join(&knowledge.project_hash)
88 .join("embeddings.json")
89 })
90 .map_or_else(|| "<unknown>".to_string(), |p| p.display().to_string());
91
92 format!(
93 "Knowledge embeddings: model={}, auto_download={}, index_entries={}, path={path}",
94 if model_available {
95 "present"
96 } else {
97 "missing"
98 },
99 if auto { "on" } else { "off" },
100 entries
101 )
102 }
103 #[cfg(not(feature = "embeddings"))]
104 {
105 let _ = project_root;
106 "ERR: embeddings feature not enabled".to_string()
107 }
108}
109
110fn handle_embeddings_reset(project_root: &str) -> String {
111 #[cfg(feature = "embeddings")]
112 {
113 let knowledge = ProjectKnowledge::load_or_create(project_root);
114 match crate::core::knowledge_embedding::reset(&knowledge.project_hash) {
115 Ok(()) => "Embeddings index reset.".to_string(),
116 Err(e) => format!("Embeddings reset failed: {e}"),
117 }
118 }
119 #[cfg(not(feature = "embeddings"))]
120 {
121 let _ = project_root;
122 "ERR: embeddings feature not enabled".to_string()
123 }
124}
125
126fn handle_embeddings_reindex(project_root: &str) -> String {
127 #[cfg(feature = "embeddings")]
128 {
129 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
130 return "No knowledge stored for this project yet.".to_string();
131 };
132
133 let Some(engine) = embedding_engine() else {
134 return "Embeddings model not available. Set LEAN_CTX_EMBEDDINGS_AUTO_DOWNLOAD=1 to allow auto-download, then re-run."
135 .to_string();
136 };
137
138 let mut idx =
139 crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::new(&knowledge.project_hash);
140
141 let mut facts: Vec<&crate::core::knowledge::KnowledgeFact> =
142 knowledge.facts.iter().filter(|f| f.is_current()).collect();
143 facts.sort_by(|a, b| {
144 b.confidence
145 .partial_cmp(&a.confidence)
146 .unwrap_or(std::cmp::Ordering::Equal)
147 .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
148 .then_with(|| a.category.cmp(&b.category))
149 .then_with(|| a.key.cmp(&b.key))
150 });
151
152 let max = crate::core::budgets::KNOWLEDGE_EMBEDDINGS_MAX_FACTS;
153 let mut embedded = 0usize;
154 for f in facts.into_iter().take(max) {
155 if crate::core::knowledge_embedding::embed_and_store(
156 &mut idx,
157 engine,
158 &f.category,
159 &f.key,
160 &f.value,
161 )
162 .is_ok()
163 {
164 embedded += 1;
165 }
166 }
167
168 crate::core::knowledge_embedding::compact_against_knowledge(&mut idx, &knowledge);
169 match idx.save() {
170 Ok(()) => format!("Embeddings reindex ok (embedded {embedded} facts)."),
171 Err(e) => format!("Embeddings reindex failed: {e}"),
172 }
173 }
174 #[cfg(not(feature = "embeddings"))]
175 {
176 let _ = project_root;
177 "ERR: embeddings feature not enabled".to_string()
178 }
179}
180
181fn handle_remember(
182 project_root: &str,
183 category: Option<&str>,
184 key: Option<&str>,
185 value: Option<&str>,
186 session_id: &str,
187 confidence: Option<f32>,
188) -> String {
189 let Some(cat) = category else {
190 return "Error: category is required for remember".to_string();
191 };
192 let Some(k) = key else {
193 return "Error: key is required for remember".to_string();
194 };
195 let Some(v) = value else {
196 return "Error: value is required for remember".to_string();
197 };
198 let conf = confidence.unwrap_or(0.8);
199 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
200 let contradiction = knowledge.remember(cat, k, v, session_id, conf);
201 let _ = knowledge.run_memory_lifecycle();
202
203 let mut result = format!(
204 "Remembered [{cat}] {k}: {v} (confidence: {:.0}%)",
205 conf * 100.0
206 );
207
208 if let Some(c) = contradiction {
209 result.push_str(&format!("\nā CONTRADICTION DETECTED: {}", c.resolution));
210 }
211
212 #[cfg(feature = "embeddings")]
213 {
214 if let Some(engine) = embedding_engine() {
215 let mut idx = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
216 &knowledge.project_hash,
217 )
218 .unwrap_or_else(|| {
219 crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::new(
220 &knowledge.project_hash,
221 )
222 });
223
224 match crate::core::knowledge_embedding::embed_and_store(&mut idx, engine, cat, k, v) {
225 Ok(()) => {
226 crate::core::knowledge_embedding::compact_against_knowledge(
227 &mut idx, &knowledge,
228 );
229 if let Err(e) = idx.save() {
230 result.push_str(&format!("\n(warn: embeddings save failed: {e})"));
231 }
232 }
233 Err(e) => {
234 result.push_str(&format!("\n(warn: embeddings update failed: {e})"));
235 }
236 }
237 }
238 }
239
240 match knowledge.save() {
241 Ok(()) => result,
242 Err(e) => format!("{result}\n(save failed: {e})"),
243 }
244}
245
246fn handle_recall(
247 project_root: &str,
248 category: Option<&str>,
249 query: Option<&str>,
250 session_id: &str,
251) -> String {
252 let Some(mut knowledge) = ProjectKnowledge::load(project_root) else {
253 return "No knowledge stored for this project yet.".to_string();
254 };
255
256 if let Some(cat) = category {
257 let limit = crate::core::budgets::KNOWLEDGE_RECALL_FACTS_LIMIT;
258 let (facts, total) = knowledge.recall_by_category_for_output(cat, limit);
259 if facts.is_empty() || total == 0 {
260 let rehydrated = rehydrate_from_archives(&mut knowledge, Some(cat), None, session_id);
262 if rehydrated {
263 let (facts2, total2) = knowledge.recall_by_category_for_output(cat, limit);
264 if !facts2.is_empty() && total2 > 0 {
265 let mut out2 = format_facts(&facts2, total2, Some(cat));
266 if let Err(e) = knowledge.save() {
267 out2.push_str(&format!(
268 "\n(warn: failed to persist retrieval signals: {e})"
269 ));
270 }
271 return out2;
272 }
273 }
274 return format!("No facts in category '{cat}'.");
275 }
276 let mut out = format_facts(&facts, total, Some(cat));
277 if let Err(e) = knowledge.save() {
278 out.push_str(&format!(
279 "\n(warn: failed to persist retrieval signals: {e})"
280 ));
281 }
282 return out;
283 }
284
285 if let Some(q) = query {
286 #[cfg(feature = "embeddings")]
287 {
288 if let Some(engine) = embedding_engine() {
289 if let Some(idx) = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
290 &knowledge.project_hash,
291 ) {
292 let limit = crate::core::budgets::KNOWLEDGE_RECALL_FACTS_LIMIT;
293 let scored = crate::core::knowledge_embedding::semantic_recall(
294 &knowledge, &idx, engine, q, limit,
295 );
296 if !scored.is_empty() {
297 let hits: Vec<SemanticHit> = scored
298 .iter()
299 .map(|s| SemanticHit {
300 category: s.fact.category.clone(),
301 key: s.fact.key.clone(),
302 value: s.fact.value.clone(),
303 score: s.score,
304 semantic_score: s.semantic_score,
305 confidence_score: s.confidence_score,
306 })
307 .collect();
308 apply_retrieval_signals_from_hits(&mut knowledge, &hits);
309 let mut out = format_semantic_facts(q, &hits);
310 if let Err(e) = knowledge.save() {
311 out.push_str(&format!(
312 "\n(warn: failed to persist retrieval signals: {e})"
313 ));
314 }
315 return out;
316 }
317 }
318 }
319 }
320
321 let limit = crate::core::budgets::KNOWLEDGE_RECALL_FACTS_LIMIT;
322 let (facts, total) = knowledge.recall_for_output(q, limit);
323 if facts.is_empty() || total == 0 {
324 let rehydrated = rehydrate_from_archives(&mut knowledge, None, Some(q), session_id);
326 if rehydrated {
327 let (facts2, total2) = knowledge.recall_for_output(q, limit);
328 if !facts2.is_empty() && total2 > 0 {
329 let mut out2 = format_facts(&facts2, total2, None);
330 if let Err(e) = knowledge.save() {
331 out2.push_str(&format!(
332 "\n(warn: failed to persist retrieval signals: {e})"
333 ));
334 }
335 return out2;
336 }
337 }
338 return format!("No facts matching '{q}'.");
339 }
340 let mut out = format_facts(&facts, total, None);
341 if let Err(e) = knowledge.save() {
342 out.push_str(&format!(
343 "\n(warn: failed to persist retrieval signals: {e})"
344 ));
345 }
346 return out;
347 }
348
349 "Error: provide query or category for recall".to_string()
350}
351
352fn rehydrate_from_archives(
353 knowledge: &mut ProjectKnowledge,
354 category: Option<&str>,
355 query: Option<&str>,
356 session_id: &str,
357) -> bool {
358 let mut archives = crate::core::memory_lifecycle::list_archives();
359 if archives.is_empty() {
360 return false;
361 }
362 archives.sort();
363 let max_archives = crate::core::budgets::KNOWLEDGE_REHYDRATE_MAX_ARCHIVES;
364 if archives.len() > max_archives {
365 archives = archives[archives.len() - max_archives..].to_vec();
366 }
367
368 let terms: Vec<String> = query
369 .unwrap_or("")
370 .to_lowercase()
371 .split_whitespace()
372 .filter(|t| !t.is_empty())
373 .map(std::string::ToString::to_string)
374 .collect();
375
376 #[derive(Clone)]
377 struct Cand {
378 category: String,
379 key: String,
380 value: String,
381 confidence: f32,
382 score: f32,
383 }
384
385 let mut cands: Vec<Cand> = Vec::new();
386
387 for p in &archives {
388 let p_str = p.to_string_lossy().to_string();
389 let Ok(facts) = crate::core::memory_lifecycle::restore_archive(&p_str) else {
390 continue;
391 };
392 for f in facts {
393 if let Some(cat) = category {
394 if f.category != cat {
395 continue;
396 }
397 }
398 if terms.is_empty() {
399 cands.push(Cand {
400 category: f.category,
401 key: f.key,
402 value: f.value,
403 confidence: f.confidence,
404 score: f.confidence,
405 });
406 } else {
407 let searchable = format!(
408 "{} {} {} {}",
409 f.category.to_lowercase(),
410 f.key.to_lowercase(),
411 f.value.to_lowercase(),
412 f.source_session.to_lowercase()
413 );
414 let match_count = terms.iter().filter(|t| searchable.contains(*t)).count();
415 if match_count == 0 {
416 continue;
417 }
418 let rel = match_count as f32 / terms.len() as f32;
419 let score = rel * f.confidence;
420 cands.push(Cand {
421 category: f.category,
422 key: f.key,
423 value: f.value,
424 confidence: f.confidence,
425 score,
426 });
427 }
428 }
429 }
430
431 if cands.is_empty() {
432 return false;
433 }
434
435 cands.sort_by(|a, b| {
436 b.score
437 .partial_cmp(&a.score)
438 .unwrap_or(std::cmp::Ordering::Equal)
439 .then_with(|| {
440 b.confidence
441 .partial_cmp(&a.confidence)
442 .unwrap_or(std::cmp::Ordering::Equal)
443 })
444 .then_with(|| a.category.cmp(&b.category))
445 .then_with(|| a.key.cmp(&b.key))
446 .then_with(|| a.value.cmp(&b.value))
447 });
448 cands.truncate(crate::core::budgets::KNOWLEDGE_REHYDRATE_LIMIT);
449
450 let mut any = false;
451 for c in &cands {
452 knowledge.remember(
453 &c.category,
454 &c.key,
455 &c.value,
456 session_id,
457 c.confidence.max(0.6),
458 );
459 any = true;
460 }
461 if any {
462 let _ = knowledge.run_memory_lifecycle();
463 }
464 any
465}
466
467fn handle_pattern(
468 project_root: &str,
469 pattern_type: Option<&str>,
470 value: Option<&str>,
471 examples: Option<Vec<String>>,
472 session_id: &str,
473) -> String {
474 let Some(pt) = pattern_type else {
475 return "Error: pattern_type is required".to_string();
476 };
477 let Some(desc) = value else {
478 return "Error: value (description) is required for pattern".to_string();
479 };
480 let exs = examples.unwrap_or_default();
481 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
482 knowledge.add_pattern(pt, desc, exs, session_id);
483 match knowledge.save() {
484 Ok(()) => format!("Pattern [{pt}] added: {desc}"),
485 Err(e) => format!("Pattern added but save failed: {e}"),
486 }
487}
488
489fn handle_status(project_root: &str) -> String {
490 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
491 return "No knowledge stored for this project yet. Use ctx_knowledge(action=\"remember\") to start.".to_string();
492 };
493
494 let current_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
495 let archived_facts = knowledge.facts.len() - current_facts;
496
497 let mut out = format!(
498 "Project Knowledge: {} active facts ({} archived), {} patterns, {} history entries\n",
499 current_facts,
500 archived_facts,
501 knowledge.patterns.len(),
502 knowledge.history.len()
503 );
504 out.push_str(&format!(
505 "Last updated: {}\n",
506 knowledge.updated_at.format("%Y-%m-%d %H:%M UTC")
507 ));
508
509 let rooms = knowledge.list_rooms();
510 if !rooms.is_empty() {
511 out.push_str("Rooms: ");
512 let room_strs: Vec<String> = rooms.iter().map(|(c, n)| format!("{c}({n})")).collect();
513 out.push_str(&room_strs.join(", "));
514 out.push('\n');
515 }
516
517 out.push_str(&knowledge.format_summary());
518 out
519}
520
521fn handle_remove(project_root: &str, category: Option<&str>, key: Option<&str>) -> String {
522 let Some(cat) = category else {
523 return "Error: category is required for remove".to_string();
524 };
525 let Some(k) = key else {
526 return "Error: key is required for remove".to_string();
527 };
528 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
529 if knowledge.remove_fact(cat, k) {
530 let _ = knowledge.run_memory_lifecycle();
531
532 #[cfg(feature = "embeddings")]
533 {
534 if let Some(mut idx) = crate::core::knowledge_embedding::KnowledgeEmbeddingIndex::load(
535 &knowledge.project_hash,
536 ) {
537 idx.remove(cat, k);
538 crate::core::knowledge_embedding::compact_against_knowledge(&mut idx, &knowledge);
539 let _ = idx.save();
540 }
541 }
542
543 match knowledge.save() {
544 Ok(()) => format!("Removed [{cat}] {k}"),
545 Err(e) => format!("Removed but save failed: {e}"),
546 }
547 } else {
548 format!("No fact found: [{cat}] {k}")
549 }
550}
551
552fn handle_export(project_root: &str) -> String {
553 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
554 return "No knowledge to export.".to_string();
555 };
556 let data_dir = match crate::core::data_dir::lean_ctx_data_dir() {
557 Ok(d) => d,
558 Err(e) => return format!("Export failed: {e}"),
559 };
560
561 let export_dir = data_dir.join("exports").join("knowledge");
562 let ts = Utc::now().format("%Y%m%d-%H%M%S");
563 let filename = format!(
564 "knowledge-{}-{ts}.json",
565 short_hash(&knowledge.project_hash)
566 );
567 let path = export_dir.join(filename);
568
569 match serde_json::to_string_pretty(&knowledge) {
570 Ok(mut json) => {
571 json.push('\n');
572 match crate::config_io::write_atomic_with_backup(&path, &json) {
573 Ok(()) => format!(
574 "Export saved: {} (active facts: {}, patterns: {}, history: {})",
575 path.display(),
576 knowledge.facts.iter().filter(|f| f.is_current()).count(),
577 knowledge.patterns.len(),
578 knowledge.history.len()
579 ),
580 Err(e) => format!("Export failed: {e}"),
581 }
582 }
583 Err(e) => format!("Export failed: {e}"),
584 }
585}
586
587fn handle_consolidate(project_root: &str) -> String {
588 let Some(session) = SessionState::load_latest() else {
589 return "No active session to consolidate.".to_string();
590 };
591
592 let mut knowledge = ProjectKnowledge::load_or_create(project_root);
593 let mut consolidated = 0u32;
594
595 for finding in &session.findings {
596 let key_text = if let Some(ref file) = finding.file {
597 if let Some(line) = finding.line {
598 format!("{file}:{line}")
599 } else {
600 file.clone()
601 }
602 } else {
603 format!("finding-{consolidated}")
604 };
605
606 knowledge.remember("finding", &key_text, &finding.summary, &session.id, 0.7);
607 consolidated += 1;
608 }
609
610 for decision in &session.decisions {
611 let key_text = decision
612 .summary
613 .chars()
614 .take(50)
615 .collect::<String>()
616 .replace(' ', "-")
617 .to_lowercase();
618
619 knowledge.remember("decision", &key_text, &decision.summary, &session.id, 0.85);
620 consolidated += 1;
621 }
622
623 let task_desc = session
624 .task
625 .as_ref()
626 .map_or_else(|| "(no task)".into(), |t| t.description.clone());
627
628 let summary = format!(
629 "Session {}: {} ā {} findings, {} decisions consolidated",
630 session.id,
631 task_desc,
632 session.findings.len(),
633 session.decisions.len()
634 );
635 knowledge.consolidate(&summary, vec![session.id.clone()]);
636 let _ = knowledge.run_memory_lifecycle();
637
638 match knowledge.save() {
639 Ok(()) => format!(
640 "Consolidated {consolidated} items from session {} into project knowledge.\n\
641 Facts: {}, Patterns: {}, History: {}",
642 session.id,
643 knowledge.facts.len(),
644 knowledge.patterns.len(),
645 knowledge.history.len()
646 ),
647 Err(e) => format!("Consolidation done but save failed: {e}"),
648 }
649}
650
651fn handle_timeline(project_root: &str, category: Option<&str>) -> String {
652 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
653 return "No knowledge stored yet.".to_string();
654 };
655
656 let Some(cat) = category else {
657 return "Error: category is required for timeline".to_string();
658 };
659
660 let facts = knowledge.timeline(cat);
661 if facts.is_empty() {
662 return format!("No history for category '{cat}'.");
663 }
664
665 let mut ordered: Vec<&crate::core::knowledge::KnowledgeFact> = facts;
666 ordered.sort_by(|a, b| {
667 let a_start = a.valid_from.unwrap_or(a.created_at);
668 let b_start = b.valid_from.unwrap_or(b.created_at);
669 a_start
670 .cmp(&b_start)
671 .then_with(|| a.last_confirmed.cmp(&b.last_confirmed))
672 .then_with(|| a.key.cmp(&b.key))
673 .then_with(|| a.value.cmp(&b.value))
674 });
675
676 let total = ordered.len();
677 let limit = crate::core::budgets::KNOWLEDGE_TIMELINE_LIMIT;
678 if ordered.len() > limit {
679 ordered = ordered[ordered.len() - limit..].to_vec();
680 }
681
682 let mut out = format!(
683 "Timeline [{cat}] (showing {}/{} entries):\n",
684 ordered.len(),
685 total
686 );
687 for f in &ordered {
688 let status = if f.is_current() {
689 "CURRENT"
690 } else {
691 "archived"
692 };
693 let valid_range = match (f.valid_from, f.valid_until) {
694 (Some(from), Some(until)) => format!(
695 "{} ā {}",
696 from.format("%Y-%m-%d %H:%M"),
697 until.format("%Y-%m-%d %H:%M")
698 ),
699 (Some(from), None) => format!("{} ā now", from.format("%Y-%m-%d %H:%M")),
700 _ => "unknown".to_string(),
701 };
702 out.push_str(&format!(
703 " {} = {} [{status}] ({valid_range}) conf={:.0}% x{}\n",
704 f.key,
705 f.value,
706 f.confidence * 100.0,
707 f.confirmation_count
708 ));
709 }
710 out
711}
712
713fn handle_rooms(project_root: &str) -> String {
714 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
715 return "No knowledge stored yet.".to_string();
716 };
717
718 let rooms = knowledge.list_rooms();
719 if rooms.is_empty() {
720 return "No knowledge rooms yet. Use ctx_knowledge(action=\"remember\", category=\"...\") to create rooms.".to_string();
721 }
722
723 let mut rooms = rooms;
724 rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
725 let total = rooms.len();
726 rooms.truncate(crate::core::budgets::KNOWLEDGE_ROOMS_LIMIT);
727
728 let mut out = format!(
729 "Knowledge Rooms (showing {}/{} rooms, project: {}):\n",
730 rooms.len(),
731 total,
732 short_hash(&knowledge.project_hash)
733 );
734 for (cat, count) in &rooms {
735 out.push_str(&format!(" [{cat}] {count} fact(s)\n"));
736 }
737 out
738}
739
740fn handle_search(query: Option<&str>) -> String {
741 let Some(q) = query else {
742 return "Error: query is required for search".to_string();
743 };
744
745 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
746 return "Cannot determine data directory.".to_string();
747 };
748
749 let sessions_dir = data_dir.join("sessions");
750
751 if !sessions_dir.exists() {
752 return "No sessions found.".to_string();
753 }
754
755 let knowledge_dir = data_dir.join("knowledge");
756
757 let q_lower = q.to_lowercase();
758 let terms: Vec<&str> = q_lower.split_whitespace().collect();
759 let mut results = Vec::new();
760
761 if knowledge_dir.exists() {
762 if let Ok(entries) = std::fs::read_dir(&knowledge_dir) {
763 for entry in entries.flatten() {
764 let knowledge_file = entry.path().join("knowledge.json");
765 if let Ok(content) = std::fs::read_to_string(&knowledge_file) {
766 if let Ok(knowledge) = serde_json::from_str::<ProjectKnowledge>(&content) {
767 for fact in &knowledge.facts {
768 let searchable = format!(
769 "{} {} {}",
770 fact.category.to_lowercase(),
771 fact.key.to_lowercase(),
772 fact.value.to_lowercase()
773 );
774 let match_count =
775 terms.iter().filter(|t| searchable.contains(**t)).count();
776 if match_count > 0 {
777 results.push((
778 knowledge.project_root.clone(),
779 fact.category.clone(),
780 fact.key.clone(),
781 fact.value.clone(),
782 fact.confidence,
783 match_count as f32 / terms.len() as f32,
784 ));
785 }
786 }
787 }
788 }
789 }
790 }
791 }
792
793 if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
794 for entry in entries.flatten() {
795 let path = entry.path();
796 if path.extension().and_then(|e| e.to_str()) != Some("json") {
797 continue;
798 }
799 if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
800 continue;
801 }
802 if let Ok(json) = std::fs::read_to_string(&path) {
803 if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
804 for finding in &session.findings {
805 let searchable = finding.summary.to_lowercase();
806 let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
807 if match_count > 0 {
808 let project = session
809 .project_root
810 .clone()
811 .unwrap_or_else(|| "unknown".to_string());
812 results.push((
813 project,
814 "session-finding".to_string(),
815 session.id.clone(),
816 finding.summary.clone(),
817 0.6,
818 match_count as f32 / terms.len() as f32,
819 ));
820 }
821 }
822 for decision in &session.decisions {
823 let searchable = decision.summary.to_lowercase();
824 let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
825 if match_count > 0 {
826 let project = session
827 .project_root
828 .clone()
829 .unwrap_or_else(|| "unknown".to_string());
830 results.push((
831 project,
832 "session-decision".to_string(),
833 session.id.clone(),
834 decision.summary.clone(),
835 0.7,
836 match_count as f32 / terms.len() as f32,
837 ));
838 }
839 }
840 }
841 }
842 }
843 }
844
845 if results.is_empty() {
846 return format!("No results found for '{q}' across all sessions and projects.");
847 }
848
849 results.sort_by(|a, b| {
850 b.5.partial_cmp(&a.5)
851 .unwrap_or(std::cmp::Ordering::Equal)
852 .then_with(|| b.4.partial_cmp(&a.4).unwrap_or(std::cmp::Ordering::Equal))
853 .then_with(|| a.0.cmp(&b.0))
854 .then_with(|| a.1.cmp(&b.1))
855 .then_with(|| a.2.cmp(&b.2))
856 .then_with(|| a.3.cmp(&b.3))
857 });
858 results.truncate(crate::core::budgets::KNOWLEDGE_CROSS_PROJECT_SEARCH_LIMIT);
859
860 let mut out = format!("Cross-session search '{q}' ({} results):\n", results.len());
861 for (project, cat, key, value, conf, _relevance) in &results {
862 let project_short = short_path(project);
863 out.push_str(&format!(
864 " [{cat}/{key}] {value} (project: {project_short}, conf: {:.0}%)\n",
865 conf * 100.0
866 ));
867 }
868 out
869}
870
871fn handle_wakeup(project_root: &str) -> String {
872 let Some(knowledge) = ProjectKnowledge::load(project_root) else {
873 return "No knowledge for wake-up briefing.".to_string();
874 };
875 let aaak = knowledge.format_aaak();
876 if aaak.is_empty() {
877 return "No knowledge yet. Start using ctx_knowledge(action=\"remember\") to build project memory.".to_string();
878 }
879 format!("WAKE-UP BRIEFING:\n{aaak}")
880}
881
882#[cfg(feature = "embeddings")]
883struct SemanticHit {
884 category: String,
885 key: String,
886 value: String,
887 score: f32,
888 semantic_score: f32,
889 confidence_score: f32,
890}
891
892#[cfg(feature = "embeddings")]
893fn apply_retrieval_signals_from_hits(knowledge: &mut ProjectKnowledge, hits: &[SemanticHit]) {
894 let now = Utc::now();
895 for s in hits {
896 for f in &mut knowledge.facts {
897 if !f.is_current() {
898 continue;
899 }
900 if f.category == s.category && f.key == s.key {
901 f.retrieval_count = f.retrieval_count.saturating_add(1);
902 f.last_retrieved = Some(now);
903 break;
904 }
905 }
906 }
907}
908
909#[cfg(feature = "embeddings")]
910fn format_semantic_facts(query: &str, hits: &[SemanticHit]) -> String {
911 if hits.is_empty() {
912 return format!("No facts matching '{query}'.");
913 }
914 let mut out = format!("Semantic recall '{query}' (showing {}):\n", hits.len());
915 for s in hits {
916 out.push_str(&format!(
917 " [{}/{}]: {} (score: {:.0}%, sem: {:.0}%, conf: {:.0}%)\n",
918 s.category,
919 s.key,
920 s.value,
921 s.score * 100.0,
922 s.semantic_score * 100.0,
923 s.confidence_score * 100.0
924 ));
925 }
926 out
927}
928
929fn format_facts(
930 facts: &[crate::core::knowledge::KnowledgeFact],
931 total: usize,
932 category: Option<&str>,
933) -> String {
934 let mut facts: Vec<&crate::core::knowledge::KnowledgeFact> = facts.iter().collect();
935 facts.sort_by(|a, b| sort_fact_for_output(a, b));
936
937 let mut out = String::new();
938 if let Some(cat) = category {
939 out.push_str(&format!(
940 "Facts [{cat}] (showing {}/{}):\n",
941 facts.len(),
942 total
943 ));
944 } else {
945 out.push_str(&format!(
946 "Matching facts (showing {}/{}):\n",
947 facts.len(),
948 total
949 ));
950 }
951 for f in facts {
952 let temporal = if f.is_current() { "" } else { " [archived]" };
953 out.push_str(&format!(
954 " [{}/{}]: {} (confidence: {:.0}%, confirmed: {} x{}){temporal}\n",
955 f.category,
956 f.key,
957 f.value,
958 f.confidence * 100.0,
959 f.last_confirmed.format("%Y-%m-%d"),
960 f.confirmation_count
961 ));
962 }
963 out
964}
965
966fn short_path(path: &str) -> String {
967 let parts: Vec<&str> = path.split('/').collect();
968 if parts.len() <= 2 {
969 return path.to_string();
970 }
971 parts[parts.len() - 2..].join("/")
972}
973
974fn short_hash(hash: &str) -> &str {
975 if hash.len() > 8 {
976 &hash[..8]
977 } else {
978 hash
979 }
980}
981
982fn sort_fact_for_output(
983 a: &crate::core::knowledge::KnowledgeFact,
984 b: &crate::core::knowledge::KnowledgeFact,
985) -> std::cmp::Ordering {
986 salience_score(b)
987 .cmp(&salience_score(a))
988 .then_with(|| {
989 b.confidence
990 .partial_cmp(&a.confidence)
991 .unwrap_or(std::cmp::Ordering::Equal)
992 })
993 .then_with(|| b.confirmation_count.cmp(&a.confirmation_count))
994 .then_with(|| b.retrieval_count.cmp(&a.retrieval_count))
995 .then_with(|| b.last_retrieved.cmp(&a.last_retrieved))
996 .then_with(|| b.last_confirmed.cmp(&a.last_confirmed))
997 .then_with(|| a.category.cmp(&b.category))
998 .then_with(|| a.key.cmp(&b.key))
999 .then_with(|| a.value.cmp(&b.value))
1000}
1001
1002fn salience_score(f: &crate::core::knowledge::KnowledgeFact) -> u32 {
1003 let cat = f.category.to_lowercase();
1004 let base: u32 = match cat.as_str() {
1005 "decision" => 70,
1006 "gotcha" => 75,
1007 "architecture" | "arch" => 60,
1008 "security" => 65,
1009 "testing" | "tests" | "deployment" | "deploy" => 55,
1010 "conventions" | "convention" => 45,
1011 "finding" => 40,
1012 _ => 30,
1013 };
1014
1015 let confidence_bonus = (f.confidence.clamp(0.0, 1.0) * 30.0) as u32;
1016 let confirmation_bonus = f.confirmation_count.min(15);
1017 let retrieval_bonus = ((f.retrieval_count as f32).ln_1p() * 8.0).min(20.0) as u32;
1018 let recency_bonus = f.last_retrieved.map_or(0u32, |t| {
1019 let days = chrono::Utc::now().signed_duration_since(t).num_days();
1020 if days <= 7 {
1021 10u32
1022 } else if days <= 30 {
1023 5u32
1024 } else {
1025 0u32
1026 }
1027 });
1028
1029 base + confidence_bonus + confirmation_bonus + retrieval_bonus + recency_bonus
1030}