diff --git a/CLAUDE.md b/CLAUDE.md index ecfdb54ba..255ce1dd1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -335,6 +335,8 @@ Follow this order so behavior is **specified**, **proven in Rust**, **proven ove 5. **App unit tests** — Cover components, hooks, and clients with **Vitest** (`yarn test` / `yarn test:unit` in `app/`). 6. **App E2E** — Add **desktop E2E** specs where the feature is user-visible (`yarn test:e2e*`, isolated workspace — see [Testing Guide (Unit + E2E)](#testing-guide-unit--e2e)) so the full stack (UI → Tauri → sidecar) behaves as intended. +**Capability catalog** — When a change adds, removes, renames, relocates, or materially changes a user-facing feature, update **`src/openhuman/about_app/`** in the same work so the runtime capability catalog remains the source of truth for what the app can do. + **Debug logging (throughout)** — Add **lots of development-oriented logging** as you build, not as an afterthought. In **Rust**, use `log` / `tracing` at **`debug`** or **`trace`** on RPC entry and exit, error paths, state transitions, and any branch that is hard to infer from tests alone. In **`app/`**, follow existing patterns (e.g. the **`debug`** npm package with a **namespace** per area) plus **dev-only** detail where useful. Prefer **grep-friendly prefixes** (`[feature]`, domain name, or JSON-RPC method) so terminal output from **sidecar**, **Tauri**, and **WebView** can be correlated during `yarn dev` / `tauri dev`. **Never** log secrets, raw JWTs, API keys, or full PII—redact or omit. **Planning rule:** When scoping a feature, define the **E2E scenarios (core RPC + app)** up front. Those scenarios should **cover the full intended scope**—happy paths, failure modes, auth or policy gates, and regressions you care about. If a scenario is not testable end-to-end, the spec is incomplete or the cut is too large; split or add harness support first. diff --git a/src/core/all.rs b/src/core/all.rs index 89f1e49ff..2e704a68f 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -38,6 +38,7 @@ fn registry() -> &'static [RegisteredController] { fn build_registered_controllers() -> Vec { let mut controllers = Vec::new(); + controllers.extend(crate::openhuman::about_app::all_about_app_registered_controllers()); controllers.extend(crate::openhuman::cron::all_cron_registered_controllers()); controllers.extend(crate::openhuman::agent::all_agent_registered_controllers()); controllers.extend(crate::openhuman::health::all_health_registered_controllers()); @@ -71,6 +72,7 @@ fn build_registered_controllers() -> Vec { fn build_declared_controller_schemas() -> Vec { let mut schemas = Vec::new(); + schemas.extend(crate::openhuman::about_app::all_about_app_controller_schemas()); schemas.extend(crate::openhuman::cron::all_cron_controller_schemas()); schemas.extend(crate::openhuman::agent::all_agent_controller_schemas()); schemas.extend(crate::openhuman::health::all_health_controller_schemas()); @@ -115,6 +117,7 @@ pub fn rpc_method_name(schema: &ControllerSchema) -> String { pub fn namespace_description(namespace: &str) -> Option<&'static str> { match namespace { + "about_app" => Some("Catalog the app's user-facing capabilities and where to find them."), "auth" => Some("Manage app session and provider credentials."), "autocomplete" => Some("Inline autocomplete engine controls and style settings."), "channels" => Some("Channel definitions, connections, and lifecycle management."), diff --git a/src/openhuman/about_app/catalog.rs b/src/openhuman/about_app/catalog.rs new file mode 100644 index 000000000..a152f567c --- /dev/null +++ b/src/openhuman/about_app/catalog.rs @@ -0,0 +1,774 @@ +use std::collections::BTreeSet; +use std::sync::OnceLock; + +use super::types::{Capability, CapabilityCategory, CapabilityStatus}; + +const CAPABILITIES: &[Capability] = &[ + Capability { + id: "conversation.create", + name: "Create Conversations", + domain: "conversation", + category: CapabilityCategory::Conversation, + description: "Start a new conversation thread with the assistant.", + how_to: "Conversations", + status: CapabilityStatus::Stable, + }, + Capability { + id: "conversation.send_text", + name: "Send Text Messages", + domain: "conversation", + category: CapabilityCategory::Conversation, + description: "Send typed messages to the assistant in a conversation.", + how_to: "Conversations > Message composer", + status: CapabilityStatus::Stable, + }, + Capability { + id: "conversation.send_voice", + name: "Send Voice Messages", + domain: "conversation", + category: CapabilityCategory::Conversation, + description: "Record or attach voice input and send it as a message.", + how_to: "Conversations > Voice input", + status: CapabilityStatus::Beta, + }, + Capability { + id: "conversation.inline_autocomplete", + name: "Inline Autocomplete", + domain: "conversation", + category: CapabilityCategory::Conversation, + description: "Show predictive inline text suggestions while you type.", + how_to: "Settings > Inline Autocomplete", + status: CapabilityStatus::Beta, + }, + Capability { + id: "conversation.copy_messages", + name: "Copy Messages", + domain: "conversation", + category: CapabilityCategory::Conversation, + description: "Copy individual assistant or user messages for reuse elsewhere.", + how_to: "Conversations > Message actions", + status: CapabilityStatus::Stable, + }, + Capability { + id: "conversation.delete_conversations", + name: "Delete Conversations", + domain: "conversation", + category: CapabilityCategory::Conversation, + description: "Remove existing conversation threads from the app.", + how_to: "Conversations > Thread actions", + status: CapabilityStatus::Stable, + }, + Capability { + id: "conversation.suggested_questions", + name: "Suggested Questions", + domain: "conversation", + category: CapabilityCategory::Conversation, + description: "Offer prompt suggestions to help continue a conversation.", + how_to: "Home or Conversations > Suggested prompts", + status: CapabilityStatus::Beta, + }, + Capability { + id: "conversation.tool_execution_timeline", + name: "Tool Execution Timeline", + domain: "conversation", + category: CapabilityCategory::Conversation, + description: "Show the sequence of tool calls and actions used to answer a request.", + how_to: "Conversations > Tool timeline", + status: CapabilityStatus::Beta, + }, + Capability { + id: "intelligence.analyze_actionable_items", + name: "Analyze Actionable Items", + domain: "intelligence", + category: CapabilityCategory::Intelligence, + description: "Extract and summarize actionable items from your activity and conversations.", + how_to: "Intelligence", + status: CapabilityStatus::Stable, + }, + Capability { + id: "intelligence.filter_actionable_items", + name: "Filter Actionable Items", + domain: "intelligence", + category: CapabilityCategory::Intelligence, + description: "Search and filter actionable items to focus on what matters now.", + how_to: "Intelligence > Filters and search", + status: CapabilityStatus::Stable, + }, + Capability { + id: "intelligence.mark_actionable_item_complete", + name: "Mark Items Complete", + domain: "intelligence", + category: CapabilityCategory::Intelligence, + description: "Mark an actionable item as completed.", + how_to: "Intelligence > Item actions", + status: CapabilityStatus::Stable, + }, + Capability { + id: "intelligence.dismiss_actionable_item", + name: "Dismiss Items", + domain: "intelligence", + category: CapabilityCategory::Intelligence, + description: "Dismiss irrelevant or already handled actionable items.", + how_to: "Intelligence > Item actions", + status: CapabilityStatus::Stable, + }, + Capability { + id: "intelligence.snooze_actionable_item", + name: "Snooze Items", + domain: "intelligence", + category: CapabilityCategory::Intelligence, + description: "Temporarily hide an actionable item until later.", + how_to: "Intelligence > Item actions", + status: CapabilityStatus::Stable, + }, + Capability { + id: "intelligence.undo_action", + name: "Undo Item Actions", + domain: "intelligence", + category: CapabilityCategory::Intelligence, + description: "Undo a recent complete, dismiss, or snooze action.", + how_to: "Intelligence > Undo snackbar or item history", + status: CapabilityStatus::Beta, + }, + Capability { + id: "intelligence.memory_workspace", + name: "Memory Workspace", + domain: "intelligence", + category: CapabilityCategory::Intelligence, + description: "Inspect or debug the app's memory workspace and stored knowledge.", + how_to: "Settings > Memory Debug", + status: CapabilityStatus::Beta, + }, + Capability { + id: "skills.discover", + name: "Discover Skills", + domain: "skills", + category: CapabilityCategory::Skills, + description: "Browse available skills that can extend the app.", + how_to: "Skills", + status: CapabilityStatus::Stable, + }, + Capability { + id: "skills.install", + name: "Install Skills", + domain: "skills", + category: CapabilityCategory::Skills, + description: "Install a skill into the local workspace.", + how_to: "Skills > Install", + status: CapabilityStatus::Stable, + }, + Capability { + id: "skills.configure", + name: "Configure Skills", + domain: "skills", + category: CapabilityCategory::Skills, + description: "Open skill setup and update skill-specific configuration.", + how_to: "Skills > Setup or Settings > Connections", + status: CapabilityStatus::Stable, + }, + Capability { + id: "skills.connection_status", + name: "Monitor Skill Connection Status", + domain: "skills", + category: CapabilityCategory::Skills, + description: "See whether a skill-backed integration is connected, offline, or needs setup.", + how_to: "Skills or Settings > Connections", + status: CapabilityStatus::Beta, + }, + Capability { + id: "skills.sync_manual", + name: "Manually Sync Skill Data", + domain: "skills", + category: CapabilityCategory::Skills, + description: "Trigger a manual data sync for a skill integration.", + how_to: "Skills > Skill card > Sync", + status: CapabilityStatus::Beta, + }, + Capability { + id: "skills.toggle_enabled", + name: "Enable or Disable Skills", + domain: "skills", + category: CapabilityCategory::Skills, + description: "Turn individual skills on or off without uninstalling them.", + how_to: "Settings > Developer Options > Skills", + status: CapabilityStatus::Stable, + }, + Capability { + id: "skills.open_connections_hub", + name: "Open Connections Hub", + domain: "skills", + category: CapabilityCategory::Skills, + description: "Browse the dedicated connections hub for external skill-backed integrations.", + how_to: "Settings > Connections", + status: CapabilityStatus::Beta, + }, + Capability { + id: "skills.connect_google", + name: "Connect Google", + domain: "skills", + category: CapabilityCategory::Skills, + description: "Connect Google services for email, contacts, and calendar workflows.", + how_to: "Settings > Connections", + status: CapabilityStatus::ComingSoon, + }, + Capability { + id: "skills.connect_notion", + name: "Connect Notion", + domain: "skills", + category: CapabilityCategory::Skills, + description: "Connect Notion for workspace sync and productivity workflows.", + how_to: "Settings > Connections", + status: CapabilityStatus::ComingSoon, + }, + Capability { + id: "skills.connect_web3_wallet", + name: "Connect Web3 Wallet", + domain: "skills", + category: CapabilityCategory::Skills, + description: "Connect a wallet for crypto workflows and onchain actions.", + how_to: "Settings > Connections", + status: CapabilityStatus::ComingSoon, + }, + Capability { + id: "skills.connect_crypto_exchange", + name: "Connect Crypto Exchange", + domain: "skills", + category: CapabilityCategory::Skills, + description: "Connect supported exchanges for trading and portfolio workflows.", + how_to: "Settings > Connections", + status: CapabilityStatus::ComingSoon, + }, + Capability { + id: "skills.browser_access_policy", + name: "Configure Browser Access Policy", + domain: "skills", + category: CapabilityCategory::Skills, + description: "Control whether browser-based tools can visit only the allowlist or any public domain.", + how_to: "Settings > Developer Options > Skills > Browser Access", + status: CapabilityStatus::Beta, + }, + Capability { + id: "local_ai.download_model", + name: "Download Local Models", + domain: "local_ai", + category: CapabilityCategory::LocalAI, + description: "Download and bootstrap local AI runtimes and model bundles.", + how_to: "Settings > Local AI Model", + status: CapabilityStatus::Beta, + }, + Capability { + id: "local_ai.manage_model_assets", + name: "Manage Model Assets", + domain: "local_ai", + category: CapabilityCategory::LocalAI, + description: "Inspect asset status and download specific chat, vision, embedding, STT, or TTS assets.", + how_to: "Settings > Local AI Model > Advanced > Capability Assets", + status: CapabilityStatus::Beta, + }, + Capability { + id: "local_ai.embed_text", + name: "Generate Text Embeddings", + domain: "local_ai", + category: CapabilityCategory::LocalAI, + description: "Create local vector embeddings for text input.", + how_to: "Settings > Local AI Model > Advanced > Test Embeddings", + status: CapabilityStatus::Beta, + }, + Capability { + id: "local_ai.speech_to_text", + name: "Speech Recognition", + domain: "local_ai", + category: CapabilityCategory::LocalAI, + description: "Transcribe audio into text using local speech recognition.", + how_to: "Settings > Local AI Model > Advanced > Test Voice Input", + status: CapabilityStatus::Beta, + }, + Capability { + id: "local_ai.text_to_speech", + name: "Text to Speech", + domain: "local_ai", + category: CapabilityCategory::LocalAI, + description: "Synthesize speech from text using local voice models.", + how_to: "Settings > Local AI Model > Advanced > Test Voice Output", + status: CapabilityStatus::Beta, + }, + Capability { + id: "local_ai.vision_processing", + name: "Vision Processing", + domain: "local_ai", + category: CapabilityCategory::LocalAI, + description: "Run vision prompts against images using a local multimodal model.", + how_to: "Settings > Local AI Model > Advanced > Test Vision Prompt", + status: CapabilityStatus::Beta, + }, + Capability { + id: "local_ai.direct_prompting", + name: "Direct Model Prompting", + domain: "local_ai", + category: CapabilityCategory::LocalAI, + description: "Send a direct prompt to the local model without using the cloud API.", + how_to: "Settings > Local AI Model > Advanced > Test Custom Prompt", + status: CapabilityStatus::Beta, + }, + Capability { + id: "team.create", + name: "Create Teams", + domain: "team", + category: CapabilityCategory::Team, + description: "Create a team and start collaborating with shared billing and members.", + how_to: "Settings > Team", + status: CapabilityStatus::Stable, + }, + Capability { + id: "team.join_via_invite_code", + name: "Join Teams via Invite Code", + domain: "team", + category: CapabilityCategory::Team, + description: "Join an existing team using an invite code.", + how_to: "Invites > Redeem an Invite Code", + status: CapabilityStatus::Stable, + }, + Capability { + id: "team.switch_active_team", + name: "Switch Active Team", + domain: "team", + category: CapabilityCategory::Team, + description: "Switch which team is currently active in the app.", + how_to: "Settings > Team", + status: CapabilityStatus::Stable, + }, + Capability { + id: "team.leave", + name: "Leave Teams", + domain: "team", + category: CapabilityCategory::Team, + description: "Leave a team that you no longer want to participate in.", + how_to: "Settings > Team", + status: CapabilityStatus::Stable, + }, + Capability { + id: "team.manage_members", + name: "Manage Team Members", + domain: "team", + category: CapabilityCategory::Team, + description: "Review members and change team roles when you have permission.", + how_to: "Settings > Team > Manage team > Members", + status: CapabilityStatus::Stable, + }, + Capability { + id: "team.generate_invite_codes", + name: "Generate Invite Codes", + domain: "team", + category: CapabilityCategory::Team, + description: "Create invite codes to bring new members into a team.", + how_to: "Settings > Team > Manage team > Invites", + status: CapabilityStatus::Stable, + }, + Capability { + id: "team.track_invite_usage", + name: "Track Invite Usage", + domain: "team", + category: CapabilityCategory::Team, + description: "View invite usage counts, limits, and revoke team invites.", + how_to: "Settings > Team > Manage team > Invites", + status: CapabilityStatus::Stable, + }, + Capability { + id: "auth.login_oauth", + name: "Login via OAuth", + domain: "auth", + category: CapabilityCategory::Auth, + description: "Sign in with the app's supported provider-based authentication flow.", + how_to: "Welcome", + status: CapabilityStatus::Stable, + }, + Capability { + id: "auth.onboarding_setup", + name: "Onboarding Setup", + domain: "auth", + category: CapabilityCategory::Auth, + description: "Walk through onboarding to configure initial permissions and preferences.", + how_to: "Onboarding", + status: CapabilityStatus::Stable, + }, + Capability { + id: "auth.configure_tool_access", + name: "Configure Tool Access", + domain: "auth", + category: CapabilityCategory::Auth, + description: "Choose which built-in tools OpenHuman can use on your behalf during setup.", + how_to: "Onboarding > Enable Tools", + status: CapabilityStatus::Stable, + }, + Capability { + id: "auth.backup_recovery_phrase", + name: "Back Up Recovery Phrase", + domain: "auth", + category: CapabilityCategory::Auth, + description: "Generate and save a recovery phrase used to secure and restore encrypted app data.", + how_to: "Onboarding > Recovery Phrase", + status: CapabilityStatus::Stable, + }, + Capability { + id: "auth.import_recovery_phrase", + name: "Import Recovery Phrase", + domain: "auth", + category: CapabilityCategory::Auth, + description: "Import an existing recovery phrase to restore encrypted app data.", + how_to: "Onboarding > Recovery Phrase > I already have a recovery phrase", + status: CapabilityStatus::Stable, + }, + Capability { + id: "auth.logout", + name: "Logout", + domain: "auth", + category: CapabilityCategory::Auth, + description: "Sign out of the current session.", + how_to: "Settings > Log out", + status: CapabilityStatus::Stable, + }, + Capability { + id: "screen_intelligence.toggle_monitoring", + name: "Enable or Disable Screen Monitoring", + domain: "screen_intelligence", + category: CapabilityCategory::ScreenIntelligence, + description: "Turn desktop screen intelligence capture on or off.", + how_to: "Settings > Screen Intelligence", + status: CapabilityStatus::Beta, + }, + Capability { + id: "screen_intelligence.manage_accessibility_permissions", + name: "Manage Accessibility Permissions", + domain: "screen_intelligence", + category: CapabilityCategory::ScreenIntelligence, + description: "Review and grant the accessibility permissions required for desktop assistance.", + how_to: "Onboarding > Screen permissions or Settings > Accessibility Automation", + status: CapabilityStatus::Stable, + }, + Capability { + id: "screen_intelligence.review_vision_data", + name: "Review Vision Data", + domain: "screen_intelligence", + category: CapabilityCategory::ScreenIntelligence, + description: "Inspect the captured screen intelligence and related vision summaries.", + how_to: "Settings > Screen Intelligence", + status: CapabilityStatus::Beta, + }, + Capability { + id: "screen_intelligence.configure_capture_fps", + name: "Configure Capture FPS", + domain: "screen_intelligence", + category: CapabilityCategory::ScreenIntelligence, + description: "Tune the screen capture frame rate used by screen intelligence.", + how_to: "Settings > Screen Intelligence", + status: CapabilityStatus::Beta, + }, + Capability { + id: "screen_intelligence.app_whitelist", + name: "Whitelist Apps for Capture", + domain: "screen_intelligence", + category: CapabilityCategory::ScreenIntelligence, + description: "Allow screen intelligence only for selected applications.", + how_to: "Settings > Screen Intelligence", + status: CapabilityStatus::Beta, + }, + Capability { + id: "screen_intelligence.app_blacklist", + name: "Blacklist Apps from Capture", + domain: "screen_intelligence", + category: CapabilityCategory::ScreenIntelligence, + description: "Exclude selected applications from screen intelligence capture.", + how_to: "Settings > Screen Intelligence", + status: CapabilityStatus::Beta, + }, + Capability { + id: "channels.connect_platform", + name: "Connect Messaging Platforms", + domain: "channels", + category: CapabilityCategory::Channels, + description: "Connect supported messaging platforms such as Telegram, Discord, or Slack.", + how_to: "Settings > Messaging Channels", + status: CapabilityStatus::Beta, + }, + Capability { + id: "channels.disconnect_platform", + name: "Disconnect Messaging Platforms", + domain: "channels", + category: CapabilityCategory::Channels, + description: "Disconnect a previously configured messaging platform.", + how_to: "Settings > Messaging Channels", + status: CapabilityStatus::Beta, + }, + Capability { + id: "channels.test_credentials", + name: "Test Channel Credentials", + domain: "channels", + category: CapabilityCategory::Channels, + description: "Validate platform credentials or connection state before using a channel.", + how_to: "Settings > Messaging Channels", + status: CapabilityStatus::Beta, + }, + Capability { + id: "channels.set_default_channel", + name: "Set Default Messaging Channel", + domain: "channels", + category: CapabilityCategory::Channels, + description: "Choose which messaging channel should be used by default.", + how_to: "Settings > Messaging Channels", + status: CapabilityStatus::Beta, + }, + Capability { + id: "settings.configure_ai", + name: "Configure AI", + domain: "settings", + category: CapabilityCategory::Settings, + description: "Adjust AI-related settings and agent behavior preferences.", + how_to: "Settings > Developer Options > AI Configuration", + status: CapabilityStatus::Stable, + }, + Capability { + id: "settings.manage_privacy_analytics", + name: "Manage Privacy and Analytics", + domain: "settings", + category: CapabilityCategory::Settings, + description: "Control privacy, analytics, and related data handling preferences.", + how_to: "Settings > Privacy (direct route)", + status: CapabilityStatus::Stable, + }, + Capability { + id: "settings.view_billing", + name: "View Billing", + domain: "settings", + category: CapabilityCategory::Settings, + description: "Open billing and usage views for your active team.", + how_to: "Settings > Billing & Usage", + status: CapabilityStatus::Stable, + }, + Capability { + id: "settings.manage_subscription_plan", + name: "Manage Subscription Plan", + domain: "settings", + category: CapabilityCategory::Settings, + description: "Upgrade plans or open the billing portal to manage subscriptions.", + how_to: "Settings > Billing & Usage", + status: CapabilityStatus::Stable, + }, + Capability { + id: "settings.manage_credits", + name: "Manage Credits", + domain: "settings", + category: CapabilityCategory::Settings, + description: "View credit balances, top up credits, and configure auto-recharge.", + how_to: "Settings > Billing & Usage", + status: CapabilityStatus::Stable, + }, + Capability { + id: "settings.add_payment_methods", + name: "Add Payment Methods", + domain: "settings", + category: CapabilityCategory::Settings, + description: "Add or manage saved payment methods for billing and auto-recharge.", + how_to: "Settings > Billing & Usage > Payment Methods", + status: CapabilityStatus::Stable, + }, + Capability { + id: "settings.developer_options", + name: "Developer Options", + domain: "settings", + category: CapabilityCategory::Settings, + description: "Open developer-focused panels for diagnostics, skills, AI config, and memory tools.", + how_to: "Settings > Developer Options", + status: CapabilityStatus::Beta, + }, + Capability { + id: "settings.manage_service", + name: "Manage Desktop Service", + domain: "settings", + category: CapabilityCategory::Settings, + description: "Install, start, stop, restart, uninstall, or refresh the required desktop service.", + how_to: "App startup > OpenHuman Service Required", + status: CapabilityStatus::Stable, + }, + Capability { + id: "settings.clear_app_data", + name: "Log Out and Clear App Data", + domain: "settings", + category: CapabilityCategory::Settings, + description: "Sign out and permanently clear local app data, including skills data.", + how_to: "Settings > Log Out & Clear App Data", + status: CapabilityStatus::Stable, + }, + Capability { + id: "settings.delete_all_data", + name: "Delete All Data", + domain: "settings", + category: CapabilityCategory::Settings, + description: "Delete all local data and reset the app from the destructive settings section.", + how_to: "Settings > Delete All Data", + status: CapabilityStatus::ComingSoon, + }, + Capability { + id: "automation.view_cron_jobs", + name: "View Cron Jobs", + domain: "automation", + category: CapabilityCategory::Automation, + description: "Review scheduled jobs available to the runtime.", + how_to: "Settings > Cron Jobs", + status: CapabilityStatus::Stable, + }, + Capability { + id: "automation.set_job_intervals", + name: "Set Job Intervals", + domain: "automation", + category: CapabilityCategory::Automation, + description: "Configure how often a scheduled job should run.", + how_to: "Settings > Cron Jobs", + status: CapabilityStatus::Stable, + }, + Capability { + id: "automation.view_execution_history", + name: "View Execution History", + domain: "automation", + category: CapabilityCategory::Automation, + description: "Inspect past runs and results for scheduled jobs.", + how_to: "Settings > Cron Jobs", + status: CapabilityStatus::Beta, + }, +]; + +static VALIDATED: OnceLock<()> = OnceLock::new(); + +pub fn all_capabilities() -> &'static [Capability] { + ensure_validated(); + CAPABILITIES +} + +pub fn capabilities_by_category(category: CapabilityCategory) -> Vec { + ensure_validated(); + CAPABILITIES + .iter() + .filter(|capability| capability.category == category) + .copied() + .collect() +} + +pub fn lookup(id: &str) -> Option { + ensure_validated(); + let normalized = id.trim(); + CAPABILITIES + .iter() + .find(|capability| capability.id == normalized) + .copied() +} + +pub fn search(query: &str) -> Vec { + ensure_validated(); + let normalized = query.trim().to_ascii_lowercase(); + if normalized.is_empty() { + return CAPABILITIES.to_vec(); + } + + CAPABILITIES + .iter() + .filter(|capability| searchable_text(capability).contains(&normalized)) + .copied() + .collect() +} + +fn searchable_text(capability: &Capability) -> String { + format!( + "{} {} {} {} {} {} {}", + capability.id, + capability.name, + capability.domain, + capability.category.as_str(), + capability.description, + capability.how_to, + capability.status.as_str() + ) + .to_ascii_lowercase() +} + +fn ensure_validated() { + VALIDATED.get_or_init(|| { + let mut ids = BTreeSet::new(); + for capability in CAPABILITIES { + assert!( + !capability.id.trim().is_empty(), + "about_app capability id must not be empty" + ); + assert!( + ids.insert(capability.id), + "duplicate about_app capability id '{}'", + capability.id + ); + } + + tracing::debug!( + count = CAPABILITIES.len(), + "[about_app] validated capability catalog" + ); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookup_returns_expected_capability() { + let capability = lookup("local_ai.download_model").expect("capability should exist"); + assert_eq!(capability.category, CapabilityCategory::LocalAI); + assert_eq!(capability.status, CapabilityStatus::Beta); + } + + #[test] + fn search_matches_keyword_across_multiple_fields() { + let matches = search("invite"); + let ids: Vec<&str> = matches.iter().map(|capability| capability.id).collect(); + + assert!(ids.contains(&"team.join_via_invite_code")); + assert!(ids.contains(&"team.generate_invite_codes")); + assert!(ids.contains(&"team.track_invite_usage")); + } + + #[test] + fn capability_ids_are_unique() { + let ids: BTreeSet<&str> = all_capabilities() + .iter() + .map(|capability| capability.id) + .collect(); + assert_eq!(ids.len(), all_capabilities().len()); + } + + #[test] + fn category_filter_returns_matching_entries() { + let capabilities = capabilities_by_category(CapabilityCategory::Automation); + assert!(capabilities + .iter() + .all(|capability| { capability.category == CapabilityCategory::Automation })); + assert!(!capabilities.is_empty()); + } + + #[test] + fn catalog_includes_additional_user_facing_surfaces() { + let ids: BTreeSet<&str> = all_capabilities() + .iter() + .map(|capability| capability.id) + .collect(); + + for expected in [ + "skills.open_connections_hub", + "skills.connect_google", + "auth.backup_recovery_phrase", + "auth.configure_tool_access", + "settings.manage_service", + "settings.clear_app_data", + ] { + assert!( + ids.contains(expected), + "missing catalog capability `{expected}`" + ); + } + } +} diff --git a/src/openhuman/about_app/mod.rs b/src/openhuman/about_app/mod.rs new file mode 100644 index 000000000..7328066ed --- /dev/null +++ b/src/openhuman/about_app/mod.rs @@ -0,0 +1,47 @@ +//! User-facing capability catalog for the OpenHuman app. +//! +//! This module is the single source of truth for what the desktop app exposes +//! to end users, including where a capability lives in the UI and whether it is +//! stable, beta, coming soon, or deprecated. + +mod catalog; +mod schemas; +mod types; + +use crate::rpc::RpcOutcome; + +pub use catalog::{all_capabilities, capabilities_by_category, lookup, search}; +pub use schemas::{ + about_app_schemas, all_about_app_controller_schemas, all_about_app_registered_controllers, +}; +pub use types::{Capability, CapabilityCategory, CapabilityStatus}; + +pub fn list_capabilities(category: Option) -> RpcOutcome> { + let capabilities = match category { + Some(category) => capabilities_by_category(category), + None => all_capabilities().to_vec(), + }; + let log = format!( + "about_app.list returned {} capabilities", + capabilities.len() + ); + RpcOutcome::single_log(capabilities, log) +} + +pub fn lookup_capability(id: &str) -> Result, String> { + let capability = lookup(id).ok_or_else(|| format!("unknown capability id '{}'", id.trim()))?; + Ok(RpcOutcome::single_log( + capability, + format!("about_app.lookup returned {}", capability.id), + )) +} + +pub fn search_capabilities(query: &str) -> RpcOutcome> { + let capabilities = search(query); + let log = format!( + "about_app.search returned {} capabilities for '{}'", + capabilities.len(), + query.trim() + ); + RpcOutcome::single_log(capabilities, log) +} diff --git a/src/openhuman/about_app/schemas.rs b/src/openhuman/about_app/schemas.rs new file mode 100644 index 000000000..f9023399b --- /dev/null +++ b/src/openhuman/about_app/schemas.rs @@ -0,0 +1,213 @@ +use std::str::FromStr; + +use serde::de::DeserializeOwned; +use serde::Deserialize; +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; +use crate::openhuman::about_app::CapabilityCategory; +use crate::rpc::RpcOutcome; + +#[derive(Debug, Deserialize, Default)] +struct AboutAppListParams { + #[serde(default)] + category: Option, +} + +#[derive(Debug, Deserialize)] +struct AboutAppLookupParams { + id: String, +} + +#[derive(Debug, Deserialize)] +struct AboutAppSearchParams { + query: String, +} + +pub fn all_about_app_controller_schemas() -> Vec { + vec![ + about_app_schemas("about_app_list"), + about_app_schemas("about_app_lookup"), + about_app_schemas("about_app_search"), + ] +} + +pub fn all_about_app_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: about_app_schemas("about_app_list"), + handler: handle_about_app_list, + }, + RegisteredController { + schema: about_app_schemas("about_app_lookup"), + handler: handle_about_app_lookup, + }, + RegisteredController { + schema: about_app_schemas("about_app_search"), + handler: handle_about_app_search, + }, + ] +} + +pub fn about_app_schemas(function: &str) -> ControllerSchema { + match function { + "about_app_list" => ControllerSchema { + namespace: "about_app", + function: "list", + description: "List all user-facing app capabilities, optionally filtered by category.", + inputs: vec![optional_category( + "category", + "Optional capability category filter.", + )], + outputs: vec![capabilities_output( + "capabilities", + "Capability catalog entries matching the list filter.", + )], + }, + "about_app_lookup" => ControllerSchema { + namespace: "about_app", + function: "lookup", + description: "Look up one user-facing capability by its stable id.", + inputs: vec![required_string( + "id", + "Capability id, such as local_ai.download_model.", + )], + outputs: vec![capability_output( + "capability", + "One capability entry for the requested id.", + )], + }, + "about_app_search" => ControllerSchema { + namespace: "about_app", + function: "search", + description: "Search user-facing capabilities by keyword.", + inputs: vec![required_string("query", "Keyword query to search for.")], + outputs: vec![capabilities_output( + "capabilities", + "Capability catalog entries matching the search query.", + )], + }, + _ => ControllerSchema { + namespace: "about_app", + function: "unknown", + description: "Unknown about_app controller.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "error", + ty: TypeSchema::String, + comment: "Lookup error details.", + required: true, + }], + }, + } +} + +fn handle_about_app_list(params: Map) -> ControllerFuture { + Box::pin(async move { + let payload = deserialize_params::(params)?; + let category = payload + .category + .as_deref() + .map(CapabilityCategory::from_str) + .transpose()?; + + tracing::debug!(?category, "[about_app] list capabilities"); + to_json(crate::openhuman::about_app::list_capabilities(category)) + }) +} + +fn handle_about_app_lookup(params: Map) -> ControllerFuture { + Box::pin(async move { + let payload = deserialize_params::(params)?; + tracing::debug!(id = %payload.id, "[about_app] lookup capability"); + to_json(crate::openhuman::about_app::lookup_capability(&payload.id)?) + }) +} + +fn handle_about_app_search(params: Map) -> ControllerFuture { + Box::pin(async move { + let payload = deserialize_params::(params)?; + tracing::debug!(query = %payload.query, "[about_app] search capabilities"); + to_json(crate::openhuman::about_app::search_capabilities( + &payload.query, + )) + }) +} + +fn deserialize_params(params: Map) -> Result { + serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}")) +} + +fn to_json(outcome: RpcOutcome) -> Result { + outcome.into_cli_compatible_json() +} + +fn required_string(name: &'static str, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty: TypeSchema::String, + comment, + required: true, + } +} + +fn optional_category(name: &'static str, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty: TypeSchema::Option(Box::new(TypeSchema::Enum { + variants: CapabilityCategory::ALL + .iter() + .map(|category| category.as_str()) + .collect(), + })), + comment, + required: false, + } +} + +fn capability_output(name: &'static str, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty: TypeSchema::Ref("Capability"), + comment, + required: true, + } +} + +fn capabilities_output(name: &'static str, comment: &'static str) -> FieldSchema { + FieldSchema { + name, + ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Capability"))), + comment, + required: true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_names_are_stable() { + let list = about_app_schemas("about_app_list"); + assert_eq!(list.namespace, "about_app"); + assert_eq!(list.function, "list"); + + let lookup = about_app_schemas("about_app_lookup"); + assert_eq!(lookup.namespace, "about_app"); + assert_eq!(lookup.function, "lookup"); + + let search = about_app_schemas("about_app_search"); + assert_eq!(search.namespace, "about_app"); + assert_eq!(search.function, "search"); + } + + #[test] + fn controller_lists_match_lengths() { + assert_eq!( + all_about_app_controller_schemas().len(), + all_about_app_registered_controllers().len() + ); + } +} diff --git a/src/openhuman/about_app/types.rs b/src/openhuman/about_app/types.rs new file mode 100644 index 000000000..897676bbc --- /dev/null +++ b/src/openhuman/about_app/types.rs @@ -0,0 +1,147 @@ +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum CapabilityCategory { + #[serde(rename = "conversation")] + Conversation, + #[serde(rename = "intelligence")] + Intelligence, + #[serde(rename = "skills")] + Skills, + #[serde(rename = "local_ai")] + LocalAI, + #[serde(rename = "team")] + Team, + #[serde(rename = "settings")] + Settings, + #[serde(rename = "auth")] + Auth, + #[serde(rename = "screen_intelligence")] + ScreenIntelligence, + #[serde(rename = "channels")] + Channels, + #[serde(rename = "automation")] + Automation, +} + +impl CapabilityCategory { + pub const ALL: [Self; 10] = [ + Self::Conversation, + Self::Intelligence, + Self::Skills, + Self::LocalAI, + Self::Team, + Self::Settings, + Self::Auth, + Self::ScreenIntelligence, + Self::Channels, + Self::Automation, + ]; + + pub const fn as_str(self) -> &'static str { + match self { + Self::Conversation => "conversation", + Self::Intelligence => "intelligence", + Self::Skills => "skills", + Self::LocalAI => "local_ai", + Self::Team => "team", + Self::Settings => "settings", + Self::Auth => "auth", + Self::ScreenIntelligence => "screen_intelligence", + Self::Channels => "channels", + Self::Automation => "automation", + } + } +} + +impl FromStr for CapabilityCategory { + type Err = String; + + fn from_str(value: &str) -> Result { + let normalized = value.trim().to_ascii_lowercase(); + match normalized.as_str() { + "conversation" => Ok(Self::Conversation), + "intelligence" => Ok(Self::Intelligence), + "skills" => Ok(Self::Skills), + "local_ai" | "local-ai" | "local ai" | "localai" => Ok(Self::LocalAI), + "team" => Ok(Self::Team), + "settings" => Ok(Self::Settings), + "auth" => Ok(Self::Auth), + "screen_intelligence" | "screen-intelligence" | "screen intelligence" => { + Ok(Self::ScreenIntelligence) + } + "channels" => Ok(Self::Channels), + "automation" => Ok(Self::Automation), + _ => Err(format!( + "unknown capability category '{value}'; expected one of: {}", + Self::ALL + .iter() + .map(|category| category.as_str()) + .collect::>() + .join(", ") + )), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum CapabilityStatus { + #[serde(rename = "stable")] + Stable, + #[serde(rename = "beta")] + Beta, + #[serde(rename = "coming_soon")] + ComingSoon, + #[serde(rename = "deprecated")] + Deprecated, +} + +impl CapabilityStatus { + pub const fn as_str(self) -> &'static str { + match self { + Self::Stable => "stable", + Self::Beta => "beta", + Self::ComingSoon => "coming_soon", + Self::Deprecated => "deprecated", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct Capability { + pub id: &'static str, + pub name: &'static str, + pub domain: &'static str, + pub category: CapabilityCategory, + pub description: &'static str, + pub how_to: &'static str, + pub status: CapabilityStatus, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn category_serializes_expected_wire_names() { + assert_eq!( + serde_json::to_string(&CapabilityCategory::LocalAI).expect("serialize LocalAI"), + "\"local_ai\"" + ); + assert_eq!( + serde_json::to_string(&CapabilityCategory::ScreenIntelligence) + .expect("serialize ScreenIntelligence"), + "\"screen_intelligence\"" + ); + } + + #[test] + fn status_serializes_expected_wire_names() { + assert_eq!( + serde_json::to_string(&CapabilityStatus::ComingSoon).expect("serialize ComingSoon"), + "\"coming_soon\"" + ); + } +} diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 5297656f1..54269ab9a 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -11,6 +11,7 @@ // Many types/functions are not yet consumed but are intentionally exported. #![allow(dead_code)] +pub mod about_app; pub mod accessibility; pub mod agent; pub mod approval; diff --git a/tests/json_rpc_e2e.rs b/tests/json_rpc_e2e.rs index 5e5d48bfc..a7ba391c7 100644 --- a/tests/json_rpc_e2e.rs +++ b/tests/json_rpc_e2e.rs @@ -1554,6 +1554,115 @@ async fn team_rpc_e2e() { rpc_join.abort(); } +#[tokio::test] +async fn about_app_rpc_list_lookup_and_search() { + let _env_lock = json_rpc_e2e_env_lock(); + let tmp = tempdir().expect("tempdir"); + let home = tmp.path(); + let openhuman_home = home.join(".openhuman"); + + let _home_guard = EnvVarGuard::set_to_path("HOME", home); + let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE"); + let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL"); + let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL"); + + let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await; + let mock_origin = format!("http://{}", mock_addr); + write_min_config(&openhuman_home, &mock_origin); + + let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await; + let rpc_base = format!("http://{}", rpc_addr); + + tokio::time::sleep(Duration::from_millis(100)).await; + + fn inner(outer: &Value) -> Value { + outer + .get("result") + .cloned() + .unwrap_or_else(|| outer.clone()) + } + + let list = post_json_rpc(&rpc_base, 200, "openhuman.about_app_list", json!({})).await; + let list_outer = assert_no_jsonrpc_error(&list, "about_app_list"); + let list_result = inner(list_outer); + let capabilities = list_result + .as_array() + .expect("about_app list should return an array"); + assert!( + capabilities.len() >= 40, + "expected large capability catalog, got: {list_result}" + ); + assert!(capabilities.iter().any(|capability| { + capability.get("id").and_then(Value::as_str) == Some("local_ai.download_model") + })); + + let filtered = post_json_rpc( + &rpc_base, + 201, + "openhuman.about_app_list", + json!({ "category": "local_ai" }), + ) + .await; + let filtered_outer = assert_no_jsonrpc_error(&filtered, "about_app_list filtered"); + let filtered_result = inner(filtered_outer); + let filtered_capabilities = filtered_result + .as_array() + .expect("filtered about_app list should return an array"); + assert!( + !filtered_capabilities.is_empty(), + "expected local_ai capabilities: {filtered_result}" + ); + assert!(filtered_capabilities.iter().all(|capability| { + capability.get("category").and_then(Value::as_str) == Some("local_ai") + })); + + let lookup = post_json_rpc( + &rpc_base, + 202, + "openhuman.about_app_lookup", + json!({ "id": "team.generate_invite_codes" }), + ) + .await; + let lookup_outer = assert_no_jsonrpc_error(&lookup, "about_app_lookup"); + let lookup_result = inner(lookup_outer); + assert_eq!( + lookup_result.get("id").and_then(Value::as_str), + Some("team.generate_invite_codes") + ); + assert_eq!( + lookup_result.get("category").and_then(Value::as_str), + Some("team") + ); + + let search = post_json_rpc( + &rpc_base, + 203, + "openhuman.about_app_search", + json!({ "query": "invite" }), + ) + .await; + let search_outer = assert_no_jsonrpc_error(&search, "about_app_search"); + let search_result = inner(search_outer); + let search_capabilities = search_result + .as_array() + .expect("about_app search should return an array"); + assert!( + search_capabilities.iter().any(|capability| { + capability.get("id").and_then(Value::as_str) == Some("team.join_via_invite_code") + }), + "expected invite-related capability in search results: {search_result}" + ); + assert!( + search_capabilities.iter().any(|capability| { + capability.get("id").and_then(Value::as_str) == Some("team.generate_invite_codes") + }), + "expected invite generation capability in search results: {search_result}" + ); + + mock_join.abort(); + rpc_join.abort(); +} + #[tokio::test] async fn voice_status_returns_availability() { let _env_lock = json_rpc_e2e_env_lock();