1use std::collections::{BTreeMap, HashSet};
12use std::fs;
13use std::io::{BufRead, BufReader};
14use std::path::{Path, PathBuf};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use serde::{Deserialize, Serialize};
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub struct SharedContextEntry {
22 pub id: String,
23 pub content: String,
24 pub agent: String,
25 pub timestamp: u64,
26 pub category: String,
27 #[serde(default = "default_confidence")]
29 pub confidence: f32,
30 pub access_count: u32,
31 pub last_accessed: u64,
32 #[serde(default)]
34 pub last_verified: Option<u64>,
35 #[serde(default)]
37 pub verification_count: u32,
38 #[serde(default)]
40 pub outcome_correlation: f32,
41 #[serde(default)]
43 pub stale_after_days: Option<u16>,
44 #[serde(default)]
46 pub created_by: String,
47 #[serde(default)]
49 pub superseded_by: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub project_root: Option<String>,
53}
54
55pub type FactEntry = SharedContextEntry;
57
58#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60pub struct SharedContextStats {
61 pub total_entries: usize,
62 pub unique_agents: usize,
63 pub categories: BTreeMap<String, usize>,
64 pub total_accesses: u64,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
69pub struct MemoryStats {
70 pub total_facts: usize,
71 pub verified_pct: f32,
72 pub avg_confidence: f32,
73 pub top_contributors: Vec<(String, usize)>,
75}
76
77#[derive(Debug, Clone)]
79pub struct SharedContext {
80 path: PathBuf,
81 project_root: Option<String>,
83}
84
85impl SharedContext {
86 pub fn new(path: PathBuf) -> Self {
88 Self {
89 path,
90 project_root: None,
91 }
92 }
93
94 pub fn legacy() -> Option<Self> {
98 let home = if crate::core::data_dir::sandboxed() {
102 crate::core::home::resolve_home_dir()
103 } else {
104 dirs::home_dir()
105 };
106 home.map(|home| Self::new(home.join(".local/share/lean-ctx/shared_context.jsonl")))
107 }
108
109 pub fn orphaned_entries() -> Vec<(PathBuf, SharedContextEntry)> {
113 let Ok(dir) = crate::core::data_dir::lean_ctx_data_dir().map(|d| d.join("shared_context"))
114 else {
115 return Vec::new();
116 };
117 let Ok(files) = fs::read_dir(&dir) else {
118 return Vec::new();
119 };
120 let mut paths: Vec<PathBuf> = files
121 .flatten()
122 .map(|file| file.path())
123 .filter(|path| path.extension().is_some_and(|ext| ext == "jsonl"))
124 .collect();
125 paths.sort();
126 let mut orphaned = Vec::new();
127 for path in paths {
128 let stem = path
129 .file_stem()
130 .map(|stem| stem.to_string_lossy().into_owned())
131 .unwrap_or_default();
132 for entry in Self::new(path.clone()).load().unwrap_or_default() {
133 if entry
134 .project_root
135 .as_deref()
136 .is_some_and(|root| crate::core::project_hash::hash_project_root(root) != stem)
137 {
138 orphaned.push((path.clone(), entry));
139 }
140 }
141 }
142 orphaned
143 }
144
145 pub(crate) fn drifted_entries(project_root: &str) -> Vec<(PathBuf, SharedContextEntry)> {
147 let canonical =
148 |root: &str| crate::core::pathutil::safe_canonicalize_bounded(Path::new(root), 2000);
149 let wanted = canonical(project_root);
150 Self::orphaned_entries()
151 .into_iter()
152 .filter(|(_, entry)| {
153 entry
154 .project_root
155 .as_deref()
156 .is_some_and(|root| canonical(root) == wanted)
157 })
158 .collect()
159 }
160
161 pub fn for_project(project_root: &str) -> Result<Self, String> {
164 if project_root.trim().is_empty() {
165 return Err("shared memory needs a project".into());
166 }
167 let path = crate::core::data_dir::lean_ctx_data_dir()?
168 .join("shared_context")
169 .join(format!(
170 "{}.jsonl",
171 crate::core::project_hash::hash_project_root(project_root)
172 ));
173 Ok(Self {
174 path,
175 project_root: Some(project_root.to_string()),
176 })
177 }
178
179 pub fn path(&self) -> &Path {
180 &self.path
181 }
182
183 pub fn put(
184 &self,
185 content: impl Into<String>,
186 agent: impl Into<String>,
187 category: impl Into<String>,
188 ) -> Result<String, String> {
189 let content = content.into();
190 let agent = agent.into();
191 let category = normalize_category(&category.into())?;
192 let id = blake3::hash(content.as_bytes()).to_hex().to_string();
193 let now = unix_timestamp();
194 let _writer = self.write_lock()?;
195 let mut entries = self.load()?;
196
197 if let Some(existing) = entries.iter_mut().find(|entry| entry.id == id) {
198 existing.agent.clone_from(&agent);
199 existing.category.clone_from(&category);
200 existing.timestamp = now;
201 existing.last_accessed = now;
202 self.save(&entries)?;
203 return Ok(id);
204 }
205
206 entries.push(SharedContextEntry {
207 id: id.clone(),
208 content,
209 agent: agent.clone(),
210 timestamp: now,
211 category,
212 confidence: default_confidence(),
213 access_count: 0,
214 last_accessed: now,
215 last_verified: None,
216 verification_count: 0,
217 outcome_correlation: 0.0,
218 stale_after_days: None,
219 created_by: agent,
220 superseded_by: None,
221 project_root: self.project_root.clone(),
222 });
223 self.save(&entries)?;
224 Ok(id)
225 }
226
227 pub fn get_relevant(
228 &self,
229 query: &str,
230 limit: usize,
231 ) -> Result<Vec<SharedContextEntry>, String> {
232 if limit == 0 {
233 return Ok(Vec::new());
234 }
235 let _writer = self.write_lock()?;
236 let mut entries = self.load()?;
237 let query_terms = terms(query);
238 if query_terms.is_empty() {
239 return Ok(Vec::new());
240 }
241 let document_count = entries.len() as f64;
242 let average_length = entries
243 .iter()
244 .map(|entry| terms(&entry.content).len() as f64)
245 .sum::<f64>()
246 / document_count.max(1.0);
247 let document_frequency = query_terms
248 .iter()
249 .map(|term| {
250 (
251 term,
252 entries
253 .iter()
254 .filter(|entry| terms(&entry.content).contains(term))
255 .count() as f64,
256 )
257 })
258 .collect::<BTreeMap<_, _>>();
259
260 let mut scored = entries
261 .iter()
262 .enumerate()
263 .filter_map(|(index, entry)| {
264 let entry_terms = terms(&entry.content);
265 let length = entry_terms.len() as f64;
266 let score = query_terms.iter().fold(0.0, |score, term| {
267 let frequency =
268 entry_terms.iter().filter(|value| *value == term).count() as f64;
269 if frequency == 0.0 {
270 return score;
271 }
272 let df = document_frequency.get(term).copied().unwrap_or_default();
273 let idf = ((document_count - df + 0.5) / (df + 0.5) + 1.0).ln();
274 let k1 = 1.2;
275 let b = 0.75;
276 score
277 + idf * frequency * (k1 + 1.0)
278 / (frequency + k1 * (1.0 - b + b * length / average_length.max(1.0)))
279 });
280 (score > 0.0).then_some((index, score))
281 })
282 .collect::<Vec<_>>();
283 scored.sort_by(|left, right| {
284 right
285 .1
286 .total_cmp(&left.1)
287 .then_with(|| left.0.cmp(&right.0))
288 });
289
290 let now = unix_timestamp();
291 let selected = scored
292 .into_iter()
293 .take(limit)
294 .map(|(index, _)| index)
295 .collect::<Vec<_>>();
296 for index in &selected {
297 let entry = &mut entries[*index];
298 entry.access_count = entry.access_count.saturating_add(1);
299 entry.last_accessed = now;
300 }
301 let results = selected
302 .into_iter()
303 .map(|index| entries[index].clone())
304 .collect();
305 self.save(&entries)?;
306 Ok(results)
307 }
308
309 pub fn get_recent(&self, limit: usize) -> Result<Vec<SharedContextEntry>, String> {
310 let mut entries = self.load()?;
311 entries.sort_by(|left, right| {
312 right
313 .timestamp
314 .cmp(&left.timestamp)
315 .then_with(|| right.id.cmp(&left.id))
316 });
317 Ok(entries.into_iter().take(limit).collect())
318 }
319
320 pub fn get_by_category(&self, category: &str) -> Result<Vec<SharedContextEntry>, String> {
321 let category = normalize_category(category)?;
322 let mut entries = self
323 .load()?
324 .into_iter()
325 .filter(|entry| entry.category == category)
326 .collect::<Vec<_>>();
327 entries.sort_by(|left, right| {
328 right
329 .timestamp
330 .cmp(&left.timestamp)
331 .then_with(|| right.id.cmp(&left.id))
332 });
333 Ok(entries)
334 }
335
336 pub fn prune(&self, max_age_seconds: u64, max_entries: usize) -> Result<usize, String> {
337 let now = unix_timestamp();
338 let _writer = self.write_lock()?;
339 let mut entries = self.load()?;
340 let original_len = entries.len();
341 entries.retain(|entry| now.saturating_sub(entry.last_accessed) <= max_age_seconds);
342 entries.sort_by(|left, right| {
343 right
344 .last_accessed
345 .cmp(&left.last_accessed)
346 .then_with(|| right.access_count.cmp(&left.access_count))
347 .then_with(|| right.timestamp.cmp(&left.timestamp))
348 });
349 entries.truncate(max_entries);
350 let removed = original_len.saturating_sub(entries.len());
351 self.save(&entries)?;
352 Ok(removed)
353 }
354
355 pub fn verify_fact(&self, hash: &str) -> Result<(), String> {
357 let _writer = self.write_lock()?;
358 let mut entries = self.load()?;
359 let entry = find_fact_mut(&mut entries, hash)?;
360 entry.last_verified = Some(unix_timestamp());
361 entry.verification_count = entry.verification_count.saturating_add(1);
362 self.save(&entries)
363 }
364
365 pub fn mark_stale(&self, hash: &str) -> Result<(), String> {
367 let _writer = self.write_lock()?;
368 let mut entries = self.load()?;
369 find_fact_mut(&mut entries, hash)?.confidence = 0.3;
370 self.save(&entries)
371 }
372
373 pub fn supersede(&self, old_hash: &str, new_fact: &str) -> Result<(), String> {
375 let _writer = self.write_lock()?;
376 let mut entries = self.load()?;
377 if old_hash == new_fact {
378 return Err("a fact cannot supersede itself".to_string());
379 }
380 if !entries.iter().any(|entry| entry.id == new_fact) {
381 return Err(format!("fact '{new_fact}' does not exist"));
382 }
383 find_fact_mut(&mut entries, old_hash)?.superseded_by = Some(new_fact.to_string());
384 self.save(&entries)
385 }
386
387 pub fn prune_stale(&self, max_age_days: u16) -> Result<usize, String> {
389 let now = unix_timestamp();
390 let _writer = self.write_lock()?;
391 let mut entries = self.load()?;
392 let original_len = entries.len();
393 entries.retain(|entry| {
394 let age_days = entry.stale_after_days.unwrap_or(max_age_days);
395 let reference_time = entry.last_verified.unwrap_or(entry.timestamp);
396 now.saturating_sub(reference_time) <= u64::from(age_days) * SECONDS_PER_DAY
397 });
398 let removed = original_len.saturating_sub(entries.len());
399 self.save(&entries)?;
400 Ok(removed)
401 }
402
403 pub fn prune_negative(&self) -> Result<usize, String> {
405 let _writer = self.write_lock()?;
406 let mut entries = self.load()?;
407 let original_len = entries.len();
408 entries.retain(|entry| entry.outcome_correlation >= NEGATIVE_CORRELATION_THRESHOLD);
409 let removed = original_len.saturating_sub(entries.len());
410 self.save(&entries)?;
411 Ok(removed)
412 }
413
414 pub fn get_best(&self, query: &str, limit: usize) -> Result<Vec<SharedContextEntry>, String> {
416 if limit == 0 {
417 return Ok(Vec::new());
418 }
419 let _writer = self.write_lock()?;
420 let mut entries = self.load()?;
421 let query_terms = terms(query);
422 if query_terms.is_empty() {
423 return Ok(Vec::new());
424 }
425
426 let mut matches = entries
427 .iter()
428 .enumerate()
429 .filter_map(|(index, entry)| {
430 let entry_terms = terms(&entry.content);
431 let relevance = query_terms
432 .iter()
433 .filter(|term| entry_terms.contains(term))
434 .count();
435 (relevance > 0).then_some((index, relevance))
436 })
437 .collect::<Vec<_>>();
438 matches.sort_by(|left, right| {
439 let left_quality = entries[left.0].confidence * entries[left.0].outcome_correlation;
440 let right_quality = entries[right.0].confidence * entries[right.0].outcome_correlation;
441 right_quality
442 .total_cmp(&left_quality)
443 .then_with(|| right.1.cmp(&left.1))
444 .then_with(|| entries[left.0].id.cmp(&entries[right.0].id))
445 });
446
447 let now = unix_timestamp();
448 let selected = matches
449 .into_iter()
450 .take(limit)
451 .map(|(index, _)| index)
452 .collect::<Vec<_>>();
453 for index in &selected {
454 let entry = &mut entries[*index];
455 entry.access_count = entry.access_count.saturating_add(1);
456 entry.last_accessed = now;
457 }
458 let results = selected
459 .into_iter()
460 .map(|index| entries[index].clone())
461 .collect();
462 self.save(&entries)?;
463 Ok(results)
464 }
465
466 pub fn record_fact_outcome(&self, hash: &str, success: bool) -> Result<(), String> {
468 let _writer = self.write_lock()?;
469 let mut entries = self.load()?;
470 let entry = find_fact_mut(&mut entries, hash)?;
471 let outcome = if success { 1.0 } else { -1.0 };
472 entry.outcome_correlation = (entry.outcome_correlation * (1.0 - OUTCOME_EMA_ALPHA)
473 + outcome * OUTCOME_EMA_ALPHA)
474 .clamp(-1.0, 1.0);
475 self.save(&entries)
476 }
477
478 pub fn cross_session_stats(&self) -> Result<MemoryStats, String> {
480 let entries = self.load()?;
481 let total_facts = entries.len();
482 let verified = entries
483 .iter()
484 .filter(|entry| entry.verification_count > 0)
485 .count();
486 let avg_confidence = (total_facts > 0).then(|| {
487 entries.iter().map(|entry| entry.confidence).sum::<f32>() / total_facts as f32
488 });
489 let mut contributors = BTreeMap::new();
490 for entry in &entries {
491 let contributor = if entry.created_by.is_empty() {
492 &entry.agent
493 } else {
494 &entry.created_by
495 };
496 *contributors.entry(contributor.clone()).or_insert(0) += 1;
497 }
498 let mut top_contributors = contributors.into_iter().collect::<Vec<_>>();
499 top_contributors
500 .sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(&right.0)));
501
502 Ok(MemoryStats {
503 total_facts,
504 verified_pct: percentage(verified, total_facts),
505 avg_confidence: avg_confidence.unwrap_or(0.0),
506 top_contributors,
507 })
508 }
509
510 pub fn stats(&self) -> Result<SharedContextStats, String> {
511 let entries = self.load()?;
512 let unique_agents = entries
513 .iter()
514 .map(|entry| entry.agent.as_str())
515 .collect::<HashSet<_>>()
516 .len();
517 let mut categories = BTreeMap::new();
518 for entry in &entries {
519 *categories.entry(entry.category.clone()).or_insert(0) += 1;
520 }
521 Ok(SharedContextStats {
522 total_entries: entries.len(),
523 unique_agents,
524 categories,
525 total_accesses: entries
526 .iter()
527 .map(|entry| u64::from(entry.access_count))
528 .sum(),
529 })
530 }
531
532 fn write_lock(&self) -> Result<fs::File, String> {
537 let lock_path = self.path.with_extension("lock");
538 if let Some(parent) = lock_path.parent() {
539 fs::create_dir_all(parent)
540 .map_err(|error| format!("create {}: {error}", parent.display()))?;
541 }
542 let file = fs::OpenOptions::new()
546 .create(true)
547 .read(true)
548 .write(true)
549 .truncate(false)
550 .open(&lock_path)
551 .map_err(|error| format!("open {}: {error}", lock_path.display()))?;
552 crate::core::file_lock::acquire_exclusive_timeout(&file, WRITE_LOCK_WAIT)
553 .map_err(|error| format!("shared memory is busy: {error}"))?;
554 Ok(file)
555 }
556
557 pub fn load(&self) -> Result<Vec<SharedContextEntry>, String> {
558 if !self.path.exists() {
559 return Ok(Vec::new());
560 }
561 let file = fs::File::open(&self.path)
562 .map_err(|error| format!("open {}: {error}", self.path.display()))?;
563 let mut entries = BufReader::new(file)
564 .lines()
565 .filter(|line| line.as_ref().is_ok_and(|value| !value.trim().is_empty()))
566 .map(|line| {
567 let line =
568 line.map_err(|error| format!("read {}: {error}", self.path.display()))?;
569 serde_json::from_str(&line)
570 .map_err(|error| format!("parse {}: {error}", self.path.display()))
571 })
572 .collect::<Result<Vec<SharedContextEntry>, String>>()?;
573 for entry in &mut entries {
574 apply_provenance_defaults(entry);
575 }
576 if let Some(root) = &self.project_root {
577 entries.retain(|entry| {
579 entry
580 .project_root
581 .as_deref()
582 .is_some_and(|stored| same_root(stored, root))
583 });
584 }
585 Ok(entries)
586 }
587
588 fn save(&self, entries: &[SharedContextEntry]) -> Result<(), String> {
589 let parent = self
590 .path
591 .parent()
592 .ok_or_else(|| format!("{} has no parent", self.path.display()))?;
593 fs::create_dir_all(parent)
594 .map_err(|error| format!("create {}: {error}", parent.display()))?;
595 let mut content = String::new();
596 for entry in entries {
597 content.push_str(
598 &serde_json::to_string(entry)
599 .map_err(|error| format!("serialize entry: {error}"))?,
600 );
601 content.push('\n');
602 }
603 crate::config_io::write_atomic(&self.path, &content)
604 .map_err(|error| format!("replace {}: {error}", self.path.display()))
605 }
606}
607
608fn same_root(stored: &str, requested: &str) -> bool {
609 use crate::core::pathutil::normalize_tool_path;
610 normalize_tool_path(stored).trim_end_matches('/')
611 == normalize_tool_path(requested).trim_end_matches('/')
612}
613
614const WRITE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(5);
616const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
617const NEGATIVE_CORRELATION_THRESHOLD: f32 = -0.3;
618const OUTCOME_EMA_ALPHA: f32 = 0.2;
619
620fn default_confidence() -> f32 {
621 0.8
622}
623
624fn find_fact_mut<'a>(
625 entries: &'a mut [SharedContextEntry],
626 hash: &str,
627) -> Result<&'a mut SharedContextEntry, String> {
628 entries
629 .iter_mut()
630 .find(|entry| entry.id == hash)
631 .ok_or_else(|| format!("fact '{hash}' does not exist"))
632}
633
634fn apply_provenance_defaults(entry: &mut SharedContextEntry) {
635 entry.confidence = entry.confidence.clamp(0.0, 1.0);
636 entry.outcome_correlation = entry.outcome_correlation.clamp(-1.0, 1.0);
637 if entry.created_by.is_empty() {
638 entry.created_by.clone_from(&entry.agent);
639 }
640}
641
642fn percentage(numerator: usize, denominator: usize) -> f32 {
643 if denominator == 0 {
644 0.0
645 } else {
646 numerator as f32 / denominator as f32 * 100.0
647 }
648}
649
650fn normalize_category(category: &str) -> Result<String, String> {
651 match category {
652 "fact" | "decision" | "blocker" | "pattern" => Ok(category.to_string()),
653 other => Err(format!(
654 "invalid category '{other}' (expected fact|decision|blocker|pattern)"
655 )),
656 }
657}
658
659fn terms(value: &str) -> Vec<String> {
660 value
661 .split(|character: char| !character.is_alphanumeric())
662 .filter(|term| !term.is_empty())
663 .map(str::to_lowercase)
664 .collect()
665}
666
667pub fn session_start_hint(project_root: &str) -> Option<String> {
671 crate::core::legacy_adoption::auto_adopt_shared_context(project_root);
672 let entries = SharedContext::for_project(project_root)
673 .ok()?
674 .get_recent(3)
675 .ok()?;
676 if entries.is_empty() {
677 return None;
678 }
679 let facts = entries
680 .iter()
681 .map(|entry| format!("- [{} · {}] {}", entry.category, entry.agent, entry.content))
682 .collect::<Vec<_>>()
683 .join("\n");
684 crate::core::admitted_sources::serve_originless(
685 &format!("--- SHARED CONTEXT ---\n{facts}"),
686 "shared memory",
687 )
688 .ok()
689}
690
691fn unix_timestamp() -> u64 {
692 SystemTime::now()
693 .duration_since(UNIX_EPOCH)
694 .map_or(0, |duration| duration.as_secs())
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 fn store(name: &str) -> SharedContext {
702 let path = std::env::temp_dir().join(format!(
703 "lean-ctx-shared-context-{name}-{}.jsonl",
704 uuid::Uuid::new_v4()
705 ));
706 SharedContext::new(path)
707 }
708
709 #[test]
712 fn shared_facts_and_hint_stay_in_their_project() {
713 let _data = crate::core::data_dir::isolated_data_dir();
714 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
715 let a = tempfile::tempdir().unwrap();
716 let b = tempfile::tempdir().unwrap();
717 let (a, b) = (a.path().to_str().unwrap(), b.path().to_str().unwrap());
718 SharedContext::for_project(a)
719 .unwrap()
720 .put("project-a-marker lives in net/retry.rs", "codex", "fact")
721 .unwrap();
722 let foreign = SharedContext::for_project(b).unwrap();
723 assert!(foreign.get_recent(10).unwrap().is_empty());
724 assert!(
725 foreign
726 .get_relevant("project-a-marker", 10)
727 .unwrap()
728 .is_empty()
729 );
730 assert_eq!(session_start_hint(b), None);
731 let own = session_start_hint(a).expect("own project hint");
732 assert!(own.contains("project-a-marker"), "{own}");
733 }
734
735 mod partitions {
741 use std::collections::BTreeSet;
742 use std::path::PathBuf;
743 use std::sync::{Arc, Barrier};
744
745 use crate::core::agents::AgentRegistry;
746 use crate::core::knowledge::ProjectKnowledge;
747 use crate::core::memory_policy::MemoryPolicy;
748 use crate::core::session::{SessionState, TaskInfo};
749 use crate::core::shared_context::SharedContext;
750
751 const KEYS: [&str; 4] = ["db", "queue", "cache", "auth"];
753
754 fn in_project<T>(root: &str, f: impl FnOnce() -> T) -> T {
755 crate::core::policy::runtime::REQUEST_PROJECT
756 .sync_scope(std::cell::RefCell::new(Some(PathBuf::from(root))), f)
757 }
758
759 fn project(dir: &tempfile::TempDir, name: &str) -> String {
760 let root = dir.path().join(name);
761 std::fs::create_dir_all(&root).unwrap();
762 root.canonicalize().unwrap().to_string_lossy().into_owned()
763 }
764
765 fn write_everywhere(root: &str, key: &str, value: &str) {
767 in_project(root, || {
768 let policy = MemoryPolicy::default();
769 let (knowledge, ()) = ProjectKnowledge::mutate_locked(root, |k| {
770 k.remember("decision", key, value, "partition-test", 0.9, &policy);
771 })
772 .expect("knowledge write acknowledged");
773 let fact = knowledge
774 .facts
775 .iter()
776 .find(|f| f.key == key && f.value == value)
777 .cloned()
778 .expect("acknowledged fact is in the returned store");
779 crate::core::memory_lifecycle::archive_facts(&[fact])
780 .expect("archive acknowledged");
781 SharedContext::for_project(root)
782 .and_then(|store| store.put(format!("{key} {value}"), key, "decision"))
783 .expect("shared memory write acknowledged");
784 let mut session = SessionState::new();
785 session.project_root = Some(root.to_owned());
786 session.task = Some(TaskInfo {
787 description: value.to_owned(),
788 intent: None,
789 progress_pct: None,
790 });
791 session.save().expect("session save acknowledged");
792 let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
798 loop {
799 match AgentRegistry::mutate_locked(|registry| {
800 registry.share_knowledge(
801 root,
802 key,
803 "decision",
804 &[(key.into(), value.into())],
805 )
806 }) {
807 Ok((_, shared)) => {
808 shared.expect("shared knowledge acknowledged");
809 break;
810 }
811 Err(error)
812 if error.contains("timed out")
813 && std::time::Instant::now() < deadline => {}
814 Err(error) => panic!("registry lock: {error}"),
815 }
816 }
817 });
818 }
819
820 fn partition_contents(root: &str) -> Vec<(&'static str, BTreeSet<String>)> {
822 in_project(root, || {
823 let knowledge: BTreeSet<String> = ProjectKnowledge::load(root)
824 .map(|k| {
825 k.facts
826 .iter()
827 .filter(|f| f.is_current())
828 .map(|f| format!("{} {}", f.key, f.value))
829 .collect()
830 })
831 .unwrap_or_default();
832 let archive: BTreeSet<String> = crate::core::memory_lifecycle::list_archives(root)
833 .iter()
834 .flat_map(|path| {
835 crate::core::memory_lifecycle::restore_archive(
836 root,
837 &path.to_string_lossy(),
838 )
839 .expect("own archive restores")
840 })
841 .map(|f| format!("{} {}", f.key, f.value))
842 .collect();
843 let shared: BTreeSet<String> = SharedContext::for_project(root)
844 .and_then(|store| store.get_recent(usize::MAX))
845 .expect("shared memory")
846 .into_iter()
847 .map(|entry| entry.content)
848 .collect();
849 let sessions: BTreeSet<String> =
850 SessionState::load_recent_for_project_root(root, usize::MAX)
851 .into_iter()
852 .filter_map(|session| session.task.map(|task| task.description))
853 .map(|value| {
854 let key = KEYS
855 .iter()
856 .find(|key| value.ends_with(&format!("-{key}")))
857 .expect("task names its key");
858 format!("{key} {value}")
859 })
860 .collect();
861 let registry: BTreeSet<String> = AgentRegistry::new()
862 .receive_shared_knowledge(root, "partition-reader")
863 .expect("shared knowledge")
864 .into_iter()
865 .map(|fact| fact.text.replacen('=', " ", 1))
866 .collect();
867 vec![
868 ("knowledge", knowledge),
869 ("archive", archive),
870 ("shared memory", shared),
871 ("sessions", sessions),
872 ("shared knowledge", registry),
873 ]
874 })
875 }
876
877 #[test]
882 fn concurrent_writes_into_two_projects_keep_every_write_in_its_own_partition() {
883 let _data = crate::core::data_dir::isolated_data_dir();
884 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
885 let dir = tempfile::tempdir().unwrap();
886 let projects = [("p1", project(&dir, "p1")), ("p2", project(&dir, "p2"))];
887 let start = Arc::new(Barrier::new(projects.len() * KEYS.len()));
888 let start = &start;
889 let writers: Vec<_> = projects
890 .iter()
891 .flat_map(|(name, root)| {
892 KEYS.iter().map(move |key| {
893 let (start, root) = (Arc::clone(start), root.clone());
894 let value = format!("canary-{name}-{key}");
895 std::thread::spawn(move || {
896 start.wait();
897 write_everywhere(&root, key, &value);
898 })
899 })
900 })
901 .collect();
902 for writer in writers {
903 writer.join().expect("writer acknowledged every write");
904 }
905
906 for (name, root) in &projects {
907 let expected: BTreeSet<String> = KEYS
908 .iter()
909 .map(|key| format!("{key} canary-{name}-{key}"))
910 .collect();
911 for (store, held) in partition_contents(root) {
912 assert_eq!(held, expected, "{store} of {name}");
913 }
914 }
915 }
916 }
917
918 #[test]
919 fn put_and_get_round_trip() {
920 let context = store("round-trip");
921 let id = context
922 .put("Use cargo test --lib before commit", "codex", "fact")
923 .unwrap();
924 let entries = context.get_relevant("cargo commit", 10).unwrap();
925 assert_eq!(entries.len(), 1);
926 assert_eq!(entries[0].id, id);
927 assert_eq!(entries[0].agent, "codex");
928 assert_eq!(entries[0].access_count, 1);
929 }
930
931 #[test]
932 fn duplicate_content_updates_in_place() {
933 let context = store("dedup");
934 let first = context
935 .put("The parser needs UTF-8 input", "claude", "fact")
936 .unwrap();
937 let second = context
938 .put("The parser needs UTF-8 input", "codex", "decision")
939 .unwrap();
940 assert_eq!(first, second);
941 let stats = context.stats().unwrap();
942 assert_eq!(stats.total_entries, 1);
943 assert_eq!(stats.unique_agents, 1);
944 assert_eq!(context.get_recent(1).unwrap()[0].category, "decision");
945 }
946
947 #[test]
948 fn bm25_prefers_more_relevant_entries() {
949 let context = store("bm25");
950 context
951 .put("SQLite migration preserves agent memory", "claude", "fact")
952 .unwrap();
953 context
954 .put(
955 "Agent memory migration migration migration is complete",
956 "cursor",
957 "fact",
958 )
959 .unwrap();
960 context
961 .put("Rendering uses SVG sprites", "codex", "pattern")
962 .unwrap();
963 let entries = context.get_relevant("agent memory migration", 2).unwrap();
964 assert_eq!(entries[0].agent, "cursor");
965 assert_eq!(entries.len(), 2);
966 }
967
968 #[test]
969 fn prune_removes_expired_and_excess_entries() {
970 let context = store("prune");
971 context.put("old", "claude", "fact").unwrap();
972 context.put("new one", "codex", "fact").unwrap();
973 context.put("new two", "cursor", "fact").unwrap();
974 let mut entries = context.load().unwrap();
975 entries
976 .iter_mut()
977 .find(|entry| entry.content == "old")
978 .unwrap()
979 .last_accessed = 0;
980 context.save(&entries).unwrap();
981 assert_eq!(context.prune(3600, 1).unwrap(), 2);
984 assert_eq!(context.stats().unwrap().total_entries, 1);
985 }
986
987 #[test]
988 fn verify_fact_records_timestamp_and_count() {
989 let context = store("verify");
990 let id = context
991 .put("Cargo checks are required", "codex", "fact")
992 .unwrap();
993
994 context.verify_fact(&id).unwrap();
995 context.verify_fact(&id).unwrap();
996
997 let entry = context.get_recent(1).unwrap().pop().unwrap();
998 assert_eq!(entry.verification_count, 2);
999 assert!(entry.last_verified.is_some());
1000 }
1001
1002 #[test]
1003 fn prune_stale_removes_old_unverified_facts() {
1004 let context = store("stale");
1005 context.put("outdated command", "codex", "fact").unwrap();
1006 context.put("current command", "codex", "fact").unwrap();
1007 let mut entries = context.load().unwrap();
1008 entries[0].timestamp = unix_timestamp().saturating_sub(2 * SECONDS_PER_DAY);
1009 entries[1].last_verified = Some(unix_timestamp());
1010 context.save(&entries).unwrap();
1011
1012 assert_eq!(context.prune_stale(1).unwrap(), 1);
1013 assert_eq!(
1014 context.get_recent(10).unwrap()[0].content,
1015 "current command"
1016 );
1017 }
1018
1019 #[test]
1020 fn get_best_prioritizes_high_quality_facts() {
1021 let context = store("best");
1022 context
1023 .put("cargo test validates changes", "claude", "fact")
1024 .unwrap();
1025 context
1026 .put("cargo test captures regressions", "codex", "fact")
1027 .unwrap();
1028 let mut entries = context.load().unwrap();
1029 entries[0].confidence = 0.4;
1030 entries[0].outcome_correlation = 0.5;
1031 entries[1].confidence = 0.9;
1032 entries[1].outcome_correlation = 0.9;
1033 context.save(&entries).unwrap();
1034
1035 let best = context.get_best("cargo test", 2).unwrap();
1036 assert_eq!(best[0].agent, "codex");
1037 }
1038
1039 #[test]
1040 fn recording_outcomes_updates_correlation_with_ema() {
1041 let context = store("outcomes");
1042 let id = context.put("test outcome", "codex", "fact").unwrap();
1043
1044 context.record_fact_outcome(&id, true).unwrap();
1045 let positive = context
1046 .get_recent(1)
1047 .unwrap()
1048 .pop()
1049 .unwrap()
1050 .outcome_correlation;
1051 context.record_fact_outcome(&id, false).unwrap();
1052 let mixed = context
1053 .get_recent(1)
1054 .unwrap()
1055 .pop()
1056 .unwrap()
1057 .outcome_correlation;
1058
1059 assert!((positive - 0.2).abs() < f32::EPSILON);
1060 assert!(mixed < positive);
1061 }
1062
1063 #[test]
1064 fn legacy_jsonl_entries_receive_provenance_defaults() {
1065 let context = store("legacy");
1066 fs::write(
1067 context.path(),
1068 r#"{"id":"legacy","content":"legacy fact","agent":"cursor","timestamp":7,"category":"fact","confidence":1.0,"access_count":0,"last_accessed":7}"#,
1069 )
1070 .unwrap();
1071
1072 let entry = context.get_recent(1).unwrap().pop().unwrap();
1073 assert_eq!(entry.confidence, 1.0);
1074 assert_eq!(entry.created_by, "cursor");
1075 assert_eq!(entry.last_verified, None);
1076 assert_eq!(entry.verification_count, 0);
1077 assert_eq!(entry.outcome_correlation, 0.0);
1078 assert_eq!(entry.stale_after_days, None);
1079 assert_eq!(entry.superseded_by, None);
1080 }
1081
1082 #[test]
1083 fn superseding_and_negative_pruning_track_quality() {
1084 let context = store("supersede");
1085 let old = context.put("old guidance", "codex", "fact").unwrap();
1086 let new = context.put("new guidance", "codex", "fact").unwrap();
1087 context.supersede(&old, &new).unwrap();
1088 context.record_fact_outcome(&old, false).unwrap();
1089 context.record_fact_outcome(&old, false).unwrap();
1090
1091 let entries = context.get_recent(2).unwrap();
1092 let old_entry = entries
1093 .iter()
1094 .find(|e| e.content == "old guidance")
1095 .unwrap();
1096 assert_eq!(old_entry.superseded_by, Some(new));
1097 assert_eq!(context.prune_negative().unwrap(), 1);
1098 }
1099}