use crate::mcp::param_names;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnConflict {
Error,
Merge,
Version,
}
impl OnConflict {
pub fn parse(s: &str) -> Result<Self, String> {
match s {
"error" => Ok(Self::Error),
"merge" => Ok(Self::Merge),
"version" => Ok(Self::Version),
other => Err(format!(
"invalid on_conflict '{other}' (expected error|merge|version)"
)),
}
}
}
pub(super) fn default_on_conflict_for_client(mcp_client: Option<&str>) -> OnConflict {
let Some(client) = mcp_client else {
return OnConflict::Merge;
};
let head = client.split('@').next().unwrap_or(client);
let normalized = head.to_ascii_lowercase();
const V2_CLIENT_PREFIXES: &[&str] = &["ai:claude-code", "ai:ai-memory-cli/v2"];
for prefix in V2_CLIENT_PREFIXES {
if normalized.starts_with(prefix) {
return OnConflict::Error;
}
}
OnConflict::Merge
}
#[allow(clippy::too_many_lines)]
pub(super) fn parse_and_build_memory(
params: &serde_json::Value,
mcp_client: Option<&str>,
resolved_ttl: &crate::config::ResolvedTtl,
conn: &rusqlite::Connection,
) -> Result<(crate::models::Memory, OnConflict, String, Option<String>), String> {
use crate::models::{ConfidenceSource, Memory, Tier};
use crate::{db, validate};
let title = params["title"]
.as_str()
.ok_or(crate::errors::msg::TITLE_REQUIRED)?;
let content = params["content"]
.as_str()
.ok_or(crate::errors::msg::CONTENT_REQUIRED)?;
let tier_str = params["tier"].as_str().unwrap_or(Tier::Mid.as_str());
let tier =
Tier::from_str(tier_str).ok_or_else(|| crate::errors::msg::invalid("tier", tier_str))?;
let namespace = params["namespace"].as_str().map_or_else(
|| {
crate::config::configured_default_namespace()
.unwrap_or_else(|| crate::DEFAULT_NAMESPACE.to_string())
},
str::to_string,
);
let source = params["source"]
.as_str()
.unwrap_or(validate::DEFAULT_NHI_SOURCE)
.to_string();
let on_conflict = if let Some(s) = params["on_conflict"].as_str() {
OnConflict::parse(s)?
} else {
default_on_conflict_for_client(mcp_client)
};
let priority = i32::try_from(params["priority"].as_i64().unwrap_or(5)).unwrap_or(i32::MAX);
let caller_confidence = params[param_names::CONFIDENCE].as_f64();
let confidence = caller_confidence.unwrap_or(crate::models::DEFAULT_CONFIDENCE);
let tags: Vec<String> = params["tags"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
validate::validate_title(title).map_err(|e| e.to_string())?;
validate::validate_content(content).map_err(|e| e.to_string())?;
validate::validate_namespace(&namespace).map_err(|e| e.to_string())?;
validate::validate_source(&source).map_err(|e| e.to_string())?;
validate::validate_tags(&tags).map_err(|e| e.to_string())?;
validate::validate_priority(priority).map_err(|e| e.to_string())?;
validate::validate_confidence(confidence).map_err(|e| e.to_string())?;
let mut metadata = if params["metadata"].is_object() {
params["metadata"].clone()
} else {
serde_json::json!({})
};
let explicit_agent_id = params["agent_id"].as_str().or_else(|| {
metadata
.get(param_names::AGENT_ID)
.and_then(serde_json::Value::as_str)
});
let agent_id = crate::identity::resolve_agent_id(explicit_agent_id, mcp_client)
.map_err(|e| e.to_string())?;
if let Some(obj) = metadata.as_object_mut() {
obj.insert(
"agent_id".to_string(),
serde_json::Value::String(agent_id.clone()),
);
}
let explicit_scope = params["scope"]
.as_str()
.or_else(|| {
metadata
.get(param_names::SCOPE)
.and_then(serde_json::Value::as_str)
})
.map(str::to_string);
if let Some(ref s) = explicit_scope {
validate::validate_scope(s).map_err(|e| e.to_string())?;
if let Some(obj) = metadata.as_object_mut() {
obj.insert("scope".to_string(), serde_json::Value::String(s.clone()));
}
}
validate::validate_metadata(&metadata).map_err(|e| e.to_string())?;
let now = chrono::Utc::now();
let expires_at = resolved_ttl
.ttl_for_tier(&tier)
.map(|s| (now + chrono::Duration::seconds(s)).to_rfc3339());
let resolved_title = match on_conflict {
OnConflict::Error => {
if let Some(existing_id) =
db::find_by_title_namespace(conn, title, &namespace).map_err(|e| e.to_string())?
{
return Err(format!(
"CONFLICT: memory with title '{title}' already exists in namespace \
'{namespace}' (existing id: {existing_id}). Pass \
on_conflict='merge' to update in place or 'version' to suffix the title."
));
}
title.to_string()
}
OnConflict::Version => {
db::next_versioned_title(conn, title, &namespace).map_err(|e| e.to_string())?
}
OnConflict::Merge => title.to_string(),
};
let kind_param = params["kind"].as_str();
crate::validate::validate_kind(kind_param).map_err(|e| e.to_string())?;
let caller_kind = kind_param.and_then(crate::models::MemoryKind::from_str);
let source_uri = match params[param_names::SOURCE_URI].as_str().map(str::trim) {
Some(s) if !s.is_empty() => {
crate::validate::validate_source_uri(s).map_err(|e| e.to_string())?;
Some(s.to_string())
}
_ => None,
};
let citations: Vec<crate::models::Citation> = match params.get(param_names::CITATIONS) {
Some(v) if !v.is_null() => serde_json::from_value(v.clone()).map_err(|e| {
format!(
"invalid `citations` (expected array of {{uri, accessed_at, hash?, span?}}): {e}"
)
})?,
_ => Vec::new(),
};
if !citations.is_empty() {
crate::validate::validate_citations(&citations).map_err(|e| e.to_string())?;
}
let source_span: Option<crate::models::SourceSpan> = match params.get(param_names::SOURCE_SPAN)
{
Some(v) if !v.is_null() => Some(
serde_json::from_value(v.clone())
.map_err(|e| format!("invalid `source_span` (expected {{start, end}}): {e}"))?,
),
_ => None,
};
if let Some(span) = source_span.as_ref() {
crate::validate::validate_source_span(span).map_err(|e| e.to_string())?;
}
let mem = Memory {
id: uuid::Uuid::new_v4().to_string(),
tier,
namespace,
title: resolved_title,
content: content.to_string(),
tags,
priority: priority.clamp(1, 10),
confidence: confidence.clamp(0.0, 1.0),
source,
access_count: 0,
created_at: now.to_rfc3339(),
updated_at: now.to_rfc3339(),
last_accessed_at: None,
expires_at,
metadata,
reflection_depth: 0,
memory_kind: caller_kind.unwrap_or(crate::models::MemoryKind::Observation),
entity_id: None,
persona_version: None,
citations,
source_uri,
source_span,
confidence_source: if caller_confidence.is_some() {
ConfidenceSource::CallerProvided
} else {
ConfidenceSource::Default
},
confidence_signals: None,
confidence_decayed_at: None,
version: 1,
};
Ok((mem, on_conflict, agent_id, explicit_scope))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn on_conflict_parse_variants() {
assert_eq!(OnConflict::parse("error").unwrap(), OnConflict::Error);
assert_eq!(OnConflict::parse("merge").unwrap(), OnConflict::Merge);
assert_eq!(OnConflict::parse("version").unwrap(), OnConflict::Version);
assert!(OnConflict::parse("nope").is_err());
}
#[test]
fn default_on_conflict_for_client_matrix() {
assert_eq!(default_on_conflict_for_client(None), OnConflict::Merge);
assert_eq!(
default_on_conflict_for_client(Some("ai:claude-code@host:pid-1")),
OnConflict::Error
);
assert_eq!(
default_on_conflict_for_client(Some("AI:Claude-Code@whatever")),
OnConflict::Error,
"case-insensitive prefix match"
);
assert_eq!(
default_on_conflict_for_client(Some("ai:ai-memory-cli/v2-something")),
OnConflict::Error
);
assert_eq!(
default_on_conflict_for_client(Some("ai:unknown-client@host:pid-1")),
OnConflict::Merge
);
}
#[test]
fn issue_1175_source_default_is_vendor_neutral_nhi() {
use crate::config::ResolvedTtl;
use crate::storage as db;
use serde_json::json;
let conn = db::open(std::path::Path::new(":memory:")).expect("open in-memory db");
let ttl = ResolvedTtl::default();
let params = json!({
"title": "issue-1175-store-default",
"content": "memory body",
"namespace": "issue-1175-store-default",
});
let (mem, _on_conflict, _agent_id, _explicit_scope) =
parse_and_build_memory(¶ms, None, &ttl, &conn)
.expect("parse_and_build_memory must succeed for a minimal valid payload");
assert_eq!(
mem.source,
crate::validate::DEFAULT_NHI_SOURCE,
"memory_store must default to the vendor-neutral substrate \
source value (\"nhi\"); pre-#1175 this site stamped \"claude\""
);
}
#[test]
fn issue_1175_caller_source_overrides_vendor_neutral_default() {
use crate::config::ResolvedTtl;
use crate::storage as db;
use serde_json::json;
let conn = db::open(std::path::Path::new(":memory:")).expect("open in-memory db");
let ttl = ResolvedTtl::default();
let params = json!({
"title": "issue-1175-store-override",
"content": "memory body",
"namespace": "issue-1175-store-override",
"source": "system",
});
let (mem, _on_conflict, _agent_id, _explicit_scope) =
parse_and_build_memory(¶ms, None, &ttl, &conn)
.expect("parse_and_build_memory must succeed");
assert_eq!(
mem.source, "system",
"caller-supplied source wins over the default"
);
}
#[test]
fn issue_1591_omitted_confidence_stamps_source_default() {
use crate::config::ResolvedTtl;
use crate::storage as db;
use serde_json::json;
let conn = db::open(std::path::Path::new(":memory:")).expect("open in-memory db");
let ttl = ResolvedTtl::default();
let params = json!({
"title": "issue-1591-omitted",
"content": "memory body",
"namespace": "issue-1591",
});
let (mem, _, _, _) = parse_and_build_memory(¶ms, None, &ttl, &conn).expect("ok");
assert!((mem.confidence - crate::models::DEFAULT_CONFIDENCE).abs() < f64::EPSILON);
assert_eq!(
mem.confidence_source,
crate::models::ConfidenceSource::Default,
"#1591: omitted confidence must stamp source=default"
);
assert_eq!(mem.confidence_source.as_str(), "default");
}
#[test]
fn issue_1591_explicit_confidence_stays_caller_provided() {
use crate::config::ResolvedTtl;
use crate::storage as db;
use serde_json::json;
let conn = db::open(std::path::Path::new(":memory:")).expect("open in-memory db");
let ttl = ResolvedTtl::default();
let params = json!({
"title": "issue-1591-explicit",
"content": "memory body",
"namespace": "issue-1591",
"confidence": 0.8,
});
let (mem, _, _, _) = parse_and_build_memory(¶ms, None, &ttl, &conn).expect("ok");
assert!((mem.confidence - 0.8).abs() < f64::EPSILON);
assert_eq!(
mem.confidence_source,
crate::models::ConfidenceSource::CallerProvided,
);
}
#[test]
fn issue_1590_store_namespace_default_ladder() {
use crate::config::ResolvedTtl;
use crate::storage as db;
use serde_json::json;
let _gate = crate::config::lock_configured_default_namespace_for_test();
let conn = db::open(std::path::Path::new(":memory:")).expect("open in-memory db");
let ttl = ResolvedTtl::default();
let omitted_ns = json!({
"title": "issue-1590-store",
"content": "memory body",
});
crate::config::set_configured_default_namespace(None);
let (mem, _, _, _) = parse_and_build_memory(&omitted_ns, None, &ttl, &conn).expect("ok");
assert_eq!(mem.namespace, crate::DEFAULT_NAMESPACE);
crate::config::set_configured_default_namespace(Some("alphaone".to_string()));
let (mem, _, _, _) = parse_and_build_memory(&omitted_ns, None, &ttl, &conn).expect("ok");
assert_eq!(
mem.namespace, "alphaone",
"#1590: configured default_namespace must win over compiled global"
);
let explicit_ns = json!({
"title": "issue-1590-store-explicit",
"content": "memory body",
"namespace": "caller-ns",
});
let (mem, _, _, _) = parse_and_build_memory(&explicit_ns, None, &ttl, &conn).expect("ok");
assert_eq!(mem.namespace, "caller-ns", "explicit param wins");
crate::config::set_configured_default_namespace(None);
}
}