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