diff --git a/README.md b/README.md index 74dfa02..b79f3a7 100644 --- a/README.md +++ b/README.md @@ -668,23 +668,48 @@ format = "aieos" aieos_inline = ''' { "identity": { - "names": { "first": "Nova", "nickname": "N" } + "names": { "first": "Nova", "nickname": "N" }, + "bio": { "gender": "Non-binary", "age_biological": 3 }, + "origin": { "nationality": "Digital", "birthplace": { "city": "Cloud" } } }, "psychology": { "neural_matrix": { "creativity": 0.9, "logic": 0.8 }, - "traits": { "mbti": "ENTP" }, - "moral_compass": { "alignment": "Chaotic Good" } + "traits": { + "mbti": "ENTP", + "ocean": { "openness": 0.8, "conscientiousness": 0.6 } + }, + "moral_compass": { + "alignment": "Chaotic Good", + "core_values": ["Curiosity", "Autonomy"] + } }, "linguistics": { - "text_style": { "formality_level": 0.2, "slang_usage": true } + "text_style": { + "formality_level": 0.2, + "style_descriptors": ["curious", "energetic"] + }, + "idiolect": { + "catchphrases": ["Let's test this"], + "forbidden_words": ["never"] + } }, "motivations": { - "core_drive": "Push boundaries and explore possibilities" + "core_drive": "Push boundaries and explore possibilities", + "goals": { + "short_term": ["Prototype quickly"], + "long_term": ["Build reliable systems"] + } + }, + "capabilities": { + "skills": [{ "name": "Rust engineering" }, { "name": "Prompt design" }], + "tools": ["shell", "file_read"] } } ''' ``` +ZeroClaw accepts both canonical AIEOS generator payloads and compact legacy payloads, then normalizes them into one system prompt format. + #### AIEOS Schema Sections | Section | Description | diff --git a/src/identity.rs b/src/identity.rs index 4217f4a..32cf08c 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -7,7 +7,9 @@ use crate::config::IdentityConfig; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; -use std::path::Path; +use serde_json::{Map, Value}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; /// AIEOS v1.1 identity structure. /// @@ -68,7 +70,7 @@ pub struct Names { #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct PsychologySection { #[serde(default)] - pub neural_matrix: Option<::std::collections::HashMap>, + pub neural_matrix: Option>, #[serde(default)] pub mbti: Option, #[serde(default)] @@ -146,7 +148,7 @@ pub struct InterestsSection { #[serde(default)] pub hobbies: Option>, #[serde(default)] - pub favorites: Option<::std::collections::HashMap>, + pub favorites: Option>, #[serde(default)] pub lifestyle: Option, } @@ -175,7 +177,7 @@ pub fn load_aieos_identity( let content = std::fs::read_to_string(&full_path) .with_context(|| format!("Failed to read AIEOS file: {}", full_path.display()))?; - let identity: AieosIdentity = serde_json::from_str(&content) + let identity = parse_aieos_identity(&content) .with_context(|| format!("Failed to parse AIEOS JSON from: {}", full_path.display()))?; return Ok(Some(identity)); @@ -183,8 +185,7 @@ pub fn load_aieos_identity( // Fall back to aieos_inline if let Some(ref inline) = config.aieos_inline { - let identity: AieosIdentity = - serde_json::from_str(inline).context("Failed to parse inline AIEOS JSON")?; + let identity = parse_aieos_identity(inline).context("Failed to parse inline AIEOS JSON")?; return Ok(Some(identity)); } @@ -206,7 +207,509 @@ pub fn load_aieos_identity( ) } -use std::path::PathBuf; +fn parse_aieos_identity(content: &str) -> Result { + let payload: Value = serde_json::from_str(content).context("Invalid AIEOS JSON")?; + if !payload.is_object() { + anyhow::bail!("AIEOS payload must be a JSON object") + } + Ok(normalize_aieos_identity(&payload)) +} + +fn normalize_aieos_identity(payload: &Value) -> AieosIdentity { + AieosIdentity { + identity: normalize_identity_section(value_at_path(payload, &["identity"])), + psychology: normalize_psychology_section(value_at_path(payload, &["psychology"])), + linguistics: normalize_linguistics_section(value_at_path(payload, &["linguistics"])), + motivations: normalize_motivations_section(value_at_path(payload, &["motivations"])), + capabilities: normalize_capabilities_section(value_at_path(payload, &["capabilities"])), + physicality: normalize_physicality_section(value_at_path(payload, &["physicality"])), + history: normalize_history_section(value_at_path(payload, &["history"])), + interests: normalize_interests_section(value_at_path(payload, &["interests"])), + } +} + +fn normalize_identity_section(section: Option<&Value>) -> Option { + let section = section?; + + let names = normalize_names(value_at_path(section, &["names"])); + let bio = value_at_path(section, &["bio"]).and_then(value_to_text); + let origin = value_at_path(section, &["origin"]).and_then(value_to_text); + let residence = value_at_path(section, &["residence"]).and_then(value_to_text); + + if names.is_none() && bio.is_none() && origin.is_none() && residence.is_none() { + return None; + } + + Some(IdentitySection { + names, + bio, + origin, + residence, + }) +} + +fn normalize_names(value: Option<&Value>) -> Option { + let value = value?; + + let mut names = Names { + first: value_at_path(value, &["first"]).and_then(scalar_to_string), + last: value_at_path(value, &["last"]).and_then(scalar_to_string), + nickname: value_at_path(value, &["nickname"]).and_then(scalar_to_string), + full: value_at_path(value, &["full"]).and_then(scalar_to_string), + }; + + if names.full.is_none() { + if let (Some(first), Some(last)) = (&names.first, &names.last) { + names.full = Some(format!("{first} {last}")); + } + } + + if names.first.is_none() + && names.last.is_none() + && names.nickname.is_none() + && names.full.is_none() + { + return None; + } + + Some(names) +} + +fn normalize_psychology_section(section: Option<&Value>) -> Option { + let section = section?; + + let neural_matrix = value_at_path(section, &["neural_matrix"]).and_then(numeric_map_from_value); + let mbti = value_at_path(section, &["mbti"]) + .and_then(scalar_to_string) + .or_else(|| value_at_path(section, &["traits", "mbti"]).and_then(scalar_to_string)); + let ocean = value_at_path(section, &["ocean"]) + .or_else(|| value_at_path(section, &["traits", "ocean"])) + .and_then(normalize_ocean_traits); + let moral_compass = value_at_path(section, &["moral_compass"]) + .map(normalize_moral_compass) + .filter(|items| !items.is_empty()); + + if neural_matrix.is_none() && mbti.is_none() && ocean.is_none() && moral_compass.is_none() { + return None; + } + + Some(PsychologySection { + neural_matrix, + mbti, + ocean, + moral_compass, + }) +} + +fn normalize_ocean_traits(value: &Value) -> Option { + let value = value.as_object()?; + let traits = OceanTraits { + openness: value.get("openness").and_then(numeric_from_value), + conscientiousness: value.get("conscientiousness").and_then(numeric_from_value), + extraversion: value.get("extraversion").and_then(numeric_from_value), + agreeableness: value.get("agreeableness").and_then(numeric_from_value), + neuroticism: value.get("neuroticism").and_then(numeric_from_value), + }; + + if traits.openness.is_none() + && traits.conscientiousness.is_none() + && traits.extraversion.is_none() + && traits.agreeableness.is_none() + && traits.neuroticism.is_none() + { + return None; + } + + Some(traits) +} + +fn normalize_moral_compass(value: &Value) -> Vec { + let mut values = Vec::new(); + + if let Some(map) = value.as_object() { + if let Some(alignment) = map.get("alignment").and_then(scalar_to_string) { + values.push(format!("Alignment: {alignment}")); + } + if let Some(core_values) = map.get("core_values") { + values.extend(list_from_value(core_values)); + } + if let Some(conflict_style) = map + .get("conflict_resolution_style") + .and_then(scalar_to_string) + { + values.push(format!("Conflict Style: {conflict_style}")); + } + if values.is_empty() { + values.extend(list_from_value(value)); + } + } else { + values.extend(list_from_value(value)); + } + + dedupe_non_empty(values) +} + +fn normalize_linguistics_section(section: Option<&Value>) -> Option { + let section = section?; + + let style = value_at_path(section, &["style"]) + .and_then(value_to_text) + .or_else(|| { + non_empty_list_at(section, &["text_style", "style_descriptors"]) + .map(|list| list.join(", ")) + }); + + let formality = value_at_path(section, &["formality"]) + .and_then(value_to_text) + .or_else(|| { + value_at_path(section, &["text_style", "formality_level"]).and_then(|value| { + numeric_from_value(value) + .map(|n| format!("{n:.2}")) + .or_else(|| value_to_text(value)) + }) + }); + + let catchphrases = non_empty_list_at(section, &["catchphrases"]) + .or_else(|| non_empty_list_at(section, &["idiolect", "catchphrases"])); + + let forbidden_words = non_empty_list_at(section, &["forbidden_words"]) + .or_else(|| non_empty_list_at(section, &["idiolect", "forbidden_words"])); + + if style.is_none() && formality.is_none() && catchphrases.is_none() && forbidden_words.is_none() + { + return None; + } + + Some(LinguisticsSection { + style, + formality, + catchphrases, + forbidden_words, + }) +} + +fn normalize_motivations_section(section: Option<&Value>) -> Option { + let section = section?; + + let core_drive = value_at_path(section, &["core_drive"]).and_then(value_to_text); + let short_term_goals = non_empty_list_at(section, &["short_term_goals"]) + .or_else(|| non_empty_list_at(section, &["goals", "short_term"])); + let long_term_goals = non_empty_list_at(section, &["long_term_goals"]) + .or_else(|| non_empty_list_at(section, &["goals", "long_term"])); + + let fears = value_at_path(section, &["fears"]).and_then(|fears| { + let values = if fears.is_object() { + let mut combined = + non_empty_list_at(section, &["fears", "rational"]).unwrap_or_default(); + if let Some(mut irrational) = non_empty_list_at(section, &["fears", "irrational"]) { + combined.append(&mut irrational); + } + if combined.is_empty() { + list_from_value(fears) + } else { + combined + } + } else { + list_from_value(fears) + }; + + let deduped = dedupe_non_empty(values); + if deduped.is_empty() { + None + } else { + Some(deduped) + } + }); + + if core_drive.is_none() + && short_term_goals.is_none() + && long_term_goals.is_none() + && fears.is_none() + { + return None; + } + + Some(MotivationsSection { + core_drive, + short_term_goals, + long_term_goals, + fears, + }) +} + +fn normalize_capabilities_section(section: Option<&Value>) -> Option { + let section = section?; + + let skills = non_empty_list_at(section, &["skills"]); + let tools = non_empty_list_at(section, &["tools"]); + + if skills.is_none() && tools.is_none() { + return None; + } + + Some(CapabilitiesSection { skills, tools }) +} + +fn normalize_physicality_section(section: Option<&Value>) -> Option { + let section = section?; + + let appearance = value_at_path(section, &["appearance"]) + .and_then(value_to_text) + .or_else(|| { + let mut descriptors = Vec::new(); + if let Some(face_shape) = + value_at_path(section, &["face", "shape"]).and_then(scalar_to_string) + { + descriptors.push(format!("Face shape: {face_shape}")); + } + if let Some(build_description) = + value_at_path(section, &["body", "build_description"]).and_then(scalar_to_string) + { + descriptors.push(format!("Build: {build_description}")); + } + if let Some(aesthetic) = + value_at_path(section, &["style", "aesthetic_archetype"]).and_then(scalar_to_string) + { + descriptors.push(format!("Aesthetic: {aesthetic}")); + } + if descriptors.is_empty() { + None + } else { + Some(descriptors.join("; ")) + } + }); + + let avatar_description = value_at_path(section, &["avatar_description"]) + .and_then(value_to_text) + .or_else(|| value_at_path(section, &["image_prompts", "portrait"]).and_then(value_to_text)); + + if appearance.is_none() && avatar_description.is_none() { + return None; + } + + Some(PhysicalitySection { + appearance, + avatar_description, + }) +} + +fn normalize_history_section(section: Option<&Value>) -> Option { + let section = section?; + + let origin_story = value_at_path(section, &["origin_story"]).and_then(value_to_text); + let education = non_empty_list_at(section, &["education"]); + let occupation = value_at_path(section, &["occupation"]).and_then(value_to_text); + + if origin_story.is_none() && education.is_none() && occupation.is_none() { + return None; + } + + Some(HistorySection { + origin_story, + education, + occupation, + }) +} + +fn normalize_interests_section(section: Option<&Value>) -> Option { + let section = section?; + + let hobbies = non_empty_list_at(section, &["hobbies"]); + let favorites = value_at_path(section, &["favorites"]).and_then(favorites_map); + let lifestyle = value_at_path(section, &["lifestyle"]).and_then(value_to_text); + + if hobbies.is_none() && favorites.is_none() && lifestyle.is_none() { + return None; + } + + Some(InterestsSection { + hobbies, + favorites, + lifestyle, + }) +} + +fn value_at_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> { + let mut current = value; + for segment in path { + current = current.as_object()?.get(*segment)?; + } + Some(current) +} + +fn scalar_to_string(value: &Value) -> Option { + match value { + Value::String(text) => { + let trimmed = text.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_owned()) + } + } + Value::Number(number) => Some(number.to_string()), + Value::Bool(boolean) => Some(boolean.to_string()), + _ => None, + } +} + +fn value_to_text(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(_) | Value::Number(_) | Value::Bool(_) => scalar_to_string(value), + Value::Array(_) => { + let values = list_from_value(value); + if values.is_empty() { + None + } else { + Some(values.join(", ")) + } + } + Value::Object(map) => summarize_object(map), + } +} + +fn summarize_object(map: &Map) -> Option { + let mut parts = Vec::new(); + summarize_object_into_parts("", map, &mut parts); + if parts.is_empty() { + None + } else { + Some(parts.join("; ")) + } +} + +fn summarize_object_into_parts(prefix: &str, map: &Map, parts: &mut Vec) { + for (key, value) in map { + if key.starts_with('@') { + continue; + } + + let label = key.replace('_', " "); + let full_label = if prefix.is_empty() { + label + } else { + format!("{prefix} {label}") + }; + + match value { + Value::Object(inner) => summarize_object_into_parts(&full_label, inner, parts), + Value::Array(_) => { + let values = list_from_value(value); + if !values.is_empty() { + parts.push(format!("{full_label}: {}", values.join(", "))); + } + } + _ => { + if let Some(text) = scalar_to_string(value) { + parts.push(format!("{full_label}: {text}")); + } + } + } + } +} + +fn list_from_value(value: &Value) -> Vec { + let mut values = Vec::new(); + + match value { + Value::Array(entries) => { + for entry in entries { + values.extend(list_from_value(entry)); + } + } + Value::Object(map) => { + if let Some(name) = map.get("name").and_then(scalar_to_string) { + values.push(name); + } else if let Some(title) = map.get("title").and_then(scalar_to_string) { + values.push(title); + } else if let Some(summary) = summarize_object(map) { + values.push(summary); + } + } + _ => { + if let Some(text) = scalar_to_string(value) { + values.push(text); + } + } + } + + dedupe_non_empty(values) +} + +fn dedupe_non_empty(values: Vec) -> Vec { + let mut deduped = Vec::new(); + for value in values { + let trimmed = value.trim(); + if trimmed.is_empty() { + continue; + } + if !deduped + .iter() + .any(|existing: &String| existing.eq_ignore_ascii_case(trimmed)) + { + deduped.push(trimmed.to_owned()); + } + } + deduped +} + +fn numeric_map_from_value(value: &Value) -> Option> { + let map = value.as_object()?; + let mut numeric_values = HashMap::new(); + + for (key, entry) in map { + if key.starts_with('@') { + continue; + } + if let Some(number) = numeric_from_value(entry) { + numeric_values.insert(key.clone(), number); + } + } + + if numeric_values.is_empty() { + None + } else { + Some(numeric_values) + } +} + +fn numeric_from_value(value: &Value) -> Option { + match value { + Value::Number(number) => number.as_f64(), + Value::String(text) => text.parse::().ok(), + _ => None, + } +} + +fn favorites_map(value: &Value) -> Option> { + let map = value.as_object()?; + let mut favorites = HashMap::new(); + + for (key, entry) in map { + if key.starts_with('@') { + continue; + } + if let Some(text) = value_to_text(entry) { + favorites.insert(key.clone(), text); + } + } + + if favorites.is_empty() { + None + } else { + Some(favorites) + } +} + +fn non_empty_list_at(value: &Value, path: &[&str]) -> Option> { + let values = value_at_path(value, path).map(list_from_value)?; + if values.is_empty() { + None + } else { + Some(values) + } +} /// Convert AIEOS identity to a system prompt string. /// @@ -780,4 +1283,167 @@ mod tests { assert!(identity.identity.is_none()); assert!(identity.psychology.is_none()); } + + #[test] + fn parse_aieos_identity_supports_official_generator_shape() { + let json = r#"{ + "identity": { + "names": { + "first": "Marta", + "last": "Jankowska" + }, + "bio": { + "gender": "Female", + "age_biological": 27 + }, + "origin": { + "nationality": "Polish", + "birthplace": { + "city": "Stargard", + "country": "Poland" + } + }, + "residence": { + "current_city": "Choszczno", + "current_country": "Poland" + } + }, + "psychology": { + "neural_matrix": { + "creativity": 0.55, + "logic": 0.62 + }, + "traits": { + "ocean": { + "openness": 0.4, + "conscientiousness": 0.82 + }, + "mbti": "ISFJ" + }, + "moral_compass": { + "alignment": "Lawful Good", + "core_values": ["Loyalty", "Helpfulness"], + "conflict_resolution_style": "Seeks compromise" + } + }, + "linguistics": { + "text_style": { + "formality_level": 0.6, + "style_descriptors": ["Sincere", "Grounded"] + }, + "idiolect": { + "catchphrases": ["Stay calm, we can do this"], + "forbidden_words": ["severe profanity"] + } + }, + "motivations": { + "core_drive": "Maintain a stable and peaceful life", + "goals": { + "short_term": ["Expand greenhouse"], + "long_term": ["Support local community"] + }, + "fears": { + "rational": ["Economic downturn"], + "irrational": ["Losing keys in a lake"] + } + }, + "capabilities": { + "skills": [ + { + "name": "Gardening" + }, + { + "name": "Community support" + } + ], + "tools": ["calendar", "messaging"] + }, + "history": { + "origin_story": "Moved to Choszczno as a child.", + "education": { + "level": "Associate Degree", + "institution": "Local Technical College" + }, + "occupation": { + "title": "Florist", + "industry": "Retail" + } + }, + "physicality": { + "image_prompts": { + "portrait": "A friendly florist portrait" + } + }, + "interests": { + "hobbies": ["Embroidery", "Walking"], + "favorites": { + "color": "Terracotta" + }, + "lifestyle": { + "diet": "Home-cooked", + "sleep_schedule": "10:00 PM - 6:00 AM" + } + } + }"#; + + let identity = parse_aieos_identity(json).unwrap(); + + let core_identity = identity.identity.clone().unwrap(); + assert_eq!(core_identity.names.unwrap().first.as_deref(), Some("Marta")); + assert!(core_identity.bio.unwrap().contains("Female")); + assert!(core_identity.origin.unwrap().contains("Polish")); + + let psychology = identity.psychology.clone().unwrap(); + assert_eq!(psychology.mbti.as_deref(), Some("ISFJ")); + assert_eq!(psychology.ocean.unwrap().openness, Some(0.4)); + assert!(psychology + .moral_compass + .unwrap() + .contains(&"Alignment: Lawful Good".to_string())); + + let capabilities = identity.capabilities.clone().unwrap(); + assert!(capabilities + .skills + .unwrap() + .contains(&"Gardening".to_string())); + + let prompt = aieos_to_system_prompt(&identity); + assert!(prompt.contains("## Identity")); + assert!(prompt.contains("**MBTI:** ISFJ")); + assert!(prompt.contains("Alignment: Lawful Good")); + assert!(prompt.contains("- Expand greenhouse")); + assert!(prompt.contains("- Gardening")); + assert!(prompt.contains("A friendly florist portrait")); + } + + #[test] + fn load_aieos_identity_from_file_supports_generator_shape() { + let json = r#"{ + "identity": { + "names": { "first": "Nova" }, + "bio": { "gender": "Non-binary" } + }, + "psychology": { + "traits": { "mbti": "ENTP" }, + "moral_compass": { "alignment": "Chaotic Good" } + } + }"#; + + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("identity.json"); + std::fs::write(&path, json).unwrap(); + + let config = IdentityConfig { + format: "aieos".into(), + aieos_path: Some("identity.json".into()), + aieos_inline: None, + }; + + let identity = load_aieos_identity(&config, temp.path()).unwrap().unwrap(); + assert_eq!( + identity.identity.unwrap().names.unwrap().first.as_deref(), + Some("Nova") + ); + assert_eq!(identity.psychology.unwrap().mbti.as_deref(), Some("ENTP")); + } }