From 58b8a0dd4da498ecd660aaaf32f9dd2e10fac2eb Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:37:41 -0700 Subject: [PATCH] fix(skills): persist OAuth credentials and fix skill auto-start lifecycle (#146) * refactor(deep-link): streamline OAuth handling and skill setup process - Removed the RPC call for persisting setup completion, now handled directly in the preferences store. - Updated comments in the deep link handler to clarify the sequence of operations during OAuth completion. - Enhanced the `set_setup_complete` function to automatically enable skills upon setup completion, improving user experience during skill activation. This refactor simplifies the OAuth deep link handling and ensures skills are automatically enabled after setup, enhancing the overall flow. * feat(skills): enhance SkillSetupModal and snapshot fetching with polling - Added a mechanism in SkillSetupModal to sync the setup mode when the setup completion status changes, improving user experience during asynchronous loading. - Updated the useSkillSnapshot and useAllSkillSnapshots hooks to include periodic polling every 3 seconds, ensuring timely updates from the core sidecar and enhancing responsiveness to state changes. These changes improve the handling of skill setup and snapshot fetching, providing a more seamless user experience. * fix(ErrorFallbackScreen): update reload button behavior to navigate to home before reloading - Modified the onClick handler of the reload button to first set the window location hash to '#/home' before reloading the application. This change improves user experience by ensuring users are directed to the home screen upon reloading. * refactor(intelligence-api): simplify local-only hooks and remove unused code - Refactored the `useIntelligenceApiFallback` hooks to focus on local-only implementations, removing reliance on backend APIs and mock data. - Streamlined the `useActionableItems`, `useUpdateActionableItem`, `useSnoozeActionableItem`, and `useChatSession` hooks to operate solely with in-memory data. - Updated comments for clarity on the local-only nature of the hooks and their intended usage. - Enhanced the `useIntelligenceStats` hook to derive entity counts from local graph relations instead of fetching from a backend API, improving performance and reliability. - Removed unused imports and code related to backend interactions, resulting in cleaner and more maintainable code. * feat(intelligence): add active tab state management for Intelligence component - Introduced a new `IntelligenceTab` type to manage the active tab state within the Intelligence component. - Initialized the `activeTab` state to 'memory', enhancing user experience by allowing tab-specific functionality and navigation. This update lays the groundwork for future enhancements related to tabbed navigation in the Intelligence feature. * feat(intelligence): implement tab navigation and enhance UI interactions - Added a tab navigation system to the Intelligence component, allowing users to switch between 'Memory', 'Subconscious', and 'Dreams' tabs. - Integrated conditional rendering for the 'Analyze Now' button, ensuring it is only displayed when the 'Memory' tab is active. - Updated the UI to include a 'Coming Soon' label for the 'Subconscious' and 'Dreams' tabs, improving user awareness of upcoming features. - Enhanced the overall layout and styling for better user experience and interaction. * refactor(intelligence): streamline UI text and enhance OAuth credential handling - Simplified text rendering in the Intelligence component for better readability. - Updated the description for subconscious and dreams sections to provide clearer context on functionality. - Refactored OAuth credential handling in the QjsSkillInstance to utilize a data directory for persistence, improving credential management and recovery. - Enhanced logging for OAuth credential restoration and persistence, ensuring better traceability of actions. * fix(skills): update OAuth credential handling in SkillManager - Modified the SkillManager to use `credentialId` instead of `integrationId` for OAuth notifications, aligning with the expectations of the JS bootstrap's oauth.fetch. - Enhanced the parameters passed during the core RPC call to include `grantedScopes` and ensure the provider defaults to "unknown" if not specified, improving the robustness of the skill activation process. * fix(skills): derive modal mode from snapshot instead of syncing via effect Avoids the react-hooks/set-state-in-effect lint warning by deriving the setup/manage mode directly from the snapshot's setup_complete flag. Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(ErrorFallbackScreen): format reload button onClick handler for improved readability - Reformatted the onClick handler of the reload button to enhance code readability by adding line breaks. - Updated import order in useIntelligenceStats for consistency. - Improved logging format in event_loop.rs and js_helpers.rs for better traceability of OAuth credential actions. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- app/src/components/ErrorFallbackScreen.tsx | 5 +- app/src/components/skills/SkillSetupModal.tsx | 11 +- app/src/hooks/useIntelligenceApiFallback.ts | 152 +------ app/src/hooks/useIntelligenceStats.ts | 42 +- app/src/lib/skills/hooks.ts | 12 +- app/src/lib/skills/manager.ts | 11 +- app/src/pages/Intelligence.tsx | 381 +++++++++++------- app/src/utils/desktopDeepLinkListener.ts | 16 +- app/src/utils/tauriCommands.ts | 7 +- src/openhuman/skills/preferences.rs | 22 +- .../skills/qjs_skill_instance/event_loop.rs | 35 +- .../skills/qjs_skill_instance/instance.rs | 3 +- .../skills/qjs_skill_instance/js_helpers.rs | 47 ++- .../skills/quickjs_libs/bootstrap.js | 15 + .../skills/quickjs_libs/qjs_ops/ops_core.rs | 35 ++ 15 files changed, 450 insertions(+), 344 deletions(-) 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() }), )?;