1use super::AgentRegistry;
15use super::persistence::{agents_dir, create_agents_dir};
16use crate::core::context_admission::stored::{self, Lineage, StoredBinding};
17use chrono::{DateTime, Utc};
18use serde::{Deserialize, Serialize};
19use std::path::{Path, PathBuf};
20
21const SHARED_SCHEMA_VERSION: u32 = 1;
22const MAX_FACTS_PER_PROJECT: usize = 500;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct SharedFact {
26 pub id: String,
27 pub from_agent: String,
28 pub category: String,
29 pub text: String,
31 pub timestamp: DateTime<Utc>,
32 #[serde(default)]
33 pub received_by: Vec<String>,
34 #[serde(default)]
35 binding: Option<StoredBinding>,
36}
37
38#[derive(Debug, Serialize, Deserialize)]
39struct SharedFile {
40 schema_version: u32,
41 project_root: String,
42 facts: Vec<SharedFact>,
43}
44
45impl AgentRegistry {
46 pub fn share_knowledge(
50 &mut self,
51 project_root: &str,
52 from: &str,
53 category: &str,
54 facts: &[(String, String)],
55 ) -> Result<usize, String> {
56 self.share(project_root, from, category, facts, None)
57 }
58
59 pub fn adopt_shared_fact(
63 &mut self,
64 project_root: &str,
65 id: &str,
66 from: &str,
67 category: &str,
68 fact: (String, String),
69 ) -> Result<(), String> {
70 self.share(project_root, from, category, &[fact], Some(id))
71 .map(|_| ())
72 }
73
74 fn share(
75 &mut self,
76 project_root: &str,
77 from: &str,
78 category: &str,
79 facts: &[(String, String)],
80 adoption: Option<&str>,
81 ) -> Result<usize, String> {
82 let mut admitted = Vec::with_capacity(facts.len());
83 for (key, value) in facts {
84 let (text, binding) = stored::admit_and_bind(
85 &format!("{key}={value}"),
86 &format!("agent:{from}"),
87 Some(project_root),
88 Lineage::CallerSupplied,
89 )
90 .map_err(|withheld| withheld.message("shared knowledge"))?;
91 admitted.push(SharedFact {
92 id: adoption.map_or_else(super::persistence::generate_short_id, str::to_owned),
93 from_agent: from.to_string(),
94 category: category.to_string(),
95 text,
96 timestamp: Utc::now(),
97 received_by: Vec::new(),
98 binding: Some(binding),
99 });
100 }
101 let mut file = load_project_file(project_root)?;
102 if adoption.is_some_and(|id| file.facts.iter().any(|fact| fact.id == id)) {
103 return Ok(0);
104 }
105 file.facts.extend(admitted);
106 if file.facts.len() > MAX_FACTS_PER_PROJECT {
107 let excess = file.facts.len() - MAX_FACTS_PER_PROJECT;
108 file.facts.drain(..excess);
109 }
110 save_project_file(&file)?;
111 self.updated_at = Utc::now();
112 Ok(facts.len())
113 }
114
115 pub fn receive_shared_knowledge(
119 &mut self,
120 project_root: &str,
121 agent_id: &str,
122 ) -> Result<Vec<SharedFact>, String> {
123 let mut file = load_project_file(project_root)?;
124 let mut delivered = Vec::new();
125 for fact in &mut file.facts {
126 if fact.from_agent != agent_id && !fact.received_by.iter().any(|id| id == agent_id) {
127 fact.received_by.push(agent_id.to_string());
128 let mut served = fact.clone();
129 served.text = match stored::serve_stored(
130 &fact.text,
131 fact.binding.as_ref(),
132 Some(project_root),
133 ) {
134 Ok(text) => {
135 stored::publish_served(fact.binding.as_ref());
136 text
137 }
138 Err(withheld) => withheld.notice(),
139 };
140 delivered.push(served);
141 }
142 }
143 if !delivered.is_empty() {
144 save_project_file(&file)?;
145 }
146 Ok(delivered)
147 }
148}
149
150fn shared_dir() -> Result<PathBuf, String> {
151 Ok(agents_dir()?.join("shared_knowledge"))
152}
153
154fn project_file_path(project_root: &str) -> Result<PathBuf, String> {
155 Ok(shared_dir()?.join(format!(
156 "{}.json",
157 crate::core::project_hash::hash_project_root(project_root)
158 )))
159}
160
161fn same_root(stored: &str, requested: &str) -> bool {
162 use crate::core::pathutil::{normalize_tool_path, safe_canonicalize_bounded};
163 let canonical = |root: &str| safe_canonicalize_bounded(Path::new(root), 2000);
164 normalize_tool_path(stored).trim_end_matches('/')
165 == normalize_tool_path(requested).trim_end_matches('/')
166 || canonical(stored) == canonical(requested)
167}
168
169fn load_project_file(project_root: &str) -> Result<SharedFile, String> {
170 quarantine_legacy_file()?;
171 let path = project_file_path(project_root)?;
172 let file = match std::fs::read_to_string(&path) {
173 Ok(content) => serde_json::from_str::<SharedFile>(&content).map_err(|error| {
174 format!("shared knowledge is corrupt at {}: {error}", path.display())
175 })?,
176 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
177 if let Some(file) = adopt_drifted_file(project_root)? {
178 return Ok(file);
179 }
180 return Ok(SharedFile {
181 schema_version: SHARED_SCHEMA_VERSION,
182 project_root: project_root.to_string(),
183 facts: Vec::new(),
184 });
185 }
186 Err(error) => return Err(format!("read shared knowledge {}: {error}", path.display())),
187 };
188 if file.schema_version != SHARED_SCHEMA_VERSION {
189 return Err(format!(
190 "shared knowledge at {} has unsupported schema {}",
191 path.display(),
192 file.schema_version
193 ));
194 }
195 if !same_root(&file.project_root, project_root) {
197 return Err("shared knowledge belongs to another project".into());
198 }
199 Ok(file)
200}
201
202fn adopt_drifted_file(project_root: &str) -> Result<Option<SharedFile>, String> {
207 const SCAN_LIMIT: usize = 2048;
208 let current = project_file_path(project_root)?;
209 let Ok(entries) = std::fs::read_dir(shared_dir()?) else {
210 return Ok(None);
211 };
212 let mut candidates: Vec<std::path::PathBuf> = entries
213 .flatten()
214 .take(SCAN_LIMIT)
215 .map(|entry| entry.path())
216 .filter(|path| *path != current && path.extension().is_some_and(|ext| ext == "json"))
217 .collect();
218 candidates.sort();
219 for path in candidates {
220 let Some(file) = std::fs::read_to_string(&path)
221 .ok()
222 .and_then(|content| serde_json::from_str::<SharedFile>(&content).ok())
223 else {
224 continue;
225 };
226 if file.schema_version != SHARED_SCHEMA_VERSION
227 || !same_root(&file.project_root, project_root)
228 {
229 continue;
230 }
231 save_project_file(&file)?;
232 let partition = crate::core::project_hash::hash_project_root(project_root);
233 let stem = path
234 .file_stem()
235 .map(|s| s.to_string_lossy().into_owned())
236 .unwrap_or_default();
237 let _ = crate::core::legacy_adoption::record(
238 "shared-knowledge",
239 &format!("shared-knowledge:{stem}"),
240 &partition,
241 true,
242 );
243 return Ok(Some(file));
244 }
245 Ok(None)
246}
247
248fn save_project_file(file: &SharedFile) -> Result<(), String> {
249 let dir = shared_dir()?;
250 create_agents_dir(&dir)?;
251 let path = project_file_path(&file.project_root)?;
252 let json = serde_json::to_string_pretty(file).map_err(|error| error.to_string())?;
253 crate::config_io::write_atomic(&path, &json)
254 .map_err(|error| format!("persist shared knowledge {}: {error}", path.display()))
255}
256
257fn legacy_file() -> Result<PathBuf, String> {
258 Ok(crate::core::paths::data_dir()?.join("shared_knowledge.json"))
259}
260
261fn quarantine_legacy_file() -> Result<(), String> {
265 let legacy = legacy_file()?;
266 let bytes = match std::fs::read(&legacy) {
267 Ok(bytes) => bytes,
268 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
269 Err(error) => {
270 return Err(format!(
271 "read legacy shared knowledge {}: {error}",
272 legacy.display()
273 ));
274 }
275 };
276 let dir = shared_dir()?.join("quarantine");
277 create_agents_dir(&dir)?;
278 let digest = blake3::hash(&bytes).to_hex();
279 let target = dir.join(format!("shared_knowledge-{}.json", &digest[..16]));
280 move_into_quarantine(&legacy, &target)
281}
282
283fn move_into_quarantine(legacy: &Path, target: &Path) -> Result<(), String> {
284 #[cfg(test)]
285 if QUARANTINE_FAILS.with(|armed| armed.replace(false)) {
286 return Err("injected crash before the quarantine move".into());
287 }
288 if target.exists() {
289 return std::fs::remove_file(legacy)
291 .map_err(|error| format!("remove legacy shared knowledge: {error}"));
292 }
293 std::fs::rename(legacy, target).map_err(|error| {
294 format!(
295 "quarantine legacy shared knowledge {}: {error}",
296 legacy.display()
297 )
298 })
299}
300
301#[cfg(test)]
302thread_local! {
303 static QUARANTINE_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
305}
306
307#[cfg(test)]
308#[path = "shared_tests.rs"]
309mod tests;