use crate::protocol::core::IngestInput;
use crate::session::SessionState;
use crate::util::now_ms;
use m1nd_core::error::{M1ndError, M1ndResult};
use serde::Deserialize;
use serde_json::{json, Value};
use std::fs;
use std::path::{Path, PathBuf};
fn default_true() -> bool {
true
}
fn default_merge() -> String {
"merge".to_string()
}
#[derive(Debug, Deserialize)]
pub struct LightClaim {
pub label: String,
#[serde(default)]
pub text: Option<String>,
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub confidence: Option<String>,
#[serde(default)]
pub ambiguity: Option<String>,
#[serde(default)]
pub evidence: Vec<String>,
#[serde(default)]
pub depends_on: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct LightAuthorInput {
pub agent_id: String,
pub node_label: String,
#[serde(default)]
pub title: Option<String>,
#[serde(default)]
pub state: Option<String>,
pub claims: Vec<LightClaim>,
#[serde(default)]
pub output_path: Option<String>,
#[serde(default)]
pub namespace: Option<String>,
#[serde(default = "default_true")]
pub ingest_after: bool,
#[serde(default = "default_merge")]
pub mode: String,
#[serde(skip)]
pub supersedes: Option<String>,
}
pub fn handle_light_author(
state: &mut SessionState,
mut input: LightAuthorInput,
) -> M1ndResult<Value> {
let out_path = resolve_output_path(state, &input)?;
let is_default_path = input.output_path.is_none();
let parent = out_path.parent().ok_or_else(|| M1ndError::InvalidParams {
tool: "memorize".into(),
detail: "output path has no parent directory".into(),
})?;
fs::create_dir_all(parent).map_err(M1ndError::Io)?;
let (markdown, supersession) = if is_default_path {
let slug = slugify(&input.node_label);
let _lock = LockGuard::acquire(&state.runtime_root, &slug)?;
match plan_supersession(&out_path, &input)? {
SupersessionPlan::WouldDowngrade { reason } => {
let path_str = out_path.to_string_lossy().to_string();
return Ok(json!({
"ok": true,
"schema": "m1nd-memorize-v0",
"path": path_str,
"bytes_written": 0,
"claims_written": 0,
"ingested": false,
"superseded": false,
"reason": reason,
"note": "weaker write refused: the stronger prior memory is kept live (invalidate-and-keep gate).",
}));
}
SupersessionPlan::Supersede => {
archive_prior_as_outdated(&out_path, &slug, &state.runtime_root)?;
input.supersedes = Some(slug.clone());
let md = render_light_markdown(&input);
write_atomic(&out_path, &md)?;
(md, Some(true))
}
SupersessionPlan::FirstWrite => {
let md = render_light_markdown(&input);
write_atomic(&out_path, &md)?;
(md, None)
}
}
} else {
let md = render_light_markdown(&input);
fs::write(&out_path, &md).map_err(M1ndError::Io)?;
(md, None)
};
let bytes_written = markdown.len();
let claims_written = input.claims.len();
let path_str = out_path.to_string_lossy().to_string();
if input.ingest_after {
let ingest_input = IngestInput {
path: path_str.clone(),
agent_id: input.agent_id.clone(),
incremental: false,
adapter: "light".into(),
mode: input.mode.clone(),
namespace: Some(input.namespace.clone().unwrap_or_else(|| "light".into())),
include_dotfiles: false,
dotfile_patterns: vec![],
};
let ingest_result = crate::tools::handle_ingest(state, ingest_input)?;
let node_count = ingest_result["node_count"].as_u64().unwrap_or(0);
let edge_count = ingest_result["edge_count"].as_u64().unwrap_or(0);
let resolved = ingest_result["light_evidence_resolved"]
.as_u64()
.unwrap_or(0);
let unresolved = ingest_result["light_evidence_unresolved"]
.as_u64()
.unwrap_or(0);
let next_action = if unresolved > 0 {
format!(
"{} evidence path(s) did not resolve to a code node — ingest the code first (ingest adapter=code) and ensure evidence paths are repo-relative to that root, then re-run memorize so the knowledge anchors and cross_verify(check:[\"evidence_freshness\"]) can track it.",
unresolved
)
} else if resolved > 0 {
"Memory anchored to code and will auto-load next session; cross_verify(check:[\"evidence_freshness\"]) flags it if the cited code changes.".to_string()
} else {
"Memory persisted and will auto-load next session. Add `evidence` paths to claims to anchor them to code and enable staleness detection.".to_string()
};
let mut resp = json!({
"ok": true,
"schema": "m1nd-memorize-v0",
"path": path_str,
"bytes_written": bytes_written,
"claims_written": claims_written,
"ingested": true,
"node_count": node_count,
"edge_count": edge_count,
"light_evidence_resolved": resolved,
"light_evidence_unresolved": unresolved,
"next_action": next_action,
"rendered": markdown,
});
if let Some(superseded) = supersession {
resp["superseded"] = json!(superseded);
}
return Ok(resp);
}
let mut resp = json!({
"ok": true,
"schema": "m1nd-memorize-v0",
"path": path_str,
"bytes_written": bytes_written,
"claims_written": claims_written,
"ingested": false,
"rendered": markdown,
});
if let Some(superseded) = supersession {
resp["superseded"] = json!(superseded);
}
Ok(resp)
}
pub fn render_light_markdown(input: &LightAuthorInput) -> String {
let state_val = input.state.as_deref().unwrap_or("authored");
let title_val = input.title.as_deref().unwrap_or(input.node_label.as_str());
let mut out = String::new();
out.push_str("---\n");
out.push_str("Protocol: L1GHT/1.0\n");
out.push_str(&format!("Node: {}\n", input.node_label));
out.push_str(&format!("State: {}\n", state_val));
out.push_str(&format!("Created: {}\n", now_ms()));
out.push_str(&format!("Source-Agent: {}\n", input.agent_id));
if let Some(superseded) = &input.supersedes {
out.push_str(&format!("Supersedes: {}\n", superseded));
}
out.push_str("---\n");
out.push('\n');
out.push_str(&format!("# {}\n", input.node_label));
out.push('\n');
out.push_str(&format!("## {}\n", title_val));
out.push('\n');
for claim in &input.claims {
let prose = claim.text.as_deref().unwrap_or(claim.label.as_str());
out.push_str(prose);
out.push('\n');
out.push('\n');
let (glyph, kind_word) = claim_glyph(claim.kind.as_deref());
out.push_str(&format!("[{} {}: {}]\n", glyph, kind_word, claim.label));
if let Some(conf) = &claim.confidence {
out.push_str(&format!("[𝔻 confidence: {}]\n", conf));
}
if let Some(amb) = &claim.ambiguity {
out.push_str(&format!("[𝔻 ambiguity: {}]\n", amb));
}
for ev in &claim.evidence {
out.push_str(&format!("[𝔻 evidence: {}]\n", ev));
}
for dep in &claim.depends_on {
out.push_str(&format!("[⟁ depends_on: {}]\n", dep));
}
out.push('\n');
}
out
}
fn claim_glyph(kind: Option<&str>) -> (&'static str, &'static str) {
match kind {
Some("state") => ("⍐", "state"),
Some("event") => ("⍌", "event"),
_ => ("⍂", "entity"),
}
}
fn resolve_output_path(state: &SessionState, input: &LightAuthorInput) -> M1ndResult<PathBuf> {
if let Some(ref override_path) = input.output_path {
return Ok(PathBuf::from(override_path));
}
let slug = slugify(&input.node_label);
let filename = format!("{}.light.md", slug);
Ok(state.runtime_root.join("agent-memory").join(filename))
}
fn slugify(s: &str) -> String {
let mut result = String::new();
let mut last_was_dash = false;
for ch in s.chars() {
if ch.is_ascii_alphanumeric() {
result.push(ch.to_ascii_lowercase());
last_was_dash = false;
} else if !last_was_dash {
result.push('-');
last_was_dash = true;
}
}
result.trim_end_matches('-').to_string()
}
struct LockGuard {
#[cfg(unix)]
fd: std::os::unix::io::RawFd,
}
impl LockGuard {
fn acquire(runtime_root: &Path, slug: &str) -> M1ndResult<Self> {
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
let locks_dir = runtime_root.join("agent-memory").join(".locks");
fs::create_dir_all(&locks_dir).map_err(M1ndError::Io)?;
let lock_path = locks_dir.join(format!("{}.lock", slug));
let file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.map_err(M1ndError::Io)?;
let fd = file.as_raw_fd();
let rc = unsafe { libc::flock(fd, libc::LOCK_EX) };
if rc != 0 {
return Err(M1ndError::Io(std::io::Error::last_os_error()));
}
let raw = {
use std::os::unix::io::IntoRawFd;
file.into_raw_fd()
};
Ok(LockGuard { fd: raw })
}
#[cfg(not(unix))]
{
let _ = (runtime_root, slug);
Ok(LockGuard {})
}
}
}
impl Drop for LockGuard {
fn drop(&mut self) {
#[cfg(unix)]
{
unsafe {
libc::flock(self.fd, libc::LOCK_UN);
libc::close(self.fd);
}
}
}
}
enum SupersessionPlan {
FirstWrite,
Supersede,
WouldDowngrade { reason: String },
}
struct PriorStrength {
state_rank: u8,
confidence: Option<f32>,
}
fn state_rank(state: &str) -> Option<u8> {
match state.trim().to_ascii_lowercase().as_str() {
"verified" => Some(2),
"authored" => Some(1),
"outdated" => Some(0),
_ => None,
}
}
fn confidence_scalar(raw: &str) -> Option<f32> {
let t = raw.trim().trim_end_matches(['.', ',', ';']);
if let Ok(v) = t.parse::<f32>() {
return Some(v);
}
match t.to_ascii_lowercase().as_str() {
"high" => Some(0.9),
"medium" => Some(0.6),
"low" => Some(0.3),
_ => None,
}
}
fn scan_prior_strength(text: &str) -> PriorStrength {
let mut state = "authored".to_string();
let mut max_conf: Option<f32> = None;
for line in text.lines() {
let trimmed = line.trim();
if let Some(v) = trimmed.strip_prefix("State:") {
state = v.trim().to_string();
} else if let Some(rest) = trimmed.strip_prefix("[𝔻 confidence:") {
let val = rest.trim_end_matches(']').trim();
if let Some(c) = confidence_scalar(val) {
max_conf = Some(max_conf.map_or(c, |m| m.max(c)));
}
}
}
PriorStrength {
state_rank: state_rank(&state).unwrap_or(0),
confidence: max_conf,
}
}
fn new_strength(input: &LightAuthorInput) -> PriorStrength {
let state = input.state.as_deref().unwrap_or("authored");
let mut max_conf: Option<f32> = None;
for claim in &input.claims {
if let Some(conf) = &claim.confidence {
if let Some(c) = confidence_scalar(conf) {
max_conf = Some(max_conf.map_or(c, |m| m.max(c)));
}
}
}
PriorStrength {
state_rank: state_rank(state).unwrap_or(0),
confidence: max_conf,
}
}
fn plan_supersession(out_path: &Path, input: &LightAuthorInput) -> M1ndResult<SupersessionPlan> {
if !out_path.exists() {
return Ok(SupersessionPlan::FirstWrite);
}
let prior_text = fs::read_to_string(out_path).map_err(M1ndError::Io)?;
let prior = scan_prior_strength(&prior_text);
let new = new_strength(input);
Ok(gate_supersession(&prior, &new))
}
fn gate_supersession(prior: &PriorStrength, new: &PriorStrength) -> SupersessionPlan {
let (Some(new_conf), Some(prior_conf)) = (new.confidence, prior.confidence) else {
return SupersessionPlan::WouldDowngrade {
reason: "would_downgrade".to_string(),
};
};
if new.state_rank >= prior.state_rank && new_conf >= prior_conf {
SupersessionPlan::Supersede
} else {
SupersessionPlan::WouldDowngrade {
reason: "would_downgrade".to_string(),
}
}
}
fn archive_prior_as_outdated(out_path: &Path, slug: &str, runtime_root: &Path) -> M1ndResult<()> {
let prior_text = fs::read_to_string(out_path).map_err(M1ndError::Io)?;
let outdated = flip_state_to_outdated(&prior_text);
let history_dir = runtime_root.join("agent-memory").join(".history");
fs::create_dir_all(&history_dir).map_err(M1ndError::Io)?;
let history_path = history_dir.join(format!("{}.{}.light.md", slug, now_ms()));
write_atomic(&history_path, &outdated)?;
Ok(())
}
fn flip_state_to_outdated(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut flipped = false;
for line in text.lines() {
if !flipped && line.trim_start().starts_with("State:") {
out.push_str("State: outdated");
flipped = true;
} else {
out.push_str(line);
}
out.push('\n');
}
out
}
fn write_atomic(path: &Path, contents: &str) -> M1ndResult<()> {
let parent = path.parent().ok_or_else(|| M1ndError::InvalidParams {
tool: "memorize".into(),
detail: "atomic write target has no parent directory".into(),
})?;
fs::create_dir_all(parent).map_err(M1ndError::Io)?;
let file_name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "memory.light.md".to_string());
let temp_path = parent.join(format!(".{}.{}.tmp", file_name, now_ms()));
fs::write(&temp_path, contents).map_err(M1ndError::Io)?;
fs::rename(&temp_path, path).map_err(M1ndError::Io)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::server::McpConfig;
use crate::session::SessionState;
use m1nd_core::domain::DomainConfig;
use m1nd_core::graph::Graph;
use m1nd_core::types::NodeType;
fn make_input(claims: Vec<LightClaim>) -> LightAuthorInput {
LightAuthorInput {
agent_id: "test-agent".into(),
node_label: "AuthSystem".into(),
title: Some("Authentication System".into()),
state: Some("verified".into()),
claims,
output_path: None,
namespace: None,
ingest_after: false,
mode: "merge".into(),
supersedes: None,
}
}
fn build_session(root: &std::path::Path) -> SessionState {
let runtime_dir = root.join("runtime");
std::fs::create_dir_all(&runtime_dir).expect("runtime dir");
let config = McpConfig {
graph_source: runtime_dir.join("graph.json"),
plasticity_state: runtime_dir.join("plasticity.json"),
runtime_dir: Some(runtime_dir),
..Default::default()
};
SessionState::initialize(Graph::new(), &config, DomainConfig::code()).expect("init session")
}
#[test]
fn memorize_renders_valid_l1ght() {
let input = make_input(vec![
LightClaim {
label: "TokenValidator".into(),
text: Some("The token validator checks JWT signatures.".into()),
kind: Some("entity".into()),
confidence: Some("0.9".into()),
ambiguity: None,
evidence: vec!["auth.rs".into()],
depends_on: vec!["JwtLibrary".into()],
},
LightClaim {
label: "SessionExpiry".into(),
text: None,
kind: Some("state".into()),
confidence: None,
ambiguity: None,
evidence: vec![],
depends_on: vec![],
},
]);
let md = render_light_markdown(&input);
assert!(
md.contains("Protocol: L1GHT/1.0"),
"missing protocol header"
);
assert!(md.contains("Node: AuthSystem"), "missing Node header");
let entity_pos = md
.find("[⍂ entity: TokenValidator]")
.expect("entity marker missing");
let conf_pos = md
.find("[𝔻 confidence: 0.9]")
.expect("confidence marker missing");
assert!(
entity_pos < conf_pos,
"entity marker must appear before 𝔻 confidence marker (parser attaches 𝔻 to last non-epistemic claim)"
);
assert!(
md.contains("[𝔻 evidence: auth.rs]"),
"evidence marker missing"
);
assert!(
md.contains("[⍐ state: SessionExpiry]"),
"state glyph missing"
);
}
#[test]
fn memorize_writes_and_ingests_with_evidence_bridge() {
let temp = tempfile::tempdir().expect("tempdir");
let proj = temp.path().join("proj");
std::fs::create_dir_all(&proj).expect("proj dir");
std::fs::write(
proj.join("auth.rs"),
"pub fn validate_token(t: &str) -> bool { !t.is_empty() }\n",
)
.expect("write auth.rs");
let runtime_dir = temp.path().join("runtime");
std::fs::create_dir_all(&runtime_dir).expect("runtime dir");
let config = McpConfig {
graph_source: runtime_dir.join("graph.json"),
plasticity_state: runtime_dir.join("plasticity.json"),
runtime_dir: Some(runtime_dir),
..Default::default()
};
let mut state = SessionState::initialize(Graph::new(), &config, DomainConfig::code())
.expect("init session");
let code_ingest = IngestInput {
path: proj.to_string_lossy().to_string(),
agent_id: "test".into(),
incremental: false,
adapter: "code".into(),
mode: "replace".into(),
namespace: None,
include_dotfiles: false,
dotfile_patterns: vec![],
};
crate::tools::handle_ingest(&mut state, code_ingest).expect("code ingest");
let input = LightAuthorInput {
agent_id: "test".into(),
node_label: "AuthNotes".into(),
title: None,
state: None,
claims: vec![LightClaim {
label: "TokenValidator".into(),
text: Some("The token validator checks JWT signatures.".into()),
kind: Some("entity".into()),
confidence: Some("0.9".into()),
ambiguity: None,
evidence: vec!["auth.rs".into()],
depends_on: vec![],
}],
output_path: None,
namespace: None,
ingest_after: true,
mode: "merge".into(),
supersedes: None,
};
let result = handle_light_author(&mut state, input).expect("memorize ok");
let path_str = result["path"].as_str().expect("path field");
assert!(
std::path::Path::new(path_str).exists(),
"output file not created: {}",
path_str
);
let resolved = result["light_evidence_resolved"].as_u64().unwrap_or(0);
assert!(
resolved >= 1,
"expected >=1 light_evidence_resolved, got {}",
resolved
);
assert_eq!(result["ok"], true);
assert_eq!(result["ingested"], true);
assert_eq!(result["schema"], "m1nd-memorize-v0");
}
#[test]
fn memorize_stamps_created_and_source_agent() {
let before = now_ms();
let input = make_input(vec![LightClaim {
label: "TokenValidator".into(),
text: None,
kind: Some("entity".into()),
confidence: None,
ambiguity: None,
evidence: vec![],
depends_on: vec![],
}]);
let md = render_light_markdown(&input);
let after = now_ms();
assert!(
md.contains("Source-Agent: test-agent"),
"Source-Agent frontmatter missing or wrong, got:\n{}",
md
);
let created_line = md
.lines()
.find_map(|l| l.strip_prefix("Created: "))
.expect("Created frontmatter line missing");
let created: u64 = created_line
.trim()
.parse()
.expect("Created value is not unix millis");
assert!(
created >= before && created <= after,
"Created={} not within [{}, {}] — implausible timestamp",
created,
before,
after
);
let created_pos = md.find("Created:").expect("Created pos");
let title_pos = md.find("# AuthSystem").expect("title pos");
assert!(
created_pos < title_pos,
"Created must be in frontmatter, before the title"
);
}
#[test]
fn legacy_light_md_without_provenance_still_ingests() {
let temp = tempfile::tempdir().expect("tempdir");
let proj = temp.path().join("proj");
std::fs::create_dir_all(&proj).expect("proj dir");
let legacy = "---\nProtocol: L1GHT/1.0\nNode: LegacyNode\nState: authored\n---\n\n# LegacyNode\n\n## LegacyNode\n\nA legacy claim with no provenance.\n\n[⍂ entity: LegacyClaim]\n[𝔻 confidence: 0.8]\n";
std::fs::write(proj.join("legacy.light.md"), legacy).expect("write legacy");
let runtime_dir = temp.path().join("runtime");
std::fs::create_dir_all(&runtime_dir).expect("runtime dir");
let config = McpConfig {
graph_source: runtime_dir.join("graph.json"),
plasticity_state: runtime_dir.join("plasticity.json"),
runtime_dir: Some(runtime_dir),
..Default::default()
};
let mut state = SessionState::initialize(Graph::new(), &config, DomainConfig::code())
.expect("init session");
let ingest = IngestInput {
path: proj.to_string_lossy().to_string(),
agent_id: "test".into(),
incremental: false,
adapter: "light".into(),
mode: "merge".into(),
namespace: Some("light".into()),
include_dotfiles: false,
dotfile_patterns: vec![],
};
let result = crate::tools::handle_ingest(&mut state, ingest)
.expect("legacy light .md must ingest without error");
let node_count = result["node_count"].as_u64().unwrap_or(0);
assert!(
node_count >= 1,
"legacy light .md should still produce nodes, got node_count={}",
node_count
);
}
#[test]
fn slugify_lowercases_and_replaces_non_alnum() {
assert_eq!(slugify("AuthSystem"), "authsystem");
assert_eq!(slugify("Hello World"), "hello-world");
assert_eq!(slugify("foo::bar::baz"), "foo-bar-baz");
assert_eq!(slugify(" leading"), "-leading");
}
fn super_input(node: &str, state: &str, confidence: &str) -> LightAuthorInput {
LightAuthorInput {
agent_id: "test-agent".into(),
node_label: node.into(),
title: None,
state: Some(state.into()),
claims: vec![LightClaim {
label: "Claim".into(),
text: Some("A claim.".into()),
kind: Some("entity".into()),
confidence: Some(confidence.into()),
ambiguity: None,
evidence: vec![],
depends_on: vec![],
}],
output_path: None,
namespace: None,
ingest_after: false,
mode: "merge".into(),
supersedes: None,
}
}
fn agent_memory_dir(state: &SessionState) -> PathBuf {
state.runtime_root.join("agent-memory")
}
#[test]
fn supersession_auto_supersedes_same_slug() {
let temp = tempfile::tempdir().expect("tempdir");
let mut state = build_session(temp.path());
handle_light_author(&mut state, super_input("X", "authored", "0.6"))
.expect("first memorize");
let live = agent_memory_dir(&state).join("x.light.md");
assert!(live.exists(), "first write should create the live file");
let result = handle_light_author(&mut state, super_input("X", "verified", "0.9"))
.expect("second memorize");
assert_eq!(result["superseded"], true, "second write should supersede");
let live_text = std::fs::read_to_string(&live).expect("read live");
assert!(
live_text.contains("Supersedes: x"),
"new live file must carry Supersedes lineage, got:\n{}",
live_text
);
assert!(
live_text.contains("State: verified"),
"new live file must be the verified claim"
);
let history_dir = agent_memory_dir(&state).join(".history");
let entries: Vec<_> = std::fs::read_dir(&history_dir)
.expect("history dir")
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| {
p.file_name()
.is_some_and(|n| n.to_string_lossy().starts_with("x."))
})
.collect();
assert_eq!(entries.len(), 1, "exactly one archived prior expected");
let archived = std::fs::read_to_string(&entries[0]).expect("read archived");
assert!(
archived.contains("State: outdated"),
"archived prior must be flipped to outdated, got:\n{}",
archived
);
assert!(
live.exists(),
"live file must still exist (nothing deleted)"
);
}
#[test]
fn supersession_downgrade_gate_refuses_weaker_write() {
let temp = tempfile::tempdir().expect("tempdir");
let mut state = build_session(temp.path());
handle_light_author(&mut state, super_input("X", "verified", "0.9"))
.expect("first memorize");
let live = agent_memory_dir(&state).join("x.light.md");
let before = std::fs::read_to_string(&live).expect("read live before");
let result = handle_light_author(&mut state, super_input("X", "authored", "0.5"))
.expect("second memorize");
assert_eq!(result["superseded"], false, "weaker write must be refused");
assert_eq!(result["reason"], "would_downgrade");
let after = std::fs::read_to_string(&live).expect("read live after");
assert_eq!(
before, after,
"live file must be unchanged by the refused write"
);
assert!(
after.contains("State: verified"),
"prior must stay verified live"
);
let history_dir = agent_memory_dir(&state).join(".history");
let history_count = std::fs::read_dir(&history_dir)
.map(|d| d.filter_map(Result::ok).count())
.unwrap_or(0);
assert_eq!(history_count, 0, "no archive on a refused downgrade");
}
#[test]
fn supersession_reload_ignores_history() {
let temp = tempfile::tempdir().expect("tempdir");
let mut state = build_session(temp.path());
handle_light_author(&mut state, super_input("Widget", "authored", "0.6"))
.expect("first memorize");
handle_light_author(&mut state, super_input("Widget", "verified", "0.9"))
.expect("supersede memorize");
let mem_dir = agent_memory_dir(&state);
assert!(
mem_dir.join(".history").exists(),
"history dir should exist"
);
assert!(
mem_dir.join("widget.light.md").exists(),
"live file should exist"
);
let live_only = IngestInput {
path: mem_dir
.join("widget.light.md")
.to_string_lossy()
.to_string(),
agent_id: "test".into(),
incremental: false,
adapter: "light".into(),
mode: "replace".into(),
namespace: Some("light".into()),
include_dotfiles: false,
dotfile_patterns: vec![],
};
let live_count = crate::tools::handle_ingest(&mut state, live_only).expect("live ingest")
["node_count"]
.as_u64()
.unwrap_or(0);
assert!(live_count >= 1, "the live memory should ingest");
let dir_ingest = IngestInput {
path: mem_dir.to_string_lossy().to_string(),
agent_id: "test".into(),
incremental: false,
adapter: "light".into(),
mode: "replace".into(),
namespace: Some("light".into()),
include_dotfiles: false,
dotfile_patterns: vec![],
};
let dir_count = crate::tools::handle_ingest(&mut state, dir_ingest).expect("dir ingest")
["node_count"]
.as_u64()
.unwrap_or(0);
assert_eq!(
dir_count, live_count,
"whole-dir ingest must equal live-only ingest — the .history copy must be pruned, not reloaded"
);
}
#[test]
fn supersession_concurrent_same_slug_is_serialized() {
use std::sync::Arc;
use std::thread;
let temp = tempfile::tempdir().expect("tempdir");
let root = Arc::new(temp.path().to_path_buf());
{
let mut state = build_session(root.as_path());
handle_light_author(&mut state, super_input("Race", "authored", "0.5")).expect("seed");
}
let sessions: Vec<SessionState> =
(0..2u32).map(|_| build_session(root.as_path())).collect();
let mut handles = Vec::new();
for (i, mut state) in sessions.into_iter().enumerate() {
handles.push(thread::spawn(move || {
let conf = if i == 0 { "0.9" } else { "0.8" };
let _ = handle_light_author(&mut state, super_input("Race", "verified", conf));
}));
}
for h in handles {
h.join().expect("thread join");
}
let mem_dir = root.join("runtime").join("agent-memory");
let live = mem_dir.join("race.light.md");
assert!(live.exists(), "exactly one live file must remain");
let text = std::fs::read_to_string(&live).expect("read live");
assert!(
text.contains("Protocol: L1GHT/1.0") && text.trim_end().ends_with("]"),
"live file must be a complete, non-torn document, got:\n{}",
text
);
let stray_temp = std::fs::read_dir(&mem_dir)
.expect("read mem dir")
.filter_map(Result::ok)
.any(|e| e.file_name().to_string_lossy().ends_with(".tmp"));
assert!(!stray_temp, "no torn/leftover .tmp files should remain");
}
#[test]
fn supersession_supersedes_frontmatter_round_trips() {
let mut input = super_input("Round", "verified", "0.9");
input.supersedes = Some("round".into());
let md = render_light_markdown(&input);
assert!(
md.contains("Supersedes: round"),
"Supersedes line must render, got:\n{}",
md
);
let temp = tempfile::tempdir().expect("tempdir");
let proj = temp.path().join("proj");
std::fs::create_dir_all(&proj).expect("proj dir");
std::fs::write(proj.join("round.light.md"), &md).expect("write");
let runtime_dir = temp.path().join("runtime");
std::fs::create_dir_all(&runtime_dir).expect("runtime dir");
let config = McpConfig {
graph_source: runtime_dir.join("graph.json"),
plasticity_state: runtime_dir.join("plasticity.json"),
runtime_dir: Some(runtime_dir),
..Default::default()
};
let mut state = SessionState::initialize(Graph::new(), &config, DomainConfig::code())
.expect("init session");
let ingest = IngestInput {
path: proj.to_string_lossy().to_string(),
agent_id: "test".into(),
incremental: false,
adapter: "light".into(),
mode: "merge".into(),
namespace: Some("light".into()),
include_dotfiles: false,
dotfile_patterns: vec![],
};
let result = crate::tools::handle_ingest(&mut state, ingest)
.expect("doc with Supersedes must ingest without error");
assert!(result["node_count"].as_u64().unwrap_or(0) >= 1);
}
}