diff --git a/packages/browseros-agent/apps/agent/components/sidebar/SettingsSidebar.tsx b/packages/browseros-agent/apps/agent/components/sidebar/SettingsSidebar.tsx index bc30093b7..ffd251412 100644 --- a/packages/browseros-agent/apps/agent/components/sidebar/SettingsSidebar.tsx +++ b/packages/browseros-agent/apps/agent/components/sidebar/SettingsSidebar.tsx @@ -80,6 +80,11 @@ const primarySettingsSections: NavSection[] = [ icon: Palette, feature: Feature.CUSTOMIZATION_SUPPORT, }, + { + name: 'Reset Data', + to: '/settings/reset-data', + icon: RotateCcw, + }, { name: 'Tool Approvals', to: '/settings/approvals', diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx index 450835411..43d64f5dd 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/App.tsx @@ -30,6 +30,7 @@ import { MagicLinkCallback } from './login/MagicLinkCallback' import { MCPSettingsPage } from './mcp-settings/MCPSettingsPage' import { MemoryPage } from './memory/MemoryPage' import { ProfilePage } from './profile/ProfilePage' +import { ResetDataPage } from './reset-data/ResetDataPage' import { ScheduledTasksPage } from './scheduled-tasks/ScheduledTasksPage' import { SearchProviderPage } from './search-provider/SearchProviderPage' import { SkillsPage } from './skills/SkillsPage' @@ -143,6 +144,7 @@ export const App: FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/memory/useMemoryContent.ts b/packages/browseros-agent/apps/agent/entrypoints/app/memory/useMemoryContent.ts index 251fe662d..ac2852a43 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/memory/useMemoryContent.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/memory/useMemoryContent.ts @@ -1,6 +1,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders' +export const MEMORY_QUERY_KEY = 'memory' + async function fetchMemory(baseUrl: string): Promise { const response = await fetch(`${baseUrl}/memory`) if (!response.ok) throw new Error(`HTTP ${response.status}`) @@ -30,7 +32,7 @@ export function useMemoryContent() { const queryClient = useQueryClient() const { data, isLoading, error, refetch } = useQuery({ - queryKey: ['memory', baseUrl], + queryKey: [MEMORY_QUERY_KEY, baseUrl], queryFn: () => fetchMemory(baseUrl as string), enabled: !!baseUrl && !urlLoading, }) @@ -38,7 +40,7 @@ export function useMemoryContent() { const saveMutation = useMutation({ mutationFn: (content: string) => saveMemory(baseUrl as string, content), onSuccess: (_data, content) => { - queryClient.setQueryData(['memory', baseUrl], content) + queryClient.setQueryData([MEMORY_QUERY_KEY, baseUrl], content) }, }) diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/reset-data/ResetDataPage.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/reset-data/ResetDataPage.tsx new file mode 100644 index 000000000..46e1f93ce --- /dev/null +++ b/packages/browseros-agent/apps/agent/entrypoints/app/reset-data/ResetDataPage.tsx @@ -0,0 +1,180 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { Brain, FileText, Loader2, RotateCcw } from 'lucide-react' +import { type FC, type ReactNode, useState } from 'react' +import { toast } from 'sonner' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Button } from '@/components/ui/button' +import { useAgentServerUrl } from '@/lib/browseros/useBrowserOSProviders' +import { MEMORY_QUERY_KEY } from '../memory/useMemoryContent' +import { SOUL_QUERY_KEY } from '../soul/useSoulContent' + +type ResetTarget = 'memory' | 'soul' + +type ResetAction = { + target: ResetTarget + title: string + description: string + buttonLabel: string + icon: ReactNode +} + +async function deleteServerResource( + baseUrl: string, + resource: ResetTarget, +): Promise { + const response = await fetch(`${baseUrl}/${resource}`, { method: 'DELETE' }) + if (!response.ok) throw new Error(`HTTP ${response.status}`) +} + +export const ResetDataPage: FC = () => { + const { + baseUrl, + isLoading: isUrlLoading, + error: urlError, + } = useAgentServerUrl() + const queryClient = useQueryClient() + const [pendingAction, setPendingAction] = useState(null) + + const resetMutation = useMutation({ + mutationFn: async (target: ResetTarget) => { + if (!baseUrl) throw new Error('BrowserOS server URL is unavailable') + await deleteServerResource(baseUrl, target) + return target + }, + onSuccess: async (target) => { + if (target === 'memory') { + queryClient.setQueryData([MEMORY_QUERY_KEY, baseUrl], '') + } + await queryClient.invalidateQueries({ + queryKey: target === 'memory' ? [MEMORY_QUERY_KEY] : [SOUL_QUERY_KEY], + }) + toast.success(target === 'memory' ? 'Memory reset' : 'SOUL.md reset') + }, + onError: (_error, target) => { + toast.error( + target === 'memory' + ? 'Failed to reset memory' + : 'Failed to reset SOUL.md', + ) + }, + }) + + const actions: ResetAction[] = [ + { + target: 'memory', + title: 'Reset memory?', + description: + 'This deletes CORE.md and daily memory files. This cannot be undone.', + buttonLabel: 'Reset memory', + icon: , + }, + { + target: 'soul', + title: 'Reset SOUL.md?', + description: + 'This replaces SOUL.md with the default template. This cannot be undone.', + buttonLabel: 'Reset SOUL.md', + icon: , + }, + ] + + const isBusy = isUrlLoading || resetMutation.isPending + const disabled = isBusy || Boolean(urlError) || !baseUrl + + const handleConfirm = () => { + if (!pendingAction) return + resetMutation.mutate(pendingAction.target) + setPendingAction(null) + } + + return ( +
+
+
+ + + Reset + +
+

Reset Data

+
+ + {urlError ? ( +
+

+ BrowserOS server is unavailable. +

+
+ ) : null} + +
+ {actions.map((action) => ( +
+
+ {action.icon} +
+

{action.buttonLabel}

+

+ {action.description} +

+
+
+ +
+ ))} +
+ + { + if (!open) setPendingAction(null) + }} + > + + + {pendingAction?.title} + + {pendingAction?.description} + + + + Cancel + + {pendingAction?.buttonLabel} + + + + +
+ ) +} diff --git a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/history/ChatHistory.tsx b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/history/ChatHistory.tsx index 4058b2169..cbf327416 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/history/ChatHistory.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/history/ChatHistory.tsx @@ -2,7 +2,8 @@ import { keepPreviousData, useQueryClient } from '@tanstack/react-query' import type { UIMessage } from 'ai' import { Loader2 } from 'lucide-react' import type { FC } from 'react' -import { useMemo } from 'react' +import { useMemo, useState } from 'react' +import { toast } from 'sonner' import { useSessionInfo } from '@/lib/auth/sessionStorage' import { useConversations } from '@/lib/conversations/conversationStorage' import { GetProfileIdByUserIdDocument } from '@/lib/conversations/graphql/uploadConversationDocument' @@ -21,8 +22,11 @@ import { import { LocalChatHistory } from './local/LocalChatHistory' const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => { - const { conversationId: activeConversationId } = useChatSessionContext() + const { conversationId: activeConversationId, resetConversation } = + useChatSessionContext() + const { clearConversations } = useConversations() const queryClient = useQueryClient() + const [isClearingAll, setIsClearingAll] = useState(false) const { data: profileData } = useGraphqlQuery(GetProfileIdByUserIdDocument, { userId, @@ -68,6 +72,50 @@ const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => { deleteConversationMutation.mutate({ rowId: id }) } + const getAllRemoteConversationIds = async () => { + let pages = graphqlData?.pages ?? [] + let hasMore = Boolean( + pages.at(-1)?.conversations?.pageInfo.hasNextPage ?? hasNextPage, + ) + + while (hasMore) { + const result = await fetchNextPage() + pages = result.data?.pages ?? pages + hasMore = Boolean(pages.at(-1)?.conversations?.pageInfo.hasNextPage) + } + + return pages.flatMap((page) => + (page.conversations?.nodes ?? []) + .filter((node): node is NonNullable => node !== null) + .map((node) => node.rowId), + ) + } + + const handleClearAll = async () => { + setIsClearingAll(true) + try { + const ids = [...new Set(await getAllRemoteConversationIds())] + for (let i = 0; i < ids.length; i += 10) { + const batch = ids.slice(i, i + 10) + await Promise.all( + batch.map((rowId) => + deleteConversationMutation.mutateAsync({ rowId }), + ), + ) + } + await clearConversations() + resetConversation() + await queryClient.invalidateQueries({ + queryKey: [getQueryKeyFromDocument(GetConversationsForHistoryDocument)], + }) + toast.success('Chat sessions cleared') + } catch { + toast.error('Failed to clear chat sessions') + } finally { + setIsClearingAll(false) + } + } + const conversations = useMemo(() => { if (!graphqlData?.pages) return [] @@ -110,6 +158,8 @@ const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => { groupedConversations={groupedConversations} activeConversationId={activeConversationId} onDelete={handleDelete} + onClearAll={handleClearAll} + isClearingAll={isClearingAll || deleteConversationMutation.isPending} hasNextPage={hasNextPage} isFetchingNextPage={isFetchingNextPage} onLoadMore={fetchNextPage} @@ -121,8 +171,6 @@ const RemoteChatHistory: FC<{ userId: string }> = ({ userId }) => { export const ChatHistory: FC = () => { const { sessionInfo } = useSessionInfo() const userId = sessionInfo.user?.id - // needed to initiate remote-sync - useConversations() if (userId) { return diff --git a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/history/components/ConversationList.tsx b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/history/components/ConversationList.tsx index e4962a293..9539a43f3 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/history/components/ConversationList.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/history/components/ConversationList.tsx @@ -1,6 +1,16 @@ -import { Loader2, MessageSquare } from 'lucide-react' -import { type FC, useEffect, useRef } from 'react' +import { Loader2, MessageSquare, Trash2 } from 'lucide-react' +import { type FC, useEffect, useRef, useState } from 'react' import { Link } from 'react-router' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { ConversationGroup } from './ConversationGroup' import type { GroupedConversations } from './types' import { TIME_GROUP_LABELS } from './utils' @@ -13,6 +23,8 @@ interface ConversationListProps { isFetchingNextPage?: boolean onLoadMore?: () => void isRefreshing?: boolean + onClearAll?: () => void + isClearingAll?: boolean } export const ConversationList: FC = ({ @@ -23,8 +35,11 @@ export const ConversationList: FC = ({ isFetchingNextPage, onLoadMore, isRefreshing, + onClearAll, + isClearingAll, }) => { const loadMoreRef = useRef(null) + const [showClearAllDialog, setShowClearAllDialog] = useState(false) useEffect(() => { if (!hasNextPage || !onLoadMore) return @@ -56,65 +71,118 @@ export const ConversationList: FC = ({ groupedConversations.thisMonth.length > 0 || groupedConversations.older.length > 0 - return ( -
-
- {isRefreshing && ( -
- - Fetching latest conversations -
- )} - {!hasConversations ? ( -
- -

- No conversations yet -

- - Start a new chat - -
- ) : ( - <> - - - - + const handleConfirmClearAll = () => { + onClearAll?.() + setShowClearAllDialog(false) + } - {hasNextPage && ( -
+
+
+
+

Chat history

+ {onClearAll && hasConversations && ( +
+ Clear sessions + )} - - )} -
-
+
+ + {isRefreshing && ( +
+ + Fetching latest conversations +
+ )} + {!hasConversations ? ( +
+ +

+ No conversations yet +

+ + Start a new chat + +
+ ) : ( + <> + + + + + + {hasNextPage && ( +
+ {isFetchingNextPage && ( + + )} +
+ )} + + )} +
+
+ + + + + Clear all sessions? + + This action permanently deletes every chat session in history. + + + + Cancel + + Clear sessions + + + + + ) } diff --git a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/history/local/LocalChatHistory.tsx b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/history/local/LocalChatHistory.tsx index 8c212e890..d7d844137 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/sidepanel/history/local/LocalChatHistory.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/sidepanel/history/local/LocalChatHistory.tsx @@ -1,5 +1,6 @@ import type { FC } from 'react' import { useMemo } from 'react' +import { toast } from 'sonner' import { useConversations } from '@/lib/conversations/conversationStorage' import { useChatSessionContext } from '../../layout/ChatSessionContext' import { ConversationList } from '../components/ConversationList' @@ -7,9 +8,13 @@ import type { HistoryConversation } from '../components/types' import { extractLastUserMessage, groupConversations } from '../components/utils' export const LocalChatHistory: FC = () => { - const { conversations: localConversations, removeConversation } = - useConversations() - const { conversationId: activeConversationId } = useChatSessionContext() + const { + conversations: localConversations, + removeConversation, + clearConversations, + } = useConversations() + const { conversationId: activeConversationId, resetConversation } = + useChatSessionContext() const conversations = useMemo(() => { return localConversations.map((conv) => ({ @@ -24,11 +29,22 @@ export const LocalChatHistory: FC = () => { [conversations], ) + const handleClearAll = async () => { + try { + await clearConversations() + resetConversation() + toast.success('Chat sessions cleared') + } catch { + toast.error('Failed to clear chat sessions') + } + } + return ( ) } diff --git a/packages/browseros-agent/apps/agent/lib/conversations/conversationStorage.ts b/packages/browseros-agent/apps/agent/lib/conversations/conversationStorage.ts index 7644fb26f..176640db7 100644 --- a/packages/browseros-agent/apps/agent/lib/conversations/conversationStorage.ts +++ b/packages/browseros-agent/apps/agent/lib/conversations/conversationStorage.ts @@ -2,7 +2,10 @@ import { storage } from '@wxt-dev/storage' import type { UIMessage } from 'ai' import { useEffect, useState } from 'react' import { useSessionInfo } from '../auth/sessionStorage' -import { removeConversationExecutionHistory } from '../execution-history/storage' +import { + clearConversationExecutionHistory, + removeConversationExecutionHistory, +} from '../execution-history/storage' import { uploadConversationsToGraphql } from './uploadConversationsToGraphql' const MAX_CONVERSATIONS = 50 @@ -46,6 +49,11 @@ export function useConversations() { await removeConversationExecutionHistory(id) } + const clearConversations = async () => { + await conversationStorage.setValue([]) + await clearConversationExecutionHistory() + } + const saveConversation = async (id: string, messages: UIMessage[]) => { const current = (await conversationStorage.getValue()) ?? [] const existingIndex = current.findIndex((c) => c.id === id) @@ -90,6 +98,7 @@ export function useConversations() { return { conversations, removeConversation, + clearConversations, saveConversation, getConversation, } diff --git a/packages/browseros-agent/apps/agent/lib/execution-history/storage.ts b/packages/browseros-agent/apps/agent/lib/execution-history/storage.ts index 2f6900fa6..f017eb2fd 100644 --- a/packages/browseros-agent/apps/agent/lib/execution-history/storage.ts +++ b/packages/browseros-agent/apps/agent/lib/execution-history/storage.ts @@ -82,6 +82,10 @@ export async function removeConversationExecutionHistory( await executionHistoryStorage.setValue(rest) } +export async function clearConversationExecutionHistory(): Promise { + await executionHistoryStorage.setValue({}) +} + export async function removeConversationExecutionTask(args: { conversationId: string taskId: string diff --git a/packages/browseros-agent/apps/server/src/api/routes/memory.ts b/packages/browseros-agent/apps/server/src/api/routes/memory.ts index 7c09d145d..56f775f39 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/memory.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/memory.ts @@ -1,4 +1,4 @@ -import { mkdir } from 'node:fs/promises' +import { mkdir, rm } from 'node:fs/promises' import { zValidator } from '@hono/zod-validator' import { Hono } from 'hono' import { z } from 'zod' @@ -26,4 +26,9 @@ export function createMemoryRoutes() { await Bun.write(getCoreMemoryPath(), content) return c.json({ success: true }) }) + .delete('/', async (c) => { + await rm(getMemoryDir(), { recursive: true, force: true }) + await mkdir(getMemoryDir(), { recursive: true }) + return c.json({ success: true }) + }) } diff --git a/packages/browseros-agent/apps/server/src/api/routes/soul.ts b/packages/browseros-agent/apps/server/src/api/routes/soul.ts index ba178f13a..9419ed368 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/soul.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/soul.ts @@ -1,7 +1,7 @@ import { zValidator } from '@hono/zod-validator' import { Hono } from 'hono' import { z } from 'zod' -import { readSoul, writeSoul } from '../../lib/soul' +import { readSoul, resetSoulTemplate, writeSoul } from '../../lib/soul' const WriteSoulSchema = z.object({ content: z.string(), @@ -18,4 +18,8 @@ export function createSoulRoutes() { const result = await writeSoul(content) return c.json(result) }) + .delete('/', async (c) => { + const result = await resetSoulTemplate() + return c.json(result) + }) } diff --git a/packages/browseros-agent/apps/server/src/lib/soul.ts b/packages/browseros-agent/apps/server/src/lib/soul.ts index 6049ee960..01cb2fa29 100644 --- a/packages/browseros-agent/apps/server/src/lib/soul.ts +++ b/packages/browseros-agent/apps/server/src/lib/soul.ts @@ -1,7 +1,7 @@ import { PATHS } from '@browseros/shared/constants/paths' import { getSoulPath } from './browseros-dir' -const SOUL_TEMPLATE = `# SOUL.md — Who You Are +export const SOUL_TEMPLATE = `# SOUL.md — Who You Are _You're not a chatbot. You're becoming someone._ ## Core Truths @@ -50,6 +50,10 @@ export async function writeSoul(content: string): Promise { } } +export async function resetSoulTemplate(): Promise { + return writeSoul(SOUL_TEMPLATE) +} + export async function seedSoulTemplate(): Promise { const file = Bun.file(getSoulPath()) if (await file.exists()) return diff --git a/packages/browseros-agent/apps/server/tests/api/routes/memory-soul-reset.test.ts b/packages/browseros-agent/apps/server/tests/api/routes/memory-soul-reset.test.ts new file mode 100644 index 000000000..06d80d32a --- /dev/null +++ b/packages/browseros-agent/apps/server/tests/api/routes/memory-soul-reset.test.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 BrowserOS + */ + +import { beforeEach, describe, it } from 'bun:test' +import assert from 'node:assert' +import { existsSync, mkdtempSync } from 'node:fs' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' + +import { createMemoryRoutes } from '../../../src/api/routes/memory' +import { createSoulRoutes } from '../../../src/api/routes/soul' +import { + getCoreMemoryPath, + getMemoryDir, + getSoulPath, +} from '../../../src/lib/browseros-dir' + +describe('memory and soul reset routes', () => { + beforeEach(() => { + process.env.BROWSEROS_DIR = mkdtempSync( + join(tmpdir(), 'browseros-reset-routes-'), + ) + }) + + it('deletes all memory files and leaves the memory directory usable', async () => { + await mkdir(getMemoryDir(), { recursive: true }) + await writeFile(getCoreMemoryPath(), 'core facts') + await writeFile(join(getMemoryDir(), '2026-05-09.md'), 'daily notes') + + const route = createMemoryRoutes() + const response = await route.request('/', { method: 'DELETE' }) + + assert.strictEqual(response.status, 200) + assert.deepStrictEqual(await response.json(), { success: true }) + assert.strictEqual(existsSync(getMemoryDir()), true) + assert.strictEqual(existsSync(getCoreMemoryPath()), false) + assert.strictEqual(existsSync(join(getMemoryDir(), '2026-05-09.md')), false) + + const getResponse = await route.request('/') + assert.deepStrictEqual(await getResponse.json(), { content: '' }) + }) + + it('resets SOUL.md to the default template', async () => { + await mkdir(dirname(getSoulPath()), { recursive: true }) + await writeFile(getSoulPath(), '# Custom soul\nBe different.') + + const route = createSoulRoutes() + const response = await route.request('/', { method: 'DELETE' }) + + assert.strictEqual(response.status, 200) + const body = await response.json() + assert.strictEqual(body.truncated, false) + assert.ok(body.linesWritten > 0) + + const content = await readFile(getSoulPath(), 'utf8') + assert.ok( + content.includes("You're not a chatbot. You're becoming someone."), + ) + assert.ok(!content.includes('Custom soul')) + }) +})