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