1use 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 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 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 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 imported_from: Some(format!("bridge:{}", entry.source_agent)),
145 archetype: entry.archetype.clone(),
146 fidelity: None,
147 }
148 }
149
150 pub fn cleanup(&mut self, max_age_days: i64, min_confidence: f32) -> usize {
152 let cutoff = Utc::now() - chrono::Duration::days(max_age_days);
153 let before = self.shared_facts.len();
154 self.shared_facts
155 .retain(|e| e.published_at >= cutoff && e.confidence >= min_confidence);
156 before - self.shared_facts.len()
157 }
158
159 pub fn entries_for_agent(&self, agent_id: &str) -> Vec<&BridgeEntry> {
160 self.shared_facts
161 .iter()
162 .filter(|e| e.source_agent == agent_id)
163 .collect()
164 }
165
166 pub fn summary(&self) -> String {
167 if self.shared_facts.is_empty() {
168 return format!(
169 "Knowledge Bridge [{}]: empty",
170 short_hash(&self.project_hash)
171 );
172 }
173
174 let mut agents: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
175 for entry in &self.shared_facts {
176 *agents.entry(&entry.source_agent).or_default() += 1;
177 }
178
179 let mut out = format!(
180 "Knowledge Bridge [{}]: {} shared facts from {} agent(s)\n",
181 short_hash(&self.project_hash),
182 self.shared_facts.len(),
183 agents.len(),
184 );
185 let mut sorted_agents: Vec<_> = agents.into_iter().collect();
186 sorted_agents.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
187 for (agent, count) in &sorted_agents {
188 out.push_str(&format!(" {agent}: {count} fact(s)\n"));
189 }
190 out.push_str(&format!(
191 "Last updated: {}",
192 self.updated_at.format("%Y-%m-%d %H:%M UTC")
193 ));
194 out
195 }
196}
197
198fn short_hash(hash: &str) -> &str {
199 if hash.len() > 8 {
200 &hash[..8]
201 } else {
202 hash
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209 use crate::core::knowledge::KnowledgeFact;
210 use crate::core::memory_boundary::FactPrivacy;
211
212 fn make_fact(
213 cat: &str,
214 key: &str,
215 val: &str,
216 confidence: f32,
217 archetype: KnowledgeArchetype,
218 ) -> KnowledgeFact {
219 KnowledgeFact {
220 category: cat.into(),
221 key: key.into(),
222 value: val.into(),
223 source_session: "test-session".into(),
224 confidence,
225 created_at: Utc::now(),
226 last_confirmed: Utc::now(),
227 retrieval_count: 0,
228 last_retrieved: None,
229 valid_from: None,
230 valid_until: None,
231 supersedes: None,
232 confirmation_count: 1,
233 feedback_up: 0,
234 feedback_down: 0,
235 last_feedback: None,
236 privacy: FactPrivacy::default(),
237 imported_from: None,
238 archetype,
239 fidelity: None,
240 }
241 }
242
243 #[test]
244 fn publish_only_eligible_facts() {
245 let mut bridge = KnowledgeBridge::new("test-hash");
246 let facts = vec![
247 make_fact(
248 "arch",
249 "db",
250 "PostgreSQL",
251 0.9,
252 KnowledgeArchetype::Architecture,
253 ),
254 make_fact("random", "x", "low-conf", 0.3, KnowledgeArchetype::Fact),
255 make_fact(
256 "gotcha",
257 "trap",
258 "watch out",
259 0.85,
260 KnowledgeArchetype::Gotcha,
261 ),
262 make_fact(
263 "pref",
264 "editor",
265 "vim",
266 0.95,
267 KnowledgeArchetype::Preference,
268 ),
269 ];
270 let count = bridge.publish("agent-1", &facts);
271 assert_eq!(count, 2);
272 assert_eq!(bridge.shared_facts.len(), 2);
273 }
274
275 #[test]
276 fn pull_excludes_own_facts() {
277 let mut bridge = KnowledgeBridge::new("test-hash");
278 let facts = vec![make_fact(
279 "arch",
280 "db",
281 "PostgreSQL",
282 0.9,
283 KnowledgeArchetype::Architecture,
284 )];
285 bridge.publish("agent-1", &facts);
286
287 let pulled = bridge.pull("agent-1");
288 assert!(pulled.is_empty(), "Should not pull own facts");
289
290 let pulled = bridge.pull("agent-2");
291 assert_eq!(pulled.len(), 1);
292 }
293
294 #[test]
295 fn entry_to_fact_preserves_provenance() {
296 let entry = BridgeEntry {
297 fact_key: "db".into(),
298 fact_category: "arch".into(),
299 fact_value: "PostgreSQL".into(),
300 source_agent: "agent-1".into(),
301 published_at: Utc::now(),
302 archetype: KnowledgeArchetype::Architecture,
303 confidence: 0.9,
304 provenance: "session-abc".into(),
305 };
306 let fact = KnowledgeBridge::entry_to_fact(&entry);
307 assert_eq!(fact.imported_from, Some("bridge:agent-1".into()));
308 assert!(fact.confidence < 0.9);
309 assert_eq!(fact.archetype, KnowledgeArchetype::Architecture);
310 }
311
312 #[test]
313 fn no_duplicate_publish() {
314 let mut bridge = KnowledgeBridge::new("test-hash");
315 let facts = vec![make_fact(
316 "arch",
317 "db",
318 "PostgreSQL",
319 0.9,
320 KnowledgeArchetype::Architecture,
321 )];
322 bridge.publish("agent-1", &facts);
323 let second = bridge.publish("agent-1", &facts);
324 assert_eq!(second, 0, "Should not re-publish same fact");
325 assert_eq!(bridge.shared_facts.len(), 1);
326 }
327
328 #[test]
329 fn cleanup_removes_old_entries() {
330 let mut bridge = KnowledgeBridge::new("test-hash");
331 bridge.shared_facts.push(BridgeEntry {
332 fact_key: "old".into(),
333 fact_category: "arch".into(),
334 fact_value: "ancient".into(),
335 source_agent: "agent-1".into(),
336 published_at: Utc::now() - chrono::Duration::days(60),
337 archetype: KnowledgeArchetype::Architecture,
338 confidence: 0.9,
339 provenance: "old-session".into(),
340 });
341 bridge.shared_facts.push(BridgeEntry {
342 fact_key: "fresh".into(),
343 fact_category: "arch".into(),
344 fact_value: "new".into(),
345 source_agent: "agent-1".into(),
346 published_at: Utc::now(),
347 archetype: KnowledgeArchetype::Architecture,
348 confidence: 0.9,
349 provenance: "new-session".into(),
350 });
351 let removed = bridge.cleanup(30, 0.5);
352 assert_eq!(removed, 1);
353 assert_eq!(bridge.shared_facts.len(), 1);
354 assert_eq!(bridge.shared_facts[0].fact_key, "fresh");
355 }
356
357 #[test]
358 fn entries_for_agent_filters_correctly() {
359 let mut bridge = KnowledgeBridge::new("test-hash");
360 let facts_a = vec![make_fact(
361 "arch",
362 "db",
363 "PostgreSQL",
364 0.9,
365 KnowledgeArchetype::Architecture,
366 )];
367 let facts_b = vec![make_fact(
368 "gotcha",
369 "trap",
370 "watch out",
371 0.85,
372 KnowledgeArchetype::Gotcha,
373 )];
374 bridge.publish("agent-a", &facts_a);
375 bridge.publish("agent-b", &facts_b);
376
377 assert_eq!(bridge.entries_for_agent("agent-a").len(), 1);
378 assert_eq!(bridge.entries_for_agent("agent-b").len(), 1);
379 assert_eq!(bridge.entries_for_agent("agent-c").len(), 0);
380 }
381
382 #[test]
383 fn summary_format() {
384 let mut bridge = KnowledgeBridge::new("test-hash");
385 assert!(bridge.summary().contains("empty"));
386
387 let facts = vec![make_fact(
388 "arch",
389 "db",
390 "PostgreSQL",
391 0.9,
392 KnowledgeArchetype::Architecture,
393 )];
394 bridge.publish("agent-1", &facts);
395 let summary = bridge.summary();
396 assert!(summary.contains("1 shared facts"));
397 assert!(summary.contains("agent-1"));
398 }
399
400 #[test]
401 fn cleanup_removes_low_confidence() {
402 let mut bridge = KnowledgeBridge::new("test-hash");
403 bridge.shared_facts.push(BridgeEntry {
404 fact_key: "weak".into(),
405 fact_category: "arch".into(),
406 fact_value: "uncertain".into(),
407 source_agent: "agent-1".into(),
408 published_at: Utc::now(),
409 archetype: KnowledgeArchetype::Architecture,
410 confidence: 0.3,
411 provenance: "session".into(),
412 });
413 bridge.shared_facts.push(BridgeEntry {
414 fact_key: "strong".into(),
415 fact_category: "arch".into(),
416 fact_value: "certain".into(),
417 source_agent: "agent-1".into(),
418 published_at: Utc::now(),
419 archetype: KnowledgeArchetype::Architecture,
420 confidence: 0.9,
421 provenance: "session".into(),
422 });
423 let removed = bridge.cleanup(365, 0.5);
424 assert_eq!(removed, 1);
425 assert_eq!(bridge.shared_facts[0].fact_key, "strong");
426 }
427
428 #[test]
429 fn trust_penalty_reduces_confidence() {
430 let entry = BridgeEntry {
431 fact_key: "k".into(),
432 fact_category: "c".into(),
433 fact_value: "v".into(),
434 source_agent: "src".into(),
435 published_at: Utc::now(),
436 archetype: KnowledgeArchetype::Decision,
437 confidence: 1.0,
438 provenance: "s".into(),
439 };
440 let fact = KnowledgeBridge::entry_to_fact(&entry);
441 assert!((fact.confidence - 0.9).abs() < f32::EPSILON);
442 }
443
444 #[test]
445 fn archived_facts_not_published() {
446 let mut bridge = KnowledgeBridge::new("test-hash");
447 let mut fact = make_fact(
448 "arch",
449 "old-db",
450 "MySQL",
451 0.95,
452 KnowledgeArchetype::Architecture,
453 );
454 fact.valid_until = Some(Utc::now() - chrono::Duration::days(1));
455 let count = bridge.publish("agent-1", &[fact]);
456 assert_eq!(count, 0);
457 }
458}