use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use m1nd_core::error::{M1ndError, M1ndResult};
use parking_lot::Mutex;
use crate::session::SessionState;
pub const DEFAULT_WARM_BRAIN_CAP: usize = 4;
const MANIFEST_FILE: &str = "project_brain.json";
pub const PROJECT_BRAINS_DIR: &str = "project-brains";
struct WarmBrain {
brain: Arc<Mutex<SessionState>>,
last_used: u64,
}
pub struct ProjectBrainRegistry {
brains: Mutex<HashMap<String, WarmBrain>>,
base_dir: PathBuf,
registry_dir: Option<PathBuf>,
capacity: usize,
tick: AtomicU64,
}
impl ProjectBrainRegistry {
pub fn new(base_dir: PathBuf, registry_dir: Option<PathBuf>) -> Self {
Self::with_capacity(base_dir, registry_dir, DEFAULT_WARM_BRAIN_CAP)
}
pub fn with_capacity(
base_dir: PathBuf,
registry_dir: Option<PathBuf>,
capacity: usize,
) -> Self {
Self {
brains: Mutex::new(HashMap::new()),
base_dir,
registry_dir,
capacity: capacity.max(1),
tick: AtomicU64::new(0),
}
}
fn next_tick(&self) -> u64 {
self.tick.fetch_add(1, Ordering::Relaxed)
}
pub fn capacity(&self) -> usize {
self.capacity
}
pub fn warm_len(&self) -> usize {
self.brains.lock().len()
}
pub fn canonical_key(root: &str) -> String {
let trimmed = root.trim().trim_end_matches('/');
Path::new(trimmed)
.canonicalize()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| trimmed.to_string())
}
pub fn store_dir_for(&self, canonical_root: &str) -> PathBuf {
self.base_dir
.join(crate::instance_registry::fingerprint_path(Path::new(
canonical_root,
)))
}
pub fn knows(&self, caller_root: &str) -> bool {
let key = Self::canonical_key(caller_root);
if self.brains.lock().contains_key(&key) {
return true;
}
self.manifest_matches(&key)
}
fn manifest_matches(&self, key: &str) -> bool {
let manifest = self.store_dir_for(key).join(MANIFEST_FILE);
let Ok(text) = std::fs::read_to_string(&manifest) else {
return false;
};
serde_json::from_str::<serde_json::Value>(&text)
.ok()
.and_then(|v| v["project_root"].as_str().map(|s| s == key))
.unwrap_or(false)
}
pub fn resolve(&self, caller_root: &str) -> Option<Arc<Mutex<SessionState>>> {
let key = Self::canonical_key(caller_root);
{
let mut map = self.brains.lock();
if let Some(warm) = map.get_mut(&key) {
warm.last_used = self.next_tick();
return Some(warm.brain.clone());
}
}
if !self.manifest_matches(&key) {
return None;
}
let state = self.boot_store(&key).ok()?;
let built = Arc::new(Mutex::new(state));
Some(self.insert_with_eviction(key, built))
}
fn boot_store(&self, key: &str) -> M1ndResult<SessionState> {
let store = self.store_dir_for(key);
std::fs::create_dir_all(&store)?;
let config = crate::server::McpConfig {
graph_source: store.join("graph_snapshot.json"),
plasticity_state: store.join("plasticity_state.json"),
runtime_dir: Some(store.clone()),
registry_dir: self.registry_dir.clone(),
..Default::default()
};
let mut state = crate::server::McpServer::new(config)?.into_session_state();
state.workspace_root = Some(key.to_string());
state.workspace_root_source = Some("project_brain_manifest".into());
let _ = state.instance.set_brain_kind("project");
Ok(state)
}
pub fn bootstrap(
&self,
project_root: &str,
ingest_args: &serde_json::Value,
) -> M1ndResult<(Arc<Mutex<SessionState>>, serde_json::Value, bool)> {
let key = Self::canonical_key(project_root);
if !Path::new(&key).is_dir() {
return Err(M1ndError::InvalidParams {
tool: "ingest".into(),
detail: format!(
"project_root '{project_root}' is not a directory on this machine — \
the one-call bootstrap ingests a local repo root"
),
});
}
let existing = self.resolve(&key);
let reused = existing.is_some();
let brain = match existing {
Some(brain) => brain,
None => {
let state = self.boot_store(&key)?;
self.write_manifest(&key, None, None)?;
let built = Arc::new(Mutex::new(state));
self.insert_with_eviction(key.clone(), built)
}
};
let mut args = ingest_args.clone();
if let Some(map) = args.as_object_mut() {
map.remove("project_root");
map.insert("path".into(), serde_json::Value::String(key.clone()));
}
let ingest_result = {
let mut state = brain.lock();
state.caller_root = Some(key.clone());
let result = crate::server::dispatch_tool(&mut state, "ingest", &args)?;
let agent_id = args
.get("agent_id")
.cloned()
.unwrap_or_else(|| serde_json::Value::String("bootstrap".into()));
let _ = crate::server::dispatch_tool(
&mut state,
"persist",
&serde_json::json!({ "agent_id": agent_id, "action": "save" }),
);
result
};
let node_count = ingest_result.get("node_count").and_then(|v| v.as_u64());
let edge_count = ingest_result.get("edge_count").and_then(|v| v.as_u64());
let _ = self.write_manifest(&key, node_count, edge_count);
Ok((brain, ingest_result, reused))
}
fn insert_with_eviction(
&self,
key: String,
built: Arc<Mutex<SessionState>>,
) -> Arc<Mutex<SessionState>> {
let victims: Vec<Arc<Mutex<SessionState>>>;
let resolved: Arc<Mutex<SessionState>>;
{
let mut map = self.brains.lock();
if let Some(warm) = map.get_mut(&key) {
warm.last_used = self.next_tick();
return warm.brain.clone();
}
let mut evicted = Vec::new();
while map.len() + 1 > self.capacity {
let Some(victim_key) = map
.iter()
.min_by_key(|(_, w)| w.last_used)
.map(|(k, _)| k.clone())
else {
break; };
if let Some(warm) = map.remove(&victim_key) {
evicted.push(warm.brain);
}
}
let last_used = self.next_tick();
map.insert(
key,
WarmBrain {
brain: built.clone(),
last_used,
},
);
victims = evicted;
resolved = built;
}
for victim in victims {
if let Err(e) = victim.lock().persist() {
eprintln!("[m1nd] WARNING: project-brain persist-on-evict failed: {e}");
}
}
resolved
}
fn write_manifest(
&self,
canonical_root: &str,
node_count: Option<u64>,
edge_count: Option<u64>,
) -> M1ndResult<()> {
let path = self.store_dir_for(canonical_root).join(MANIFEST_FILE);
let created_ms = std::fs::read_to_string(&path)
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.and_then(|v| v["created_ms"].as_u64())
.unwrap_or_else(crate::util::now_ms);
let mut record = serde_json::json!({
"schema": "m1nd-project-brain-v0",
"project_root": canonical_root,
"brain_kind": "project",
"created_ms": created_ms,
});
if let (Some(n), Some(e)) = (node_count, edge_count) {
record["node_count"] = serde_json::json!(n);
record["edge_count"] = serde_json::json!(e);
record["updated_ms"] = serde_json::json!(crate::util::now_ms());
}
std::fs::create_dir_all(self.store_dir_for(canonical_root))?;
std::fs::write(&path, serde_json::to_string_pretty(&record)?)?;
Ok(())
}
pub fn ensure_registered(&self, root: &str) -> M1ndResult<String> {
let key = Self::canonical_key(root);
if !self.manifest_matches(&key) {
self.write_manifest(&key, None, None)?;
}
Ok(key)
}
pub fn base_dir(&self) -> &Path {
&self.base_dir
}
pub fn warm_counts(&self, canonical_root: &str) -> Option<(u64, u64)> {
let key = Self::canonical_key(canonical_root);
let brain = self.brains.lock().get(&key).map(|w| w.brain.clone())?;
let state = brain.lock();
let g = state.graph.read();
Some((g.num_nodes() as u64, g.num_edges() as u64))
}
pub fn warm_session_stats(&self, canonical_root: &str) -> Option<(u64, u64, bool)> {
let key = Self::canonical_key(canonical_root);
let brain = self.brains.lock().get(&key).map(|w| w.brain.clone())?;
let state = brain.lock();
Some((
state.sessions.len() as u64,
state.queries_processed,
state.calibration_armed(),
))
}
pub fn disk_roster(&self) -> Vec<(String, StoreFacts, PathBuf)> {
let Ok(entries) = std::fs::read_dir(&self.base_dir) else {
return Vec::new(); };
let mut out = Vec::new();
for entry in entries.flatten() {
let store_dir = entry.path();
if !store_dir.is_dir() {
continue;
}
if let Some(facts) = store_facts_for_store(&store_dir) {
let key = Self::canonical_key(&facts.project_root);
out.push((key, facts, store_dir));
}
}
out
}
pub fn covering_brain(&self, caller_root: &str) -> Option<String> {
let caller_key = Self::canonical_key(caller_root);
let caller_path = Path::new(&caller_key);
let mut related: Vec<String> = Vec::new();
for (brain_key, _facts, _dir) in self.disk_roster() {
if brain_key == caller_key {
continue;
}
let brain_path = Path::new(&brain_key);
let related_pair = path_starts_with_loosely(caller_path, brain_path)
|| path_starts_with_loosely(brain_path, caller_path);
if related_pair && !related.iter().any(|r| r == &brain_key) {
related.push(brain_key);
}
}
match related.as_slice() {
[only] => Some(only.clone()),
_ => None, }
}
}
fn path_starts_with_loosely(path: &Path, root: &Path) -> bool {
if root.as_os_str().is_empty() {
return false;
}
if path.starts_with(root) {
return true;
}
if let (Ok(path), Ok(root)) = (path.canonicalize(), root.canonicalize()) {
if path.starts_with(root) {
return true;
}
}
let path_text = normalized_path_for_compare(path);
let root_text = normalized_path_for_compare(root);
if path_text == root_text {
return true;
}
path_text.starts_with(&format!("{root_text}/"))
}
fn normalized_path_for_compare(path: &Path) -> String {
path.to_string_lossy()
.replace('\\', "/")
.trim_end_matches('/')
.to_string()
}
pub fn project_root_for_store(store_dir: &Path) -> Option<String> {
store_facts_for_store(store_dir).map(|f| f.project_root)
}
pub fn store_facts_for_store(store_dir: &Path) -> Option<StoreFacts> {
let text = std::fs::read_to_string(store_dir.join(MANIFEST_FILE)).ok()?;
let record = serde_json::from_str::<serde_json::Value>(&text).ok()?;
let project_root = record["project_root"].as_str()?.to_string();
Some(StoreFacts {
project_root,
node_count: record["node_count"].as_u64(),
edge_count: record["edge_count"].as_u64(),
updated_ms: record["updated_ms"]
.as_u64()
.or_else(|| record["created_ms"].as_u64()),
})
}
#[derive(Clone, Debug)]
pub struct StoreFacts {
pub project_root: String,
pub node_count: Option<u64>,
pub edge_count: Option<u64>,
pub updated_ms: Option<u64>,
}
#[cfg(test)]
mod eviction_gate_tests {
use super::*;
#[test]
fn eviction_persists_unpersisted_state() {
let tmp = tempfile::tempdir().expect("tempdir");
let base = tmp.path().join("project-brains");
let reg = ProjectBrainRegistry::with_capacity(base, None, 1);
let root_a = tmp.path().join("repo-a").to_string_lossy().to_string();
let root_b = tmp.path().join("repo-b").to_string_lossy().to_string();
let key_a = ProjectBrainRegistry::canonical_key(&root_a);
let key_b = ProjectBrainRegistry::canonical_key(&root_b);
let state_a = reg.boot_store(&key_a).expect("boot A");
let store_a = reg.store_dir_for(&key_a);
let snapshot_a = store_a.join("graph_snapshot.json");
assert!(
!snapshot_a.exists(),
"precondition: A has no snapshot on disk yet"
);
{
let mut g = state_a.graph.write();
g.add_node(
"evict::sentinel",
"evict_sentinel",
m1nd_core::types::NodeType::Function,
&[],
0.0,
0.0,
)
.expect("add sentinel node");
g.finalize().expect("finalize A's graph");
}
let brain_a = Arc::new(Mutex::new(state_a));
reg.insert_with_eviction(key_a.clone(), brain_a);
assert_eq!(reg.warm_len(), 1, "A is the sole warm brain");
assert!(
!snapshot_a.exists(),
"A's mutation is still only in memory — no snapshot yet"
);
let state_b = reg.boot_store(&key_b).expect("boot B");
let brain_b = Arc::new(Mutex::new(state_b));
reg.insert_with_eviction(key_b.clone(), brain_b);
assert_eq!(reg.warm_len(), 1, "map stays at the cap after B lands");
assert!(
reg.warm_counts(&key_b).is_some(),
"B is the surviving warm brain"
);
assert!(
reg.warm_counts(&key_a).is_none(),
"A was evicted from the warm map"
);
assert!(
snapshot_a.exists(),
"persist-on-evict must have written A's snapshot before dropping it"
);
let reloaded = m1nd_core::snapshot::load_graph(&snapshot_a).expect("reload A's store");
let sentinel = reloaded
.strings
.lookup("evict::sentinel")
.and_then(|interned| reloaded.id_to_node.get(&interned));
assert!(
sentinel.is_some(),
"A's evicted snapshot must contain the node mutated after its last \
persist — persist-on-evict is the only thing that could have saved it"
);
}
#[test]
fn map_never_exceeds_capacity() {
let tmp = tempfile::tempdir().expect("tempdir");
let reg = ProjectBrainRegistry::with_capacity(tmp.path().join("pb"), None, 3);
for i in 0..10 {
let key = ProjectBrainRegistry::canonical_key(
&tmp.path().join(format!("r{i}")).to_string_lossy(),
);
let state = reg.boot_store(&key).expect("boot");
reg.insert_with_eviction(key, Arc::new(Mutex::new(state)));
assert!(
reg.warm_len() <= 3,
"warm map exceeded cap after insert {i}: {}",
reg.warm_len()
);
}
assert_eq!(reg.warm_len(), 3, "map sits at the cap after churn");
}
#[test]
fn covering_brain_prefers_the_unique_related_brain_and_abstains_on_ambiguity() {
let tmp = tempfile::tempdir().expect("tempdir");
let reg = ProjectBrainRegistry::with_capacity(tmp.path().join("pb"), None, 8);
let workspace = tmp.path().join("workspace");
let repo_a = workspace.join("repo-a");
std::fs::create_dir_all(&repo_a).expect("mk repo-a");
let key_a = ProjectBrainRegistry::canonical_key(&repo_a.to_string_lossy());
reg.write_manifest(&key_a, Some(1), Some(0))
.expect("manifest A");
assert_eq!(
reg.covering_brain(&workspace.to_string_lossy()),
Some(key_a.clone()),
"a brain root UNDER the caller (host-cwd-above-repo) must be found"
);
let subdir = repo_a.join("src").join("deep");
std::fs::create_dir_all(&subdir).expect("mk subdir");
assert_eq!(
reg.covering_brain(&subdir.to_string_lossy()),
Some(key_a.clone()),
"a caller UNDER a brain root must be found"
);
assert_eq!(
reg.covering_brain(&repo_a.to_string_lossy()),
None,
"an exact-match root is a silent bind, never a mismatch suggestion"
);
let stranger = tmp.path().join("elsewhere").join("stranger");
std::fs::create_dir_all(&stranger).expect("mk stranger");
assert_eq!(
reg.covering_brain(&stranger.to_string_lossy()),
None,
"an unrelated root has no covering brain (plain reception)"
);
let repo_b = workspace.join("repo-b");
std::fs::create_dir_all(&repo_b).expect("mk repo-b");
let key_b = ProjectBrainRegistry::canonical_key(&repo_b.to_string_lossy());
reg.write_manifest(&key_b, Some(1), Some(0))
.expect("manifest B");
assert_eq!(
reg.covering_brain(&workspace.to_string_lossy()),
None,
"two brains under one caller root is ambiguous → honest None, not a guess"
);
assert_eq!(
reg.covering_brain(&subdir.to_string_lossy()),
Some(key_a),
"a caller deep inside one repo still resolves to that repo unambiguously"
);
}
}