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 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 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}