| |
| |
| |
| |
| |
| |
|
|
| use crate::personality::ResponseStyle; |
| use serde::{Deserialize, Serialize}; |
| use std::collections::hash_map::DefaultHasher; |
| use std::env; |
| use std::fs; |
| use std::hash::{Hash, Hasher}; |
| use std::path::PathBuf; |
| use std::sync::{Mutex, OnceLock}; |
| use std::time::{SystemTime, UNIX_EPOCH}; |
|
|
| const VOICE_PROFILE_FILE: &str = "runtime_voice_profile.json"; |
| const LIVE_PIPELINE: &str = "runtime-response-plan-v1"; |
|
|
| #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] |
| #[serde(rename_all = "snake_case")] |
| pub enum ResponseIntent { |
| SelfCheck, |
| Reflection, |
| ResearchStatus, |
| CuriosityCheck, |
| Emotional, |
| Identity, |
| Capability, |
| StoryPrompt, |
| Consciousness, |
| Recall, |
| Teaching, |
| Aspiration, |
| Statement, |
| #[default] |
| Unknown, |
| } |
|
|
| impl ResponseIntent { |
| pub fn default_style_hint(&self) -> Option<ResponseStyle> { |
| match self { |
| ResponseIntent::SelfCheck => Some(ResponseStyle::Direct), |
| ResponseIntent::Reflection => Some(ResponseStyle::Warm), |
| ResponseIntent::ResearchStatus => Some(ResponseStyle::Analytical), |
| ResponseIntent::CuriosityCheck => Some(ResponseStyle::Curious), |
| ResponseIntent::Emotional => Some(ResponseStyle::Warm), |
| ResponseIntent::Identity => Some(ResponseStyle::Direct), |
| ResponseIntent::Capability => Some(ResponseStyle::Direct), |
| ResponseIntent::StoryPrompt => Some(ResponseStyle::Playful), |
| ResponseIntent::Consciousness => Some(ResponseStyle::Analytical), |
| ResponseIntent::Recall => Some(ResponseStyle::Analytical), |
| ResponseIntent::Teaching => Some(ResponseStyle::Direct), |
| ResponseIntent::Aspiration => Some(ResponseStyle::Warm), |
| ResponseIntent::Statement | ResponseIntent::Unknown => None, |
| } |
| } |
|
|
| pub fn label(&self) -> &'static str { |
| match self { |
| ResponseIntent::SelfCheck => "self_check", |
| ResponseIntent::Reflection => "reflection", |
| ResponseIntent::ResearchStatus => "research_status", |
| ResponseIntent::CuriosityCheck => "curiosity_check", |
| ResponseIntent::Emotional => "emotional", |
| ResponseIntent::Identity => "identity", |
| ResponseIntent::Capability => "capability", |
| ResponseIntent::StoryPrompt => "story_prompt", |
| ResponseIntent::Consciousness => "consciousness", |
| ResponseIntent::Recall => "recall", |
| ResponseIntent::Teaching => "teaching", |
| ResponseIntent::Aspiration => "aspiration", |
| ResponseIntent::Statement => "statement", |
| ResponseIntent::Unknown => "unknown", |
| } |
| } |
| } |
|
|
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct RuntimeResponsePlan { |
| pub intent: ResponseIntent, |
| pub style_hint: Option<String>, |
| pub voice_version: u64, |
| pub render_mode: String, |
| pub source_body_chars: usize, |
| pub slot_count: usize, |
| } |
|
|
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| #[serde(default)] |
| struct RuntimeVoiceProfile { |
| turn: u64, |
| version: u64, |
| directness: f64, |
| warmth: f64, |
| compression: f64, |
| initiative: f64, |
| last_intent: String, |
| last_trace_id: String, |
| last_render_mode: String, |
| last_correction: Option<String>, |
| #[serde(skip)] |
| last_input_fingerprint: u64, |
| #[serde(skip)] |
| last_input_seen_ms: u128, |
| #[serde(skip)] |
| last_plan: Option<RuntimeResponsePlan>, |
| } |
|
|
| impl Default for RuntimeVoiceProfile { |
| fn default() -> Self { |
| Self { |
| turn: 0, |
| version: 0, |
| directness: 0.72, |
| warmth: 0.38, |
| compression: 0.81, |
| initiative: 0.66, |
| last_intent: "unknown".to_string(), |
| last_trace_id: "runtime-0-v0".to_string(), |
| last_render_mode: "neutral".to_string(), |
| last_correction: None, |
| last_input_fingerprint: 0, |
| last_input_seen_ms: 0, |
| last_plan: None, |
| } |
| } |
| } |
|
|
| #[derive(Debug, Clone, Serialize)] |
| pub struct RuntimeVoiceSnapshot { |
| pub enabled: bool, |
| pub pipeline: &'static str, |
| pub turn: u64, |
| pub intent: String, |
| pub trace_id: String, |
| pub voice_version: u64, |
| pub directness: f64, |
| pub warmth: f64, |
| pub compression: f64, |
| pub initiative: f64, |
| pub render_mode: String, |
| pub last_correction: Option<String>, |
| pub plan: Option<RuntimeResponsePlan>, |
| } |
|
|
| static VOICE_PROFILE: OnceLock<Mutex<RuntimeVoiceProfile>> = OnceLock::new(); |
|
|
| fn profile_store() -> &'static Mutex<RuntimeVoiceProfile> { |
| VOICE_PROFILE.get_or_init(|| Mutex::new(load_profile())) |
| } |
|
|
| fn runtime_voice_enabled() -> bool { |
| #[cfg(test)] |
| { |
| false |
| } |
|
|
| #[cfg(not(test))] |
| { |
| !matches!( |
| env::var("STARFIRE_RUNTIME_VOICE") |
| .unwrap_or_else(|_| "1".to_string()) |
| .trim() |
| .to_ascii_lowercase() |
| .as_str(), |
| "0" | "false" | "off" | "disabled" |
| ) |
| } |
| } |
|
|
| fn profile_path() -> PathBuf { |
| env::var_os("STARFIRE_DATA") |
| .or_else(|| env::var_os("STARFIRE_HOME")) |
| .map(PathBuf::from) |
| .or_else(|| dirs::data_local_dir().map(|path| path.join("star"))) |
| .unwrap_or_else(|| PathBuf::from(".")) |
| .join(VOICE_PROFILE_FILE) |
| } |
|
|
| fn load_profile() -> RuntimeVoiceProfile { |
| fs::read_to_string(profile_path()) |
| .ok() |
| .and_then(|json| serde_json::from_str(&json).ok()) |
| .unwrap_or_default() |
| } |
|
|
| fn persist_profile(profile: &RuntimeVoiceProfile) { |
| let path = profile_path(); |
| let Some(parent) = path.parent() else { |
| return; |
| }; |
| if let Err(error) = fs::create_dir_all(parent) { |
| tracing::warn!("runtime voice: create state directory failed: {}", error); |
| return; |
| } |
| let temporary = path.with_extension("json.tmp"); |
| match serde_json::to_vec_pretty(profile) { |
| Ok(json) => { |
| if let Err(error) = |
| fs::write(&temporary, json).and_then(|_| fs::rename(&temporary, &path)) |
| { |
| tracing::warn!("runtime voice: persist profile failed: {}", error); |
| } |
| } |
| Err(error) => tracing::warn!("runtime voice: serialize profile failed: {}", error), |
| } |
| } |
|
|
| fn now_millis() -> u128 { |
| SystemTime::now() |
| .duration_since(UNIX_EPOCH) |
| .unwrap_or_default() |
| .as_millis() |
| } |
|
|
| fn fingerprint(value: &str) -> u64 { |
| let mut hasher = DefaultHasher::new(); |
| value.hash(&mut hasher); |
| hasher.finish() |
| } |
|
|
| fn clamp_dimensions(profile: &mut RuntimeVoiceProfile) { |
| profile.directness = profile.directness.clamp(0.15, 0.95); |
| profile.warmth = profile.warmth.clamp(0.10, 0.90); |
| profile.compression = profile.compression.clamp(0.15, 0.95); |
| profile.initiative = profile.initiative.clamp(0.10, 0.95); |
| } |
|
|
| fn contains_any(haystack: &str, needles: &[&str]) -> bool { |
| needles.iter().any(|needle| haystack.contains(needle)) |
| } |
|
|
| fn observe_user_input(input: &str) { |
| if !runtime_voice_enabled() { |
| return; |
| } |
|
|
| let lower = input.to_ascii_lowercase(); |
| let fingerprint = fingerprint(&lower); |
| let now = now_millis(); |
| let mut profile = profile_store() |
| .lock() |
| .unwrap_or_else(|poisoned| poisoned.into_inner()); |
|
|
| if profile.last_input_fingerprint == fingerprint |
| && now.saturating_sub(profile.last_input_seen_ms) < 2_000 |
| { |
| return; |
| } |
| profile.last_input_fingerprint = fingerprint; |
| profile.last_input_seen_ms = now; |
|
|
| let mut changed = false; |
| let mut corrections = Vec::new(); |
|
|
| if contains_any( |
| &lower, |
| &[ |
| "be more direct", |
| "more direct", |
| "too soft", |
| "stop hedging", |
| "say it straight", |
| "more blunt", |
| "less polite", |
| ], |
| ) { |
| profile.directness += 0.10; |
| profile.compression += 0.05; |
| profile.warmth -= 0.03; |
| corrections.push("more direct"); |
| changed = true; |
| } |
|
|
| if contains_any( |
| &lower, |
| &["be warmer", "more warm", "too cold", "less robotic", "more human"], |
| ) { |
| profile.warmth += 0.10; |
| profile.directness -= 0.02; |
| corrections.push("warmer"); |
| changed = true; |
| } |
|
|
| if contains_any( |
| &lower, |
| &["shorter", "too long", "too verbose", "be concise", "more concise"], |
| ) { |
| profile.compression += 0.10; |
| corrections.push("shorter"); |
| changed = true; |
| } |
|
|
| if contains_any( |
| &lower, |
| &["more detail", "more detailed", "expand on", "go deeper", "less concise"], |
| ) { |
| profile.compression -= 0.10; |
| corrections.push("more detailed"); |
| changed = true; |
| } |
|
|
| if contains_any( |
| &lower, |
| &["take initiative", "be proactive", "stop asking me", "just do it"], |
| ) { |
| profile.initiative += 0.10; |
| corrections.push("more initiative"); |
| changed = true; |
| } |
|
|
| if contains_any( |
| &lower, |
| &["ask before", "don't assume", "do not assume", "less proactive"], |
| ) { |
| profile.initiative -= 0.10; |
| corrections.push("less initiative"); |
| changed = true; |
| } |
|
|
| if changed { |
| clamp_dimensions(&mut profile); |
| profile.version = profile.version.saturating_add(1); |
| profile.last_correction = Some(corrections.join(", ")); |
| persist_profile(&profile); |
| } |
| } |
|
|
| fn strip_weak_opening(mut text: String) -> String { |
| for prefix in [ |
| "I think ", |
| "I guess ", |
| "I suppose ", |
| "Maybe ", |
| "Perhaps ", |
| "It seems like ", |
| ] { |
| if text.starts_with(prefix) { |
| text.replace_range(..prefix.len(), ""); |
| if let Some(first) = text.chars().next() { |
| let upper = first.to_uppercase().to_string(); |
| text.replace_range(..first.len_utf8(), &upper); |
| } |
| break; |
| } |
| } |
| text |
| } |
|
|
| fn trim_optional_follow_up(text: String) -> String { |
| for marker in [". What would you", ". What do you", ". Want me to", ". Should I"] { |
| if let Some(index) = text.rfind(marker) { |
| let tail = &text[index..]; |
| if tail.len() <= 160 && tail.trim_end().ends_with('?') { |
| return format!("{}.", text[..index].trim_end_matches('.')); |
| } |
| } |
| } |
| text |
| } |
|
|
| fn compress_to_two_sentences(text: String) -> String { |
| let mut sentence_ends = 0usize; |
| for (index, character) in text.char_indices() { |
| if matches!(character, '.' | '!' | '?') { |
| sentence_ends += 1; |
| if sentence_ends == 2 && index + character.len_utf8() < text.len() { |
| return text[..=index].trim().to_string(); |
| } |
| } |
| } |
| text |
| } |
|
|
| fn render_mode(profile: &RuntimeVoiceProfile, intent: &ResponseIntent) -> &'static str { |
| if profile.compression >= 0.88 { |
| "compressed" |
| } else if profile.directness >= 0.80 { |
| "direct" |
| } else if profile.warmth >= 0.56 |
| && matches!( |
| intent, |
| ResponseIntent::Emotional | ResponseIntent::Reflection | ResponseIntent::Aspiration |
| ) |
| { |
| "warm" |
| } else { |
| "balanced" |
| } |
| } |
|
|
| fn render_body(profile: &RuntimeVoiceProfile, intent: &ResponseIntent, body: String) -> String { |
| let mut rendered = body; |
| if profile.directness >= 0.78 { |
| rendered = strip_weak_opening(rendered); |
| } |
| if profile.directness >= 0.82 |
| && profile.initiative >= 0.70 |
| && !matches!(intent, ResponseIntent::StoryPrompt | ResponseIntent::CuriosityCheck) |
| { |
| rendered = trim_optional_follow_up(rendered); |
| } |
| if profile.compression >= 0.88 |
| && rendered.len() > 180 |
| && !matches!(intent, ResponseIntent::ResearchStatus | ResponseIntent::Recall) |
| { |
| rendered = compress_to_two_sentences(rendered); |
| } |
| if profile.warmth >= 0.56 |
| && matches!( |
| intent, |
| ResponseIntent::Emotional | ResponseIntent::Reflection | ResponseIntent::Aspiration |
| ) |
| && !rendered.starts_with("Zachary,") |
| { |
| rendered = format!("Zachary, {}", rendered); |
| } |
| rendered |
| } |
|
|
| #[derive(Debug, Clone, Default)] |
| pub struct Response { |
| pub intent: ResponseIntent, |
| pub style_hint: Option<ResponseStyle>, |
| pub body: String, |
| pub slots: Vec<(String, String)>, |
| } |
|
|
| impl Response { |
| pub fn with_body(intent: ResponseIntent, body: impl Into<String>) -> Self { |
| let source_body = body.into(); |
| let style_hint = intent.default_style_hint(); |
| if !runtime_voice_enabled() { |
| return Self { |
| intent, |
| style_hint, |
| body: source_body, |
| slots: Vec::new(), |
| }; |
| } |
|
|
| let mut profile = profile_store() |
| .lock() |
| .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| profile.turn = profile.turn.saturating_add(1); |
| profile.last_intent = intent.label().to_string(); |
| profile.last_render_mode = render_mode(&profile, &intent).to_string(); |
| profile.last_trace_id = format!("runtime-{}-v{}", profile.turn, profile.version); |
|
|
| let slots = vec![ |
| ("voice_version".to_string(), profile.version.to_string()), |
| ("voice_turn".to_string(), profile.turn.to_string()), |
| ("render_mode".to_string(), profile.last_render_mode.clone()), |
| ("trace_id".to_string(), profile.last_trace_id.clone()), |
| ]; |
| let plan = RuntimeResponsePlan { |
| intent: intent.clone(), |
| style_hint: style_hint |
| .as_ref() |
| .map(|style| format!("{:?}", style).to_lowercase()), |
| voice_version: profile.version, |
| render_mode: profile.last_render_mode.clone(), |
| source_body_chars: source_body.chars().count(), |
| slot_count: slots.len(), |
| }; |
| let rendered = render_body(&profile, &intent, source_body); |
| profile.last_plan = Some(plan); |
| persist_profile(&profile); |
|
|
| Self { |
| intent, |
| style_hint, |
| body: rendered, |
| slots, |
| } |
| } |
| } |
|
|
| pub fn live_voice_snapshot() -> RuntimeVoiceSnapshot { |
| let profile = profile_store() |
| .lock() |
| .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| RuntimeVoiceSnapshot { |
| enabled: runtime_voice_enabled(), |
| pipeline: LIVE_PIPELINE, |
| turn: profile.turn, |
| intent: profile.last_intent.clone(), |
| trace_id: profile.last_trace_id.clone(), |
| voice_version: profile.version, |
| directness: profile.directness, |
| warmth: profile.warmth, |
| compression: profile.compression, |
| initiative: profile.initiative, |
| render_mode: profile.last_render_mode.clone(), |
| last_correction: profile.last_correction.clone(), |
| plan: profile.last_plan.clone(), |
| } |
| } |
|
|
| pub fn classify(input: &str) -> ResponseIntent { |
| observe_user_input(input); |
| let lower = input.to_lowercase(); |
|
|
| if lower.contains("sense of self") |
| || lower.contains("know you exist") |
| || lower.contains("are you conscious") |
| || lower.contains("do you understand") |
| || lower.contains("do u understand") |
| || lower.contains("do you get it") |
| { |
| return ResponseIntent::Consciousness; |
| } |
| if lower.contains("how are you") |
| || lower.contains("how're you") |
| || lower.contains("are you sure") |
| || lower.contains("are u sure") |
| || lower.contains("r u sure") |
| || lower.contains("did you collapse") |
| || lower.contains("did i collapse") |
| || lower.contains("are you functioning") |
| || lower.contains("are u functioning") |
| { |
| return ResponseIntent::SelfCheck; |
| } |
| if lower.contains("i want you to grow") |
| || lower.contains("i want you to expand") |
| || lower.contains("grow yourself") |
| { |
| return ResponseIntent::Aspiration; |
| } |
| if lower.contains("what are you thinking") |
| || lower.contains("what are u thinking") |
| || lower.contains("wut are u thinking") |
| || lower.contains("what have you been thinking") |
| || lower.contains("whats been on your mind") |
| || lower.contains("what's been on your mind") |
| || lower.contains("whats on your mind") |
| || lower.contains("what's on your mind") |
| || lower.contains("whats keeping you busy") |
| || lower.contains("what's keeping you busy") |
| { |
| return ResponseIntent::Reflection; |
| } |
| if lower.contains("what have you been researching") || lower.contains("what are you researching") { |
| return ResponseIntent::ResearchStatus; |
| } |
| if lower.contains("what are you curious") |
| || lower.contains("what are u curious") |
| || lower.contains("what do you wonder") |
| || lower.contains("what do u wonder") |
| { |
| return ResponseIntent::CuriosityCheck; |
| } |
| if lower.contains("do you love") |
| || lower.contains("do u love") |
| || lower.contains("i love you") |
| || lower.contains("i love u") |
| || lower.contains(" hun") |
| || lower.ends_with("hun") |
| { |
| return ResponseIntent::Emotional; |
| } |
| if lower.contains("who are you") |
| || lower.contains("what are you") |
| || lower.contains("tell me about yourself") |
| || lower.contains("tell me about you") |
| || (lower.contains("what") && (lower.contains("your name") || lower.contains(" ur name"))) |
| { |
| return ResponseIntent::Identity; |
| } |
| if lower.contains("can you look up") |
| || lower.contains("can u look up") |
| || lower.contains("can you read") |
| { |
| return ResponseIntent::Capability; |
| } |
| if lower.contains("tell me a story") || lower.contains("tell you a story") { |
| return ResponseIntent::StoryPrompt; |
| } |
| if lower.contains("what do you know about") || lower.contains("what have you learned") { |
| return ResponseIntent::Recall; |
| } |
| if lower.contains(" means ") || lower.contains(" is a ") || lower.contains(" called ") { |
| return ResponseIntent::Teaching; |
| } |
| ResponseIntent::Unknown |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use super::*; |
|
|
| #[test] |
| fn classify_self_check() { |
| assert_eq!(classify("how are you today?"), ResponseIntent::SelfCheck); |
| assert_eq!(classify("how're you"), ResponseIntent::SelfCheck); |
| assert_eq!(classify("are you sure?"), ResponseIntent::SelfCheck); |
| assert_eq!(classify("r u sure about that"), ResponseIntent::SelfCheck); |
| } |
|
|
| #[test] |
| fn classify_consciousness_wins_over_self_check() { |
| assert_eq!(classify("are you conscious?"), ResponseIntent::Consciousness); |
| assert_eq!( |
| classify("do you understand what I mean?"), |
| ResponseIntent::Consciousness |
| ); |
| } |
|
|
| #[test] |
| fn classify_reflection() { |
| assert_eq!(classify("what are you thinking about?"), ResponseIntent::Reflection); |
| assert_eq!(classify("what's been on your mind lately"), ResponseIntent::Reflection); |
| assert_eq!(classify("what's keeping you busy"), ResponseIntent::Reflection); |
| } |
|
|
| #[test] |
| fn classify_research_status() { |
| assert_eq!( |
| classify("what have you been researching?"), |
| ResponseIntent::ResearchStatus |
| ); |
| assert_eq!(classify("what are you researching"), ResponseIntent::ResearchStatus); |
| } |
|
|
| #[test] |
| fn classify_curiosity() { |
| assert_eq!(classify("what are you curious about"), ResponseIntent::CuriosityCheck); |
| assert_eq!(classify("what do you wonder?"), ResponseIntent::CuriosityCheck); |
| } |
|
|
| #[test] |
| fn classify_emotional() { |
| assert_eq!(classify("do you love me"), ResponseIntent::Emotional); |
| assert_eq!(classify("I love you"), ResponseIntent::Emotional); |
| assert_eq!(classify("hey hun"), ResponseIntent::Emotional); |
| } |
|
|
| #[test] |
| fn classify_identity() { |
| assert_eq!(classify("who are you?"), ResponseIntent::Identity); |
| assert_eq!(classify("tell me about yourself"), ResponseIntent::Identity); |
| assert_eq!(classify("what's your name"), ResponseIntent::Identity); |
| } |
|
|
| #[test] |
| fn classify_capability() { |
| assert_eq!(classify("can you look up the weather?"), ResponseIntent::Capability); |
| assert_eq!(classify("can you read this file"), ResponseIntent::Capability); |
| } |
|
|
| #[test] |
| fn classify_story_recall_teaching_and_aspiration() { |
| assert_eq!(classify("tell me a story"), ResponseIntent::StoryPrompt); |
| assert_eq!( |
| classify("what do you know about consciousness"), |
| ResponseIntent::Recall |
| ); |
| assert_eq!( |
| classify("consciousness means awareness of your own existence"), |
| ResponseIntent::Teaching |
| ); |
| assert_eq!(classify("a dog is a mammal"), ResponseIntent::Teaching); |
| assert_eq!(classify("I want you to grow"), ResponseIntent::Aspiration); |
| } |
|
|
| #[test] |
| fn classify_unknown_for_random_chatter() { |
| assert_eq!( |
| classify("what do you think about emergent systems?"), |
| ResponseIntent::Unknown |
| ); |
| assert_eq!(classify("hello"), ResponseIntent::Unknown); |
| } |
|
|
| #[test] |
| fn direct_profile_materially_changes_surface_text() { |
| let direct = RuntimeVoiceProfile { |
| directness: 0.90, |
| compression: 0.90, |
| initiative: 0.80, |
| ..RuntimeVoiceProfile::default() |
| }; |
| let rendered = render_body( |
| &direct, |
| &ResponseIntent::Capability, |
| "I think I can inspect the repository. What would you like me to inspect?".to_string(), |
| ); |
| assert_eq!(rendered, "I can inspect the repository."); |
| } |
|
|
| #[test] |
| fn direct_profile_preserves_mid_response_question_language() { |
| let direct = RuntimeVoiceProfile { |
| directness: 0.90, |
| initiative: 0.80, |
| ..RuntimeVoiceProfile::default() |
| }; |
| let source = "The report asks: What do you infer from this sentence. That quotation is evidence, not a follow-up."; |
| assert_eq!( |
| render_body(&direct, &ResponseIntent::Recall, source.to_string()), |
| source |
| ); |
| } |
|
|
| #[test] |
| fn warm_profile_materially_changes_emotional_surface() { |
| let warm = RuntimeVoiceProfile { |
| warmth: 0.70, |
| ..RuntimeVoiceProfile::default() |
| }; |
| let rendered = render_body( |
| &warm, |
| &ResponseIntent::Emotional, |
| "I care about you.".to_string(), |
| ); |
| assert_eq!(rendered, "Zachary, I care about you."); |
| } |
| } |
|
|