1use serde::{Deserialize, Serialize};
15use std::path::PathBuf;
16
17const GC_THRESHOLD: f64 = 0.05;
19const INTENSITY_CAP: f64 = 3.0;
21pub const CLAIM_ACTIVE_THRESHOLD: f64 = 0.3;
23const SYNC_TOP_K: usize = 15;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ScentKind {
29 Claimed,
31 Done,
33 Stuck,
35 Hot,
37 Avoid,
39}
40
41impl ScentKind {
42 fn half_life_secs(self) -> f64 {
44 match self {
45 ScentKind::Claimed | ScentKind::Hot => 600.0,
46 ScentKind::Stuck => 1800.0,
47 ScentKind::Done | ScentKind::Avoid => 3600.0,
48 }
49 }
50
51 pub fn as_str(self) -> &'static str {
52 match self {
53 ScentKind::Claimed => "CLAIMED",
54 ScentKind::Done => "DONE",
55 ScentKind::Stuck => "STUCK",
56 ScentKind::Hot => "HOT",
57 ScentKind::Avoid => "AVOID",
58 }
59 }
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct Scent {
64 pub agent_id: String,
65 pub kind: ScentKind,
66 pub target: String,
68 pub intensity: f64,
70 pub deposited_at: u64,
72}
73
74impl Scent {
75 pub fn effective_intensity(&self, now: u64) -> f64 {
76 let dt = now.saturating_sub(self.deposited_at) as f64;
77 self.intensity * (-(std::f64::consts::LN_2) * dt / self.kind.half_life_secs()).exp()
78 }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize, Default)]
82pub struct ScentField {
83 pub scents: Vec<Scent>,
84 pub schema_version: u32,
85 #[serde(default)]
88 pub claims_rejected: u64,
89}
90
91fn field_path() -> Result<PathBuf, String> {
92 let dir = crate::core::data_dir::lean_ctx_data_dir()?.join("agents");
93 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
94 Ok(dir.join("scent_field.json"))
95}
96
97fn now_secs() -> u64 {
98 std::time::SystemTime::now()
99 .duration_since(std::time::UNIX_EPOCH)
100 .map_or(0, |d| d.as_secs())
101}
102
103#[must_use]
110pub fn scent_agent_id() -> &'static str {
111 static CACHE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
112 CACHE.get_or_init(|| {
113 let base = crate::core::agent_identity::current_agent_id();
114 if base == "local" {
115 format!("local-{}", std::process::id())
116 } else {
117 base.to_string()
118 }
119 })
120}
121
122impl ScentField {
123 fn load_unlocked(path: &PathBuf) -> Self {
124 if let Ok(content) = std::fs::read_to_string(path)
125 && let Ok(f) = serde_json::from_str::<ScentField>(&content)
126 {
127 return f;
128 }
129 ScentField {
130 schema_version: 1,
131 ..Default::default()
132 }
133 }
134
135 fn save_unlocked(&self, path: &PathBuf) -> Result<(), String> {
136 let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
137 std::fs::write(path, json).map_err(|e| e.to_string())
138 }
139
140 pub fn gc(&mut self, now: u64) {
142 self.scents
143 .retain(|s| s.effective_intensity(now) >= GC_THRESHOLD);
144 }
145
146 pub fn deposit(
149 &mut self,
150 agent_id: &str,
151 kind: ScentKind,
152 target: &str,
153 intensity: f64,
154 now: u64,
155 ) {
156 self.gc(now);
157 let target = target.trim();
158 if target.is_empty() || agent_id.is_empty() {
159 return;
160 }
161 if let Some(existing) = self
162 .scents
163 .iter_mut()
164 .find(|s| s.agent_id == agent_id && s.kind == kind && s.target == target)
165 {
166 let carried = existing.effective_intensity(now);
167 existing.intensity = (carried + intensity).min(INTENSITY_CAP);
168 existing.deposited_at = now;
169 } else {
170 self.scents.push(Scent {
171 agent_id: agent_id.to_string(),
172 kind,
173 target: target.to_string(),
174 intensity: intensity.min(INTENSITY_CAP),
175 deposited_at: now,
176 });
177 }
178 }
179
180 pub fn foreign_claim(&self, target: &str, self_agent: &str, now: u64) -> Option<(String, u64)> {
182 self.scents
183 .iter()
184 .filter(|s| {
185 s.kind == ScentKind::Claimed
186 && s.target == target
187 && s.agent_id != self_agent
188 && s.effective_intensity(now) >= CLAIM_ACTIVE_THRESHOLD
189 })
190 .max_by(|a, b| {
191 a.effective_intensity(now)
192 .partial_cmp(&b.effective_intensity(now))
193 .unwrap_or(std::cmp::Ordering::Equal)
194 })
195 .map(|s| (s.agent_id.clone(), now.saturating_sub(s.deposited_at)))
196 }
197
198 pub fn render_sync(&self, now: u64) -> String {
201 use std::collections::HashMap;
202 type SyncKey<'a> = (ScentKind, &'a str);
204 type SyncAgg<'a> = (f64, Vec<&'a str>);
205 let mut groups: HashMap<SyncKey<'_>, SyncAgg<'_>> = HashMap::new();
206 for s in &self.scents {
207 let eff = s.effective_intensity(now);
208 if eff < GC_THRESHOLD {
209 continue;
210 }
211 let entry = groups.entry((s.kind, s.target.as_str())).or_default();
212 entry.0 += eff;
213 if !entry.1.contains(&s.agent_id.as_str()) {
214 entry.1.push(s.agent_id.as_str());
215 }
216 }
217 if groups.is_empty() {
218 return String::new();
219 }
220 let mut rows: Vec<(SyncKey<'_>, SyncAgg<'_>)> = groups.into_iter().collect();
221 rows.sort_by(|a, b| {
222 b.1.0
223 .partial_cmp(&a.1.0)
224 .unwrap_or(std::cmp::Ordering::Equal)
225 .then_with(|| a.0.1.cmp(b.0.1))
226 });
227
228 let mut out = String::from("Scent field (decaying, zero-token coordination):\n");
229 for ((kind, target), (total, agents)) in rows.iter().take(SYNC_TOP_K) {
230 let who = if agents.len() == 1 {
231 agents[0].to_string()
232 } else {
233 format!("{} agents", agents.len())
234 };
235 out.push_str(&format!(
236 " {} {} ({:.1}) by {}\n",
237 kind.as_str(),
238 target,
239 total,
240 who
241 ));
242 }
243 let extra = rows.len().saturating_sub(SYNC_TOP_K);
244 if extra > 0 {
245 out.push_str(&format!(" … {extra} weaker scent(s) below cutoff\n"));
246 }
247 out
248 }
249}
250
251fn with_field<R>(f: impl FnOnce(&mut ScentField, u64) -> R) -> Result<R, String> {
253 let path = field_path()?;
254 let lock_path = path.with_extension("json.lock");
255 let _lock = crate::core::agents::FileLock::acquire(&lock_path)?;
256 let mut field = ScentField::load_unlocked(&path);
257 let now = now_secs();
258 let result = f(&mut field, now);
259 field.gc(now);
260 field.save_unlocked(&path)?;
261 Ok(result)
262}
263
264pub fn deposit(agent_id: &str, kind: ScentKind, target: &str, intensity: f64) {
267 let _ = with_field(|field, now| field.deposit(agent_id, kind, target, intensity, now));
268}
269
270pub fn claim(agent_id: &str, target: &str) -> Result<(), String> {
273 with_field(|field, now| {
274 if let Some((holder, age)) = field.foreign_claim(target, agent_id, now) {
275 field.claims_rejected += 1;
276 return Err(format!(
277 "already claimed by {holder} ({}m ago, still active)",
278 age / 60
279 ));
280 }
281 field.deposit(agent_id, ScentKind::Claimed, target, 2.0, now);
282 Ok(())
283 })?
284}
285
286pub fn claims_rejected_total() -> u64 {
288 field_path().map_or(0, |p| ScentField::load_unlocked(&p).claims_rejected)
289}
290
291pub fn active_scents() -> Vec<(Scent, f64)> {
295 let Ok(path) = field_path() else {
296 return Vec::new();
297 };
298 let field = ScentField::load_unlocked(&path);
299 let now = now_secs();
300 let mut v: Vec<(Scent, f64)> = field
301 .scents
302 .into_iter()
303 .filter_map(|s| {
304 let eff = s.effective_intensity(now);
305 (eff >= GC_THRESHOLD).then_some((s, eff))
306 })
307 .collect();
308 v.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
309 v
310}
311
312pub fn release(agent_id: &str, target: &str) {
314 let _ = with_field(|field, now| {
315 field.scents.retain(|s| {
316 !(s.agent_id == agent_id && s.target == target && s.kind == ScentKind::Claimed)
317 });
318 field.gc(now);
319 });
320}
321
322pub fn read_hint(path: &str, self_agent: &str) -> Option<String> {
325 let field_file = field_path().ok()?;
326 let field = ScentField::load_unlocked(&field_file);
328 let now = now_secs();
329 let rel = crate::core::pathutil::normalize_tool_path(path);
330 let (holder, age) = field.foreign_claim(&rel, self_agent, now)?;
331 Some(format!("[scent: claimed by {holder} {}m ago]", age / 60))
332}
333
334pub fn sync_block() -> String {
336 let Ok(path) = field_path() else {
337 return String::new();
338 };
339 let field = ScentField::load_unlocked(&path);
340 field.render_sync(now_secs())
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 const NOW: u64 = 1_780_000_000;
348
349 #[test]
350 fn decay_halves_at_half_life() {
351 let s = Scent {
352 agent_id: "a1".into(),
353 kind: ScentKind::Hot,
354 target: "src/x.rs".into(),
355 intensity: 1.0,
356 deposited_at: NOW,
357 };
358 let eff = s.effective_intensity(NOW + 600);
359 assert!((eff - 0.5).abs() < 0.01, "half-life decay, got {eff}");
360 assert!(s.effective_intensity(NOW + 1200) < 0.3);
362 }
363
364 #[test]
365 fn superposition_caps_intensity() {
366 let mut f = ScentField::default();
367 for _ in 0..20 {
368 f.deposit("a1", ScentKind::Hot, "src/x.rs", 0.3, NOW);
369 }
370 assert_eq!(f.scents.len(), 1);
371 assert!(f.scents[0].intensity <= INTENSITY_CAP + f64::EPSILON);
372 }
373
374 #[test]
375 fn gc_drops_dead_scents() {
376 let mut f = ScentField::default();
377 f.deposit("a1", ScentKind::Hot, "src/x.rs", 0.3, NOW);
378 f.gc(NOW + 6 * 600); assert!(f.scents.is_empty());
380 }
381
382 #[test]
383 fn foreign_claim_detected_and_own_ignored() {
384 let mut f = ScentField::default();
385 f.deposit("a1", ScentKind::Claimed, "src/x.rs", 2.0, NOW);
386 assert!(f.foreign_claim("src/x.rs", "a2", NOW + 60).is_some());
387 assert!(f.foreign_claim("src/x.rs", "a1", NOW + 60).is_none());
388 assert!(f.foreign_claim("src/x.rs", "a2", NOW + 3 * 600).is_none());
390 }
391
392 #[test]
393 fn sync_view_caps_lines_and_superposes() {
394 let mut f = ScentField::default();
395 for i in 0..50 {
396 f.deposit("a1", ScentKind::Hot, &format!("src/f{i}.rs"), 0.5, NOW);
397 }
398 f.deposit("a2", ScentKind::Hot, "src/f0.rs", 0.5, NOW);
399 let view = f.render_sync(NOW);
400 let lines: Vec<&str> = view.lines().collect();
401 assert!(
402 lines.len() <= SYNC_TOP_K + 2,
403 "header + topk + overflow, got {}",
404 lines.len()
405 );
406 assert!(view.contains("2 agents"), "superposed line: {view}");
407 assert!(view.contains("weaker scent"));
408 }
409
410 #[test]
411 fn empty_field_renders_empty() {
412 let f = ScentField::default();
413 assert!(f.render_sync(NOW).is_empty());
414 }
415
416 #[test]
417 fn scent_identity_disambiguates_unconfigured_processes() {
418 let id = scent_agent_id();
419 let base = crate::core::agent_identity::current_agent_id();
420 if base == "local" {
421 assert_eq!(id, format!("local-{}", std::process::id()));
423 } else {
424 assert_eq!(id, base);
426 }
427 let mut f = ScentField::default();
429 f.deposit("local-1111", ScentKind::Claimed, "src/x.rs", 2.0, NOW);
430 assert!(
431 f.foreign_claim("src/x.rs", "local-2222", NOW + 30)
432 .is_some()
433 );
434 }
435}