diff --git a/app/src/components/ErrorFallbackScreen.tsx b/app/src/components/ErrorFallbackScreen.tsx index 7f6cdcd42..fdd42596c 100644 --- a/app/src/components/ErrorFallbackScreen.tsx +++ b/app/src/components/ErrorFallbackScreen.tsx @@ -80,7 +80,10 @@ export default function ErrorFallbackScreen({ Try to Recover diff --git a/app/src/components/skills/SkillSetupModal.tsx b/app/src/components/skills/SkillSetupModal.tsx index 86d16e909..f7872f235 100644 --- a/app/src/components/skills/SkillSetupModal.tsx +++ b/app/src/components/skills/SkillSetupModal.tsx @@ -31,10 +31,13 @@ export default function SkillSetupModal({ const modalRef = useRef(null); const snap = useSkillSnapshot(skillId); const setupComplete = snap?.setup_complete ?? false; - // Skills without setup hooks always go straight to manage mode. - const [mode, setMode] = useState<"manage" | "setup">( - !hasSetup || setupComplete ? "manage" : "setup", - ); + // Track whether the user has explicitly chosen to reconfigure (setup mode) + // even though setup is already complete. + const [forceSetup, setForceSetup] = useState(false); + // Derive mode: show manage if setup is complete (or no setup needed), + // unless the user explicitly chose to reconfigure. + const mode = forceSetup ? "setup" : (!hasSetup || setupComplete ? "manage" : "setup"); + const setMode = (m: "manage" | "setup") => setForceSetup(m === "setup"); // Handle escape key useEffect(() => { diff --git a/app/src/hooks/useIntelligenceApiFallback.ts b/app/src/hooks/useIntelligenceApiFallback.ts index d476cc759..d548fa44d 100644 --- a/app/src/hooks/useIntelligenceApiFallback.ts +++ b/app/src/hooks/useIntelligenceApiFallback.ts @@ -1,73 +1,13 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useState } from 'react'; -import { MOCK_ACTIONABLE_ITEMS } from '../components/intelligence/mockData'; -import { type ConnectedTool, intelligenceApi } from '../services/intelligenceApi'; -import type { ActionableItem, ActionableItemStatus, ChatMessage } from '../types/intelligence'; -import { - transformBackendItemsToFrontend, - transformBackendMessagesToFrontend, -} from '../utils/intelligenceTransforms'; +import type { ConnectedTool } from '../services/intelligenceApi'; +import type { ActionableItemStatus, ChatMessage } from '../types/intelligence'; /** - * Fallback implementation of Intelligence API hooks without React Query - * Used when React Query is not available + * Local-only implementations of Intelligence action hooks. + * Items come from the local conscious memory layer — actions are applied in-memory. */ -interface UseActionableItemsResult { - data: ActionableItem[] | undefined; - loading: boolean; - error: string | null; - refetch: () => Promise; -} - -/** - * Hook for fetching actionable items (fallback version) - * TODO: Remove MOCK_ACTIONABLE_ITEMS once backend APIs are ready - */ -export const useActionableItems = (options?: { - refetchInterval?: number; - enabled?: boolean; -}): UseActionableItemsResult => { - const [data, setData] = useState(MOCK_ACTIONABLE_ITEMS); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const fetchItems = useCallback(async () => { - if (options?.enabled === false) return; - - try { - setLoading(true); - setError(null); - const backendItems = await intelligenceApi.getActionableItems(); - const items = transformBackendItemsToFrontend(backendItems); - setData(items.length > 0 ? items : MOCK_ACTIONABLE_ITEMS); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to fetch items'; - setError(errorMessage); - console.error('Failed to fetch actionable items:', err); - // TODO: Replace with actual data - setData(MOCK_ACTIONABLE_ITEMS); - } finally { - setLoading(false); - } - }, [options?.enabled]); - - // Initial fetch - useEffect(() => { - fetchItems(); - }, [fetchItems]); - - // Set up refetch interval - useEffect(() => { - if (options?.refetchInterval) { - const interval = setInterval(fetchItems, options.refetchInterval); - return () => clearInterval(interval); - } - }, [options?.refetchInterval, fetchItems]); - - return { data, loading, error, refetch: fetchItems }; -}; - interface UseUpdateActionableItemResult { mutateAsync: (variables: { itemId: string; @@ -78,7 +18,7 @@ interface UseUpdateActionableItemResult { } /** - * Hook for updating actionable item status (fallback version) + * Hook for updating actionable item status (local-only). */ export const useUpdateActionableItem = (): UseUpdateActionableItemResult => { const [loading, setLoading] = useState(false); @@ -86,15 +26,11 @@ export const useUpdateActionableItem = (): UseUpdateActionableItemResult => { const mutateAsync = useCallback( async (variables: { itemId: string; status: ActionableItemStatus }) => { + setLoading(true); + setError(null); try { - setLoading(true); - setError(null); - await intelligenceApi.updateItemStatus(variables.itemId, variables.status); + // Items are managed locally; just acknowledge the status change. return { ...variables, updatedAt: new Date() }; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to update item'; - setError(errorMessage); - throw err; } finally { setLoading(false); } @@ -115,22 +51,17 @@ interface UseSnoozeActionableItemResult { } /** - * Hook for snoozing actionable item (fallback version) + * Hook for snoozing actionable item (local-only). */ export const useSnoozeActionableItem = (): UseSnoozeActionableItemResult => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const mutateAsync = useCallback(async (variables: { itemId: string; snoozeUntil: Date }) => { + setLoading(true); + setError(null); try { - setLoading(true); - setError(null); - await intelligenceApi.snoozeItem(variables.itemId, variables.snoozeUntil); return { ...variables, updatedAt: new Date() }; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to snooze item'; - setError(errorMessage); - throw err; } finally { setLoading(false); } @@ -146,38 +77,10 @@ interface UseChatSessionResult { } /** - * Hook for creating or getting chat session (fallback version) + * Chat session stub (local-only — no remote thread API). */ -export const useChatSession = (itemId: string | null): UseChatSessionResult => { - const [data, setData] = useState<{ threadId: string; messages: ChatMessage[] } | null>(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - if (!itemId) return; - - const fetchSession = async () => { - try { - setLoading(true); - setError(null); - const response = await intelligenceApi.getOrCreateThread(itemId); - setData({ - threadId: response.threadId, - messages: transformBackendMessagesToFrontend(response.messages), - }); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to create session'; - setError(errorMessage); - console.error('Failed to create chat session:', err); - } finally { - setLoading(false); - } - }; - - fetchSession(); - }, [itemId]); - - return { data, loading, error }; +export const useChatSession = (_itemId: string | null): UseChatSessionResult => { + return { data: null, loading: false, error: null }; }; interface UseExecuteTaskResult { @@ -190,34 +93,17 @@ interface UseExecuteTaskResult { } /** - * Hook for executing tasks (fallback version) + * Task execution stub (local-only — no remote execution API). */ export const useExecuteTask = (): UseExecuteTaskResult => { - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const mutateAsync = useCallback( - async (variables: { itemId: string; connectedTools: ConnectedTool[] }) => { - try { - setLoading(true); - setError(null); - const result = await intelligenceApi.executeTask( - variables.itemId, - variables.connectedTools - ); - return result; - } catch (err) { - const errorMessage = err instanceof Error ? err.message : 'Failed to execute task'; - setError(errorMessage); - throw err; - } finally { - setLoading(false); - } + async (_variables: { itemId: string; connectedTools: ConnectedTool[] }) => { + return { executionId: '', sessionId: '', status: 'unsupported' }; }, [] ); - return { mutateAsync, loading, error }; + return { mutateAsync, loading: false, error: null }; }; // Export query key utilities for consistency diff --git a/app/src/hooks/useIntelligenceStats.ts b/app/src/hooks/useIntelligenceStats.ts index 3eb913068..b2ebbcc13 100644 --- a/app/src/hooks/useIntelligenceStats.ts +++ b/app/src/hooks/useIntelligenceStats.ts @@ -1,10 +1,9 @@ import { useCallback, useEffect, useState } from 'react'; -import { apiClient } from '../services/apiClient'; import { callCoreRpc } from '../services/coreRpcClient'; import type { AIStatus } from '../store/aiSlice'; import { useAppSelector } from '../store/hooks'; -import { aiListMemoryFiles } from '../utils/tauriCommands'; +import { aiListMemoryFiles, type GraphRelation, memoryGraphQuery } from '../utils/tauriCommands'; interface SessionEntry { sessionId: string; @@ -33,7 +32,18 @@ export interface IntelligenceStats { refetch: () => void; } -const ENTITY_TYPES = ['contact', 'chat', 'message', 'wallet', 'token', 'transaction']; +/** Derive entity-type counts from local graph relations. */ +function entityCountsFromRelations(relations: GraphRelation[]): Record { + const counts: Record = {}; + for (const rel of relations) { + const types = (rel.attrs?.entity_types ?? {}) as Record; + const subjectType = types.subject ?? 'entity'; + const objectType = types.object ?? 'entity'; + counts[subjectType] = (counts[subjectType] ?? 0) + 1; + counts[objectType] = (counts[objectType] ?? 0) + 1; + } + return counts; +} export function useIntelligenceStats(): IntelligenceStats { const aiStatus = useAppSelector(state => state.ai.status); @@ -69,32 +79,16 @@ export function useIntelligenceStats(): IntelligenceStats { setMemoryFiles(null); } - // Fetch entity counts from backend API (graceful degradation) + // Derive entity counts from local graph store try { - const counts: Record = {}; - const results = await Promise.allSettled( - ENTITY_TYPES.map(async type => { - const resp = await apiClient.get<{ count?: number; total?: number; data?: unknown[] }>( - `/api/entity-graph/entities?type=${type}&limit=1` - ); - return { type, count: resp.count ?? resp.total ?? (resp.data ? resp.data.length : 0) }; - }) - ); - - let anySuccess = false; - for (const result of results) { - if (result.status === 'fulfilled') { - counts[result.value.type] = result.value.count; - anySuccess = true; - } - } - - if (anySuccess) { + const relations = await memoryGraphQuery(); + const counts = entityCountsFromRelations(relations); + if (Object.keys(counts).length > 0) { setEntities(counts); setEntityError(false); } else { setEntities(null); - setEntityError(true); + setEntityError(false); } } catch { setEntities(null); diff --git a/app/src/lib/skills/hooks.ts b/app/src/lib/skills/hooks.ts index 66698bf24..40aa0c988 100644 --- a/app/src/lib/skills/hooks.ts +++ b/app/src/lib/skills/hooks.ts @@ -64,7 +64,10 @@ export function deriveConnectionStatus( // RPC-backed hooks // --------------------------------------------------------------------------- -/** Fetch a single skill snapshot, re-fetching on skill events. */ +/** + * Fetch a single skill snapshot, re-fetching on skill events and polling + * periodically (the core sidecar has no push channel to the frontend). + */ export function useSkillSnapshot(skillId: string | undefined): SkillSnapshotRpc | null { const [snap, setSnap] = useState(null); const mountedRef = useRef(true); @@ -85,16 +88,19 @@ export function useSkillSnapshot(skillId: string | undefined): SkillSnapshotRpc const unsub = onSkillStateChange((changedId) => { if (!changedId || changedId === skillId) refresh(); }); + // Poll every 3s to catch background state changes from the core sidecar + const interval = setInterval(refresh, 3000); return () => { mountedRef.current = false; unsub(); + clearInterval(interval); }; }, [skillId, refresh]); return snap; } -/** Fetch all running skill snapshots, re-fetching on skill events. */ +/** Fetch all running skill snapshots, re-fetching on skill events and polling. */ export function useAllSkillSnapshots(): SkillSnapshotRpc[] { const [snaps, setSnaps] = useState([]); const mountedRef = useRef(true); @@ -112,9 +118,11 @@ export function useAllSkillSnapshots(): SkillSnapshotRpc[] { mountedRef.current = true; refresh(); const unsub = onSkillStateChange(() => refresh()); + const interval = setInterval(refresh, 3000); return () => { mountedRef.current = false; unsub(); + clearInterval(interval); }; }, [refresh]); diff --git a/app/src/lib/skills/manager.ts b/app/src/lib/skills/manager.ts index 0400b095f..14faec55d 100644 --- a/app/src/lib/skills/manager.ts +++ b/app/src/lib/skills/manager.ts @@ -283,14 +283,21 @@ class SkillManager { } await this.activateSkill(skillId); } else { - // No local runtime — try notifying via core RPC pass-through + // No local runtime — try notifying via core RPC pass-through. + // The credential object must use `credentialId` (not `integrationId`) + // to match what the JS bootstrap's oauth.fetch expects. try { await callCoreRpc({ method: "openhuman.skills_rpc", params: { skill_id: skillId, method: "oauth/complete", - params: { integrationId, provider, ...extraCredential }, + params: { + credentialId: integrationId, + provider: provider ?? "unknown", + grantedScopes: [] as string[], + ...extraCredential, + }, }, }); } catch { diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index ddee084d3..3b6cff4a3 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -27,10 +27,14 @@ import type { ToastNotification, } from '../types/intelligence'; +type IntelligenceTab = 'memory' | 'subconscious' | 'dreams'; + export default function Intelligence() { const dispatch = useDispatch(); const { aiStatus } = useIntelligenceStats(); + const [activeTab, setActiveTab] = useState('memory'); + // Redux state const intelligenceState = useSelector((state: RootState) => state.intelligence); const { filters } = intelligenceState; @@ -239,6 +243,12 @@ export default function Intelligence() { ? 'bg-coral-400' : 'bg-stone-600'; + const tabs: { id: IntelligenceTab; label: string; comingSoon?: boolean }[] = [ + { id: 'memory', label: 'Memory' }, + { id: 'subconscious', label: 'Subconscious', comingSoon: true }, + { id: 'dreams', label: 'Dreams', comingSoon: true }, + ]; + return (
@@ -248,178 +258,265 @@ export default function Intelligence() {

Intelligence

- {stats.total > 0 && ( + {activeTab === 'memory' && stats.total > 0 && (
{stats.total}
)} - {usingMemoryData && ( -
- Memory -
- )}
{systemStatusLabel}
- {/* Analyze Now / Refresh button */} + {activeTab === 'memory' && ( + + )} +
+
+ + {/* Tabs */} +
+ {tabs.map(tab => ( -
+ ))}
- + {/* Tab content */} + {activeTab === 'memory' && ( + <> + - {/* Filters */} -
-
- dispatch(setSearchFilter(e.target.value))} - className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white placeholder-stone-500 focus:outline-none focus:border-primary-500/50 transition-colors" - /> -
- -
+ {/* Filters */} +
+
+ dispatch(setSearchFilter(e.target.value))} + className="w-full px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white placeholder-stone-500 focus:outline-none focus:border-primary-500/50 transition-colors" + /> +
+ +
- {/* Content */} - {itemsLoading && !usingMemoryData ? ( - /* Loading State */ + {/* Content */} + {itemsLoading && !usingMemoryData ? ( +
+
+
+
+

+ Loading Intelligence... +

+

Fetching your actionable items

+
+ ) : isRunning && items.length === 0 ? ( +
+
+
+
+

Analyzing your data…

+

+ The conscious loop is reviewing your connected skills +

+
+ ) : timeGroups.length === 0 ? ( +
+
+ + + +
+ {filters.search || filters.source !== 'all' ? ( + <> +

No matches

+

+ No items match your current filters. +

+ + ) : usingMemoryData ? ( + <> +

All caught up!

+

No actionable items at the moment.

+ + ) : ( + <> +

No analysis yet

+

+ Run an analysis to extract actionable items from your connected skills. +

+ + + )} +
+ ) : ( +
+ {isRunning && ( +
+
+ Analyzing your data… +
+ )} + {timeGroups.map((group, groupIndex) => ( +
+
+

+ {group.label} +

+
+ {group.count} +
+
+
+ {group.items.map((item, itemIndex) => ( +
+ +
+ ))} +
+
+ ))} +
+ )} + + )} + + {activeTab === 'subconscious' && (
-
-
-
-

Loading Intelligence...

-

Fetching your actionable items

-
- ) : isRunning && items.length === 0 ? ( - /* Analyzing State (no items yet) */ -
-
-
-
-

Analyzing your data…

-

- The conscious loop is reviewing your connected skills -

-
- ) : timeGroups.length === 0 ? ( - /* Empty State */ -
-
+
+
- {filters.search || filters.source !== 'all' ? ( - <> -

No matches

-

No items match your current filters.

- - ) : usingMemoryData ? ( - <> -

All caught up!

-

No actionable items at the moment.

- - ) : ( - <> -

No analysis yet

-

- Run an analysis to extract actionable items from your connected skills. -

- - - )} +

Subconscious

+

+ OpenHuman will constantly have subconscious thoughts based on all the information + it has access to and the activity you have engaged with it in. +

+

Coming soon

- ) : ( - /* Time Groups */ -
- {/* Inline analyzing indicator when refreshing with existing items */} - {isRunning && ( -
-
- Analyzing your data… -
- )} - {timeGroups.map((group, groupIndex) => ( -
- {/* Group Header */} -
-

{group.label}

-
- {group.count} -
-
+ )} - {/* Items */} -
- {group.items.map((item, itemIndex) => ( -
- -
- ))} -
-
- ))} + {activeTab === 'dreams' && ( +
+
+ + + + +
+

Dreams

+

+ Twice everyday, OpenHuman will generate a dream (or a summary) based on everything + that has happened in your life today. These dreams re then indexed and can be used + to influence OpenHuman's behavior. +

+

Coming soon

)}
diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts index 368e24a21..89a6d4b2d 100644 --- a/app/src/utils/desktopDeepLinkListener.ts +++ b/app/src/utils/desktopDeepLinkListener.ts @@ -4,7 +4,7 @@ import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; import { skillManager } from '../lib/skills/manager'; import { emitSkillStateChange } from '../lib/skills/skillEvents'; -import { setSetupComplete as rpcSetSetupComplete, startSkill } from '../lib/skills/skillsApi'; +import { startSkill } from '../lib/skills/skillsApi'; import { consumeLoginToken } from '../services/api/authApi'; import { store } from '../store'; import { setToken } from '../store/authSlice'; @@ -122,13 +122,8 @@ const handleOAuthDeepLink = async (parsed: URL) => { console.log(`[DeepLink] OAuth success for skill=${skillId} integration=${integrationId}`); - // 1. Persist setup completion - await rpcSetSetupComplete(skillId, true).catch(err => - console.warn('[DeepLink] Failed to persist setup_complete via RPC:', err) - ); - emitSkillStateChange(skillId); - - // 2. Start the skill in the core QuickJS runtime (if not already running) + // 1. Start the skill in the core QuickJS runtime (if not already running). + // This also sets enabled=true via the preferences store. try { await startSkill(skillId); console.log(`[DeepLink] Skill '${skillId}' started in core runtime`); @@ -136,7 +131,8 @@ const handleOAuthDeepLink = async (parsed: URL) => { console.warn(`[DeepLink] Could not start skill '${skillId}' in runtime:`, startErr); } - // 3. Send oauth/complete to the running skill with the credential + // 2. Notify the running skill of the OAuth credential, mark setup_complete, + // and activate (list tools, sync to backend). try { await skillManager.notifyOAuthComplete(skillId, integrationId); console.log(`[DeepLink] OAuth complete sent to skill '${skillId}'`); @@ -144,7 +140,7 @@ const handleOAuthDeepLink = async (parsed: URL) => { console.warn('[DeepLink] Runtime notify failed:', runtimeErr); } - // 4. Trigger initial data sync + // 3. Trigger initial data sync try { await skillManager.triggerSync(skillId); } catch { diff --git a/app/src/utils/tauriCommands.ts b/app/src/utils/tauriCommands.ts index c8844c00d..9c37891ae 100644 --- a/app/src/utils/tauriCommands.ts +++ b/app/src/utils/tauriCommands.ts @@ -291,10 +291,15 @@ export async function memoryGraphQuery( if (!isTauri()) { throw new Error('Not running in Tauri'); } - return await callCoreRpc({ + const raw = await callCoreRpc({ method: 'openhuman.memory_graph_query', params: { namespace, subject, predicate }, }); + // RpcOutcome wraps with { result, logs } when logs are present — unwrap if needed. + if (Array.isArray(raw)) return raw; + if (raw && typeof raw === 'object' && 'result' in raw && Array.isArray(raw.result)) + return raw.result; + return []; } export async function memoryDocIngest(params: { diff --git a/src/openhuman/skills/preferences.rs b/src/openhuman/skills/preferences.rs index 749194c8a..eaa55c918 100644 --- a/src/openhuman/skills/preferences.rs +++ b/src/openhuman/skills/preferences.rs @@ -100,8 +100,15 @@ impl PreferencesStore { } /// Set the setup completion flag for a skill. Persists immediately. + /// When marking setup as complete, also sets `enabled = true` so the skill + /// auto-starts on subsequent app launches. pub fn set_setup_complete(&self, skill_id: &str, complete: bool) { - self.update(skill_id, |p| p.setup_complete = complete); + self.update(skill_id, |p| { + p.setup_complete = complete; + if complete { + p.enabled = true; + } + }); log::info!( "[preferences] setup_complete for '{}' set to {}", skill_id, @@ -114,10 +121,17 @@ impl PreferencesStore { self.cache.read().clone() } - /// Resolve whether a skill should start, considering user preference and manifest default. + /// Resolve whether a skill should start, considering user preference, + /// setup completion, and manifest default. + /// + /// A skill with `setup_complete = true` always starts — the user explicitly + /// went through setup/OAuth, so the intent is to have it running. + /// Otherwise fall back to the explicit `enabled` preference, then the manifest default. pub fn resolve_should_start(&self, skill_id: &str, manifest_auto_start: bool) -> bool { - match self.is_enabled(skill_id) { - Some(enabled) => enabled, + let pref = self.cache.read().get(skill_id).cloned(); + match pref { + Some(p) if p.setup_complete => true, + Some(p) => p.enabled, None => manifest_auto_start, } } diff --git a/src/openhuman/skills/qjs_skill_instance/event_loop.rs b/src/openhuman/skills/qjs_skill_instance/event_loop.rs index ad58e3018..37e1077fc 100644 --- a/src/openhuman/skills/qjs_skill_instance/event_loop.rs +++ b/src/openhuman/skills/qjs_skill_instance/event_loop.rs @@ -39,6 +39,7 @@ pub(crate) async fn run_event_loop( timer_state: &Arc>, ops_state: &Arc>, memory_client: Option, + data_dir: &std::path::Path, ) { // Maximum sleep duration when no timers are pending const MAX_IDLE_SLEEP: Duration = Duration::from_millis(100); @@ -76,6 +77,7 @@ pub(crate) async fn run_event_loop( &mut pending_tool, &memory_client, ops_state, + data_dir, ) .await; if should_stop { @@ -212,6 +214,7 @@ async fn handle_message( pending_tool: &mut Option, memory_client: &Option, ops_state: &Arc>, + data_dir: &std::path::Path, ) -> bool { match msg { SkillMessage::CallTool { @@ -226,7 +229,7 @@ async fn handle_message( ); // Lazy-load persisted OAuth credential before calling the tool - restore_oauth_credential(ctx, skill_id).await; + restore_oauth_credential(ctx, skill_id, data_dir).await; log::debug!( "[skill:{}] event_loop: OAuth credential restored for tool '{}'", skill_id, @@ -370,7 +373,7 @@ async fn handle_message( let result = match method.as_str() { "oauth/complete" => { - // Set credential on the oauth bridge + persist to store + // Set credential on the oauth bridge + in-memory state let cred_json = serde_json::to_string(¶ms).unwrap_or_else(|_| "null".to_string()); let code = format!( @@ -388,10 +391,21 @@ async fn handle_message( let _ = js_ctx.eval::(code.as_bytes()); }) .await; - log::info!( - "[skill:{}] OAuth credential set and persisted to store", - skill_id - ); + + // Persist credential to disk so it survives restarts + let cred_path = data_dir.join("oauth_credential.json"); + if let Err(e) = std::fs::write(&cred_path, &cred_json) { + log::error!( + "[skill:{}] Failed to persist OAuth credential: {e}", + skill_id + ); + } else { + log::info!( + "[skill:{}] OAuth credential persisted to {}", + skill_id, + cred_path.display() + ); + } let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); handle_js_call(rt, ctx, "onOAuthComplete", ¶ms_str).await @@ -445,7 +459,14 @@ async fn handle_message( let _ = js_ctx.eval::(clear_code.as_bytes()); }) .await; - log::info!("[skill:{}] OAuth credential cleared from store", skill_id); + + // Remove persisted credential file + let cred_path = data_dir.join("oauth_credential.json"); + let _ = std::fs::remove_file(&cred_path); + log::info!( + "[skill:{}] OAuth credential cleared from store and disk", + skill_id + ); // Fire-and-forget: delete memory for this integration if let Some(client) = memory_client_opt { diff --git a/src/openhuman/skills/qjs_skill_instance/instance.rs b/src/openhuman/skills/qjs_skill_instance/instance.rs index 404baa8ff..066f7b09e 100644 --- a/src/openhuman/skills/qjs_skill_instance/instance.rs +++ b/src/openhuman/skills/qjs_skill_instance/instance.rs @@ -191,7 +191,7 @@ impl QjsSkillInstance { return; } - restore_oauth_credential(&ctx, &config.skill_id).await; + restore_oauth_credential(&ctx, &config.skill_id, &data_dir).await; // Call init() lifecycle if let Err(e) = call_lifecycle(&rt, &ctx, "init").await { @@ -242,6 +242,7 @@ impl QjsSkillInstance { &timer_state, &published_state, _deps.memory_client.clone(), + &data_dir, ) .await; }) diff --git a/src/openhuman/skills/qjs_skill_instance/js_helpers.rs b/src/openhuman/skills/qjs_skill_instance/js_helpers.rs index c821e084f..c46da97c9 100644 --- a/src/openhuman/skills/qjs_skill_instance/js_helpers.rs +++ b/src/openhuman/skills/qjs_skill_instance/js_helpers.rs @@ -77,25 +77,46 @@ pub(crate) fn extract_tools(js_ctx: &rquickjs::Ctx<'_>, state: &Arc s, + _ => return, + }; + + // Inject credential into both oauth bridge and in-memory state + let code = format!( + r#"(function() {{ + var cred = {cred}; + if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {{ + globalThis.oauth.__setCredential(cred); + }} + if (typeof globalThis.state !== 'undefined' && globalThis.state.set) {{ + globalThis.state.set('__oauth_credential', cred); + }} return true; - } - return false; - })()"#; + }})()"#, + cred = cred_json + ); let restored = ctx .with(|js_ctx| js_ctx.eval::(code.as_bytes()).unwrap_or(false)) .await; if restored { - log::info!("[skill:{}] Restored OAuth credential from store", skill_id); + log::info!( + "[skill:{}] Restored OAuth credential from {}", + skill_id, + cred_path.display() + ); } } diff --git a/src/openhuman/skills/quickjs_libs/bootstrap.js b/src/openhuman/skills/quickjs_libs/bootstrap.js index 8413c1885..66c277c1a 100644 --- a/src/openhuman/skills/quickjs_libs/bootstrap.js +++ b/src/openhuman/skills/quickjs_libs/bootstrap.js @@ -867,6 +867,21 @@ globalThis.data = { console.log('[oauth.fetch] ' + method + ' ' + proxyUrl + ' (credentialId=' + globalThis.__oauthCredential.credentialId + ')'); var result = await net.fetch(proxyUrl, fetchOpts); console.log('[oauth.fetch] response status=' + result.status + ' body_len=' + (result.body ? result.body.length : 0)); + + // Auto-clear invalid/expired credentials so the user is prompted to re-auth + if (result.status === 401 || result.status === 403) { + console.warn('[oauth.fetch] Got ' + result.status + ' — clearing invalid credential for re-auth'); + globalThis.__oauthCredential = null; + if (typeof globalThis.state !== 'undefined' && globalThis.state.set) { + globalThis.state.set('__oauth_credential', ''); + globalThis.state.setPartial({ + connection_status: 'error', + connection_error: 'Integration token expired or invalid. Please reconnect.', + auth_status: 'not_authenticated', + }); + } + } + return result; }, diff --git a/src/openhuman/skills/quickjs_libs/qjs_ops/ops_core.rs b/src/openhuman/skills/quickjs_libs/qjs_ops/ops_core.rs index 0dd6b9044..9662584d2 100644 --- a/src/openhuman/skills/quickjs_libs/qjs_ops/ops_core.rs +++ b/src/openhuman/skills/quickjs_libs/qjs_ops/ops_core.rs @@ -7,6 +7,36 @@ use std::time::{Duration, Instant}; use super::types::{TimerEntry, TimerState, ALLOWED_ENV_VARS}; +/// Read the session JWT from the on-disk credentials store. +/// +/// Returns `None` on any failure so the caller can fall back to env vars. +fn token_from_credentials_store() -> Option { + use crate::openhuman::credentials::{AuthService, APP_SESSION_PROVIDER}; + + let home = directories::UserDirs::new()?.home_dir().to_path_buf(); + let default_dir = home.join(".openhuman"); + + let state_dir = match std::env::var("OPENHUMAN_WORKSPACE") { + Ok(ws) if !ws.is_empty() => { + let ws_path = std::path::PathBuf::from(&ws); + if ws_path.join("config.toml").exists() { + ws_path + } else { + default_dir + } + } + _ => default_dir, + }; + + if !state_dir.exists() { + return None; + } + + let auth = AuthService::new(&state_dir, true); + let profile = auth.get_profile(APP_SESSION_PROVIDER, None).ok()??; + profile.token.filter(|t| !t.trim().is_empty()) +} + pub fn register<'js>( ctx: &Ctx<'js>, ops: &Object<'js>, @@ -115,6 +145,11 @@ pub fn register<'js>( ops.set( "get_session_token", Function::new(ctx.clone(), || -> String { + // Try the on-disk credentials store first (where login actually persists + // the JWT), then fall back to the legacy JWT_TOKEN env var. + if let Some(token) = token_from_credentials_store() { + return token; + } std::env::var("JWT_TOKEN").unwrap_or_default() }), )?;