Skip to main content

lean_ctx/core/
shared_context.rs

1//! Durable facts agents share through `ctx_memory`, partitioned by project.
2//!
3//! Each project has its own `shared_context/<project hash>.jsonl` in the
4//! LeanCTX data directory, and every entry records the project it was stored
5//! for; that stored root, not the file name, decides whether an entry is
6//! served. Releases before 3.11.5 kept one installation-wide
7//! `~/.local/share/lean-ctx/shared_context.jsonl` without a project. Those
8//! entries carry no provable scope: the file is left in place, unread, and is
9//! never served to any project.
10
11use 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/// A fact shared between independent agent sessions.
20#[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    /// Confidence assigned to this fact, from 0.0 (untrusted) to 1.0 (certain).
28    #[serde(default = "default_confidence")]
29    pub confidence: f32,
30    pub access_count: u32,
31    pub last_accessed: u64,
32    /// Unix timestamp when an outcome last confirmed this fact.
33    #[serde(default)]
34    pub last_verified: Option<u64>,
35    /// Number of successful verification events recorded for this fact.
36    #[serde(default)]
37    pub verification_count: u32,
38    /// Exponential-moving-average correlation between this fact and successful outcomes.
39    #[serde(default)]
40    pub outcome_correlation: f32,
41    /// Optional per-fact verification window before the fact becomes stale.
42    #[serde(default)]
43    pub stale_after_days: Option<u16>,
44    /// Agent or user that first added this fact.
45    #[serde(default)]
46    pub created_by: String,
47    /// Hash of the fact that supersedes this one.
48    #[serde(default)]
49    pub superseded_by: Option<String>,
50    /// The project this fact was stored for; the only authority for serving it.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub project_root: Option<String>,
53}
54
55/// Backward-compatible name for an entry in shared fact memory.
56pub type FactEntry = SharedContextEntry;
57
58/// Summary of the contents of a [`SharedContext`] store.
59#[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/// Quality and contributor summary across shared-memory sessions.
68#[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    /// Contributors ordered by descending fact count, then contributor name.
74    pub top_contributors: Vec<(String, usize)>,
75}
76
77/// Durable cross-agent memory backed by an appendable JSONL file.
78#[derive(Debug, Clone)]
79pub struct SharedContext {
80    path: PathBuf,
81    /// Set for a project partition: only entries stored for this root load.
82    project_root: Option<String>,
83}
84
85impl SharedContext {
86    /// An unpartitioned store at `path` (tests and explicit tooling).
87    pub fn new(path: PathBuf) -> Self {
88        Self {
89            path,
90            project_root: None,
91        }
92    }
93
94    /// The installation-wide store of releases before 3.11.5. Served to no
95    /// project; read for the explicit `lean-ctx migrate legacy-context`
96    /// adoption only.
97    pub fn legacy() -> Option<Self> {
98        // Those releases wrote under `dirs::home_dir()`. Tests redirect the
99        // home through `HOME`, which `dirs` ignores on Windows (known folder):
100        // without this they would share the runner's real profile.
101        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    /// Facts left in a partition that is no longer the current one of the
110    /// root they recorded: that project's identity (remote, package name)
111    /// changed since. Served to no project until adopted.
112    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    /// The orphaned facts that recorded `project_root` (canonically).
146    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    /// The shared facts of `project_root`, in its own partition of the data
162    /// directory.
163    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    /// Record an explicit confirmation of a fact.
356    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    /// Lower the confidence of a fact identified as stale without removing it.
366    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    /// Mark `old_hash` as replaced by the existing fact identified by `new_fact`.
374    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    /// Remove facts whose latest verification (or creation) predates their expiry window.
388    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    /// Remove facts consistently associated with unsuccessful outcomes.
404    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    /// Return query-matching facts ordered by their provenance quality score.
415    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    /// Fold an outcome into a fact's correlation score with an exponential moving average.
467    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    /// Summarize provenance quality and the agents or users that contributed facts.
479    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    /// Every read-modify-write of this store holds an exclusive lock on a
533    /// sibling `.lock` file from its load to its save, so writers in other
534    /// threads or processes (two agents of one project) cannot replace the
535    /// file with a copy that lacks their entries. Released on drop.
536    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        // Windows `LockFileEx` needs a handle with GENERIC_READ or
543        // GENERIC_WRITE; an append-only handle lacks FILE_WRITE_DATA, so every
544        // lock attempt failed with "Access is denied" (os error 5).
545        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            // The file name is a hash; the stored root is the authority.
578            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
614/// How long a writer waits for another writer of the same store.
615const 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
667/// The newest shared facts of `project_root` as a context hint, re-admitted
668/// under the originless rule; nothing when the project has none or the hint
669/// may not be served now.
670pub 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    /// AP05-G2: one installation-wide file served every project's facts, and
710    /// the session hint decorated every tool result with them.
711    #[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    /// Several projects through several stores at once, in one data
736    /// directory (AP01 §1/§3; ABNAHME I02, C01): a scope that leaks through
737    /// process-wide state (cwd, environment, a "current project" global) or a
738    /// store without a writer lock shows up as a value in the wrong partition
739    /// or as a lost acknowledged write.
740    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        /// The same fact keys in every project: a partition mix-up overwrites.
752        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        /// Every write one writer makes, each acknowledged before the writer ends.
766        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                // The registry lock is a polled try-lock (750 ms, not a fair
793                // queue, GitLab #1295): with every writer released by one
794                // barrier, a write can be refused under load. A refused write
795                // was never acknowledged and is retried; the property under
796                // test is that no acknowledged write is lost.
797                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        /// What each store of `root` holds now, as one set of values per store.
821        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        /// I02/C01: writers of two projects start together (one barrier) and write
878        /// the same fact keys into one data directory through knowledge, archive,
879        /// shared memory, sessions and shared agent knowledge. Every acknowledged
880        /// write is kept, each in its own project only.
881        #[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        // An hour, not a second: a slow runner must not age the fresh entries
982        // out. "old" expires by age, and one fresh entry goes to the cap.
983        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}