Skip to main content

lean_ctx/core/
knowledge_bridge.rs

1//! Cross-Agent Knowledge Bridge — controlled sharing of high-confidence facts between agents.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6
7use crate::core::knowledge::{KnowledgeArchetype, KnowledgeFact};
8use crate::core::memory_boundary::FactPrivacy;
9
10const PUBLISHABLE_ARCHETYPES: &[KnowledgeArchetype] = &[
11    KnowledgeArchetype::Architecture,
12    KnowledgeArchetype::Convention,
13    KnowledgeArchetype::Decision,
14    KnowledgeArchetype::Dependency,
15    KnowledgeArchetype::Gotcha,
16];
17
18const MIN_PUBLISH_CONFIDENCE: f32 = 0.8;
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct BridgeEntry {
22    pub fact_key: String,
23    pub fact_category: String,
24    pub fact_value: String,
25    pub source_agent: String,
26    pub published_at: DateTime<Utc>,
27    pub archetype: KnowledgeArchetype,
28    pub confidence: f32,
29    pub provenance: String,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct KnowledgeBridge {
34    pub project_hash: String,
35    pub shared_facts: Vec<BridgeEntry>,
36    pub updated_at: DateTime<Utc>,
37}
38
39impl KnowledgeBridge {
40    pub fn new(project_hash: &str) -> Self {
41        Self {
42            project_hash: project_hash.to_string(),
43            shared_facts: Vec::new(),
44            updated_at: Utc::now(),
45        }
46    }
47
48    pub fn path(project_hash: &str) -> Result<PathBuf, String> {
49        Ok(crate::core::data_dir::lean_ctx_data_dir()?
50            .join("knowledge")
51            .join(project_hash)
52            .join("bridge.json"))
53    }
54
55    pub fn load(project_hash: &str) -> Option<Self> {
56        let path = Self::path(project_hash).ok()?;
57        let content = std::fs::read_to_string(&path).ok()?;
58        serde_json::from_str::<Self>(&content).ok()
59    }
60
61    pub fn load_or_create(project_hash: &str) -> Self {
62        Self::load(project_hash).unwrap_or_else(|| Self::new(project_hash))
63    }
64
65    pub fn save(&mut self) -> Result<(), String> {
66        self.updated_at = Utc::now();
67        let path = Self::path(&self.project_hash)?;
68        if let Some(parent) = path.parent() {
69            std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
70        }
71        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
72        crate::config_io::write_atomic(&path, &json)
73    }
74
75    /// Publish eligible facts from an agent's knowledge store.
76    /// Only publishes facts with sufficient confidence, a publishable archetype,
77    /// and that haven't already been published by this agent.
78    pub fn publish(&mut self, agent_id: &str, facts: &[KnowledgeFact]) -> u32 {
79        let mut count = 0u32;
80        for fact in facts {
81            if !fact.is_current() {
82                continue;
83            }
84            if fact.confidence < MIN_PUBLISH_CONFIDENCE {
85                continue;
86            }
87            if !PUBLISHABLE_ARCHETYPES.contains(&fact.archetype) {
88                continue;
89            }
90            let already_published = self.shared_facts.iter().any(|e| {
91                e.fact_key == fact.key
92                    && e.fact_category == fact.category
93                    && e.source_agent == agent_id
94            });
95            if already_published {
96                continue;
97            }
98            self.shared_facts.push(BridgeEntry {
99                fact_key: fact.key.clone(),
100                fact_category: fact.category.clone(),
101                fact_value: fact.value.clone(),
102                source_agent: agent_id.to_string(),
103                published_at: Utc::now(),
104                archetype: fact.archetype.clone(),
105                confidence: fact.confidence,
106                provenance: fact.source_session.clone(),
107            });
108            count += 1;
109        }
110        count
111    }
112
113    /// Pull facts from the bridge that were published by other agents.
114    pub fn pull(&self, requesting_agent: &str) -> Vec<BridgeEntry> {
115        self.shared_facts
116            .iter()
117            .filter(|e| e.source_agent != requesting_agent)
118            .cloned()
119            .collect()
120    }
121
122    /// Convert a [`BridgeEntry`] into a [`KnowledgeFact`] for import.
123    /// Applies a 10% trust penalty to imported confidence.
124    pub fn entry_to_fact(entry: &BridgeEntry) -> KnowledgeFact {
125        let now = Utc::now();
126        KnowledgeFact {
127            category: entry.fact_category.clone(),
128            key: entry.fact_key.clone(),
129            value: entry.fact_value.clone(),
130            source_session: entry.provenance.clone(),
131            confidence: entry.confidence * 0.9,
132            created_at: now,
133            last_confirmed: now,
134            retrieval_count: 0,
135            last_retrieved: None,
136            valid_from: Some(now),
137            valid_until: None,
138            supersedes: None,
139            confirmation_count: 1,
140            feedback_up: 0,
141            feedback_down: 0,
142            last_feedback: None,
143            privacy: FactPrivacy::default(),
144            sensitivity: crate::core::sensitivity::classify_content(&entry.fact_value),
145            imported_from: Some(format!("bridge:{}", entry.source_agent)),
146            archetype: entry.archetype.clone(),
147            fidelity: None,
148            revision_count: 0,
149        }
150    }
151
152    /// Remove entries older than `max_age_days` or below `min_confidence`.
153    pub fn cleanup(&mut self, max_age_days: i64, min_confidence: f32) -> usize {
154        let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
155        let before = self.shared_facts.len();
156        self.shared_facts
157            .retain(|e| e.published_at >= cutoff && e.confidence >= min_confidence);
158        before - self.shared_facts.len()
159    }
160
161    pub fn entries_for_agent(&self, agent_id: &str) -> Vec<&BridgeEntry> {
162        self.shared_facts
163            .iter()
164            .filter(|e| e.source_agent == agent_id)
165            .collect()
166    }
167
168    pub fn summary(&self) -> String {
169        if self.shared_facts.is_empty() {
170            return format!(
171                "Knowledge Bridge [{}]: empty",
172                short_hash(&self.project_hash)
173            );
174        }
175
176        let mut agents: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
177        for entry in &self.shared_facts {
178            *agents.entry(&entry.source_agent).or_default() += 1;
179        }
180
181        let mut out = format!(
182            "Knowledge Bridge [{}]: {} shared facts from {} agent(s)\n",
183            short_hash(&self.project_hash),
184            self.shared_facts.len(),
185            agents.len(),
186        );
187        let mut sorted_agents: Vec<_> = agents.into_iter().collect();
188        sorted_agents.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
189        for (agent, count) in &sorted_agents {
190            out.push_str(&format!("  {agent}: {count} fact(s)\n"));
191        }
192        out.push_str(&format!(
193            "Last updated: {}",
194            self.updated_at.format("%Y-%m-%d %H:%M UTC")
195        ));
196        out
197    }
198}
199
200fn short_hash(hash: &str) -> &str {
201    if hash.len() > 8 {
202        &hash[..8]
203    } else {
204        hash
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::core::knowledge::KnowledgeFact;
212    use crate::core::memory_boundary::FactPrivacy;
213
214    fn make_fact(
215        cat: &str,
216        key: &str,
217        val: &str,
218        confidence: f32,
219        archetype: KnowledgeArchetype,
220    ) -> KnowledgeFact {
221        KnowledgeFact {
222            category: cat.into(),
223            key: key.into(),
224            value: val.into(),
225            source_session: "test-session".into(),
226            confidence,
227            created_at: Utc::now(),
228            last_confirmed: Utc::now(),
229            retrieval_count: 0,
230            last_retrieved: None,
231            valid_from: None,
232            valid_until: None,
233            supersedes: None,
234            confirmation_count: 1,
235            feedback_up: 0,
236            feedback_down: 0,
237            last_feedback: None,
238            privacy: FactPrivacy::default(),
239            sensitivity: crate::core::sensitivity::SensitivityLevel::default(),
240            imported_from: None,
241            archetype,
242            fidelity: None,
243            revision_count: 0,
244        }
245    }
246
247    #[test]
248    fn publish_only_eligible_facts() {
249        let mut bridge = KnowledgeBridge::new("test-hash");
250        let facts = vec![
251            make_fact(
252                "arch",
253                "db",
254                "PostgreSQL",
255                0.9,
256                KnowledgeArchetype::Architecture,
257            ),
258            make_fact("random", "x", "low-conf", 0.3, KnowledgeArchetype::Fact),
259            make_fact(
260                "gotcha",
261                "trap",
262                "watch out",
263                0.85,
264                KnowledgeArchetype::Gotcha,
265            ),
266            make_fact(
267                "pref",
268                "editor",
269                "vim",
270                0.95,
271                KnowledgeArchetype::Preference,
272            ),
273        ];
274        let count = bridge.publish("agent-1", &facts);
275        assert_eq!(count, 2);
276        assert_eq!(bridge.shared_facts.len(), 2);
277    }
278
279    #[test]
280    fn pull_excludes_own_facts() {
281        let mut bridge = KnowledgeBridge::new("test-hash");
282        let facts = vec![make_fact(
283            "arch",
284            "db",
285            "PostgreSQL",
286            0.9,
287            KnowledgeArchetype::Architecture,
288        )];
289        bridge.publish("agent-1", &facts);
290
291        let pulled = bridge.pull("agent-1");
292        assert!(pulled.is_empty(), "Should not pull own facts");
293
294        let pulled = bridge.pull("agent-2");
295        assert_eq!(pulled.len(), 1);
296    }
297
298    #[test]
299    fn entry_to_fact_preserves_provenance() {
300        let entry = BridgeEntry {
301            fact_key: "db".into(),
302            fact_category: "arch".into(),
303            fact_value: "PostgreSQL".into(),
304            source_agent: "agent-1".into(),
305            published_at: Utc::now(),
306            archetype: KnowledgeArchetype::Architecture,
307            confidence: 0.9,
308            provenance: "session-abc".into(),
309        };
310        let fact = KnowledgeBridge::entry_to_fact(&entry);
311        assert_eq!(fact.imported_from, Some("bridge:agent-1".into()));
312        assert!(fact.confidence < 0.9);
313        assert_eq!(fact.archetype, KnowledgeArchetype::Architecture);
314    }
315
316    #[test]
317    fn no_duplicate_publish() {
318        let mut bridge = KnowledgeBridge::new("test-hash");
319        let facts = vec![make_fact(
320            "arch",
321            "db",
322            "PostgreSQL",
323            0.9,
324            KnowledgeArchetype::Architecture,
325        )];
326        bridge.publish("agent-1", &facts);
327        let second = bridge.publish("agent-1", &facts);
328        assert_eq!(second, 0, "Should not re-publish same fact");
329        assert_eq!(bridge.shared_facts.len(), 1);
330    }
331
332    #[test]
333    fn cleanup_removes_old_entries() {
334        let mut bridge = KnowledgeBridge::new("test-hash");
335        bridge.shared_facts.push(BridgeEntry {
336            fact_key: "old".into(),
337            fact_category: "arch".into(),
338            fact_value: "ancient".into(),
339            source_agent: "agent-1".into(),
340            published_at: Utc::now() - chrono::Duration::days(60),
341            archetype: KnowledgeArchetype::Architecture,
342            confidence: 0.9,
343            provenance: "old-session".into(),
344        });
345        bridge.shared_facts.push(BridgeEntry {
346            fact_key: "fresh".into(),
347            fact_category: "arch".into(),
348            fact_value: "new".into(),
349            source_agent: "agent-1".into(),
350            published_at: Utc::now(),
351            archetype: KnowledgeArchetype::Architecture,
352            confidence: 0.9,
353            provenance: "new-session".into(),
354        });
355        let removed = bridge.cleanup(30, 0.5);
356        assert_eq!(removed, 1);
357        assert_eq!(bridge.shared_facts.len(), 1);
358        assert_eq!(bridge.shared_facts[0].fact_key, "fresh");
359    }
360
361    #[test]
362    fn entries_for_agent_filters_correctly() {
363        let mut bridge = KnowledgeBridge::new("test-hash");
364        let facts_a = vec![make_fact(
365            "arch",
366            "db",
367            "PostgreSQL",
368            0.9,
369            KnowledgeArchetype::Architecture,
370        )];
371        let facts_b = vec![make_fact(
372            "gotcha",
373            "trap",
374            "watch out",
375            0.85,
376            KnowledgeArchetype::Gotcha,
377        )];
378        bridge.publish("agent-a", &facts_a);
379        bridge.publish("agent-b", &facts_b);
380
381        assert_eq!(bridge.entries_for_agent("agent-a").len(), 1);
382        assert_eq!(bridge.entries_for_agent("agent-b").len(), 1);
383        assert_eq!(bridge.entries_for_agent("agent-c").len(), 0);
384    }
385
386    #[test]
387    fn summary_format() {
388        let mut bridge = KnowledgeBridge::new("test-hash");
389        assert!(bridge.summary().contains("empty"));
390
391        let facts = vec![make_fact(
392            "arch",
393            "db",
394            "PostgreSQL",
395            0.9,
396            KnowledgeArchetype::Architecture,
397        )];
398        bridge.publish("agent-1", &facts);
399        let summary = bridge.summary();
400        assert!(summary.contains("1 shared facts"));
401        assert!(summary.contains("agent-1"));
402    }
403
404    #[test]
405    fn cleanup_removes_low_confidence() {
406        let mut bridge = KnowledgeBridge::new("test-hash");
407        bridge.shared_facts.push(BridgeEntry {
408            fact_key: "weak".into(),
409            fact_category: "arch".into(),
410            fact_value: "uncertain".into(),
411            source_agent: "agent-1".into(),
412            published_at: Utc::now(),
413            archetype: KnowledgeArchetype::Architecture,
414            confidence: 0.3,
415            provenance: "session".into(),
416        });
417        bridge.shared_facts.push(BridgeEntry {
418            fact_key: "strong".into(),
419            fact_category: "arch".into(),
420            fact_value: "certain".into(),
421            source_agent: "agent-1".into(),
422            published_at: Utc::now(),
423            archetype: KnowledgeArchetype::Architecture,
424            confidence: 0.9,
425            provenance: "session".into(),
426        });
427        let removed = bridge.cleanup(365, 0.5);
428        assert_eq!(removed, 1);
429        assert_eq!(bridge.shared_facts[0].fact_key, "strong");
430    }
431
432    #[test]
433    fn trust_penalty_reduces_confidence() {
434        let entry = BridgeEntry {
435            fact_key: "k".into(),
436            fact_category: "c".into(),
437            fact_value: "v".into(),
438            source_agent: "src".into(),
439            published_at: Utc::now(),
440            archetype: KnowledgeArchetype::Decision,
441            confidence: 1.0,
442            provenance: "s".into(),
443        };
444        let fact = KnowledgeBridge::entry_to_fact(&entry);
445        assert!((fact.confidence - 0.9).abs() < f32::EPSILON);
446    }
447
448    #[test]
449    fn archived_facts_not_published() {
450        let mut bridge = KnowledgeBridge::new("test-hash");
451        let mut fact = make_fact(
452            "arch",
453            "old-db",
454            "MySQL",
455            0.95,
456            KnowledgeArchetype::Architecture,
457        );
458        fact.valid_until = Some(Utc::now() - chrono::Duration::days(1));
459        let count = bridge.publish("agent-1", &[fact]);
460        assert_eq!(count, 0);
461    }
462}