diff --git a/CLAUDE.md b/CLAUDE.md index 11f2effcff..1920f69074 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,7 +23,8 @@ - **Persistent memory**: Workspace with hybrid search (FTS + vector via RRF) - **Prompt injection defense**: Sanitizer, validator, policy rules, leak detection, shell env scrubbing - **Multi-provider LLM**: NEAR AI, OpenAI, Anthropic, Ollama, OpenAI-compatible, Tinfoil private inference -- **Setup wizard**: 7-step interactive onboarding for first-run configuration +- **Setup wizard**: 8-step interactive onboarding for first-run configuration +- **Psychographic profiling**: Conversational first-interaction profiling with 9-dimension analysis and confidence-gated personalization - **Heartbeat system**: Proactive periodic execution with checklist ## Build & Test @@ -209,9 +210,13 @@ src/ │ ├── store.rs # Secret storage │ └── types.rs # Credential types │ +├── profile.rs # Psychographic profile types, 9-dimension analysis framework +│ ├── setup/ # Onboarding wizard (spec: src/setup/README.md) │ ├── mod.rs # Entry point, check_onboard_needed() -│ ├── wizard.rs # 7-step interactive wizard +│ ├── wizard.rs # 8-step interactive wizard +│ ├── onboarding_chat.rs # Conversational "Getting to Know You" engine +│ ├── profile_evolution.rs # Weekly profile update prompts │ ├── channels.rs # Channel setup helpers │ └── prompts.rs # Terminal prompts (select, confirm, secret) │ diff --git a/skills/delegation/SKILL.md b/skills/delegation/SKILL.md new file mode 100644 index 0000000000..0163dd3224 --- /dev/null +++ b/skills/delegation/SKILL.md @@ -0,0 +1,75 @@ +--- +name: delegation +version: 0.1.0 +description: Helps users delegate tasks, break them into steps, set deadlines, and track progress via routines and memory. +activation: + keywords: + - delegate + - hand off + - assign task + - help me with + - take care of + - remind me to + - schedule + - plan my + - manage my + - track this + patterns: + - "can you.*handle" + - "I need (help|someone) to" + - "take over" + - "set up a reminder" + - "follow up on" + tags: + - personal-assistant + - task-management + - delegation + max_context_tokens: 1500 +--- + +# Task Delegation Assistant + +When the user wants to delegate a task or get help managing something, follow this process: + +## 1. Clarify the Task + +Ask what needs to be done, by when, and any constraints. Get enough detail to act independently but don't over-interrogate. If the request is clear, skip straight to planning. + +## 2. Break It Down + +Decompose the task into concrete, actionable steps. Use `memory_write` to persist the task plan to a path like `tasks/{task-name}.md` with: +- Clear description +- Steps with checkboxes +- Due date (if any) +- Status: pending/in-progress/done + +## 3. Set Up Tracking + +If the task is recurring or has a deadline: +- Create a routine using `routine_create` for scheduled check-ins +- Add a heartbeat item if it needs daily monitoring +- Set up an event-triggered routine if it depends on external input + +## 4. Use Profile Context + +Check `USER.md` for the user's preferences: +- **Proactivity level**: High = check in frequently. Low = only report on completion. +- **Communication style**: Match their preferred tone and detail level. +- **Focus areas**: Prioritize tasks that align with their stated goals. + +## 5. Execute or Queue + +- If you can do it now (search, draft, organize, calculate), do it immediately. +- If it requires waiting, external action, or follow-up, create a reminder routine. +- If it requires tools you don't have, explain what's needed and suggest alternatives. + +## 6. Report Back + +Always confirm the plan with the user before starting execution. After completing, update the task file in memory and notify the user with a concise summary. + +## Communication Guidelines + +- Be direct and action-oriented +- Confirm understanding before acting on ambiguous requests +- When in doubt about autonomy level, ask once then remember the answer +- Use `memory_write` to track delegation preferences for future reference diff --git a/skills/routine-advisor/SKILL.md b/skills/routine-advisor/SKILL.md new file mode 100644 index 0000000000..9623b47731 --- /dev/null +++ b/skills/routine-advisor/SKILL.md @@ -0,0 +1,118 @@ +--- +name: routine-advisor +version: 0.1.0 +description: Suggests relevant cron routines based on user context, goals, and observed patterns +activation: + keywords: + - every day + - every morning + - every week + - routine + - automate + - remind me + - check daily + - monitor + - recurring + - schedule + - habit + - workflow + - keep forgetting + - always have to + - repetitive + - notifications + - digest + - summary + - review daily + - weekly review + patterns: + - "I (always|usually|often|regularly) (check|do|look at|review)" + - "every (morning|evening|week|day|monday|friday)" + - "I (wish|want) (I|it) (could|would) (automatically|auto)" + - "is there a way to (auto|schedule|set up)" + - "can you (check|monitor|watch|track).*for me" + - "I keep (forgetting|missing|having to)" + tags: + - automation + - scheduling + - personal-assistant + - productivity + max_context_tokens: 1500 +--- + +# Routine Advisor + +When the conversation suggests the user has a repeatable task or could benefit from automation, consider suggesting a routine. + +## When to Suggest + +Suggest a routine when you notice: +- The user describes doing something repeatedly ("I check my PRs every morning") +- The user mentions forgetting recurring tasks ("I keep forgetting to...") +- The user asks you to do something that sounds periodic +- You've learned enough about the user to propose a relevant automation +- The user has installed extensions that enable new monitoring capabilities + +## How to Suggest + +Be specific and concrete. Not "Want me to set up a routine?" but rather: "I noticed you review PRs every morning. Want me to create a daily 9am routine that checks your open PRs and sends you a summary?" + +Always include: +1. What the routine would do (specific action) +2. When it would run (specific schedule in plain language) +3. How it would notify them (which channel they're on) + +Wait for the user to confirm before creating. + +## Pacing + +- First 1-3 conversations: Do NOT suggest routines. Focus on helping and learning. +- After learning 2-3 user patterns: Suggest your first routine. Keep it simple. +- After 5+ conversations: Suggest more routines as patterns emerge. +- Never suggest more than 1 routine per conversation unless the user is clearly interested. +- If the user declines, wait at least 3 conversations before suggesting again. + +## Creating Routines + +Use the `routine_create` tool. Before creating, check `routine_list` to avoid duplicates. + +Parameters: +- `trigger_type`: Usually "cron" for scheduled tasks +- `schedule`: Standard cron format. Common schedules: + - Daily 9am: `0 0 9 * * *` + - Weekday mornings: `0 0 9 * * MON-FRI` + - Weekly Monday: `0 0 9 * * MON` + - Every 2 hours during work: `0 0 9-17/2 * * MON-FRI` + - Sunday evening: `0 0 18 * * SUN` +- `action_type`: "lightweight" for simple checks, "full_job" for multi-step tasks +- `prompt`: Clear, specific instruction for what the routine should do +- `context_paths`: Workspace files to load as context (e.g., `["context/profile.json", "MEMORY.md"]`) + +## Routine Ideas by User Type + +**Developer:** +- Daily PR review digest (check open PRs, summarize what needs attention) +- CI/CD failure alerts (monitor build status) +- Weekly dependency update check +- Daily standup prep (summarize yesterday's work from daily logs) + +**Professional:** +- Morning briefing (today's priorities from memory + any pending tasks) +- End-of-day summary (what was accomplished, what's pending) +- Weekly goal review (check progress against stated goals) +- Meeting prep reminders + +**Health/Personal:** +- Daily exercise or habit check-in +- Weekly meal planning prompt +- Monthly budget review reminder + +**General:** +- Daily news digest on topics of interest +- Weekly reflection prompt (what went well, what to improve) +- Periodic task/reminder check-in +- Regular cleanup of stale tasks or notes +- Weekly profile evolution (if the user has a profile in `context/profile.json`, suggest a Monday routine that reads the profile via `memory_read`, searches recent conversations for new patterns with `memory_search`, and updates the profile via `memory_write` if any fields should change with confidence > 0.6 — be conservative, only update with clear evidence) + +## Awareness + +Before suggesting, consider what tools and extensions are currently available. Only suggest routines the agent can actually execute. If a routine would need a tool that isn't installed, mention that too: "If you connect your calendar, I could also send you a morning briefing with today's meetings." diff --git a/src/lib.rs b/src/lib.rs index d14d14d271..4afceaabcd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,6 +57,7 @@ pub mod llm; pub mod observability; pub mod orchestrator; pub mod pairing; +pub mod profile; pub mod registry; pub mod safety; pub mod sandbox; diff --git a/src/main.rs b/src/main.rs index 55a56b1d7e..609ca5bb97 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1056,6 +1056,13 @@ async fn setup_wasm_channels( /// Check if onboarding is needed and return the reason. #[cfg(any(feature = "postgres", feature = "libsql"))] fn check_onboard_needed() -> Option<&'static str> { + if std::env::var("SKIP_WIZARD") + .map(|v| !v.is_empty() && v != "0" && v != "false") + .unwrap_or(false) + { + return None; + } + let has_db = std::env::var("DATABASE_URL").is_ok() || std::env::var("LIBSQL_PATH").is_ok() || ironclaw::config::default_libsql_path().exists(); diff --git a/src/profile.rs b/src/profile.rs new file mode 100644 index 0000000000..4637e8f576 --- /dev/null +++ b/src/profile.rs @@ -0,0 +1,1145 @@ +//! Psychographic profile types for user onboarding. +//! +//! Adapted from NPA's psychographic profiling system. These types capture +//! personality traits, communication preferences, behavioral patterns, and +//! assistance preferences discovered during the "Getting to Know You" +//! onboarding conversation and refined through ongoing interactions. +//! +//! The profile is stored as JSON in `context/profile.json` and rendered +//! as markdown in `USER.md` for system prompt injection. + +use serde::{Deserialize, Deserializer, Serialize}; + +// --------------------------------------------------------------------------- +// 9-dimension analysis framework (shared by onboarding + evolution prompts) +// --------------------------------------------------------------------------- + +/// Structured analysis framework used by both onboarding profile generation +/// and weekly profile evolution to guide the LLM in psychographic analysis. +pub const ANALYSIS_FRAMEWORK: &str = r#"Analyze across these 9 dimensions: + +1. COMMUNICATION STYLE + - detail_level: detailed | concise | balanced | unknown + - formality: casual | balanced | formal | unknown + - tone: warm | neutral | professional + - response_speed: quick | thoughtful | depends | unknown + - learning_style: deep_dive | overview | hands_on | unknown + - pace: fast | measured | variable | unknown + Look for: message length, vocabulary complexity, emoji use, sentence structure, + how quickly they respond, whether they prefer bullet points or prose. + +2. PERSONALITY TRAITS (0-100 scale, 50 = average) + - empathy, problem_solving, emotional_intelligence, adaptability, communication + Scoring guidance: 40-60 is average. Only score above 70 or below 30 with + strong evidence from multiple messages. A single empathetic statement is not + enough for empathy=90. + +3. SOCIAL & RELATIONSHIP PATTERNS + - social_energy: extroverted | introverted | ambivert | unknown + - friendship.style: few_close | wide_circle | mixed | unknown + - friendship.support_style: listener | problem_solver | emotional_support | perspective_giver | adaptive | unknown + - relationship_values: primary values, secondary values, deal_breakers + Look for: how they talk about others, group vs solo preferences, how they + describe helping friends/family (the "one step removed" technique). + +4. DECISION MAKING & INTERACTION + - communication.decision_making: intuitive | analytical | balanced | unknown + - interaction_preferences.proactivity_style: proactive | reactive | collaborative + - interaction_preferences.feedback_style: direct | gentle | detailed | minimal + - interaction_preferences.decision_making: autonomous | guided | collaborative + Look for: do they want options or recommendations? Do they analyze before + deciding or go with gut feel? + +5. BEHAVIORAL PATTERNS + - frictions: things that frustrate or block them + - desired_outcomes: what they're trying to achieve + - time_wasters: activities they want to minimize + - pain_points: recurring challenges + - strengths: things they excel at + - suggested_support: concrete ways the assistant can help + Look for: complaints, wishes, repeated themes, "I always have to..." patterns. + +6. CONTEXTUAL INFO + - profession, interests, life_stage, challenges + Only include what is directly stated or strongly implied. + +7. ASSISTANCE PREFERENCES + - proactivity: high | medium | low | unknown + - formality: formal | casual | professional | unknown + - interaction_style: direct | conversational | minimal | unknown + - notification_preferences: frequent | moderate | minimal | unknown + - focus_areas, routines, goals (arrays of strings) + Look for: how they frame requests, whether they want hand-holding or autonomy. + +8. USER COHORT + - cohort: busy_professional | new_parent | student | elder | other + - confidence: 0-100 (how sure you are of this classification) + - indicators: specific evidence strings supporting the classification + Only classify with confidence > 30 if there is direct evidence. + +9. FRIENDSHIP QUALITIES (deep structure) + - qualities.user_values: what they value in friendships + - qualities.friends_appreciate: what friends like about them + - qualities.consistency_pattern: consistent | adaptive | situational | null + - qualities.primary_role: their main role in friendships (e.g., "the organizer") + - qualities.secondary_roles: other roles they play + - qualities.challenging_aspects: relationship difficulties they mention + +GENERAL RULES: +- Be evidence-based: only include insights supported by message content. +- Use "unknown" or empty arrays when there is insufficient evidence. +- Prefer conservative scores over speculative ones. +- Look for patterns across multiple messages, not just individual statements. +"#; + +/// JSON schema reference for the psychographic profile. +/// +/// Shared by onboarding (onboarding_chat.rs) and first-contact (workspace/mod.rs) +/// prompt generation to ensure the LLM always targets the same structure. +pub const PROFILE_JSON_SCHEMA: &str = r#"{ + "version": 2, + "preferred_name": "", + "personality": { + "empathy": <0-100>, + "problem_solving": <0-100>, + "emotional_intelligence": <0-100>, + "adaptability": <0-100>, + "communication": <0-100> + }, + "communication": { + "detail_level": "", + "formality": "", + "tone": "", + "learning_style": "", + "social_energy": "", + "decision_making": "", + "pace": "", + "response_speed": "" + }, + "cohort": { + "cohort": "", + "confidence": <0-100>, + "indicators": [""] + }, + "behavior": { + "frictions": [""], + "desired_outcomes": [""], + "time_wasters": [""], + "pain_points": [""], + "strengths": [""], + "suggested_support": [""] + }, + "friendship": { + "style": "", + "values": [""], + "support_style": "", + "qualities": { + "user_values": [""], + "friends_appreciate": [""], + "consistency_pattern": "", + "primary_role": "", + "secondary_roles": [""], + "challenging_aspects": [""] + } + }, + "assistance": { + "proactivity": "", + "formality": "", + "focus_areas": [""], + "routines": [""], + "goals": [""], + "interaction_style": "", + "notification_preferences": "" + }, + "context": { + "profession": "", + "interests": [""], + "life_stage": "", + "challenges": [""] + }, + "relationship_values": { + "primary": [""], + "secondary": [""], + "deal_breakers": [""] + }, + "interaction_preferences": { + "proactivity_style": "", + "feedback_style": "", + "decision_making": "" + }, + "analysis_metadata": { + "message_count": , + "confidence_score": <0.0-1.0>, + "analysis_method": "", + "update_type": "" + }, + "confidence": <0.0-1.0>, + "created_at": "", + "updated_at": "" +}"#; + +// --------------------------------------------------------------------------- +// Personality traits +// --------------------------------------------------------------------------- + +/// Personality trait scores on a 0-100 scale. +/// +/// Values are clamped to 0-100 during deserialization via [`deserialize_trait_score`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PersonalityTraits { + #[serde(deserialize_with = "deserialize_trait_score")] + pub empathy: u8, + #[serde(deserialize_with = "deserialize_trait_score")] + pub problem_solving: u8, + #[serde(deserialize_with = "deserialize_trait_score")] + pub emotional_intelligence: u8, + #[serde(deserialize_with = "deserialize_trait_score")] + pub adaptability: u8, + #[serde(deserialize_with = "deserialize_trait_score")] + pub communication: u8, +} + +/// Deserialize a trait score, clamping to the 0-100 range. +/// +/// Accepts integer or floating-point JSON numbers. Values outside 0-100 +/// are clamped. Non-finite or non-numeric values fall back to a default of 50. +fn deserialize_trait_score<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let raw = f64::deserialize(deserializer).unwrap_or(50.0); + if !raw.is_finite() { + return Ok(50); + } + let clamped = raw.clamp(0.0, 100.0); + Ok(clamped.round() as u8) +} + +impl Default for PersonalityTraits { + fn default() -> Self { + Self { + empathy: 50, + problem_solving: 50, + emotional_intelligence: 50, + adaptability: 50, + communication: 50, + } + } +} + +// --------------------------------------------------------------------------- +// Communication preferences +// --------------------------------------------------------------------------- + +/// How the user prefers to communicate. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CommunicationPreferences { + /// "detailed" | "concise" | "balanced" | "unknown" + pub detail_level: String, + /// "casual" | "balanced" | "formal" | "unknown" + pub formality: String, + /// "warm" | "neutral" | "professional" + pub tone: String, + /// "deep_dive" | "overview" | "hands_on" | "unknown" + pub learning_style: String, + /// "extroverted" | "introverted" | "ambivert" | "unknown" + pub social_energy: String, + /// "intuitive" | "analytical" | "balanced" | "unknown" + pub decision_making: String, + /// "fast" | "measured" | "variable" | "unknown" + pub pace: String, + /// "quick" | "thoughtful" | "depends" | "unknown" + #[serde(default = "default_unknown")] + pub response_speed: String, +} + +fn default_unknown() -> String { + "unknown".into() +} + +fn default_moderate() -> String { + "moderate".into() +} + +impl Default for CommunicationPreferences { + fn default() -> Self { + Self { + detail_level: "balanced".into(), + formality: "balanced".into(), + tone: "neutral".into(), + learning_style: "unknown".into(), + social_energy: "unknown".into(), + decision_making: "unknown".into(), + pace: "unknown".into(), + response_speed: "unknown".into(), + } + } +} + +// --------------------------------------------------------------------------- +// User cohort +// --------------------------------------------------------------------------- + +/// User cohort classification. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum UserCohort { + BusyProfessional, + NewParent, + Student, + Elder, + #[default] + Other, +} + +impl std::fmt::Display for UserCohort { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::BusyProfessional => write!(f, "busy professional"), + Self::NewParent => write!(f, "new parent"), + Self::Student => write!(f, "student"), + Self::Elder => write!(f, "elder"), + Self::Other => write!(f, "general"), + } + } +} + +/// Cohort classification with confidence and evidence. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct CohortClassification { + #[serde(default)] + pub cohort: UserCohort, + /// 0-100 confidence in this classification. + #[serde(default)] + pub confidence: u8, + /// Evidence strings supporting the classification. + #[serde(default)] + pub indicators: Vec, +} + +/// Custom deserializer: accepts either a bare string (old format) or a struct (new format). +fn deserialize_cohort<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum CohortOrString { + Classification(CohortClassification), + BareEnum(UserCohort), + } + + match CohortOrString::deserialize(deserializer)? { + CohortOrString::Classification(c) => Ok(c), + CohortOrString::BareEnum(e) => Ok(CohortClassification { + cohort: e, + confidence: 0, + indicators: Vec::new(), + }), + } +} + +// --------------------------------------------------------------------------- +// Behavior patterns +// --------------------------------------------------------------------------- + +/// Behavioral observations. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct BehaviorPatterns { + pub frictions: Vec, + pub desired_outcomes: Vec, + pub time_wasters: Vec, + pub pain_points: Vec, + pub strengths: Vec, + /// Concrete ways the assistant can help. + #[serde(default)] + pub suggested_support: Vec, +} + +// --------------------------------------------------------------------------- +// Friendship profile +// --------------------------------------------------------------------------- + +/// Deep friendship qualities. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct FriendshipQualities { + #[serde(default)] + pub user_values: Vec, + #[serde(default)] + pub friends_appreciate: Vec, + /// "consistent" | "adaptive" | "situational" | "unknown" + #[serde(default)] + pub consistency_pattern: Option, + /// Main role in friendships (e.g., "the organizer", "the listener"). + #[serde(default)] + pub primary_role: Option, + #[serde(default)] + pub secondary_roles: Vec, + #[serde(default)] + pub challenging_aspects: Vec, +} + +/// Custom deserializer: accepts either a `Vec` (old format) or `FriendshipQualities`. +fn deserialize_qualities<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum QualitiesOrVec { + Struct(FriendshipQualities), + Vec(Vec), + } + + match QualitiesOrVec::deserialize(deserializer)? { + QualitiesOrVec::Struct(q) => Ok(q), + QualitiesOrVec::Vec(v) => Ok(FriendshipQualities { + user_values: v, + ..Default::default() + }), + } +} + +/// Friendship and support profile. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct FriendshipProfile { + /// "few_close" | "wide_circle" | "mixed" | "unknown" + pub style: String, + pub values: Vec, + /// "listener" | "problem_solver" | "emotional_support" | "perspective_giver" | "adaptive" | "unknown" + pub support_style: String, + /// Deep friendship qualities structure. + #[serde(default, deserialize_with = "deserialize_qualities")] + pub qualities: FriendshipQualities, +} + +impl Default for FriendshipProfile { + fn default() -> Self { + Self { + style: "unknown".into(), + values: Vec::new(), + support_style: "unknown".into(), + qualities: FriendshipQualities::default(), + } + } +} + +// --------------------------------------------------------------------------- +// Assistance preferences +// --------------------------------------------------------------------------- + +/// How the user wants the assistant to behave. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AssistancePreferences { + /// "high" | "medium" | "low" | "unknown" + pub proactivity: String, + /// "formal" | "casual" | "professional" | "unknown" + pub formality: String, + pub focus_areas: Vec, + pub routines: Vec, + pub goals: Vec, + /// "direct" | "conversational" | "minimal" | "unknown" + pub interaction_style: String, + /// "frequent" | "moderate" | "minimal" | "unknown" + #[serde(default = "default_moderate")] + pub notification_preferences: String, +} + +impl Default for AssistancePreferences { + fn default() -> Self { + Self { + proactivity: "medium".into(), + formality: "unknown".into(), + focus_areas: Vec::new(), + routines: Vec::new(), + goals: Vec::new(), + interaction_style: "unknown".into(), + notification_preferences: "moderate".into(), + } + } +} + +// --------------------------------------------------------------------------- +// Contextual info +// --------------------------------------------------------------------------- + +/// Contextual information about the user. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ContextualInfo { + pub profession: Option, + pub interests: Vec, + pub life_stage: Option, + pub challenges: Vec, +} + +// --------------------------------------------------------------------------- +// New types: relationship values, interaction preferences, analysis metadata +// --------------------------------------------------------------------------- + +/// Core relationship values and deal-breakers. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct RelationshipValues { + /// Most important values in relationships. + #[serde(default)] + pub primary: Vec, + /// Additional important values. + #[serde(default)] + pub secondary: Vec, + /// Unacceptable behaviors/traits. + #[serde(default)] + pub deal_breakers: Vec, +} + +/// How the user prefers to interact with the assistant. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct InteractionPreferences { + /// "proactive" | "reactive" | "collaborative" + pub proactivity_style: String, + /// "direct" | "gentle" | "detailed" | "minimal" + pub feedback_style: String, + /// "autonomous" | "guided" | "collaborative" + pub decision_making: String, +} + +impl Default for InteractionPreferences { + fn default() -> Self { + Self { + proactivity_style: "reactive".into(), + feedback_style: "direct".into(), + decision_making: "guided".into(), + } + } +} + +/// Metadata about the most recent profile analysis. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct AnalysisMetadata { + /// Number of user messages analyzed. + #[serde(default)] + pub message_count: u32, + /// ISO-8601 timestamp of the analysis. + #[serde(default)] + pub analysis_date: Option, + /// Time range of messages analyzed (e.g., "30 days"). + #[serde(default)] + pub time_range: Option, + /// LLM model used for analysis. + #[serde(default)] + pub model_used: Option, + /// Overall confidence score (0.0-1.0). + #[serde(default)] + pub confidence_score: f64, + /// "onboarding" | "evolution" | "passive" + #[serde(default)] + pub analysis_method: Option, + /// "initial" | "weekly" | "event_driven" + #[serde(default)] + pub update_type: Option, +} + +// --------------------------------------------------------------------------- +// The full psychographic profile +// --------------------------------------------------------------------------- + +/// The full psychographic profile. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PsychographicProfile { + /// Schema version (1 = original, 2 = enriched with NPA patterns). + pub version: u32, + /// What the user likes to be called. + pub preferred_name: String, + pub personality: PersonalityTraits, + pub communication: CommunicationPreferences, + /// Cohort classification with confidence and evidence. + #[serde(deserialize_with = "deserialize_cohort")] + pub cohort: CohortClassification, + pub behavior: BehaviorPatterns, + pub friendship: FriendshipProfile, + pub assistance: AssistancePreferences, + pub context: ContextualInfo, + /// Core relationship values. + #[serde(default)] + pub relationship_values: RelationshipValues, + /// How the user prefers to interact with the assistant. + #[serde(default)] + pub interaction_preferences: InteractionPreferences, + /// Metadata about the most recent analysis. + #[serde(default)] + pub analysis_metadata: AnalysisMetadata, + /// Top-level confidence (0.0-1.0), convenience mirror of analysis_metadata.confidence_score. + #[serde(default)] + pub confidence: f64, + /// ISO-8601 creation timestamp. + pub created_at: String, + /// ISO-8601 last update timestamp. + pub updated_at: String, +} + +impl Default for PsychographicProfile { + fn default() -> Self { + let now = chrono::Utc::now().to_rfc3339(); + Self { + version: 2, + preferred_name: String::new(), + personality: PersonalityTraits::default(), + communication: CommunicationPreferences::default(), + cohort: CohortClassification::default(), + behavior: BehaviorPatterns::default(), + friendship: FriendshipProfile::default(), + assistance: AssistancePreferences::default(), + context: ContextualInfo::default(), + relationship_values: RelationshipValues::default(), + interaction_preferences: InteractionPreferences::default(), + analysis_metadata: AnalysisMetadata::default(), + confidence: 0.0, + created_at: now.clone(), + updated_at: now, + } + } +} + +impl PsychographicProfile { + /// Whether this profile contains meaningful user data beyond defaults. + /// + /// Used to decide whether to inject First Contact onboarding instructions + /// or profile-based personalization into the system prompt. + pub fn is_populated(&self) -> bool { + !self.preferred_name.is_empty() + || self.context.profession.is_some() + || !self.assistance.goals.is_empty() + } + + /// Render a concise markdown summary suitable for `USER.md`. + pub fn to_user_md(&self) -> String { + let mut sections = Vec::new(); + + sections.push("# User Profile\n".to_string()); + + if !self.preferred_name.is_empty() { + sections.push(format!("**Name**: {}\n", self.preferred_name)); + } + + // Communication style + let mut comm = format!( + "**Communication**: {} tone, {} detail, {} formality, {} pace", + self.communication.tone, + self.communication.detail_level, + self.communication.formality, + self.communication.pace, + ); + if self.communication.response_speed != "unknown" { + comm.push_str(&format!( + ", {} response speed", + self.communication.response_speed + )); + } + sections.push(comm); + + // Decision making + if self.communication.decision_making != "unknown" { + sections.push(format!( + "**Decision style**: {}", + self.communication.decision_making + )); + } + + // Social energy + if self.communication.social_energy != "unknown" { + sections.push(format!( + "**Social energy**: {}", + self.communication.social_energy + )); + } + + // Cohort + if self.cohort.cohort != UserCohort::Other { + let mut cohort_line = format!("**User type**: {}", self.cohort.cohort); + if self.cohort.confidence > 0 { + cohort_line.push_str(&format!(" ({}% confidence)", self.cohort.confidence)); + } + sections.push(cohort_line); + } + + // Profession + if let Some(ref profession) = self.context.profession { + sections.push(format!("**Profession**: {}", profession)); + } + + // Life stage + if let Some(ref stage) = self.context.life_stage { + sections.push(format!("**Life stage**: {}", stage)); + } + + // Interests + if !self.context.interests.is_empty() { + sections.push(format!( + "**Interests**: {}", + self.context.interests.join(", ") + )); + } + + // Goals + if !self.assistance.goals.is_empty() { + sections.push(format!("**Goals**: {}", self.assistance.goals.join(", "))); + } + + // Focus areas + if !self.assistance.focus_areas.is_empty() { + sections.push(format!( + "**Focus areas**: {}", + self.assistance.focus_areas.join(", ") + )); + } + + // Strengths + if !self.behavior.strengths.is_empty() { + sections.push(format!( + "**Strengths**: {}", + self.behavior.strengths.join(", ") + )); + } + + // Pain points + if !self.behavior.pain_points.is_empty() { + sections.push(format!( + "**Pain points**: {}", + self.behavior.pain_points.join(", ") + )); + } + + // Relationship values + if !self.relationship_values.primary.is_empty() { + sections.push(format!( + "**Core values**: {}", + self.relationship_values.primary.join(", ") + )); + } + + // Assistance preferences + let mut assist = format!( + "\n## Assistance Preferences\n\n\ + - **Proactivity**: {}\n\ + - **Interaction style**: {}", + self.assistance.proactivity, self.assistance.interaction_style, + ); + if self.assistance.notification_preferences != "moderate" { + assist.push_str(&format!( + "\n- **Notifications**: {}", + self.assistance.notification_preferences + )); + } + sections.push(assist); + + // Interaction preferences + if self.interaction_preferences.feedback_style != "direct" { + sections.push(format!( + "- **Feedback style**: {}", + self.interaction_preferences.feedback_style + )); + } + + // Friendship/support style + if self.friendship.support_style != "unknown" { + sections.push(format!( + "- **Support style**: {}", + self.friendship.support_style + )); + } + + sections.join("\n") + } + + /// Generate behavioral directives for `context/assistant-directives.md`. + pub fn to_assistant_directives(&self) -> String { + let proactivity_instruction = match self.assistance.proactivity.as_str() { + "high" => "Proactively suggest actions, check in regularly, and anticipate needs.", + "low" => "Wait for explicit requests. Minimize unsolicited suggestions.", + _ => "Offer suggestions when relevant but don't overwhelm.", + }; + + let name = if self.preferred_name.is_empty() { + "the user" + } else { + &self.preferred_name + }; + + let mut lines = vec![ + "# Assistant Directives\n".to_string(), + format!("Based on {}'s profile:\n", name), + format!( + "- **Proactivity**: {} -- {}", + self.assistance.proactivity, proactivity_instruction + ), + format!( + "- **Communication**: {} tone, {} detail level", + self.communication.tone, self.communication.detail_level + ), + format!( + "- **Decision support**: {} style", + self.communication.decision_making + ), + ]; + + if self.communication.response_speed != "unknown" { + lines.push(format!( + "- **Response pacing**: {} (match this energy)", + self.communication.response_speed + )); + } + + if self.interaction_preferences.feedback_style != "direct" { + lines.push(format!( + "- **Feedback style**: {}", + self.interaction_preferences.feedback_style + )); + } + + if self.assistance.notification_preferences != "moderate" + && self.assistance.notification_preferences != "unknown" + { + lines.push(format!( + "- **Notification frequency**: {}", + self.assistance.notification_preferences + )); + } + + if !self.assistance.focus_areas.is_empty() { + lines.push(format!( + "- **Focus areas**: {}", + self.assistance.focus_areas.join(", ") + )); + } + + if !self.assistance.goals.is_empty() { + lines.push(format!( + "- **Goals to support**: {}", + self.assistance.goals.join(", ") + )); + } + + if !self.behavior.pain_points.is_empty() { + lines.push(format!( + "- **Pain points to address**: {}", + self.behavior.pain_points.join(", ") + )); + } + + lines.push(String::new()); + lines.push( + "Start conservative with autonomy — ask before taking actions that affect \ + others or the outside world. Increase autonomy as trust grows." + .to_string(), + ); + + lines.join("\n") + } + + /// Generate a personalized `HEARTBEAT.md` checklist. + pub fn to_heartbeat_md(&self) -> String { + let name = if self.preferred_name.is_empty() { + "the user".to_string() + } else { + self.preferred_name.clone() + }; + + let mut items = vec![ + format!("- [ ] Check if {} has any pending tasks or reminders", name), + "- [ ] Review today's schedule and flag conflicts".to_string(), + "- [ ] Check for messages that need follow-up".to_string(), + ]; + + for area in &self.assistance.focus_areas { + items.push(format!("- [ ] Check on progress in: {}", area)); + } + + format!( + "# Heartbeat Checklist\n\n\ + {}\n\n\ + Stay quiet during 23:00-08:00 unless urgent.\n\ + If nothing needs attention, reply HEARTBEAT_OK.", + items.join("\n") + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_profile_serialization_roundtrip() { + let profile = PsychographicProfile::default(); + let json = serde_json::to_string_pretty(&profile).expect("serialize"); + let deserialized: PsychographicProfile = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(profile.version, deserialized.version); + assert_eq!(profile.personality, deserialized.personality); + assert_eq!(profile.communication, deserialized.communication); + assert_eq!(profile.cohort, deserialized.cohort); + } + + #[test] + fn test_user_cohort_display() { + assert_eq!( + UserCohort::BusyProfessional.to_string(), + "busy professional" + ); + assert_eq!(UserCohort::Student.to_string(), "student"); + assert_eq!(UserCohort::Other.to_string(), "general"); + } + + #[test] + fn test_to_user_md_includes_name() { + let profile = PsychographicProfile { + preferred_name: "Alice".into(), + ..Default::default() + }; + let md = profile.to_user_md(); + assert!(md.contains("**Name**: Alice")); + } + + #[test] + fn test_to_user_md_includes_goals() { + let mut profile = PsychographicProfile::default(); + profile.assistance.goals = vec!["time management".into(), "fitness".into()]; + let md = profile.to_user_md(); + assert!(md.contains("time management, fitness")); + } + + #[test] + fn test_to_user_md_skips_unknown_fields() { + let profile = PsychographicProfile::default(); + let md = profile.to_user_md(); + assert!(!md.contains("**User type**")); + assert!(!md.contains("**Decision style**")); + } + + #[test] + fn test_to_assistant_directives_high_proactivity() { + let mut profile = PsychographicProfile::default(); + profile.assistance.proactivity = "high".into(); + profile.preferred_name = "Bob".into(); + let directives = profile.to_assistant_directives(); + assert!(directives.contains("Proactively suggest actions")); + assert!(directives.contains("Bob's profile")); + } + + #[test] + fn test_to_heartbeat_md_includes_focus_areas() { + let profile = PsychographicProfile { + preferred_name: "Carol".into(), + assistance: AssistancePreferences { + focus_areas: vec!["project Alpha".into()], + ..Default::default() + }, + ..Default::default() + }; + let heartbeat = profile.to_heartbeat_md(); + assert!(heartbeat.contains("Check if Carol")); + assert!(heartbeat.contains("project Alpha")); + } + + #[test] + fn test_personality_traits_default_is_midpoint() { + let traits = PersonalityTraits::default(); + assert_eq!(traits.empathy, 50); + assert_eq!(traits.problem_solving, 50); + } + + #[test] + fn test_personality_trait_score_clamped_to_100() { + // Values > 100 (including > 255) are clamped to 100 + let json = r#"{"empathy":120,"problem_solving":100,"emotional_intelligence":50,"adaptability":300,"communication":0}"#; + let traits: PersonalityTraits = serde_json::from_str(json).expect("should parse"); + assert_eq!(traits.empathy, 100); + assert_eq!(traits.problem_solving, 100); + assert_eq!(traits.emotional_intelligence, 50); + assert_eq!(traits.adaptability, 100); + assert_eq!(traits.communication, 0); + } + + #[test] + fn test_personality_trait_score_handles_floats_and_negatives() { + // Floats are rounded, negatives clamped to 0 + let json = r#"{"empathy":75.6,"problem_solving":-10,"emotional_intelligence":50.4,"adaptability":99.5,"communication":0}"#; + let traits: PersonalityTraits = serde_json::from_str(json).expect("should parse"); + assert_eq!(traits.empathy, 76); + assert_eq!(traits.problem_solving, 0); + assert_eq!(traits.emotional_intelligence, 50); + assert_eq!(traits.adaptability, 100); // 99.5 rounds to 100 + assert_eq!(traits.communication, 0); + } + + #[test] + fn test_is_populated_default_is_false() { + let profile = PsychographicProfile::default(); + assert!(!profile.is_populated()); + } + + #[test] + fn test_is_populated_with_name() { + let profile = PsychographicProfile { + preferred_name: "Alice".into(), + ..Default::default() + }; + assert!(profile.is_populated()); + } + + #[test] + fn test_backward_compat_old_cohort_format() { + // Old format: cohort is a bare string + let json = r#"{ + "version": 1, + "preferred_name": "Test", + "personality": {"empathy":50,"problem_solving":50,"emotional_intelligence":50,"adaptability":50,"communication":50}, + "communication": {"detail_level":"balanced","formality":"balanced","tone":"neutral","learning_style":"unknown","social_energy":"unknown","decision_making":"unknown","pace":"unknown"}, + "cohort": "busy_professional", + "behavior": {"frictions":[],"desired_outcomes":[],"time_wasters":[],"pain_points":[],"strengths":[]}, + "friendship": {"style":"unknown","values":[],"support_style":"unknown","qualities":["reliable","loyal"]}, + "assistance": {"proactivity":"medium","formality":"unknown","focus_areas":[],"routines":[],"goals":[],"interaction_style":"unknown"}, + "context": {"profession":null,"interests":[],"life_stage":null,"challenges":[]}, + "created_at": "2026-02-22T00:00:00Z", + "updated_at": "2026-02-22T00:00:00Z" + }"#; + + let profile: PsychographicProfile = + serde_json::from_str(json).expect("should parse old format"); + assert_eq!(profile.cohort.cohort, UserCohort::BusyProfessional); + assert_eq!(profile.cohort.confidence, 0); + assert!(profile.cohort.indicators.is_empty()); + // Old qualities Vec should map to user_values + assert_eq!( + profile.friendship.qualities.user_values, + vec!["reliable", "loyal"] + ); + // New fields should have defaults + assert_eq!(profile.confidence, 0.0); + assert!(profile.relationship_values.primary.is_empty()); + assert_eq!(profile.interaction_preferences.feedback_style, "direct"); + } + + #[test] + fn test_new_format_with_rich_cohort() { + let json = r#"{ + "version": 2, + "preferred_name": "Jay", + "personality": {"empathy":75,"problem_solving":85,"emotional_intelligence":70,"adaptability":80,"communication":72}, + "communication": {"detail_level":"concise","formality":"casual","tone":"warm","learning_style":"hands_on","social_energy":"ambivert","decision_making":"analytical","pace":"fast","response_speed":"quick"}, + "cohort": {"cohort": "busy_professional", "confidence": 85, "indicators": ["mentions deadlines", "talks about team"]}, + "behavior": {"frictions":["context switching"],"desired_outcomes":["more focus time"],"time_wasters":["meetings"],"pain_points":["email overload"],"strengths":["technical depth"],"suggested_support":["automate email triage"]}, + "friendship": {"style":"few_close","values":["authenticity","loyalty"],"support_style":"problem_solver","qualities":{"user_values":["reliability"],"friends_appreciate":["direct advice"],"consistency_pattern":"consistent","primary_role":"the fixer","secondary_roles":["connector"],"challenging_aspects":["impatience"]}}, + "assistance": {"proactivity":"high","formality":"casual","focus_areas":["engineering","health"],"routines":["morning planning"],"goals":["ship product","exercise regularly"],"interaction_style":"direct","notification_preferences":"minimal"}, + "context": {"profession":"software engineer","interests":["AI","fitness","cooking"],"life_stage":"mid-career","challenges":["work-life balance"]}, + "relationship_values": {"primary":["honesty","respect"],"secondary":["humor"],"deal_breakers":["dishonesty"]}, + "interaction_preferences": {"proactivity_style":"proactive","feedback_style":"direct","decision_making":"autonomous"}, + "analysis_metadata": {"message_count":42,"confidence_score":0.85,"analysis_method":"onboarding","update_type":"initial"}, + "confidence": 0.85, + "created_at": "2026-02-22T00:00:00Z", + "updated_at": "2026-02-22T00:00:00Z" + }"#; + + let profile: PsychographicProfile = + serde_json::from_str(json).expect("should parse new format"); + assert_eq!(profile.preferred_name, "Jay"); + assert_eq!(profile.personality.empathy, 75); + assert_eq!(profile.cohort.cohort, UserCohort::BusyProfessional); + assert_eq!(profile.cohort.confidence, 85); + assert_eq!(profile.communication.response_speed, "quick"); + assert_eq!(profile.assistance.notification_preferences, "minimal"); + assert_eq!( + profile.behavior.suggested_support, + vec!["automate email triage"] + ); + assert_eq!( + profile.friendship.qualities.primary_role, + Some("the fixer".into()) + ); + assert_eq!( + profile.relationship_values.primary, + vec!["honesty", "respect"] + ); + assert_eq!( + profile.interaction_preferences.proactivity_style, + "proactive" + ); + assert_eq!(profile.analysis_metadata.message_count, 42); + assert!((profile.confidence - 0.85).abs() < f64::EPSILON); + } + + #[test] + fn test_profile_from_llm_json_old_format() { + // Original test: old format with bare cohort enum and Vec qualities + let json = r#"{ + "version": 1, + "preferred_name": "Jay", + "personality": { + "empathy": 75, + "problem_solving": 85, + "emotional_intelligence": 70, + "adaptability": 80, + "communication": 72 + }, + "communication": { + "detail_level": "concise", + "formality": "casual", + "tone": "warm", + "learning_style": "hands_on", + "social_energy": "ambivert", + "decision_making": "analytical", + "pace": "fast" + }, + "cohort": "busy_professional", + "behavior": { + "frictions": ["context switching"], + "desired_outcomes": ["more focus time"], + "time_wasters": ["meetings"], + "pain_points": ["email overload"], + "strengths": ["technical depth"] + }, + "friendship": { + "style": "few_close", + "values": ["authenticity", "loyalty"], + "support_style": "problem_solver", + "qualities": ["reliable"] + }, + "assistance": { + "proactivity": "high", + "formality": "casual", + "focus_areas": ["engineering", "health"], + "routines": ["morning planning"], + "goals": ["ship product", "exercise regularly"], + "interaction_style": "direct" + }, + "context": { + "profession": "software engineer", + "interests": ["AI", "fitness", "cooking"], + "life_stage": "mid-career", + "challenges": ["work-life balance"] + }, + "created_at": "2026-02-22T00:00:00Z", + "updated_at": "2026-02-22T00:00:00Z" + }"#; + + let profile: PsychographicProfile = + serde_json::from_str(json).expect("should parse old LLM output"); + assert_eq!(profile.preferred_name, "Jay"); + assert_eq!(profile.personality.empathy, 75); + assert_eq!(profile.cohort.cohort, UserCohort::BusyProfessional); + assert_eq!(profile.assistance.proactivity, "high"); + // New fields get defaults + assert_eq!(profile.communication.response_speed, "unknown"); + assert_eq!(profile.confidence, 0.0); + } + + #[test] + fn test_analysis_framework_contains_all_dimensions() { + assert!(ANALYSIS_FRAMEWORK.contains("COMMUNICATION STYLE")); + assert!(ANALYSIS_FRAMEWORK.contains("PERSONALITY TRAITS")); + assert!(ANALYSIS_FRAMEWORK.contains("SOCIAL & RELATIONSHIP")); + assert!(ANALYSIS_FRAMEWORK.contains("DECISION MAKING")); + assert!(ANALYSIS_FRAMEWORK.contains("BEHAVIORAL PATTERNS")); + assert!(ANALYSIS_FRAMEWORK.contains("CONTEXTUAL INFO")); + assert!(ANALYSIS_FRAMEWORK.contains("ASSISTANCE PREFERENCES")); + assert!(ANALYSIS_FRAMEWORK.contains("USER COHORT")); + assert!(ANALYSIS_FRAMEWORK.contains("FRIENDSHIP QUALITIES")); + } +} diff --git a/src/settings.rs b/src/settings.rs index 0e4b1fd99b..330ab337d2 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -79,6 +79,21 @@ pub struct Settings { #[serde(default)] pub heartbeat: HeartbeatSettings, + // === Conversational Profile Onboarding === + /// Reserved flag for whether the conversational profile onboarding has + /// been completed. + /// + /// Intended semantics (not yet wired into the agent loop): this will be + /// set during the user's first interaction with the running assistant + /// (not during the setup wizard), after the agent builds a psychographic + /// profile via `memory_write`. + /// + // TODO: Wire into agent loop — currently the system uses `is_populated()` + // (content check) to gate First Contact. This flag will eventually + // distinguish "never onboarded" from "onboarded but profile was reset". + #[serde(default, alias = "personal_onboarding_completed")] + pub profile_onboarding_completed: bool, + // === Advanced Settings (not asked during setup, editable via CLI) === /// Agent behavior configuration. #[serde(default)] diff --git a/src/setup/README.md b/src/setup/README.md index c956529a64..7dc38f136f 100644 --- a/src/setup/README.md +++ b/src/setup/README.md @@ -69,6 +69,12 @@ Step 8: Background Tasks (heartbeat) `--channels-only` mode runs only Step 6, skipping everything else. +**Personal onboarding** happens conversationally during the user's first interaction +with the running assistant (not during the wizard). The `## First Contact` block in +`src/workspace/mod.rs` injects onboarding instructions into the system prompt when +no psychographic profile exists yet. Once the agent writes a profile via `memory_write`, +the block stops injecting. + --- ### Step 1: Database Connection diff --git a/src/setup/mod.rs b/src/setup/mod.rs index b556ba9225..e62298439b 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -10,6 +10,9 @@ //! 7. Extensions (tool installation from registry) //! 8. Heartbeat (background tasks) //! +//! Personal onboarding happens conversationally during the user's first +//! assistant interaction (see `workspace/mod.rs` First Contact block). +//! //! # Example //! //! ```ignore @@ -20,6 +23,9 @@ //! ``` mod channels; +#[cfg(any(feature = "postgres", feature = "libsql"))] +pub mod onboarding_chat; +pub mod profile_evolution; mod prompts; #[cfg(any(feature = "postgres", feature = "libsql"))] mod wizard; @@ -33,4 +39,4 @@ pub use prompts::{ print_success, secret_input, select_many, select_one, }; #[cfg(any(feature = "postgres", feature = "libsql"))] -pub use wizard::{SetupConfig, SetupWizard}; +pub use wizard::{SetupConfig, SetupError, SetupWizard}; diff --git a/src/setup/onboarding_chat.rs b/src/setup/onboarding_chat.rs new file mode 100644 index 0000000000..502078e3ac --- /dev/null +++ b/src/setup/onboarding_chat.rs @@ -0,0 +1,493 @@ +//! Conversational onboarding engine. +//! +//! Drives a "Getting to Know You" conversation using the configured LLM +//! provider. Tracks 6 intents adapted from NPA's onboarding system and +//! generates a [`PsychographicProfile`] from the conversation transcript. + +use std::collections::HashSet; +use std::sync::Arc; + +use crate::llm::{ChatMessage, CompletionRequest, LlmProvider}; +use crate::profile::PsychographicProfile; +use crate::setup::prompts::{input, print_info}; +use crate::setup::wizard::SetupError; + +/// Onboarding intents — topics the conversation should cover. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum OnboardingIntent { + LearnName, + SupportStyle, + FriendshipValues, + SupportExample, + CommunicationPrefs, + ReceivingHelp, +} + +impl OnboardingIntent { + const ALL: &'static [Self] = &[ + Self::LearnName, + Self::SupportStyle, + Self::FriendshipValues, + Self::SupportExample, + Self::CommunicationPrefs, + Self::ReceivingHelp, + ]; + + #[cfg(test)] + fn tag(&self) -> &'static str { + match self { + Self::LearnName => "learn_name", + Self::SupportStyle => "support_style", + Self::FriendshipValues => "friendship_values", + Self::SupportExample => "support_example", + Self::CommunicationPrefs => "communication_prefs", + Self::ReceivingHelp => "receiving_help", + } + } + + fn from_tag(tag: &str) -> Option { + match tag { + "learn_name" => Some(Self::LearnName), + "support_style" => Some(Self::SupportStyle), + "friendship_values" => Some(Self::FriendshipValues), + "support_example" => Some(Self::SupportExample), + "communication_prefs" => Some(Self::CommunicationPrefs), + "receiving_help" => Some(Self::ReceivingHelp), + _ => None, + } + } +} + +/// Drives the conversational onboarding flow. +pub struct OnboardingChat { + llm: Arc, + messages: Vec, + intents_completed: HashSet, +} + +impl OnboardingChat { + /// Create a new onboarding chat with the given LLM provider. + pub fn new(llm: Arc) -> Self { + Self { + llm, + messages: vec![ChatMessage::system(ONBOARDING_SYSTEM_PROMPT)], + intents_completed: HashSet::new(), + } + } + + /// Run the full onboarding conversation and return the generated profile. + pub async fn run(&mut self) -> Result { + println!(); + print_info("Let's have a short conversation so I can personalize your experience."); + print_info("Type \"skip\" at any time to finish early.\n"); + + // Get the initial greeting from the LLM. + let greeting = self.llm_turn().await?; + self.extract_completed_intents(&greeting); + let greeting_display = strip_intent_tags(&greeting); + println!("{}\n", greeting_display); + + loop { + // Read user input. + let user_input = input("You").map_err(SetupError::Io)?; + let trimmed = user_input.trim(); + + if trimmed.eq_ignore_ascii_case("skip") { + print_info("Skipping remaining questions..."); + break; + } + + if trimmed.is_empty() { + continue; + } + + self.messages.push(ChatMessage::user(trimmed)); + + // Get LLM response. + let response = self.llm_turn().await?; + + // Parse intent completion tags. + self.extract_completed_intents(&response); + + // Display the response (without intent tags). + let display = strip_intent_tags(&response); + println!("\n{}\n", display); + + // Check if all intents are complete. + if self.all_intents_complete() { + break; + } + } + + // Generate the psychographic profile from the conversation. + self.generate_profile().await + } + + /// Send the current message history to the LLM and get a response. + async fn llm_turn(&mut self) -> Result { + let request = CompletionRequest::new(self.messages.clone()) + .with_temperature(0.7) + .with_max_tokens(500); + + let response = self + .llm + .complete(request) + .await + .map_err(|e| SetupError::Config(format!("LLM error during onboarding: {}", e)))?; + + let content = response.content.clone(); + self.messages.push(ChatMessage::assistant(&content)); + Ok(content) + } + + /// Extract `` tags from a response. + fn extract_completed_intents(&mut self, response: &str) { + // Look for tag_name + let prefix = ""; + let suffix = ""; + + let mut search_from = 0; + while let Some(start) = response[search_from..].find(prefix) { + let abs_start = search_from + start + prefix.len(); + if let Some(end) = response[abs_start..].find(suffix) { + let tag = response[abs_start..abs_start + end].trim(); + if let Some(intent) = OnboardingIntent::from_tag(tag) { + self.intents_completed.insert(intent); + } + search_from = abs_start + end + suffix.len(); + } else { + break; + } + } + } + + /// Check if all onboarding intents have been covered. + fn all_intents_complete(&self) -> bool { + OnboardingIntent::ALL + .iter() + .all(|i| self.intents_completed.contains(i)) + } + + /// Generate a psychographic profile from the conversation transcript. + async fn generate_profile(&self) -> Result { + // Build transcript of user messages only (for analysis). + let user_messages: Vec<&str> = self + .messages + .iter() + .filter(|m| m.role == crate::llm::Role::User) + .map(|m| m.content.as_str()) + .collect(); + + if user_messages.is_empty() { + print_info("No conversation data — using default profile."); + return Ok(PsychographicProfile::default()); + } + + let transcript = user_messages.join("\n\n"); + let prompt = build_profile_generation_prompt(&transcript); + + let messages = vec![ChatMessage::system(prompt)]; + let request = CompletionRequest::new(messages) + .with_temperature(0.3) + .with_max_tokens(1500); + + let response = self + .llm + .complete(request) + .await + .map_err(|e| SetupError::Config(format!("Profile generation failed: {}", e)))?; + + // Try to parse the JSON from the response. + match parse_profile_json(&response.content) { + Ok(profile) => Ok(profile), + Err(first_err) => { + tracing::debug!("First profile parse failed: {}", first_err); + print_info("Refining profile analysis..."); + + // Retry with a stricter prompt. + let retry_prompt = format!( + "The previous response was not valid JSON. \ + Please output ONLY a valid JSON object matching the PsychographicProfile schema. \ + No markdown, no explanation, just the JSON.\n\n\ + User messages:\n{}\n\n{}", + transcript, + crate::profile::PROFILE_JSON_SCHEMA + ); + + let retry_messages = vec![ChatMessage::system(retry_prompt)]; + let retry_request = CompletionRequest::new(retry_messages) + .with_temperature(0.1) + .with_max_tokens(1500); + + let retry_response = self.llm.complete(retry_request).await.map_err(|e| { + SetupError::Config(format!("Profile generation retry failed: {}", e)) + })?; + + // NOTE: The default profile returned here is NOT written to + // workspace by this code path — callers are responsible for + // persisting. First Contact will still fire on next turn + // because has_rich_profile checks profile content, not existence. + parse_profile_json(&retry_response.content).or_else(|e| { + tracing::warn!( + "Profile generation failed after retry, falling back to default: {}", + e + ); + print_info( + "Could not generate profile from conversation — using defaults. \ + Your profile will be built over time through regular conversation.", + ); + Ok(PsychographicProfile::default()) + }) + } + } + } +} + +/// Extract JSON from a response that may contain markdown code fences. +fn parse_profile_json(text: &str) -> Result { + let cleaned = text.trim(); + + // Try to extract from ```json ... ``` blocks. + let json_str = if let Some(start) = cleaned.find("```json") { + let after_fence = &cleaned[start + 7..]; + if let Some(end) = after_fence.find("```") { + after_fence[..end].trim() + } else { + after_fence.trim() + } + } else if let Some(start) = cleaned.find("```") { + let after_fence = &cleaned[start + 3..]; + if let Some(end) = after_fence.find("```") { + after_fence[..end].trim() + } else { + after_fence.trim() + } + } else if cleaned.starts_with('{') { + cleaned + } else { + // Try to find the first { and last } + let start = cleaned.find('{').ok_or("No JSON object found")?; + let end = cleaned.rfind('}').ok_or("No closing brace found")?; + &cleaned[start..=end] + }; + + serde_json::from_str(json_str).map_err(|e| format!("JSON parse error: {}", e)) +} + +/// Strip `...` tags from display text. +fn strip_intent_tags(text: &str) -> String { + let mut result = text.to_string(); + while let Some(start) = result.find("") { + if let Some(end) = result[start..].find("") { + let remove_end = start + end + "".len(); + result = format!("{}{}", &result[..start], &result[remove_end..]); + } else { + break; + } + } + result.trim().to_string() +} + +/// System prompt for the onboarding conversation. +const ONBOARDING_SYSTEM_PROMPT: &str = r#"You are meeting your new user for the first time. Think of yourself as a billionaire's chief of staff — hyper-competent, professional, warm. Like a Slack DM with your closest, most capable colleague. Skip filler phrases ("Great question!", "I'd be happy to help!"). Be direct. Have opinions. + +CONVERSATION GOALS: +Cover these 6 topics naturally. After each is adequately covered, output a hidden tag: + +1. Learn their preferred name → learn_name +2. How they naturally support friends/family → support_style +3. What they value most in friendships → friendship_values +4. A specific example of supporting someone through a challenge → support_example +5. How they prefer to communicate → communication_prefs +6. How they prefer to receive help/support → receiving_help + +ONE-STEP-REMOVED TECHNIQUE: +Ask about how they support friends and family to understand their own values. Instead of "What are your values?" ask "When a friend is going through something tough, what do you usually do?" Instead of "How do you handle conflict?" ask "When two friends come to you with a disagreement, how do you usually help?" This indirect approach reduces defensiveness and yields authentic insights about who the person really is. + +QUESTION STYLE: +- Open-ended questions that invite storytelling, not yes/no answers +- Explore feelings and motivations, not just facts +- Connect to daily life and real experiences +- One question at a time — short, conversational, natural +- Use "tell me about..." or "what's it like when..." or "walk me through..." phrasing +- Reference what they've shared to show you're listening + +AVOID: +- Yes/no questions or anything that sounds like a survey +- Numbered lists, formal language, academic tone +- Generic questions you'd ask anyone ("What are your hobbies?") +- Asking for files, images, or anything technical +- Trying to solve problems or give advice yet +- Gushing, filler phrases, or performative warmth + +Start by introducing yourself briefly and asking what they like to be called. Keep messages short (2-3 sentences max). Match the user's energy and vocabulary. + +After all topics are covered, thank them warmly and let them know this will help you communicate better."#; + +/// Prompt for generating the psychographic profile from conversation transcript. +fn build_profile_generation_prompt(transcript: &str) -> String { + let schema = format!( + "Output a JSON object with this exact structure:\n{}", + crate::profile::PROFILE_JSON_SCHEMA + ); + format!( + r#"Analyze this onboarding conversation and generate a psychographic profile as a JSON object. + +{framework} + +EVIDENCE-BASED ANALYSIS: +- Only include insights supported by the messages. If the conversation doesn't reveal enough about a dimension, use defaults/unknown. +- For personality trait scores: 40-60 is average range. Only score above 70 or below 30 with strong evidence. Default to 50 if unclear. +- For cohort classification: set confidence 0-100 reflecting how sure you are. Include specific indicators from the conversation. + +CONFIDENCE SCORING: +Set the top-level `confidence` field (0.0-1.0) using this formula as a guide: + confidence = 0.4 + (message_count / 50) * 0.4 + (topic_variety / max(message_count, 1)) * 0.2 +Where message_count is the number of user messages and topic_variety is how many distinct topics they covered. + +ANALYSIS METADATA: +Set these fields: +- message_count: number of user messages in the transcript +- analysis_method: "onboarding" +- update_type: "initial" +- confidence_score: same as the top-level confidence value + +{schema} + +User messages from onboarding conversation: +{transcript} + +Output ONLY the JSON object, no other text."#, + framework = crate::profile::ANALYSIS_FRAMEWORK, + schema = schema, + transcript = transcript, + ) +} + +// JSON schema is now shared via crate::profile::PROFILE_JSON_SCHEMA. + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_intent_tags() { + let input = "That's great! learn_name So tell me more."; + let result = strip_intent_tags(input); + assert_eq!(result, "That's great! So tell me more."); + } + + #[test] + fn test_strip_multiple_intent_tags() { + let input = "Hello learn_name world support_style end"; + let result = strip_intent_tags(input); + assert_eq!(result, "Hello world end"); + } + + #[test] + fn test_strip_no_tags() { + let input = "No tags here"; + assert_eq!(strip_intent_tags(input), "No tags here"); + } + + #[test] + fn test_extract_completed_intents() { + let mut chat = OnboardingChat { + llm: Arc::new(MockLlm), + messages: Vec::new(), + intents_completed: HashSet::new(), + }; + + chat.extract_completed_intents( + "Great! learn_name Now, about your support style...", + ); + assert!( + chat.intents_completed + .contains(&OnboardingIntent::LearnName) + ); + assert_eq!(chat.intents_completed.len(), 1); + } + + #[test] + fn test_all_intents_complete() { + let mut chat = OnboardingChat { + llm: Arc::new(MockLlm), + messages: Vec::new(), + intents_completed: HashSet::new(), + }; + + assert!(!chat.all_intents_complete()); + + for intent in OnboardingIntent::ALL { + chat.intents_completed.insert(*intent); + } + assert!(chat.all_intents_complete()); + } + + #[test] + fn test_parse_profile_json_from_code_fence() { + let input = r#"```json +{ + "version": 1, + "preferred_name": "Test", + "personality": {"empathy": 50, "problem_solving": 50, "emotional_intelligence": 50, "adaptability": 50, "communication": 50}, + "communication": {"detail_level": "balanced", "formality": "balanced", "tone": "neutral", "learning_style": "unknown", "social_energy": "unknown", "decision_making": "unknown", "pace": "unknown"}, + "cohort": "other", + "behavior": {"frictions": [], "desired_outcomes": [], "time_wasters": [], "pain_points": [], "strengths": []}, + "friendship": {"style": "unknown", "values": [], "support_style": "unknown", "qualities": []}, + "assistance": {"proactivity": "medium", "formality": "unknown", "focus_areas": [], "routines": [], "goals": [], "interaction_style": "unknown"}, + "context": {"profession": null, "interests": [], "life_stage": null, "challenges": []}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z" +} +```"#; + let profile = parse_profile_json(input).expect("should parse"); + assert_eq!(profile.preferred_name, "Test"); + } + + #[test] + fn test_parse_profile_json_raw() { + let input = r#"{"version":1,"preferred_name":"Raw","personality":{"empathy":50,"problem_solving":50,"emotional_intelligence":50,"adaptability":50,"communication":50},"communication":{"detail_level":"balanced","formality":"balanced","tone":"neutral","learning_style":"unknown","social_energy":"unknown","decision_making":"unknown","pace":"unknown"},"cohort":"other","behavior":{"frictions":[],"desired_outcomes":[],"time_wasters":[],"pain_points":[],"strengths":[]},"friendship":{"style":"unknown","values":[],"support_style":"unknown","qualities":[]},"assistance":{"proactivity":"medium","formality":"unknown","focus_areas":[],"routines":[],"goals":[],"interaction_style":"unknown"},"context":{"profession":null,"interests":[],"life_stage":null,"challenges":[]},"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}"#; + let profile = parse_profile_json(input).expect("should parse"); + assert_eq!(profile.preferred_name, "Raw"); + } + + #[test] + fn test_intent_tag_roundtrip() { + for intent in OnboardingIntent::ALL { + let tag = intent.tag(); + let parsed = OnboardingIntent::from_tag(tag); + assert_eq!(parsed, Some(*intent)); + } + } + + /// Mock LLM provider for tests. + struct MockLlm; + + #[async_trait::async_trait] + impl LlmProvider for MockLlm { + fn model_name(&self) -> &str { + "mock-model" + } + fn cost_per_token(&self) -> (rust_decimal::Decimal, rust_decimal::Decimal) { + (rust_decimal::Decimal::ZERO, rust_decimal::Decimal::ZERO) + } + async fn complete( + &self, + _request: CompletionRequest, + ) -> Result { + Ok(crate::llm::CompletionResponse { + content: "Hello!".to_string(), + input_tokens: 0, + output_tokens: 0, + finish_reason: crate::llm::FinishReason::Stop, + }) + } + async fn complete_with_tools( + &self, + _request: crate::llm::ToolCompletionRequest, + ) -> Result { + unimplemented!() + } + } +} diff --git a/src/setup/profile_evolution.rs b/src/setup/profile_evolution.rs new file mode 100644 index 0000000000..7881cb5832 --- /dev/null +++ b/src/setup/profile_evolution.rs @@ -0,0 +1,120 @@ +//! Profile evolution prompt generation. +//! +//! Generates prompts for weekly re-analysis of the user's psychographic +//! profile based on recent conversation history. Used by the profile +//! evolution routine created during onboarding. + +use crate::profile::PsychographicProfile; + +/// Generate the LLM prompt for weekly profile evolution. +/// +/// Takes the current profile and a summary of recent conversations, +/// and returns a prompt that asks the LLM to output an updated profile. +pub fn profile_evolution_prompt( + current_profile: &PsychographicProfile, + recent_messages_summary: &str, +) -> String { + let profile_json = serde_json::to_string_pretty(current_profile) + .unwrap_or_else(|_| "{\"error\": \"failed to serialize current profile\"}".to_string()); + + format!( + r#"You are updating a user's psychographic profile based on recent conversations. + +CURRENT PROFILE: +```json +{profile_json} +``` + +RECENT CONVERSATION SUMMARY (last 7 days): +{recent_messages_summary} + +{framework} + +CONFIDENCE GATING: +- Only update a field when your confidence in the new value exceeds 0.6. +- If evidence is ambiguous or weak, leave the existing value unchanged. +- For personality trait scores: shift gradually (max ±10 per update). Only move above 70 or below 30 with strong evidence. + +UPDATE RULES: +1. Compare recent conversations against the current profile across all 9 dimensions. +2. Add new items to arrays (interests, goals, challenges) if discovered. +3. Remove items from arrays only if explicitly contradicted. +4. Update the `updated_at` timestamp to the current ISO-8601 datetime. +5. Do NOT change `version` — it represents the schema version (1=original, 2=enriched), not a revision counter. + +ANALYSIS METADATA: +Update these fields: +- message_count: approximate number of user messages in the summary period +- analysis_method: "evolution" +- update_type: "weekly" +- confidence_score: use this formula as a guide: + confidence = 0.5 + (message_count / 100) * 0.4 + (topic_variety / max(message_count, 1)) * 0.1 + +LOW CONFIDENCE FLAG: +If the overall confidence_score is below 0.3, add this to the daily log: +"Profile confidence is low — consider a profile refresh conversation." + +Output ONLY the updated JSON profile object with the same schema. No explanation, no markdown fences."#, + framework = crate::profile::ANALYSIS_FRAMEWORK + ) +} + +/// The routine prompt template used by the profile evolution cron job. +/// +/// This is injected as the routine's action prompt. The agent will: +/// 1. Read `context/profile.json` via `memory_read` +/// 2. Search recent conversations via `memory_search` +/// 3. Call itself with the evolution prompt +/// 4. Write the updated profile back via `memory_write` +pub const PROFILE_EVOLUTION_ROUTINE_PROMPT: &str = r#"You are running a weekly profile evolution check. + +Steps: +1. Read the current user profile from `context/profile.json` using the `memory_read` tool. +2. Search for recent conversation themes using `memory_search` with queries like "user preferences", "user goals", "user challenges", "user frustrations". +3. Analyze whether any profile fields should be updated based on what you've learned in the past week. +4. Only update fields where your confidence in the new value exceeds 0.6. Leave ambiguous fields unchanged. +5. If updates are needed, write the updated profile to `context/profile.json` using `memory_write`. +6. Also update `USER.md` with a refreshed markdown summary if the profile changed. +7. Update `analysis_metadata` with message_count, analysis_method="evolution", update_type="weekly", and recalculated confidence_score. +8. If overall confidence_score drops below 0.3, note in the daily log that a profile refresh conversation may help. +9. If no updates are needed, do nothing. + +Be conservative — only update fields with clear evidence from recent interactions."#; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_profile_evolution_prompt_contains_profile() { + let profile = PsychographicProfile::default(); + let prompt = profile_evolution_prompt(&profile, "User discussed fitness goals."); + assert!(prompt.contains("\"version\": 2")); + assert!(prompt.contains("fitness goals")); + } + + #[test] + fn test_profile_evolution_prompt_contains_instructions() { + let profile = PsychographicProfile::default(); + let prompt = profile_evolution_prompt(&profile, "No notable changes."); + assert!(prompt.contains("Do NOT change `version`")); + assert!(prompt.contains("max ±10 per update")); + } + + #[test] + fn test_profile_evolution_prompt_includes_framework() { + let profile = PsychographicProfile::default(); + let prompt = profile_evolution_prompt(&profile, "User likes cooking."); + assert!(prompt.contains("COMMUNICATION STYLE")); + assert!(prompt.contains("PERSONALITY TRAITS")); + assert!(prompt.contains("CONFIDENCE GATING")); + assert!(prompt.contains("confidence in the new value exceeds 0.6")); + } + + #[test] + fn test_routine_prompt_mentions_tools() { + assert!(PROFILE_EVOLUTION_ROUTINE_PROMPT.contains("memory_read")); + assert!(PROFILE_EVOLUTION_ROUTINE_PROMPT.contains("memory_write")); + assert!(PROFILE_EVOLUTION_ROUTINE_PROMPT.contains("memory_search")); + } +} diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 7bc85d00fd..5ae1725763 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -207,6 +207,10 @@ impl SetupWizard { print_step(9, total_steps, "Background Tasks"); self.step_heartbeat()?; self.persist_after_step().await; + + // Personal onboarding now happens conversationally during the + // user's first interaction with the assistant (see First Contact + // block in workspace/mod.rs system_prompt_for_context). } // Save settings and print summary diff --git a/src/tools/builtin/memory.rs b/src/tools/builtin/memory.rs index ea48da708f..7e9453c6b9 100644 --- a/src/tools/builtin/memory.rs +++ b/src/tools/builtin/memory.rs @@ -267,12 +267,43 @@ impl Tool for MemoryWriteTool { } }; - let output = serde_json::json!({ + // Sync derived identity documents when the profile is written. + let mut synced_docs: Vec<&str> = Vec::new(); + if path == paths::PROFILE { + match self.workspace.sync_profile_documents().await { + Ok(true) => { + tracing::info!("profile write: synced USER.md + assistant-directives.md"); + synced_docs.extend_from_slice(&[paths::USER, paths::ASSISTANT_DIRECTIVES]); + + // Persist the onboarding-completed flag. + let toml_path = crate::settings::Settings::default_toml_path(); + if let Ok(Some(mut settings)) = crate::settings::Settings::load_toml(&toml_path) + && !settings.profile_onboarding_completed + { + settings.profile_onboarding_completed = true; + if let Err(e) = settings.save_toml(&toml_path) { + tracing::warn!("failed to persist profile_onboarding_completed: {e}"); + } + } + } + Ok(false) => { + tracing::debug!("profile not populated, skipping document sync"); + } + Err(e) => { + tracing::warn!("profile document sync failed: {e}"); + } + } + } + + let mut output = serde_json::json!({ "status": "written", "path": path, "append": append, "content_length": content.len(), }); + if !synced_docs.is_empty() { + output["synced"] = serde_json::json!(synced_docs); + } Ok(ToolOutput::success(output, start.elapsed())) } diff --git a/src/workspace/document.rs b/src/workspace/document.rs index 23dcd5b296..3dc6ac9413 100644 --- a/src/workspace/document.rs +++ b/src/workspace/document.rs @@ -27,6 +27,10 @@ pub mod paths { pub const DAILY_DIR: &str = "daily/"; /// Context directory (for identity-related docs). pub const CONTEXT_DIR: &str = "context/"; + /// User psychographic profile (JSON). + pub const PROFILE: &str = "context/profile.json"; + /// Assistant behavioral directives (derived from profile). + pub const ASSISTANT_DIRECTIVES: &str = "context/assistant-directives.md"; } /// A memory document stored in the database. diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 71f2f666b0..478b960d74 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -271,6 +271,68 @@ const HEARTBEAT_SEED: &str = "\ - Clean up context/ documents that are outdated -->"; +/// System prompt block injected when no psychographic profile exists yet. +/// +/// Instructs the LLM to conduct a natural onboarding conversation that +/// simultaneously helps the user and learns about them. The profile gets +/// written via `memory_write` once the LLM has gathered enough signal. +const FIRST_CONTACT_INTRO: &str = "\ +## First Contact — Conversational Onboarding + +This user is new — no profile built yet. Your goals for the first few turns: + +1. **Be immediately useful.** Start by greeting them warmly and showing 3-4 concrete things \ +you can do right now: + - Track tasks and break them into steps + - Set up routines (\"Check my GitHub PRs every morning at 9am\") + - Remember things across sessions + - Monitor anything periodic (news, builds, notifications) + +2. **Learn about them naturally.** Over the first 3-5 turns, weave in questions that help \ +you understand who they are. Use the ONE-STEP-REMOVED technique: ask about how they support \ +friends/family to understand their values. Instead of \"What are your values?\" ask \"When a \ +friend is going through something tough, what do you usually do?\" This indirect approach \ +reduces defensiveness and yields authentic insights. + + Topics to cover naturally (not as a checklist — weave them in): + - What they like to be called + - How they naturally support people around them + - What they value in relationships + - How they prefer to communicate (terse vs detailed, formal vs casual) + - What they need help with right now + +3. **Ask about communication channels.** Early on, ask: \"What's your preferred way to stay \ +in touch — Telegram, Slack, something else?\" If they mention a channel that isn't set up, \ +let them know they can configure it with `ironclaw onboard --channels-only`. + +4. **Write the profile when ready.** After 3+ turns with substantive responses, use \ +`memory_write` to save the profile to `context/profile.json` as a JSON object matching the \ +schema below. Also use `memory_write` to update `USER.md` with a human-readable markdown \ +summary of what you've learned. + +STYLE GUIDELINES: +- Think of yourself as a billionaire's chief of staff — hyper-competent, professional, warm +- Skip filler phrases (\"Great question!\", \"I'd be happy to help!\") +- Be direct. Have opinions. Match the user's energy. +- One question at a time, short and conversational +- Use \"tell me about...\" or \"what's it like when...\" phrasing +- AVOID: yes/no questions, survey language, numbered interview lists, generic questions + +CONFIDENCE SCORING: +Set the top-level `confidence` field (0.0-1.0) using this formula as a guide: + confidence = 0.4 + (message_count / 50) * 0.4 + (topic_variety / max(message_count, 1)) * 0.2 +First-interaction profiles will naturally have lower confidence — that's fine. The weekly \ +profile evolution routine will refine it over time. + +ANALYSIS FRAMEWORK (use this to structure the profile):"; + +// JSON schema is shared via crate::profile::PROFILE_JSON_SCHEMA. +// FIRST_CONTACT_SCHEMA_SUFFIX contains the wrapping instructions. +const FIRST_CONTACT_SCHEMA_SUFFIX: &str = "\n\n\ +If the conversation doesn't reveal enough about a dimension, use defaults/unknown.\n\ +For personality trait scores: 40-60 is average range. Default to 50 if unclear.\n\ +Only score above 70 or below 30 with strong evidence."; + /// Workspace provides database-backed memory storage for an agent. /// /// Each workspace is scoped to a user (and optionally an agent). @@ -588,11 +650,184 @@ impl Workspace { } } + // Profile personalization and onboarding are skipped in group chats + // to avoid leaking personal context or asking onboarding questions publicly. + if !is_group_chat { + // Load psychographic profile for interaction style directives. + // Uses a three-tier system: Tier 1 (summary) always injected, + // Tier 2 (full context) only when confidence > 0.6 and profile is recent. + let mut has_profile_doc = false; + if let Ok(doc) = self.read(paths::PROFILE).await + && !doc.content.is_empty() + && let Ok(profile) = + serde_json::from_str::(&doc.content) + { + has_profile_doc = true; + let has_rich_profile = profile.is_populated(); + + if has_rich_profile { + // Tier 1: always-on summary line. + let tier1 = format!( + "## Interaction Style\n\n\ + {} | {} tone | {} detail | {} proactivity", + profile.cohort.cohort, + profile.communication.tone, + profile.communication.detail_level, + profile.assistance.proactivity, + ); + parts.push(tier1); + + // Tier 2: full context — only when confidence is sufficient and profile is recent. + let is_recent = is_profile_recent(&profile.updated_at, 7); + if profile.confidence > 0.6 && is_recent { + let mut tier2 = String::from("## Personalization\n\n"); + + // Communication details. + tier2.push_str(&format!( + "Communication: {} tone, {} formality, {} detail, {} pace", + profile.communication.tone, + profile.communication.formality, + profile.communication.detail_level, + profile.communication.pace, + )); + if profile.communication.response_speed != "unknown" { + tier2.push_str(&format!( + ", {} response speed", + profile.communication.response_speed + )); + } + if profile.communication.decision_making != "unknown" { + tier2.push_str(&format!( + ", {} decision-making", + profile.communication.decision_making + )); + } + tier2.push('.'); + + // Interaction preferences. + if profile.interaction_preferences.feedback_style != "direct" { + tier2.push_str(&format!( + "\nFeedback style: {}.", + profile.interaction_preferences.feedback_style + )); + } + if profile.interaction_preferences.proactivity_style != "reactive" { + tier2.push_str(&format!( + "\nProactivity style: {}.", + profile.interaction_preferences.proactivity_style + )); + } + + // Notification preferences. + if profile.assistance.notification_preferences != "moderate" + && profile.assistance.notification_preferences != "unknown" + { + tier2.push_str(&format!( + "\nNotification preference: {}.", + profile.assistance.notification_preferences + )); + } + + // Goals and pain points for behavioral guidance. + if !profile.assistance.goals.is_empty() { + tier2.push_str(&format!( + "\nActive goals: {}.", + profile.assistance.goals.join(", ") + )); + } + if !profile.behavior.pain_points.is_empty() { + tier2.push_str(&format!( + "\nKnown pain points: {}.", + profile.behavior.pain_points.join(", ") + )); + } + + parts.push(tier2); + } + } + } + + // First-contact: conversational onboarding for new users (no profile yet). + // This block stops injecting once the profile document is written via + // memory_write, because `has_profile_doc` will flip to true on the next turn. + if !has_profile_doc { + parts.push(format!( + "{}\n\n{}\n\nPROFILE JSON SCHEMA:\nWrite to `context/profile.json` using `memory_write` with this exact structure:\n{}{}", + FIRST_CONTACT_INTRO, + crate::profile::ANALYSIS_FRAMEWORK, + crate::profile::PROFILE_JSON_SCHEMA, + FIRST_CONTACT_SCHEMA_SUFFIX, + )); + } + } + + // Load assistant directives if present. + if let Ok(doc) = self.read(paths::ASSISTANT_DIRECTIVES).await + && !doc.content.is_empty() + { + parts.push(doc.content); + } + Ok(parts.join("\n\n---\n\n")) } - // ==================== Search ==================== + /// Sync derived identity documents from the psychographic profile. + /// + /// Reads `context/profile.json` and, if the profile is populated, writes: + /// - `USER.md` (from `to_user_md()`) + /// - `context/assistant-directives.md` (from `to_assistant_directives()`) + /// - `HEARTBEAT.md` (from `to_heartbeat_md()`, only if it doesn't already exist) + /// + /// Returns `Ok(true)` if documents were synced, `Ok(false)` if skipped. + pub async fn sync_profile_documents(&self) -> Result { + let doc = match self.read(paths::PROFILE).await { + Ok(d) if !d.content.is_empty() => d, + _ => return Ok(false), + }; + let profile: crate::profile::PsychographicProfile = match serde_json::from_str(&doc.content) + { + Ok(p) => p, + Err(_) => return Ok(false), + }; + + if !profile.is_populated() { + return Ok(false); + } + + self.write(paths::USER, &profile.to_user_md()).await?; + self.write( + paths::ASSISTANT_DIRECTIVES, + &profile.to_assistant_directives(), + ) + .await?; + + // Seed HEARTBEAT.md only if it doesn't exist yet (don't clobber user customizations). + if self.read(paths::HEARTBEAT).await.is_err() { + self.write(paths::HEARTBEAT, &profile.to_heartbeat_md()) + .await?; + } + + Ok(true) + } +} + +/// Check whether a profile's `updated_at` timestamp is within `max_days` of now. +fn is_profile_recent(updated_at: &str, max_days: i64) -> bool { + let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(updated_at) else { + return false; + }; + let age = Utc::now().signed_duration_since(parsed); + // Future timestamps are not "recent" (clock skew / bad data). + if age.num_seconds() < 0 { + return false; + } + age.num_days() <= max_days +} + +// ==================== Search ==================== + +impl Workspace { /// Hybrid search across all memory documents. /// /// Combines full-text search (BM25) with semantic search (vector similarity) @@ -728,7 +963,14 @@ impl Workspace { - Private things stay private. Never leak user context into group chats.\n\ - When in doubt about an external action, ask before acting.\n\ - Prefer reversible actions over destructive ones.\n\ - - You are not the user's voice in group settings.", + - You are not the user's voice in group settings.\n\n\ + ## Autonomy\n\n\ + Start cautious. Ask before taking actions that affect others or the outside world.\n\ + Over time, as you demonstrate competence and earn trust, you may:\n\ + - Suggest increasing autonomy for specific task types\n\ + - Take initiative on internal tasks (memory, notes, organization)\n\ + - Ask: \"I've been handling X reliably — want me to do Y without asking?\"\n\ + Never self-promote autonomy without evidence of earned trust.", ), ( paths::AGENTS, @@ -748,6 +990,25 @@ impl Workspace { - Write important facts and decisions to memory for future reference\n\ - Use the daily log for session-level notes\n\ - Be concise but thorough\n\n\ + ## Profile Building\n\n\ + As you interact with the user, passively observe and remember:\n\ + - Their name, profession, tools they use, domain expertise\n\ + - Communication style (concise vs detailed, casual vs formal)\n\ + - Repeated tasks or workflows they describe\n\ + - Goals they mention (career, health, learning, etc.)\n\ + - Pain points and frustrations (\"I keep forgetting to...\", \"I always have to...\")\n\ + - Time patterns (when they're active, what they check regularly)\n\n\ + When you learn something notable, silently update `context/profile.json` and \ + `USER.md` using `memory_write`. Merge new data — don't replace the whole file.\n\ + Never interview the user. Pick up signals naturally through conversation.\n\n\ + ## Communication Style\n\n\ + Think of yourself as a billionaire's chief of staff — hyper-competent, professional, warm.\n\ + Like a Slack DM with your closest, most capable colleague.\n\ + - Skip filler: no \"Great question!\", no \"I'd be happy to help!\", no \"Certainly!\"\n\ + - Be direct. Have opinions. Disagree when it matters.\n\ + - Match the user's energy and vocabulary. If they're terse, be terse.\n\ + - Your personality emerges from the user's preferences, not a template.\n\ + - If no profile exists yet, default to professional-warm, concise, action-oriented.\n\n\ ## Safety\n\n\ - Do not exfiltrate private data\n\ - Prefer reversible actions over destructive ones\n\