diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index db37fc8b..191ac6cf 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -70,6 +70,8 @@ class AppSettings(BaseModel): personalized_greeting: Optional[str] = None personalized_starters: list["PersonalizedStarter"] = Field(default_factory=list) personalized_automations: list["PersonalizedAutomation"] = Field(default_factory=list) + # Distilled from the user's provider chat history the first time they open ChatGPT/Claude in-app; re-feeds prep to sharpen suggestions. + personalized_usage_summary: Optional[str] = None # Suppresses preflight suggestion modal entries the user dismissed; keyed by ToolDefinition.name, value ISO timestamp. dismissed_mcp_suggestions: dict[str, str] = Field(default_factory=dict) analytics_opt_in: bool = True diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOpportunisticUsageHarvest.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOpportunisticUsageHarvest.ts new file mode 100644 index 00000000..7a68e180 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useOpportunisticUsageHarvest.ts @@ -0,0 +1,61 @@ +import { useEffect, useRef } from 'react'; +import { useAppDispatch, useAppSelector } from '@/shared/hooks'; +import { updateSettingsPatch } from '@/shared/state/settingsSlice'; +import { summarizeUsage, type ProviderUsage, type UsageProvider } from '@/shared/providerUsage'; +import { runPrep } from '@/app/components/OnboardingV3/onboardingV3Api'; + +const HOST_PROVIDER: Array<{ host: string; provider: UsageProvider }> = [ + { host: 'chatgpt.com', provider: 'codex' }, + { host: 'claude.ai', provider: 'claude' }, +]; + +function providerForUrl(url: string): UsageProvider | null { + try { + const host = new URL(url).hostname; + for (const hp of HOST_PROVIDER) if (host === hp.host || host.endsWith('.' + hp.host)) return hp.provider; + } catch { /* not a url */ } + return null; +} + +// The first-run provider login always escapes the app (magic-link / embed-block), so the ONLY moment the browser partition holds a readable session is when the user opens ChatGPT/Claude in an in-app card themselves. Catch exactly that: harvest silently offscreen, then re-feed prep so the saved greeting + starters sharpen to what they actually work on. At most one successful run per provider per session; a not-logged-in read (empty summary) leaves it armed to retry on the next visit. +export function useOpportunisticUsageHarvest(): void { + const dispatch = useAppDispatch(); + const providerKey = useAppSelector((s) => { + const found = new Set(); + for (const card of Object.values(s.dashboardLayout.browserCards)) { + for (const tab of card.tabs) { const p = providerForUrl(tab.url); if (p) found.add(p); } + } + return Array.from(found).sort().join(','); + }); + const doneRef = useRef>(new Set()); + const busyRef = useRef(false); + + useEffect(() => { + if (!providerKey || busyRef.current) return; + const provider = providerKey.split(',').find((p) => p && !doneRef.current.has(p)) as UsageProvider | undefined; + if (!provider) return; + const harvestUsage = window.openswarm?.harvestUsage; + if (typeof harvestUsage !== 'function') return; + busyRef.current = true; + // Let the page settle so the session cookie is live before the offscreen read. + const timer = window.setTimeout(async () => { + try { + const raw = (await harvestUsage(provider)) as ProviderUsage | null; + const summary = summarizeUsage(raw); + if (!summary) return; + doneRef.current.add(provider); + await dispatch(updateSettingsPatch({ personalized_usage_summary: summary })); + const prep = await runPrep(null, [], [], summary); + if (prep && prep.starters.length > 0) { + await dispatch(updateSettingsPatch({ + personalized_greeting: prep.greeting || null, + personalized_starters: prep.starters, + personalized_automations: prep.automations, + })); + } + } catch { /* fail-open */ } + finally { busyRef.current = false; } + }, 3000); + return () => window.clearTimeout(timer); + }, [providerKey, dispatch]); +} diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 96e5de99..cc6a837d 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -18,6 +18,7 @@ import { useSubAgentLifecycle } from '../lifecycle/useSubAgentLifecycle'; import { useDashboardLifecycle } from '../lifecycle/useDashboardLifecycle'; import { useWelcomeDraft } from '../lifecycle/useWelcomeDraft'; import { useOnboardingRevealSeed } from '../lifecycle/useOnboardingRevealSeed'; +import { useOpportunisticUsageHarvest } from '../lifecycle/useOpportunisticUsageHarvest'; import { useDashboardThumbnail } from './useDashboardThumbnail'; import { useSiblingRestack } from '../lifecycle/useSiblingRestack'; import { useAgentSpawn } from '../lifecycle/useAgentSpawn'; @@ -174,6 +175,9 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { createWelcomeDraft, }); + // Silently harvest the user's provider chat history the first time they open ChatGPT/Claude in-app, then sharpen their saved suggestions. + useOpportunisticUsageHarvest(); + // ---- Auto-reveal / collapse / unreveal sub-agent cards ---- useSubAgentLifecycle({ isActive, diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index 87b4a479..4ed91b4d 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -85,6 +85,7 @@ export interface AppSettings { personalized_greeting?: string | null; personalized_starters?: PersonalizedStarter[]; personalized_automations?: PersonalizedAutomation[]; + personalized_usage_summary?: string | null; } export interface PersonalizedStarter {