Skip to main content

lean_ctx_server/tools/
ctx_knowledge_relations.rs

1// SPDX-License-Identifier: Apache-2.0
2use lean_ctx::core::knowledge::ProjectKnowledge;
3use lean_ctx::core::knowledge_relations::{
4    KnowledgeEdge, KnowledgeEdgeKind, KnowledgeNodeRef, KnowledgeRelationGraph, format_mermaid,
5    parse_node_ref,
6};
7
8fn load_policy_or_error() -> Result<lean_ctx::core::memory_policy::MemoryPolicy, String> {
9    super::knowledge_shared::load_policy_or_error()
10}
11
12fn ensure_current_fact_exists(knowledge: &ProjectKnowledge, node: &KnowledgeNodeRef) -> bool {
13    knowledge
14        .facts
15        .iter()
16        .any(|f| f.is_current() && f.category == node.category && f.key == node.key)
17}
18
19fn parse_kind_or_default(value: Option<&str>) -> Result<KnowledgeEdgeKind, String> {
20    let kind_str = value.unwrap_or("related_to");
21    KnowledgeEdgeKind::parse(kind_str).ok_or_else(|| {
22        "Error: relation kind must be one of depends_on|related_to|supports|contradicts|supersedes"
23            .to_string()
24    })
25}
26
27fn parse_target_or_error(query: Option<&str>) -> Result<KnowledgeNodeRef, String> {
28    let Some(q) = query else {
29        return Err("Error: query is required and must be 'category/key'".to_string());
30    };
31    parse_node_ref(q).ok_or_else(|| "Error: query must be 'category/key'".to_string())
32}
33
34fn parse_direction(query: Option<&str>) -> &'static str {
35    match query.unwrap_or("all").trim().to_lowercase().as_str() {
36        "in" | "incoming" => "in",
37        "out" | "outgoing" => "out",
38        _ => "all",
39    }
40}
41
42fn derived_supersedes_edges(
43    knowledge: &ProjectKnowledge,
44    focus: &KnowledgeNodeRef,
45) -> Vec<KnowledgeEdge> {
46    let mut out = Vec::new();
47    let focus_id = focus.id();
48
49    for f in knowledge.facts.iter().filter(|f| f.is_current()) {
50        if f.category == focus.category && f.key == focus.key {
51            if let Some(s) = &f.supersedes
52                && let Some(to) = parse_node_ref(s)
53            {
54                if to == *focus {
55                    continue;
56                }
57                out.push(KnowledgeEdge {
58                    from: focus.clone(),
59                    to,
60                    kind: KnowledgeEdgeKind::Supersedes,
61                    created_at: f.created_at,
62                    last_seen: None,
63                    count: 0,
64                    source_session: f.source_session.clone(),
65                    strength: 0.5,
66                    decay_rate: 0.02,
67                });
68            }
69        } else if f.supersedes.as_deref() == Some(&focus_id) {
70            out.push(KnowledgeEdge {
71                from: KnowledgeNodeRef::new(&f.category, &f.key),
72                to: focus.clone(),
73                kind: KnowledgeEdgeKind::Supersedes,
74                created_at: f.created_at,
75                last_seen: None,
76                count: 0,
77                source_session: f.source_session.clone(),
78                strength: 0.5,
79                decay_rate: 0.02,
80            });
81        }
82    }
83
84    out
85}
86
87pub fn handle_relate(
88    project_root: &str,
89    category: Option<&str>,
90    key: Option<&str>,
91    value: Option<&str>,
92    query: Option<&str>,
93    session_id: &str,
94) -> String {
95    let Some(cat) = category else {
96        return "Error: category is required for relate".to_string();
97    };
98    let Some(k) = key else {
99        return "Error: key is required for relate".to_string();
100    };
101
102    let from = KnowledgeNodeRef::new(cat, k);
103    let to = match parse_target_or_error(query) {
104        Ok(n) => n,
105        Err(e) => return e,
106    };
107    let kind = match parse_kind_or_default(value) {
108        Ok(k) => k,
109        Err(e) => return e,
110    };
111
112    // An invalid memory policy still refuses the write.
113    if let Err(e) = load_policy_or_error() {
114        return e;
115    }
116
117    let knowledge = ProjectKnowledge::load_or_create(project_root);
118    if !ensure_current_fact_exists(&knowledge, &from) {
119        return format!(
120            "Error: no current fact exists for [{}] {}. Use ctx_knowledge remember first.",
121            from.category, from.key
122        );
123    }
124    if !ensure_current_fact_exists(&knowledge, &to) {
125        return format!(
126            "Error: no current fact exists for [{}] {}. Use ctx_knowledge remember first.",
127            to.category, to.key
128        );
129    }
130
131    let max_edges = lean_ctx::core::knowledge_relations::edge_bound(
132        knowledge.facts.iter().filter(|f| f.is_current()).count(),
133    );
134    let saved =
135        KnowledgeRelationGraph::mutate_locked(project_root, &knowledge.project_hash, |graph| {
136            let created = graph.upsert_edge(from.clone(), to.clone(), kind, session_id);
137            Some((created, graph.enforce_cap(max_edges)))
138        });
139
140    match saved.map(Option::unwrap_or_default) {
141        Ok((created, capped)) => {
142            let verb = if created { "added" } else { "reinforced" };
143            let mut out = format!(
144                "Relation {verb}: {} -({})-> {}",
145                from.id(),
146                kind.as_str(),
147                to.id()
148            );
149            if capped {
150                out.push_str(&format!(" (note: capped to {max_edges} edges)"));
151            }
152            out
153        }
154        Err(e) => format!(
155            "Relation not saved: {e} ({} -({})-> {}); retry once the knowledge store is free",
156            from.id(),
157            kind.as_str(),
158            to.id()
159        ),
160    }
161}
162
163pub fn handle_unrelate(
164    project_root: &str,
165    category: Option<&str>,
166    key: Option<&str>,
167    value: Option<&str>,
168    query: Option<&str>,
169) -> String {
170    let Some(cat) = category else {
171        return "Error: category is required for unrelate".to_string();
172    };
173    let Some(k) = key else {
174        return "Error: key is required for unrelate".to_string();
175    };
176
177    let from = KnowledgeNodeRef::new(cat, k);
178    let to = match parse_target_or_error(query) {
179        Ok(n) => n,
180        Err(e) => return e,
181    };
182    let kind = if let Some(v) = value {
183        match KnowledgeEdgeKind::parse(v) {
184            Some(k) => Some(k),
185            None => {
186                return "Error: relation kind must be one of depends_on|related_to|supports|contradicts|supersedes".to_string();
187            }
188        }
189    } else {
190        None
191    };
192
193    let knowledge = ProjectKnowledge::load_or_create(project_root);
194    let removed =
195        KnowledgeRelationGraph::mutate_locked(project_root, &knowledge.project_hash, |graph| {
196            let removed = graph.remove_edge(&from, &to, kind);
197            (removed > 0).then_some(removed)
198        });
199
200    match removed {
201        Ok(Some(removed)) => format!("Relation removed ({removed}): {} -> {}", from.id(), to.id()),
202        Ok(None) => format!("No matching relation found: {} -> {}", from.id(), to.id()),
203        Err(e) => format!(
204            "Relation not removed: {e} ({} -> {}); retry once the knowledge store is free",
205            from.id(),
206            to.id()
207        ),
208    }
209}
210
211pub fn handle_relations(
212    project_root: &str,
213    category: Option<&str>,
214    key: Option<&str>,
215    value: Option<&str>,
216    query: Option<&str>,
217) -> String {
218    let Some(cat) = category else {
219        return "Error: category is required for relations".to_string();
220    };
221    let Some(k) = key else {
222        return "Error: key is required for relations".to_string();
223    };
224
225    let focus = KnowledgeNodeRef::new(cat, k);
226    let dir = parse_direction(query);
227    let kind_filter = match value {
228        Some(v) => match KnowledgeEdgeKind::parse(v) {
229            Some(k) => Some(k),
230            None => {
231                return "Error: relation kind must be one of depends_on|related_to|supports|contradicts|supersedes".to_string();
232            }
233        },
234        None => None,
235    };
236
237    let policy = match load_policy_or_error() {
238        Ok(p) => p,
239        Err(e) => return e,
240    };
241    let limit = policy.knowledge.relations_limit;
242
243    let knowledge = ProjectKnowledge::load_or_create(project_root);
244    let graph = KnowledgeRelationGraph::load_or_create(&knowledge.project_hash);
245
246    let mut edges: Vec<&KnowledgeEdge> = graph
247        .incident_edges(&focus)
248        .into_iter()
249        .filter(|e| match dir {
250            "in" => e.to == focus,
251            "out" => e.from == focus,
252            _ => e.from == focus || e.to == focus,
253        })
254        .filter(|e| kind_filter.is_none_or(|k| e.kind == k))
255        .collect();
256
257    edges.sort_by(|a, b| {
258        a.kind
259            .as_str()
260            .cmp(b.kind.as_str())
261            .then_with(|| a.from.category.cmp(&b.from.category))
262            .then_with(|| a.from.key.cmp(&b.from.key))
263            .then_with(|| a.to.category.cmp(&b.to.category))
264            .then_with(|| a.to.key.cmp(&b.to.key))
265            .then_with(|| b.count.cmp(&a.count))
266            .then_with(|| b.last_seen.cmp(&a.last_seen))
267            .then_with(|| b.created_at.cmp(&a.created_at))
268    });
269
270    let derived = derived_supersedes_edges(&knowledge, &focus);
271    let mut derived_filtered: Vec<KnowledgeEdge> = derived
272        .into_iter()
273        .filter(|e| match dir {
274            "in" => e.to == focus,
275            "out" => e.from == focus,
276            _ => e.from == focus || e.to == focus,
277        })
278        .filter(|e| kind_filter.is_none_or(|k| e.kind == k))
279        .collect();
280    derived_filtered.sort_by(|a, b| {
281        a.kind
282            .as_str()
283            .cmp(b.kind.as_str())
284            .then_with(|| a.from.category.cmp(&b.from.category))
285            .then_with(|| a.from.key.cmp(&b.from.key))
286            .then_with(|| a.to.category.cmp(&b.to.category))
287            .then_with(|| a.to.key.cmp(&b.to.key))
288    });
289
290    let mut seen = std::collections::HashSet::<(String, String, KnowledgeEdgeKind)>::new();
291    for e in &edges {
292        let _ = seen.insert((e.from.id(), e.to.id(), e.kind));
293    }
294    let derived_filtered: Vec<_> = derived_filtered
295        .into_iter()
296        .filter(|e| seen.insert((e.from.id(), e.to.id(), e.kind)))
297        .collect();
298
299    if edges.is_empty() && derived_filtered.is_empty() {
300        return format!("No relations for {}.", focus.id());
301    }
302
303    let mut out = Vec::new();
304    let total = edges.len() + derived_filtered.len();
305    let mut shown = 0usize;
306    let mut remaining = limit;
307
308    for e in edges.iter().take(remaining) {
309        let arrow = if e.from == focus { "->" } else { "<-" };
310        let other = if e.from == focus { &e.to } else { &e.from };
311        out.push(format!(
312            "  {arrow} {} {} (count={}, last_seen={})",
313            e.kind.as_str(),
314            other.id(),
315            e.count.max(1),
316            e.last_seen
317                .map_or_else(|| "n/a".to_string(), |t| t.format("%Y-%m-%d").to_string(),)
318        ));
319        shown += 1;
320        remaining = remaining.saturating_sub(1);
321    }
322
323    for e in derived_filtered.into_iter().take(remaining) {
324        let arrow = if e.from == focus { "->" } else { "<-" };
325        let other = if e.from == focus { &e.to } else { &e.from };
326        out.push(format!(
327            "  {arrow} {} {} (derived)",
328            e.kind.as_str(),
329            other.id()
330        ));
331        shown += 1;
332        remaining = remaining.saturating_sub(1);
333    }
334
335    out.insert(
336        0,
337        format!(
338            "Relations for {} (dir={dir}, showing {shown}/{total}):",
339            focus.id()
340        ),
341    );
342    if total > shown {
343        out.push(format!("  … +{} more", total - shown));
344    }
345    out.join("\n")
346}
347
348pub fn handle_relations_diagram(
349    project_root: &str,
350    category: Option<&str>,
351    key: Option<&str>,
352    value: Option<&str>,
353    query: Option<&str>,
354) -> String {
355    let Some(cat) = category else {
356        return "Error: category is required for relations_diagram".to_string();
357    };
358    let Some(k) = key else {
359        return "Error: key is required for relations_diagram".to_string();
360    };
361
362    let focus = KnowledgeNodeRef::new(cat, k);
363    let dir = parse_direction(query);
364    let kind_filter = match value {
365        Some(v) => match KnowledgeEdgeKind::parse(v) {
366            Some(k) => Some(k),
367            None => {
368                return "Error: relation kind must be one of depends_on|related_to|supports|contradicts|supersedes".to_string();
369            }
370        },
371        None => None,
372    };
373
374    let policy = match load_policy_or_error() {
375        Ok(p) => p,
376        Err(e) => return e,
377    };
378    let limit = policy.knowledge.relations_limit;
379
380    let knowledge = ProjectKnowledge::load_or_create(project_root);
381    let graph = KnowledgeRelationGraph::load_or_create(&knowledge.project_hash);
382
383    let mut edges: Vec<KnowledgeEdge> = graph
384        .incident_edges(&focus)
385        .into_iter()
386        .filter(|e| match dir {
387            "in" => e.to == focus,
388            "out" => e.from == focus,
389            _ => e.from == focus || e.to == focus,
390        })
391        .filter(|e| kind_filter.is_none_or(|k| e.kind == k))
392        .cloned()
393        .collect();
394
395    let derived = derived_supersedes_edges(&knowledge, &focus);
396    let derived_filtered = derived
397        .into_iter()
398        .filter(|e| match dir {
399            "in" => e.to == focus,
400            "out" => e.from == focus,
401            _ => e.from == focus || e.to == focus,
402        })
403        .filter(|e| kind_filter.is_none_or(|k| e.kind == k))
404        .collect::<Vec<_>>();
405
406    let mut seen = std::collections::HashSet::<(String, String, KnowledgeEdgeKind)>::new();
407    edges.retain(|e| seen.insert((e.from.id(), e.to.id(), e.kind)));
408    for e in derived_filtered {
409        if seen.insert((e.from.id(), e.to.id(), e.kind)) {
410            edges.push(e);
411        }
412    }
413
414    edges.sort_by(|a, b| {
415        a.kind
416            .as_str()
417            .cmp(b.kind.as_str())
418            .then_with(|| a.from.category.cmp(&b.from.category))
419            .then_with(|| a.from.key.cmp(&b.from.key))
420            .then_with(|| a.to.category.cmp(&b.to.category))
421            .then_with(|| a.to.key.cmp(&b.to.key))
422    });
423
424    let truncated = edges.len() > limit;
425    if truncated {
426        edges.truncate(limit);
427    }
428
429    let mut out = format_mermaid(&edges);
430    if truncated {
431        out = format!("%% truncated to {limit} edges\n{out}");
432    }
433    out
434}