diff --git a/app/package.json b/app/package.json index b12ce86d4..ada29e1f8 100644 --- a/app/package.json +++ b/app/package.json @@ -40,8 +40,7 @@ "format": "prettier --write . && cargo fmt --manifest-path ../Cargo.toml --all", "format:check": "prettier --check . && cargo fmt --manifest-path ../Cargo.toml --all --check", "lint": "eslint . --ext .ts,.tsx", - "lint:fix": "eslint . --ext .ts,.tsx --fix", - "prepare": "husky" + "lint:fix": "eslint . --ext .ts,.tsx --fix" }, "dependencies": { "@heroicons/react": "^2.2.0", diff --git a/app/src-tauri/src/core_process.rs b/app/src-tauri/src/core_process.rs index 1cc9c78be..cc3c7fd53 100644 --- a/app/src-tauri/src/core_process.rs +++ b/app/src-tauri/src/core_process.rs @@ -208,7 +208,10 @@ impl CoreProcessHandle { ); self.shutdown().await; - log::debug!("[core] restart: shutdown complete, checking port {}", self.port); + log::debug!( + "[core] restart: shutdown complete, checking port {}", + self.port + ); // If we never spawned the sidecar (something else was already listening), we cannot free // the port — fail fast with a clear message instead of polling for 8s. @@ -265,12 +268,7 @@ impl CoreProcessHandle { } // Wait for the process to exit so the RPC listen socket is released before restart // checks the port (otherwise we can spuriously hit "port still in use"). - match timeout( - Duration::from_secs(12), - child.wait(), - ) - .await - { + match timeout(Duration::from_secs(12), child.wait()).await { Ok(Ok(status)) => { log::debug!("[core] child core process reaped after kill: {status}"); } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index b04a2268d..2292ad43e 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -143,10 +143,7 @@ async fn restart_core_process( /// Register (or re-register) the global dictation toggle hotkey. /// Emits `dictation://toggle` to all webviews when the shortcut is pressed. #[tauri::command] -async fn register_dictation_hotkey( - app: AppHandle, - shortcut: String, -) -> Result<(), String> { +async fn register_dictation_hotkey(app: AppHandle, shortcut: String) -> Result<(), String> { log::info!("[dictation] register_dictation_hotkey: shortcut={shortcut}"); let old_shortcuts = { @@ -189,7 +186,9 @@ async fn register_dictation_hotkey( ); } } - return Err(format!("Failed to unregister previous shortcut '{old}': {e}")); + return Err(format!( + "Failed to unregister previous shortcut '{old}': {e}" + )); } unregistered_old.push(old.clone()); } diff --git a/app/src/components/intelligence/MemoryWorkspace.tsx b/app/src/components/intelligence/MemoryWorkspace.tsx index f0a14f98c..7bd617f04 100644 --- a/app/src/components/intelligence/MemoryWorkspace.tsx +++ b/app/src/components/intelligence/MemoryWorkspace.tsx @@ -350,7 +350,7 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
-

Memory

+

Memory (EverMind)

Your AI's knowledge graph, extracted insights, and ingestion activity.

diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx index d0987c850..2ae416baa 100644 --- a/app/src/components/settings/SettingsHome.tsx +++ b/app/src/components/settings/SettingsHome.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { skillManager } from '../../lib/skills/manager'; import { useCoreState } from '../../providers/CoreStateProvider'; import { persistor } from '../../store'; +import { resetOpenHumanDataAndRestartCore } from '../../utils/tauriCommands'; import SettingsHeader from './components/SettingsHeader'; import SettingsMenuItem from './components/SettingsMenuItem'; import { useSettingsNavigation } from './hooks/useSettingsNavigation'; @@ -29,29 +30,31 @@ const SettingsHome = () => { }; const clearAllAppData = async () => { - try { - await setOnboardingCompletedFlag(false); - } catch (err) { - console.warn('[Settings] Failed to clear onboarding_completed in config:', err); - } try { await clearSession(); } catch (err) { console.warn('[Settings] Rust logout failed during clearAllAppData:', err); } - await persistor.purge(); - window.localStorage.clear(); - window.sessionStorage.clear(); + try { + await resetOpenHumanDataAndRestartCore(); + } catch (err) { + console.warn('[Settings] Failed to reset OpenHuman data dir and restart core:', err); + throw err; + } - // NEW: Clear skills databases (emails, chats, cached files) + // Best-effort cleanup for in-memory and browser-side caches that live outside the Rust core. try { await skillManager.clearAllSkillsData(); } catch (error) { console.warn('Failed to clear skills data:', error); - // Continue with logout even if skills clearing fails + // Continue even if skill cleanup fails because the backend reset already completed. } + await persistor.purge(); + window.localStorage.clear(); + window.sessionStorage.clear(); + // Complete reset - redirect to login for fresh start window.location.hash = '/'; }; diff --git a/app/src/components/settings/panels/AccessibilityPanel.tsx b/app/src/components/settings/panels/AccessibilityPanel.tsx index 94de6785b..6b58ab7e4 100644 --- a/app/src/components/settings/panels/AccessibilityPanel.tsx +++ b/app/src/components/settings/panels/AccessibilityPanel.tsx @@ -85,7 +85,6 @@ const AccessibilityPanel = () => { }, [dispatch]); const anyPermissionDenied = - status?.permissions.screen_recording === 'denied' || status?.permissions.accessibility === 'denied' || status?.permissions.input_monitoring === 'denied'; @@ -119,10 +118,6 @@ const AccessibilityPanel = () => {

Permissions

- {
)} -
-

+

Connected!

{state.message && ( -

{state.message}

+

{state.message}

)}
-

+

Setup Failed

-

{state.message}

+

{state.message}

@@ -621,7 +621,7 @@ function LoadingView() { d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> - + Starting setup...
@@ -664,17 +664,17 @@ function OAuthLoginView({
{/* Provider icon */}
-
+
{/* Title and description */}
-

+

Connect to {providerName}

-

+

{waiting ? "Waiting for authorization. Complete the login in your browser..." : `Sign in with your ${providerName} account to connect this skill.`} @@ -684,9 +684,9 @@ function OAuthLoginView({ {/* Login button or waiting state */} {waiting ? (

-
+
- + Waiting for {providerName} authorization...
@@ -743,7 +743,7 @@ function OAuthLoginView({
@@ -796,7 +796,7 @@ function ProviderIcon({ provider }: { provider: string }) { ); case "github": return ( - + ({ callCoreRpc: vi.fn() })); describe('tauriCommands', () => { const mockIsTauri = isTauri as Mock; + const mockInvoke = invoke as Mock; const mockCallCoreRpc = callCoreRpc as Mock; let getAuthState: typeof import('../tauriCommands').getAuthState; + let resetOpenHumanDataAndRestartCore: typeof import('../tauriCommands').resetOpenHumanDataAndRestartCore; let storeSession: typeof import('../tauriCommands').storeSession; let openhumanLocalAiStatus: typeof import('../tauriCommands').openhumanLocalAiStatus; let openhumanServiceStatus: typeof import('../tauriCommands').openhumanServiceStatus; @@ -19,6 +21,7 @@ describe('tauriCommands', () => { mockIsTauri.mockReturnValue(true); const actual = await vi.importActual('../tauriCommands'); getAuthState = actual.getAuthState; + resetOpenHumanDataAndRestartCore = actual.resetOpenHumanDataAndRestartCore; storeSession = actual.storeSession; openhumanLocalAiStatus = actual.openhumanLocalAiStatus; openhumanServiceStatus = actual.openhumanServiceStatus; @@ -44,6 +47,13 @@ describe('tauriCommands', () => { }); }); + test('resetOpenHumanDataAndRestartCore invokes the destructive Tauri command', async () => { + await resetOpenHumanDataAndRestartCore(); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.config_reset_local_data' }); + expect(mockInvoke).toHaveBeenCalledWith('restart_core_process'); + }); + test('openhumanLocalAiStatus returns upgrade hint on unknown method', async () => { mockCallCoreRpc.mockRejectedValueOnce(new Error('unknown method: openhuman.local_ai_status')); diff --git a/app/src/utils/tauriCommands.ts b/app/src/utils/tauriCommands.ts index e31a435ce..ff76a434b 100644 --- a/app/src/utils/tauriCommands.ts +++ b/app/src/utils/tauriCommands.ts @@ -200,6 +200,22 @@ export async function restartCoreProcess(): Promise { console.debug('[core] restartCoreProcess: done'); } +export async function resetOpenHumanDataAndRestartCore(): Promise { + if (!isTauri()) { + console.debug('[core] resetOpenHumanDataAndRestartCore: skipped — not running in Tauri'); + return; + } + console.debug( + '[core] resetOpenHumanDataAndRestartCore: invoking openhuman.config_reset_local_data' + ); + await callCoreRpc({ method: 'openhuman.config_reset_local_data' }); + console.debug( + '[core] resetOpenHumanDataAndRestartCore: local data reset complete, restarting core' + ); + await restartCoreProcess(); + console.debug('[core] resetOpenHumanDataAndRestartCore: done'); +} + // --- Memory Commands --- /** diff --git a/package.json b/package.json index 4d1ef3e7e..f84fb4185 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "format:check": "yarn workspace openhuman-app format:check", "lint": "yarn workspace openhuman-app lint", "lint:fix": "yarn workspace openhuman-app lint:fix", + "prepare": "husky", + "postinstall": "husky", "tauri": "yarn workspace openhuman-app tauri", "test": "yarn workspace openhuman-app test", "test:coverage": "yarn workspace openhuman-app test:coverage", @@ -24,6 +26,9 @@ "mock:api": "node scripts/mock-api-server.mjs", "typecheck": "yarn workspace openhuman-app compile" }, + "devDependencies": { + "husky": "^9.1.7" + }, "dependencies": { "@tauri-apps/api": "2.10.1" } diff --git a/src/core/skills_cli.rs b/src/core/skills_cli.rs index 4314d9ebc..79412333f 100644 --- a/src/core/skills_cli.rs +++ b/src/core/skills_cli.rs @@ -417,9 +417,7 @@ fn run_skills_test(args: &[String]) -> Result<()> { // --------------------------------------------------------------------------- fn build_skills_only_router() -> axum::Router { - use axum::response::IntoResponse; use axum::routing::{get, post}; - use axum::Json; axum::Router::new() .route("/health", get(health)) diff --git a/src/openhuman/config/ops.rs b/src/openhuman/config/ops.rs index 261927ccc..794cd3136 100644 --- a/src/openhuman/config/ops.rs +++ b/src/openhuman/config/ops.rs @@ -1,6 +1,6 @@ //! JSON-RPC / CLI controller surface for persisted config and runtime flags. -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use serde::Serialize; use serde_json::json; @@ -39,6 +39,86 @@ fn fallback_workspace_dir() -> PathBuf { .join("workspace") } +fn default_openhuman_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".openhuman") +} + +fn active_workspace_marker_path(default_openhuman_dir: &Path) -> PathBuf { + default_openhuman_dir.join("active_workspace.toml") +} + +fn config_openhuman_dir(config: &Config) -> PathBuf { + config + .config_path + .parent() + .map_or_else(|| PathBuf::from("."), PathBuf::from) +} + +async fn reset_local_data_for_paths( + current_openhuman_dir: &Path, + default_openhuman_dir: &Path, +) -> Result, String> { + let active_workspace_marker = active_workspace_marker_path(default_openhuman_dir); + tracing::debug!( + current_dir = %current_openhuman_dir.display(), + default_dir = %default_openhuman_dir.display(), + marker = %active_workspace_marker.display(), + "[config] reset_local_data: starting" + ); + + let mut removed_paths = Vec::new(); + + if active_workspace_marker.exists() { + tokio::fs::remove_file(&active_workspace_marker) + .await + .map_err(|e| format!("Failed to remove active workspace marker: {e}"))?; + tracing::debug!( + marker = %active_workspace_marker.display(), + "[config] reset_local_data: removed active workspace marker" + ); + removed_paths.push(active_workspace_marker.display().to_string()); + } + + for target_dir in [current_openhuman_dir, default_openhuman_dir] { + if !target_dir.exists() { + tracing::debug!( + dir = %target_dir.display(), + "[config] reset_local_data: directory already absent" + ); + continue; + } + + tokio::fs::remove_dir_all(target_dir) + .await + .map_err(|e| format!("Failed to remove {}: {e}", target_dir.display()))?; + tracing::debug!( + dir = %target_dir.display(), + "[config] reset_local_data: removed directory" + ); + removed_paths.push(target_dir.display().to_string()); + } + + Ok(RpcOutcome::new( + json!({ + "removed_paths": removed_paths, + "current_openhuman_dir": current_openhuman_dir.display().to_string(), + "default_openhuman_dir": default_openhuman_dir.display().to_string(), + }), + vec![ + format!( + "reset local data for active config dir {}", + current_openhuman_dir.display() + ), + format!( + "removed default data dir {} if present", + default_openhuman_dir.display() + ), + ], + )) +} + pub fn snapshot_config_json(config: &Config) -> Result { let value = serde_json::to_value(config).map_err(|e| e.to_string())?; Ok(json!({ @@ -459,3 +539,46 @@ pub fn agent_server_status() -> RpcOutcome { }); RpcOutcome::single_log(payload, "agent server status checked") } + +pub async fn reset_local_data() -> Result, String> { + let config = load_config_with_timeout().await?; + let current_openhuman_dir = config_openhuman_dir(&config); + let default_openhuman_dir = default_openhuman_dir(); + reset_local_data_for_paths(¤t_openhuman_dir, &default_openhuman_dir).await +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[tokio::test] + async fn reset_local_data_removes_current_dir_default_dir_and_marker() { + let temp = tempdir().unwrap(); + let default_openhuman_dir = temp.path().join("default-openhuman"); + let current_openhuman_dir = temp.path().join("custom-openhuman"); + let marker = active_workspace_marker_path(&default_openhuman_dir); + + tokio::fs::create_dir_all(default_openhuman_dir.join("workspace")) + .await + .unwrap(); + tokio::fs::create_dir_all(current_openhuman_dir.join("workspace")) + .await + .unwrap(); + tokio::fs::write(&marker, "config_dir = '/tmp/custom-openhuman'\n") + .await + .unwrap(); + + let outcome = reset_local_data_for_paths(¤t_openhuman_dir, &default_openhuman_dir) + .await + .unwrap(); + + assert!(!current_openhuman_dir.exists()); + assert!(!default_openhuman_dir.exists()); + assert!(outcome + .value + .get("removed_paths") + .and_then(|value| value.as_array()) + .is_some_and(|paths| !paths.is_empty())); + } +} diff --git a/src/openhuman/config/schemas.rs b/src/openhuman/config/schemas.rs index b466358b2..d98eb6d36 100644 --- a/src/openhuman/config/schemas.rs +++ b/src/openhuman/config/schemas.rs @@ -91,6 +91,7 @@ pub fn all_controller_schemas() -> Vec { schemas("update_analytics_settings"), schemas("get_analytics_settings"), schemas("agent_server_status"), + schemas("reset_local_data"), schemas("get_onboarding_completed"), schemas("set_onboarding_completed"), ] @@ -154,6 +155,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("agent_server_status"), handler: handle_agent_server_status, }, + RegisteredController { + schema: schemas("reset_local_data"), + handler: handle_reset_local_data, + }, RegisteredController { schema: schemas("get_onboarding_completed"), handler: handle_get_onboarding_completed, @@ -383,6 +388,14 @@ pub fn schemas(function: &str) -> ControllerSchema { inputs: vec![], outputs: vec![json_output("status", "Agent server status payload.")], }, + "reset_local_data" => ControllerSchema { + namespace: "config", + function: "reset_local_data", + description: + "Delete local OpenHuman data for the active config/workspace so the next restart boots clean.", + inputs: vec![], + outputs: vec![json_output("result", "Reset result with removed paths.")], + }, "get_onboarding_completed" => ControllerSchema { namespace: "config", function: "get_onboarding_completed", @@ -565,6 +578,10 @@ fn handle_agent_server_status(_params: Map) -> ControllerFuture { Box::pin(async { to_json(config_rpc::agent_server_status()) }) } +fn handle_reset_local_data(_params: Map) -> ControllerFuture { + Box::pin(async { to_json(config_rpc::reset_local_data().await?) }) +} + fn handle_get_onboarding_completed(_params: Map) -> ControllerFuture { Box::pin(async { to_json(config_rpc::get_onboarding_completed().await?) }) } diff --git a/src/openhuman/screen_intelligence/engine.rs b/src/openhuman/screen_intelligence/engine.rs index d26506cdd..d35163cf3 100644 --- a/src/openhuman/screen_intelligence/engine.rs +++ b/src/openhuman/screen_intelligence/engine.rs @@ -277,8 +277,6 @@ impl AccessibilityEngine { }); } - self.request_permission(PermissionKind::ScreenRecording) - .await?; self.request_permission(PermissionKind::Accessibility) .await?; self.request_permission(PermissionKind::InputMonitoring) @@ -286,7 +284,7 @@ impl AccessibilityEngine { let mut state = self.inner.lock().await; state.permissions = detect_permissions(); - state.last_event = Some("permissions_requested".to_string()); + state.last_event = Some("permissions_requested:accessibility,input_monitoring".to_string()); Ok(state.permissions.clone()) } diff --git a/src/openhuman/screen_intelligence/ops.rs b/src/openhuman/screen_intelligence/ops.rs index 58b560917..2b5a3b940 100644 --- a/src/openhuman/screen_intelligence/ops.rs +++ b/src/openhuman/screen_intelligence/ops.rs @@ -90,7 +90,7 @@ pub async fn accessibility_request_permissions() -> Result ControllerSchema { "request_permissions" => ControllerSchema { namespace: "screen_intelligence", function: "request_permissions", - description: "Request required accessibility permissions.", + description: + "Request accessibility automation permissions without prompting for screen recording.", inputs: vec![], outputs: vec![json_output("permissions", "Permission status payload.")], }, "request_permission" => ControllerSchema { namespace: "screen_intelligence", function: "request_permission", - description: "Request one accessibility permission.", + description: "Request one permission such as accessibility, input monitoring, or screen recording.", inputs: vec![FieldSchema { name: "permission", ty: TypeSchema::String, diff --git a/src/openhuman/skills/registry_ops.rs b/src/openhuman/skills/registry_ops.rs index c5eb31859..226c4418c 100644 --- a/src/openhuman/skills/registry_ops.rs +++ b/src/openhuman/skills/registry_ops.rs @@ -1,10 +1,10 @@ -use std::path::{Path, PathBuf}; +use std::path::Path; use sha2::{Digest, Sha256}; use super::registry_cache::{ - cache_path, is_cache_fresh, is_local_path, local_skills_dir, read_cache, read_local_file, - registry_url, tag_categories, write_cache, + is_cache_fresh, is_local_path, local_skills_dir, read_cache, read_local_file, registry_url, + tag_categories, write_cache, }; use super::registry_types::{ AvailableSkillEntry, InstalledSkillInfo, RegistrySkillEntry, RemoteSkillRegistry, diff --git a/src/openhuman/subconscious/engine.rs b/src/openhuman/subconscious/engine.rs index 81ff7ec0f..a2acbacfc 100644 --- a/src/openhuman/subconscious/engine.rs +++ b/src/openhuman/subconscious/engine.rs @@ -9,9 +9,9 @@ use super::prompt::build_subconscious_prompt; use super::situation_report::build_situation_report; use super::types::{Decision, SubconsciousStatus, TickOutput, TickResult}; use crate::openhuman::config::Config; -use crate::openhuman::memory::{MemoryClient, MemoryClientRef}; +use crate::openhuman::memory::MemoryClientRef; use anyhow::Result; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; use tokio::time::{self, Duration}; diff --git a/tests/skills_debug_e2e.rs b/tests/skills_debug_e2e.rs index 31cd8f5e0..642bc9d6c 100644 --- a/tests/skills_debug_e2e.rs +++ b/tests/skills_debug_e2e.rs @@ -3,9 +3,9 @@ //! Exercises the full skill lifecycle through the RuntimeEngine: //! discover → start → list tools → call tool → setup flow → tick/sync → stop //! -//! By default uses the bundled `example-skill` from the openhuman-skills repo. +//! By default uses a preferred skill from the checked-out openhuman-skills repo. //! Override with env vars: -//! SKILL_DEBUG_ID — skill ID to test (default: "example-skill") +//! SKILL_DEBUG_ID — skill ID to test //! SKILL_DEBUG_DIR — path to skills directory containing skill folders //! SKILL_DEBUG_TOOL — specific tool name to call (default: first tool found) //! SKILL_DEBUG_TOOL_ARGS — JSON args for the tool call (default: "{}") @@ -37,6 +37,46 @@ fn is_verbose() -> bool { .unwrap_or(false) } +fn manifests_in_dir(skills_dir: &Path) -> Vec { + let mut ids = Vec::new(); + let Ok(entries) = std::fs::read_dir(skills_dir) else { + return ids; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() || !path.join("manifest.json").exists() { + continue; + } + if let Some(name) = path.file_name().and_then(|name| name.to_str()) { + ids.push(name.to_string()); + } + } + + ids.sort(); + ids +} + +fn select_skill_id(skills_dir: &Path, env_key: &str, preferred: &[&str]) -> String { + if let Ok(skill_id) = std::env::var(env_key) { + if !skill_id.trim().is_empty() { + return skill_id; + } + } + + let available = manifests_in_dir(skills_dir); + for candidate in preferred { + if available.iter().any(|id| id == candidate) { + return (*candidate).to_string(); + } + } + + available + .into_iter() + .next() + .unwrap_or_else(|| "example-skill".to_string()) +} + fn banner(label: &str) { let sep = "=".repeat(60); eprintln!("\n{sep}"); @@ -104,7 +144,7 @@ fn try_find_skills_dir() -> Option { if let Some(parent) = cwd.parent() { for entry in std::fs::read_dir(parent).into_iter().flatten().flatten() { let candidate = entry.path().join("skills/skills"); - if candidate.exists() && candidate.join("example-skill/manifest.json").exists() { + if candidate.exists() && candidate.read_dir().is_ok() { return Some(candidate.canonicalize().unwrap()); } } @@ -155,8 +195,12 @@ async fn skill_full_lifecycle() { .is_test(true) .try_init(); - let skill_id = env_or("SKILL_DEBUG_ID", "example-skill"); let skills_dir = require_skills_dir!(); + let skill_id = select_skill_id( + &skills_dir, + "SKILL_DEBUG_ID", + &["server-ping", "gmail", "notion"], + ); let tmp = tempdir().expect("tempdir"); let data_dir = tmp.path().join("skills_data"); std::fs::create_dir_all(&data_dir).expect("create data_dir"); @@ -570,8 +614,12 @@ async fn skill_rapid_start_stop() { .is_test(true) .try_init(); - let skill_id = env_or("SKILL_DEBUG_ID", "example-skill"); let skills_dir = require_skills_dir!(); + let skill_id = select_skill_id( + &skills_dir, + "SKILL_DEBUG_ID", + &["server-ping", "gmail", "notion"], + ); let tmp = tempdir().expect("tempdir"); let data_dir = tmp.path().join("skills_data"); std::fs::create_dir_all(&data_dir).unwrap(); @@ -611,8 +659,8 @@ async fn skill_disconnect_flow() { .is_test(true) .try_init(); - let skill_id = env_or("SKILL_DEBUG_ID", "example-skill"); let skills_dir = require_skills_dir!(); + let skill_id = select_skill_id(&skills_dir, "SKILL_DEBUG_ID", &["gmail", "notion"]); let tmp = tempdir().expect("tempdir"); let data_dir = tmp.path().join("skills_data"); std::fs::create_dir_all(&data_dir).unwrap();