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