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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>
This commit is contained in:
Steven Enamakel
2026-03-31 16:37:41 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3369454cbe
commit 58b8a0dd4d
15 changed files with 450 additions and 344 deletions
+4 -1
View File
@@ -80,7 +80,10 @@ export default function ErrorFallbackScreen({
Try to Recover
</button>
<button
onClick={() => window.location.reload()}
onClick={() => {
window.location.hash = '#/home';
window.location.reload();
}}
className="flex-1 bg-coral-500 hover:bg-coral-600 text-white text-sm font-medium rounded-xl px-4 py-3 transition-colors">
Reload App
</button>
@@ -31,10 +31,13 @@ export default function SkillSetupModal({
const modalRef = useRef<HTMLDivElement>(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(() => {
+19 -133
View File
@@ -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<void>;
}
/**
* 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<ActionableItem[] | undefined>(MOCK_ACTIONABLE_ITEMS);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<string | null>(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<string | null>(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<string | null>(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
+18 -24
View File
@@ -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<string, number> {
const counts: Record<string, number> = {};
for (const rel of relations) {
const types = (rel.attrs?.entity_types ?? {}) as Record<string, string>;
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<string, number> = {};
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);
+10 -2
View File
@@ -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<SkillSnapshotRpc | null>(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<SkillSnapshotRpc[]>([]);
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]);
+9 -2
View File
@@ -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 {
+239 -142
View File
@@ -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<IntelligenceTab>('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 (
<div className="min-h-full relative">
<div className="relative z-10 min-h-full flex flex-col">
@@ -248,178 +258,265 @@ export default function Intelligence() {
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<h1 className="text-xl font-bold text-white">Intelligence</h1>
{stats.total > 0 && (
{activeTab === 'memory' && stats.total > 0 && (
<div className="text-xs bg-white/10 text-white px-2 py-1 rounded-full">
{stats.total}
</div>
)}
{usingMemoryData && (
<div className="text-xs bg-primary-500/20 text-primary-300 px-2 py-1 rounded-full border border-primary-500/20">
Memory
</div>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${systemStatusDot}`} />
<span className="text-xs text-stone-400">{systemStatusLabel}</span>
</div>
{/* Analyze Now / Refresh button */}
{activeTab === 'memory' && (
<button
onClick={usingMemoryData ? refreshConscious : handleAnalyzeNow}
disabled={isRunning || itemsLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-white/5 hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed border border-white/10 rounded-lg text-stone-300 transition-colors">
{isRunning || itemsLoading ? (
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
) : (
<svg
className="w-3 h-3"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
)}
{usingMemoryData ? 'Refresh' : 'Analyze Now'}
</button>
)}
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-white/10 mb-6">
{tabs.map(tab => (
<button
onClick={usingMemoryData ? refreshConscious : handleAnalyzeNow}
disabled={isRunning || itemsLoading}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs bg-white/5 hover:bg-white/10 disabled:opacity-40 disabled:cursor-not-allowed border border-white/10 rounded-lg text-stone-300 transition-colors">
{isRunning || itemsLoading ? (
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
) : (
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`relative px-4 py-2.5 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'text-primary-400 border-b-2 border-primary-400'
: 'text-stone-400 hover:text-stone-300'
}`}>
{tab.label}
{tab.comingSoon && (
<span className="ml-1.5 text-[10px] px-1.5 py-0.5 rounded-full bg-white/5 text-stone-500 border border-white/10">
Soon
</span>
)}
{usingMemoryData ? 'Refresh' : 'Analyze Now'}
</button>
</div>
))}
</div>
<MemoryWorkspace onToast={addToast} />
{/* Tab content */}
{activeTab === 'memory' && (
<>
<MemoryWorkspace onToast={addToast} />
{/* Filters */}
<div className="flex items-center gap-3 mt-6 mb-6 animate-fade-up">
<div className="flex-1">
<input
type="text"
placeholder="Search actionable items..."
value={filters.search}
onChange={e => 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"
/>
</div>
<select
value={filters.source}
onChange={e =>
dispatch(setSourceFilter(e.target.value as ActionableItemSource | 'all'))
}
className="px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500/50 transition-colors">
<option value="all">All Sources</option>
<option value="email">Email</option>
<option value="calendar">Calendar</option>
<option value="telegram">Telegram</option>
<option value="ai_insight">AI Insights</option>
<option value="system">System</option>
<option value="trading">Trading</option>
<option value="security">Security</option>
</select>
</div>
{/* Filters */}
<div className="flex items-center gap-3 mt-6 mb-6 animate-fade-up">
<div className="flex-1">
<input
type="text"
placeholder="Search actionable items..."
value={filters.search}
onChange={e => 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"
/>
</div>
<select
value={filters.source}
onChange={e =>
dispatch(setSourceFilter(e.target.value as ActionableItemSource | 'all'))
}
className="px-3 py-2 text-sm bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500/50 transition-colors">
<option value="all">All Sources</option>
<option value="email">Email</option>
<option value="calendar">Calendar</option>
<option value="telegram">Telegram</option>
<option value="ai_insight">AI Insights</option>
<option value="system">System</option>
<option value="trading">Trading</option>
<option value="security">Security</option>
</select>
</div>
{/* Content */}
{itemsLoading && !usingMemoryData ? (
/* Loading State */
{/* Content */}
{itemsLoading && !usingMemoryData ? (
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin" />
</div>
<h2 className="text-lg font-semibold text-white mb-2">
Loading Intelligence...
</h2>
<p className="text-stone-400 text-sm">Fetching your actionable items</p>
</div>
) : isRunning && items.length === 0 ? (
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin" />
</div>
<h2 className="text-lg font-semibold text-white mb-2">Analyzing your data</h2>
<p className="text-stone-400 text-sm">
The conscious loop is reviewing your connected skills
</p>
</div>
) : timeGroups.length === 0 ? (
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
<svg
className="w-8 h-8 text-primary-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
</div>
{filters.search || filters.source !== 'all' ? (
<>
<h2 className="text-lg font-semibold text-white mb-2">No matches</h2>
<p className="text-stone-400 text-sm">
No items match your current filters.
</p>
</>
) : usingMemoryData ? (
<>
<h2 className="text-lg font-semibold text-white mb-2">All caught up!</h2>
<p className="text-stone-400 text-sm">No actionable items at the moment.</p>
</>
) : (
<>
<h2 className="text-lg font-semibold text-white mb-2">No analysis yet</h2>
<p className="text-stone-400 text-sm mb-4">
Run an analysis to extract actionable items from your connected skills.
</p>
<button
onClick={handleAnalyzeNow}
disabled={isRunning}
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-40 text-white text-sm rounded-lg transition-colors">
Analyze Now
</button>
</>
)}
</div>
) : (
<div className="space-y-6">
{isRunning && (
<div className="flex items-center gap-2 text-xs text-stone-400 animate-fade-up">
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
Analyzing your data
</div>
)}
{timeGroups.map((group, groupIndex) => (
<div
key={group.label}
className="animate-fade-up"
style={{ animationDelay: `${groupIndex * 50}ms` }}>
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-white opacity-80">
{group.label}
</h2>
<div className="text-xs bg-white/10 text-white px-2 py-1 rounded-full">
{group.count}
</div>
</div>
<div className="space-y-3">
{group.items.map((item, itemIndex) => (
<div
key={item.id}
className="animate-fade-up"
style={{ animationDelay: `${groupIndex * 50 + itemIndex * 25}ms` }}>
<ActionableCard
item={item}
onComplete={handleComplete}
onDismiss={handleDismiss}
onSnooze={handleSnooze}
/>
</div>
))}
</div>
</div>
))}
</div>
)}
</>
)}
{activeTab === 'subconscious' && (
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin" />
</div>
<h2 className="text-lg font-semibold text-white mb-2">Loading Intelligence...</h2>
<p className="text-stone-400 text-sm">Fetching your actionable items</p>
</div>
) : isRunning && items.length === 0 ? (
/* Analyzing State (no items yet) */
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
<div className="w-8 h-8 border-2 border-primary-400 border-t-transparent rounded-full animate-spin" />
</div>
<h2 className="text-lg font-semibold text-white mb-2">Analyzing your data</h2>
<p className="text-stone-400 text-sm">
The conscious loop is reviewing your connected skills
</p>
</div>
) : timeGroups.length === 0 ? (
/* Empty State */
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-primary-500/10">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-lavender-500/10">
<svg
className="w-8 h-8 text-primary-400"
className="w-8 h-8 text-lavender-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
strokeWidth={1.5}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 9.563C9 9.252 9.252 9 9.563 9h4.874c.311 0 .563.252.563.563v4.874c0 .311-.252.563-.563.563H9.564A.562.562 0 019 14.437V9.564z"
/>
</svg>
</div>
{filters.search || filters.source !== 'all' ? (
<>
<h2 className="text-lg font-semibold text-white mb-2">No matches</h2>
<p className="text-stone-400 text-sm">No items match your current filters.</p>
</>
) : usingMemoryData ? (
<>
<h2 className="text-lg font-semibold text-white mb-2">All caught up!</h2>
<p className="text-stone-400 text-sm">No actionable items at the moment.</p>
</>
) : (
<>
<h2 className="text-lg font-semibold text-white mb-2">No analysis yet</h2>
<p className="text-stone-400 text-sm mb-4">
Run an analysis to extract actionable items from your connected skills.
</p>
<button
onClick={handleAnalyzeNow}
disabled={isRunning}
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-40 text-white text-sm rounded-lg transition-colors">
Analyze Now
</button>
</>
)}
<h2 className="text-lg font-semibold text-white mb-2">Subconscious</h2>
<p className="text-stone-400 text-sm mb-1">
OpenHuman will constantly have subconscious thoughts based on all the information
it has access to and the activity you have engaged with it in.
</p>
<p className="text-xs text-stone-500">Coming soon</p>
</div>
) : (
/* Time Groups */
<div className="space-y-6">
{/* Inline analyzing indicator when refreshing with existing items */}
{isRunning && (
<div className="flex items-center gap-2 text-xs text-stone-400 animate-fade-up">
<div className="w-3 h-3 border border-stone-400 border-t-transparent rounded-full animate-spin" />
Analyzing your data
</div>
)}
{timeGroups.map((group, groupIndex) => (
<div
key={group.label}
className="animate-fade-up"
style={{ animationDelay: `${groupIndex * 50}ms` }}>
{/* Group Header */}
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-white opacity-80">{group.label}</h2>
<div className="text-xs bg-white/10 text-white px-2 py-1 rounded-full">
{group.count}
</div>
</div>
)}
{/* Items */}
<div className="space-y-3">
{group.items.map((item, itemIndex) => (
<div
key={item.id}
className="animate-fade-up"
style={{ animationDelay: `${groupIndex * 50 + itemIndex * 25}ms` }}>
<ActionableCard
item={item}
onComplete={handleComplete}
onDismiss={handleDismiss}
onSnooze={handleSnooze}
/>
</div>
))}
</div>
</div>
))}
{activeTab === 'dreams' && (
<div className="glass rounded-2xl p-8 text-center animate-fade-up">
<div className="w-16 h-16 mx-auto mb-4 flex items-center justify-center rounded-full bg-sky-500/10">
<svg
className="w-8 h-8 text-sky-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</div>
<h2 className="text-lg font-semibold text-white mb-2">Dreams</h2>
<p className="text-stone-400 text-sm mb-1">
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.
</p>
<p className="text-xs text-stone-500">Coming soon</p>
</div>
)}
</div>
+6 -10
View File
@@ -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 {
+6 -1
View File
@@ -291,10 +291,15 @@ export async function memoryGraphQuery(
if (!isTauri()) {
throw new Error('Not running in Tauri');
}
return await callCoreRpc<GraphRelation[]>({
const raw = await callCoreRpc<GraphRelation[] | { result: GraphRelation[] }>({
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: {
+18 -4
View File
@@ -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,
}
}
@@ -39,6 +39,7 @@ pub(crate) async fn run_event_loop(
timer_state: &Arc<RwLock<qjs_ops::TimerState>>,
ops_state: &Arc<RwLock<qjs_ops::SkillState>>,
memory_client: Option<MemoryClientRef>,
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<PendingToolCall>,
memory_client: &Option<MemoryClientRef>,
ops_state: &Arc<RwLock<qjs_ops::SkillState>>,
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(&params).unwrap_or_else(|_| "null".to_string());
let code = format!(
@@ -388,10 +391,21 @@ async fn handle_message(
let _ = js_ctx.eval::<rquickjs::Value, _>(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(&params).unwrap_or_else(|_| "{}".to_string());
handle_js_call(rt, ctx, "onOAuthComplete", &params_str).await
@@ -445,7 +459,14 @@ async fn handle_message(
let _ = js_ctx.eval::<rquickjs::Value, _>(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 {
@@ -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;
})
@@ -77,25 +77,46 @@ pub(crate) fn extract_tools(js_ctx: &rquickjs::Ctx<'_>, state: &Arc<RwLock<Skill
}
}
/// Load a persisted OAuth credential from the skill's store and inject it
/// into the JS context so tools have access to the credential.
/// An empty string means "disconnected" — only non-empty values are restored.
pub(crate) async fn restore_oauth_credential(ctx: &rquickjs::AsyncContext, skill_id: &str) {
let code = r#"(function() {
if (typeof globalThis.state === 'undefined' || typeof globalThis.oauth === 'undefined') return false;
var cred = globalThis.state.get('__oauth_credential');
if (cred && cred !== '' && globalThis.oauth.__setCredential) {
globalThis.oauth.__setCredential(cred);
/// Load a persisted OAuth credential from the skill's data directory and inject
/// it into the JS context so tools have access to the credential.
///
/// Reads `{data_dir}/oauth_credential.json` which is written by the
/// `oauth/complete` handler and deleted by `oauth/revoked`.
pub(crate) async fn restore_oauth_credential(
ctx: &rquickjs::AsyncContext,
skill_id: &str,
data_dir: &std::path::Path,
) {
let cred_path = data_dir.join("oauth_credential.json");
let cred_json = match std::fs::read_to_string(&cred_path) {
Ok(s) if !s.is_empty() => 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::<bool, _>(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()
);
}
}
+15
View File
@@ -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;
},
@@ -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<String> {
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()
}),
)?;