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;
19const 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 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 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 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 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 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 #[test]
358 fn parallel_publishers_keep_every_entry() {
359 use std::sync::{Arc, Barrier};
360 const AGENTS: usize = 8;
361 let _data = crate::core::data_dir::isolated_data_dir();
362 let barrier = Arc::new(Barrier::new(AGENTS));
363 let publishers: Vec<_> = (0..AGENTS)
364 .map(|index| {
365 let barrier = Arc::clone(&barrier);
366 std::thread::spawn(move || {
367 let facts = vec![make_fact(
368 "arch",
369 &format!("component-{index}"),
370 "shared",
371 0.9,
372 KnowledgeArchetype::Architecture,
373 )];
374 barrier.wait();
375 KnowledgeBridge::mutate_locked("bridge-hash", |bridge| {
376 bridge.publish(&format!("agent-{index}"), &facts)
377 })
378 })
379 })
380 .collect();
381 for publisher in publishers {
382 assert_eq!(publisher.join().unwrap(), Ok(1));
383 }
384 assert_eq!(
385 KnowledgeBridge::load("bridge-hash")
386 .unwrap()
387 .shared_facts
388 .len(),
389 AGENTS
390 );
391 }
392
393 #[test]
394 fn no_duplicate_publish() {
395 let mut bridge = KnowledgeBridge::new("test-hash");
396 let facts = vec![make_fact(
397 "arch",
398 "db",
399 "PostgreSQL",
400 0.9,
401 KnowledgeArchetype::Architecture,
402 )];
403 bridge.publish("agent-1", &facts);
404 let second = bridge.publish("agent-1", &facts);
405 assert_eq!(second, 0, "Should not re-publish same fact");
406 assert_eq!(bridge.shared_facts.len(), 1);
407 }
408
409 #[test]
410 fn cleanup_removes_old_entries() {
411 let mut bridge = KnowledgeBridge::new("test-hash");
412 bridge.shared_facts.push(BridgeEntry {
413 origin: crate::core::knowledge::FactOrigin::Local,
414 fact_key: "old".into(),
415 fact_category: "arch".into(),
416 fact_value: "ancient".into(),
417 source_agent: "agent-1".into(),
418 published_at: Utc::now() - chrono::Duration::days(60),
419 archetype: KnowledgeArchetype::Architecture,
420 confidence: 0.9,
421 provenance: "old-session".into(),
422 });
423 bridge.shared_facts.push(BridgeEntry {
424 origin: crate::core::knowledge::FactOrigin::Local,
425 fact_key: "fresh".into(),
426 fact_category: "arch".into(),
427 fact_value: "new".into(),
428 source_agent: "agent-1".into(),
429 published_at: Utc::now(),
430 archetype: KnowledgeArchetype::Architecture,
431 confidence: 0.9,
432 provenance: "new-session".into(),
433 });
434 let removed = bridge.cleanup(30, 0.5);
435 assert_eq!(removed, 1);
436 assert_eq!(bridge.shared_facts.len(), 1);
437 assert_eq!(bridge.shared_facts[0].fact_key, "fresh");
438 }
439
440 #[test]
441 fn entries_for_agent_filters_correctly() {
442 let mut bridge = KnowledgeBridge::new("test-hash");
443 let facts_a = vec![make_fact(
444 "arch",
445 "db",
446 "PostgreSQL",
447 0.9,
448 KnowledgeArchetype::Architecture,
449 )];
450 let facts_b = vec![make_fact(
451 "gotcha",
452 "trap",
453 "watch out",
454 0.85,
455 KnowledgeArchetype::Gotcha,
456 )];
457 bridge.publish("agent-a", &facts_a);
458 bridge.publish("agent-b", &facts_b);
459
460 assert_eq!(bridge.entries_for_agent("agent-a").len(), 1);
461 assert_eq!(bridge.entries_for_agent("agent-b").len(), 1);
462 assert_eq!(bridge.entries_for_agent("agent-c").len(), 0);
463 }
464
465 #[test]
466 fn summary_format() {
467 let mut bridge = KnowledgeBridge::new("test-hash");
468 assert!(bridge.summary().contains("empty"));
469
470 let facts = vec![make_fact(
471 "arch",
472 "db",
473 "PostgreSQL",
474 0.9,
475 KnowledgeArchetype::Architecture,
476 )];
477 bridge.publish("agent-1", &facts);
478 let summary = bridge.summary();
479 assert!(summary.contains("1 shared facts"));
480 assert!(summary.contains("agent-1"));
481 }
482
483 #[test]
484 fn cleanup_removes_low_confidence() {
485 let mut bridge = KnowledgeBridge::new("test-hash");
486 bridge.shared_facts.push(BridgeEntry {
487 origin: crate::core::knowledge::FactOrigin::Local,
488 fact_key: "weak".into(),
489 fact_category: "arch".into(),
490 fact_value: "uncertain".into(),
491 source_agent: "agent-1".into(),
492 published_at: Utc::now(),
493 archetype: KnowledgeArchetype::Architecture,
494 confidence: 0.3,
495 provenance: "session".into(),
496 });
497 bridge.shared_facts.push(BridgeEntry {
498 origin: crate::core::knowledge::FactOrigin::Local,
499 fact_key: "strong".into(),
500 fact_category: "arch".into(),
501 fact_value: "certain".into(),
502 source_agent: "agent-1".into(),
503 published_at: Utc::now(),
504 archetype: KnowledgeArchetype::Architecture,
505 confidence: 0.9,
506 provenance: "session".into(),
507 });
508 let removed = bridge.cleanup(365, 0.5);
509 assert_eq!(removed, 1);
510 assert_eq!(bridge.shared_facts[0].fact_key, "strong");
511 }
512
513 #[test]
514 fn trust_penalty_reduces_confidence() {
515 let entry = BridgeEntry {
516 origin: crate::core::knowledge::FactOrigin::Local,
517 fact_key: "k".into(),
518 fact_category: "c".into(),
519 fact_value: "v".into(),
520 source_agent: "src".into(),
521 published_at: Utc::now(),
522 archetype: KnowledgeArchetype::Decision,
523 confidence: 1.0,
524 provenance: "s".into(),
525 };
526 let fact = KnowledgeBridge::entry_to_fact(&entry);
527 assert!((fact.confidence - 0.9).abs() < f32::EPSILON);
528 }
529
530 #[test]
531 fn archived_facts_not_published() {
532 let mut bridge = KnowledgeBridge::new("test-hash");
533 let mut fact = make_fact(
534 "arch",
535 "old-db",
536 "MySQL",
537 0.95,
538 KnowledgeArchetype::Architecture,
539 );
540 fact.valid_until = Some(Utc::now() - chrono::Duration::days(1));
541 let count = bridge.publish("agent-1", &[fact]);
542 assert_eq!(count, 0);
543 }
544}