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