Skip to main content

lean_ctx/tools/
ctx_knowledge.rs

1use crate::core::knowledge::ProjectKnowledge;
2use crate::core::session::SessionState;
3
4#[allow(clippy::too_many_arguments)]
5pub fn handle(
6    project_root: &str,
7    action: &str,
8    category: Option<&str>,
9    key: Option<&str>,
10    value: Option<&str>,
11    query: Option<&str>,
12    session_id: &str,
13    pattern_type: Option<&str>,
14    examples: Option<Vec<String>>,
15    confidence: Option<f32>,
16) -> String {
17    match action {
18        "remember" => handle_remember(project_root, category, key, value, session_id, confidence),
19        "recall" => handle_recall(project_root, category, query),
20        "pattern" => handle_pattern(project_root, pattern_type, value, examples, session_id),
21        "status" => handle_status(project_root),
22        "remove" => handle_remove(project_root, category, key),
23        "export" => handle_export(project_root),
24        "consolidate" => handle_consolidate(project_root),
25        "timeline" => handle_timeline(project_root, category),
26        "rooms" => handle_rooms(project_root),
27        "search" => handle_search(query),
28        "wakeup" => handle_wakeup(project_root),
29        _ => format!(
30            "Unknown action: {action}. Use: remember, recall, pattern, status, remove, export, consolidate, timeline, rooms, search, wakeup"
31        ),
32    }
33}
34
35fn handle_remember(
36    project_root: &str,
37    category: Option<&str>,
38    key: Option<&str>,
39    value: Option<&str>,
40    session_id: &str,
41    confidence: Option<f32>,
42) -> String {
43    let cat = match category {
44        Some(c) => c,
45        None => return "Error: category is required for remember".to_string(),
46    };
47    let k = match key {
48        Some(k) => k,
49        None => return "Error: key is required for remember".to_string(),
50    };
51    let v = match value {
52        Some(v) => v,
53        None => return "Error: value is required for remember".to_string(),
54    };
55    let conf = confidence.unwrap_or(0.8);
56    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
57    let contradiction = knowledge.remember(cat, k, v, session_id, conf);
58
59    let mut result = format!(
60        "Remembered [{cat}] {k}: {v} (confidence: {:.0}%)",
61        conf * 100.0
62    );
63
64    if let Some(c) = contradiction {
65        result.push_str(&format!("\n⚠ CONTRADICTION DETECTED: {}", c.resolution));
66    }
67
68    match knowledge.save() {
69        Ok(()) => result,
70        Err(e) => format!("{result}\n(save failed: {e})"),
71    }
72}
73
74fn handle_recall(project_root: &str, category: Option<&str>, query: Option<&str>) -> String {
75    let knowledge = match ProjectKnowledge::load(project_root) {
76        Some(k) => k,
77        None => return "No knowledge stored for this project yet.".to_string(),
78    };
79
80    if let Some(cat) = category {
81        let facts = knowledge.recall_by_category(cat);
82        if facts.is_empty() {
83            return format!("No facts in category '{cat}'.");
84        }
85        return format_facts(&facts, Some(cat));
86    }
87
88    if let Some(q) = query {
89        let facts = knowledge.recall(q);
90        if facts.is_empty() {
91            return format!("No facts matching '{q}'.");
92        }
93        return format_facts(&facts, None);
94    }
95
96    "Error: provide query or category for recall".to_string()
97}
98
99fn handle_pattern(
100    project_root: &str,
101    pattern_type: Option<&str>,
102    value: Option<&str>,
103    examples: Option<Vec<String>>,
104    session_id: &str,
105) -> String {
106    let pt = match pattern_type {
107        Some(p) => p,
108        None => return "Error: pattern_type is required".to_string(),
109    };
110    let desc = match value {
111        Some(v) => v,
112        None => return "Error: value (description) is required for pattern".to_string(),
113    };
114    let exs = examples.unwrap_or_default();
115    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
116    knowledge.add_pattern(pt, desc, exs, session_id);
117    match knowledge.save() {
118        Ok(()) => format!("Pattern [{pt}] added: {desc}"),
119        Err(e) => format!("Pattern added but save failed: {e}"),
120    }
121}
122
123fn handle_status(project_root: &str) -> String {
124    let knowledge = match ProjectKnowledge::load(project_root) {
125        Some(k) => k,
126        None => {
127            return "No knowledge stored for this project yet. Use ctx_knowledge(action=\"remember\") to start.".to_string();
128        }
129    };
130
131    let current_facts = knowledge.facts.iter().filter(|f| f.is_current()).count();
132    let archived_facts = knowledge.facts.len() - current_facts;
133
134    let mut out = format!(
135        "Project Knowledge: {} active facts ({} archived), {} patterns, {} history entries\n",
136        current_facts,
137        archived_facts,
138        knowledge.patterns.len(),
139        knowledge.history.len()
140    );
141    out.push_str(&format!(
142        "Last updated: {}\n",
143        knowledge.updated_at.format("%Y-%m-%d %H:%M UTC")
144    ));
145
146    let rooms = knowledge.list_rooms();
147    if !rooms.is_empty() {
148        out.push_str("Rooms: ");
149        let room_strs: Vec<String> = rooms.iter().map(|(c, n)| format!("{c}({n})")).collect();
150        out.push_str(&room_strs.join(", "));
151        out.push('\n');
152    }
153
154    out.push_str(&knowledge.format_summary());
155    out
156}
157
158fn handle_remove(project_root: &str, category: Option<&str>, key: Option<&str>) -> String {
159    let cat = match category {
160        Some(c) => c,
161        None => return "Error: category is required for remove".to_string(),
162    };
163    let k = match key {
164        Some(k) => k,
165        None => return "Error: key is required for remove".to_string(),
166    };
167    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
168    if knowledge.remove_fact(cat, k) {
169        match knowledge.save() {
170            Ok(()) => format!("Removed [{cat}] {k}"),
171            Err(e) => format!("Removed but save failed: {e}"),
172        }
173    } else {
174        format!("No fact found: [{cat}] {k}")
175    }
176}
177
178fn handle_export(project_root: &str) -> String {
179    let knowledge = match ProjectKnowledge::load(project_root) {
180        Some(k) => k,
181        None => return "No knowledge to export.".to_string(),
182    };
183    match serde_json::to_string_pretty(&knowledge) {
184        Ok(json) => json,
185        Err(e) => format!("Export failed: {e}"),
186    }
187}
188
189fn handle_consolidate(project_root: &str) -> String {
190    let session = match SessionState::load_latest() {
191        Some(s) => s,
192        None => return "No active session to consolidate.".to_string(),
193    };
194
195    let mut knowledge = ProjectKnowledge::load_or_create(project_root);
196    let mut consolidated = 0u32;
197
198    for finding in &session.findings {
199        let key_text = if let Some(ref file) = finding.file {
200            if let Some(line) = finding.line {
201                format!("{file}:{line}")
202            } else {
203                file.clone()
204            }
205        } else {
206            format!("finding-{consolidated}")
207        };
208
209        knowledge.remember("finding", &key_text, &finding.summary, &session.id, 0.7);
210        consolidated += 1;
211    }
212
213    for decision in &session.decisions {
214        let key_text = decision
215            .summary
216            .chars()
217            .take(50)
218            .collect::<String>()
219            .replace(' ', "-")
220            .to_lowercase();
221
222        knowledge.remember("decision", &key_text, &decision.summary, &session.id, 0.85);
223        consolidated += 1;
224    }
225
226    let task_desc = session
227        .task
228        .as_ref()
229        .map(|t| t.description.clone())
230        .unwrap_or_else(|| "(no task)".into());
231
232    let summary = format!(
233        "Session {}: {} — {} findings, {} decisions consolidated",
234        session.id,
235        task_desc,
236        session.findings.len(),
237        session.decisions.len()
238    );
239    knowledge.consolidate(&summary, vec![session.id.clone()]);
240
241    match knowledge.save() {
242        Ok(()) => format!(
243            "Consolidated {consolidated} items from session {} into project knowledge.\n\
244             Facts: {}, Patterns: {}, History: {}",
245            session.id,
246            knowledge.facts.len(),
247            knowledge.patterns.len(),
248            knowledge.history.len()
249        ),
250        Err(e) => format!("Consolidation done but save failed: {e}"),
251    }
252}
253
254fn handle_timeline(project_root: &str, category: Option<&str>) -> String {
255    let knowledge = match ProjectKnowledge::load(project_root) {
256        Some(k) => k,
257        None => return "No knowledge stored yet.".to_string(),
258    };
259
260    let cat = match category {
261        Some(c) => c,
262        None => return "Error: category is required for timeline".to_string(),
263    };
264
265    let facts = knowledge.timeline(cat);
266    if facts.is_empty() {
267        return format!("No history for category '{cat}'.");
268    }
269
270    let mut out = format!("Timeline [{cat}] ({} entries):\n", facts.len());
271    for f in &facts {
272        let status = if f.is_current() {
273            "CURRENT"
274        } else {
275            "archived"
276        };
277        let valid_range = match (f.valid_from, f.valid_until) {
278            (Some(from), Some(until)) => format!(
279                "{} → {}",
280                from.format("%Y-%m-%d %H:%M"),
281                until.format("%Y-%m-%d %H:%M")
282            ),
283            (Some(from), None) => format!("{} → now", from.format("%Y-%m-%d %H:%M")),
284            _ => "unknown".to_string(),
285        };
286        out.push_str(&format!(
287            "  {} = {} [{status}] ({valid_range}) conf={:.0}% x{}\n",
288            f.key,
289            f.value,
290            f.confidence * 100.0,
291            f.confirmation_count
292        ));
293    }
294    out
295}
296
297fn handle_rooms(project_root: &str) -> String {
298    let knowledge = match ProjectKnowledge::load(project_root) {
299        Some(k) => k,
300        None => return "No knowledge stored yet.".to_string(),
301    };
302
303    let rooms = knowledge.list_rooms();
304    if rooms.is_empty() {
305        return "No knowledge rooms yet. Use ctx_knowledge(action=\"remember\", category=\"...\") to create rooms.".to_string();
306    }
307
308    let mut out = format!(
309        "Knowledge Rooms ({} rooms, project: {}):\n",
310        rooms.len(),
311        short_hash(&knowledge.project_hash)
312    );
313    for (cat, count) in &rooms {
314        out.push_str(&format!("  [{cat}] {count} fact(s)\n"));
315    }
316    out
317}
318
319fn handle_search(query: Option<&str>) -> String {
320    let q = match query {
321        Some(q) => q,
322        None => return "Error: query is required for search".to_string(),
323    };
324
325    let sessions_dir = match dirs::home_dir() {
326        Some(h) => h.join(".lean-ctx").join("sessions"),
327        None => return "Cannot determine home directory.".to_string(),
328    };
329
330    if !sessions_dir.exists() {
331        return "No sessions found.".to_string();
332    }
333
334    let knowledge_dir = match dirs::home_dir() {
335        Some(h) => h.join(".lean-ctx").join("knowledge"),
336        None => return "Cannot determine home directory.".to_string(),
337    };
338
339    let q_lower = q.to_lowercase();
340    let terms: Vec<&str> = q_lower.split_whitespace().collect();
341    let mut results = Vec::new();
342
343    if knowledge_dir.exists() {
344        if let Ok(entries) = std::fs::read_dir(&knowledge_dir) {
345            for entry in entries.flatten() {
346                let knowledge_file = entry.path().join("knowledge.json");
347                if let Ok(content) = std::fs::read_to_string(&knowledge_file) {
348                    if let Ok(knowledge) = serde_json::from_str::<ProjectKnowledge>(&content) {
349                        for fact in &knowledge.facts {
350                            let searchable = format!(
351                                "{} {} {}",
352                                fact.category.to_lowercase(),
353                                fact.key.to_lowercase(),
354                                fact.value.to_lowercase()
355                            );
356                            let match_count =
357                                terms.iter().filter(|t| searchable.contains(**t)).count();
358                            if match_count > 0 {
359                                results.push((
360                                    knowledge.project_root.clone(),
361                                    fact.category.clone(),
362                                    fact.key.clone(),
363                                    fact.value.clone(),
364                                    fact.confidence,
365                                    match_count as f32 / terms.len() as f32,
366                                ));
367                            }
368                        }
369                    }
370                }
371            }
372        }
373    }
374
375    if let Ok(entries) = std::fs::read_dir(&sessions_dir) {
376        for entry in entries.flatten() {
377            let path = entry.path();
378            if path.extension().and_then(|e| e.to_str()) != Some("json") {
379                continue;
380            }
381            if path.file_name().and_then(|n| n.to_str()) == Some("latest.json") {
382                continue;
383            }
384            if let Ok(json) = std::fs::read_to_string(&path) {
385                if let Ok(session) = serde_json::from_str::<SessionState>(&json) {
386                    for finding in &session.findings {
387                        let searchable = finding.summary.to_lowercase();
388                        let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
389                        if match_count > 0 {
390                            let project = session
391                                .project_root
392                                .clone()
393                                .unwrap_or_else(|| "unknown".to_string());
394                            results.push((
395                                project,
396                                "session-finding".to_string(),
397                                session.id.clone(),
398                                finding.summary.clone(),
399                                0.6,
400                                match_count as f32 / terms.len() as f32,
401                            ));
402                        }
403                    }
404                    for decision in &session.decisions {
405                        let searchable = decision.summary.to_lowercase();
406                        let match_count = terms.iter().filter(|t| searchable.contains(**t)).count();
407                        if match_count > 0 {
408                            let project = session
409                                .project_root
410                                .clone()
411                                .unwrap_or_else(|| "unknown".to_string());
412                            results.push((
413                                project,
414                                "session-decision".to_string(),
415                                session.id.clone(),
416                                decision.summary.clone(),
417                                0.7,
418                                match_count as f32 / terms.len() as f32,
419                            ));
420                        }
421                    }
422                }
423            }
424        }
425    }
426
427    if results.is_empty() {
428        return format!("No results found for '{q}' across all sessions and projects.");
429    }
430
431    results.sort_by(|a, b| b.5.partial_cmp(&a.5).unwrap_or(std::cmp::Ordering::Equal));
432    results.truncate(20);
433
434    let mut out = format!("Cross-session search '{q}' ({} results):\n", results.len());
435    for (project, cat, key, value, conf, _relevance) in &results {
436        let project_short = short_path(project);
437        out.push_str(&format!(
438            "  [{cat}/{key}] {value} (project: {project_short}, conf: {:.0}%)\n",
439            conf * 100.0
440        ));
441    }
442    out
443}
444
445fn handle_wakeup(project_root: &str) -> String {
446    let knowledge = match ProjectKnowledge::load(project_root) {
447        Some(k) => k,
448        None => return "No knowledge for wake-up briefing.".to_string(),
449    };
450    let aaak = knowledge.format_aaak();
451    if aaak.is_empty() {
452        return "No knowledge yet. Start using ctx_knowledge(action=\"remember\") to build project memory.".to_string();
453    }
454    format!("WAKE-UP BRIEFING:\n{aaak}")
455}
456
457fn format_facts(
458    facts: &[&crate::core::knowledge::KnowledgeFact],
459    category: Option<&str>,
460) -> String {
461    let mut out = String::new();
462    if let Some(cat) = category {
463        out.push_str(&format!("Facts [{cat}] ({}):\n", facts.len()));
464    } else {
465        out.push_str(&format!("Matching facts ({}):\n", facts.len()));
466    }
467    for f in facts {
468        let temporal = if !f.is_current() { " [archived]" } else { "" };
469        out.push_str(&format!(
470            "  [{}/{}]: {} (confidence: {:.0}%, confirmed: {} x{}){temporal}\n",
471            f.category,
472            f.key,
473            f.value,
474            f.confidence * 100.0,
475            f.last_confirmed.format("%Y-%m-%d"),
476            f.confirmation_count
477        ));
478    }
479    out
480}
481
482fn short_path(path: &str) -> String {
483    let parts: Vec<&str> = path.split('/').collect();
484    if parts.len() <= 2 {
485        return path.to_string();
486    }
487    parts[parts.len() - 2..].join("/")
488}
489
490fn short_hash(hash: &str) -> &str {
491    if hash.len() > 8 {
492        &hash[..8]
493    } else {
494        hash
495    }
496}