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