use crate::protocol::core::IngestInput;
use crate::session::SessionState;
use m1nd_core::error::{M1ndError, M1ndResult};
use serde::Deserialize;
use serde_json::{json, Value};
use std::fs;
use std::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,
}
pub fn handle_light_author(state: &mut SessionState, input: LightAuthorInput) -> M1ndResult<Value> {
let out_path = resolve_output_path(state, &input)?;
let markdown = render_light_markdown(&input);
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)?;
fs::write(&out_path, &markdown).map_err(M1ndError::Io)?;
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()
};
return Ok(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,
}));
}
Ok(json!({
"ok": true,
"schema": "m1nd-memorize-v0",
"path": path_str,
"bytes_written": bytes_written,
"claims_written": claims_written,
"ingested": false,
"rendered": markdown,
}))
}
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("---\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()
}
#[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(),
}
}
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(),
};
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 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");
}
}