diff --git a/CHANGELOG.md b/CHANGELOG.md index b2930d770..3bd71fb19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ and versions follow [Semantic Versioning](https://semver.org/). ## [Unreleased] ### Added +- **Keep work, personal and project memories apart.** Each memory is now kept for Personal, Work, a project you name, + or every chat (for things like your name or how you like answers). Pick a chat's memories from the button at the + top of the chat, or before you start one. A work chat then uses your work memories and the ones for every chat, + and leaves out your personal memories (like family or health) unless you ask about them; what Sentient learns + there is kept for work too. How you like things done (your answer style and other preferences it has picked up) + still applies in every chat. Tasks a chat creates keep its memories, and each messaging app can be set to one in + Settings. On the Memory page you can show one place at a time, move a memory to another place, and forget + everything kept for one place without touching the rest. Everything you had before is in Personal. - **Sentient checks what really happened, not what the model says happened.** After it writes a file, sends an email or a message, creates a calendar event or runs a Home Assistant action, Sentient checks the result itself: the app said it worked, and where it can, it reads it back (the file holds what was written, the email is in your Gmail Sent folder, the event diff --git a/desktop/src/features/chat/ChatPage.tsx b/desktop/src/features/chat/ChatPage.tsx index 8ba036f5e..b07cf3c53 100644 --- a/desktop/src/features/chat/ChatPage.tsx +++ b/desktop/src/features/chat/ChatPage.tsx @@ -7,7 +7,7 @@ import { Button, EmptyState, IconButton, Skeleton } from '@/components/ui' import { useBrowserView } from '@/features/browser/state' import { ChannelBadge } from '@/features/channels/meta' import { HelpersTray } from './HelpersTray' -import { useBootstrap, useConfig, useMessages, useRuleProposals, useSessions, useSetChatModel } from '@/hooks/core' +import { useBootstrap, useConfig, useMessages, useRuleProposals, useSessions, useSetChatModel, useSetChatScope } from '@/hooks/core' import { qk } from '@/hooks/queryKeys' import { api, errorMessage, isApiError } from '@/lib/api' import { foldTranscript, type AttachmentView, type TimelineItem } from '@/lib/chatFold' @@ -17,6 +17,8 @@ import type { ApprovalDecision, Session } from '@/lib/types' import { cn } from '@/lib/utils' import { liveItems, useChat } from '@/stores/chat' import { AssistantTurn } from './AssistantTurn' +import { scopeLabel } from '@/features/memory/bits' +import { ChatScopeMenu } from './ChatScopeMenu' import { Composer } from './Composer' import { useMemorySaveToasts } from './memoryToasts' import { RuleProposalCard } from './RuleProposalCard' @@ -109,6 +111,28 @@ function ChatView({ [sessionId, setModel] ) + // The memories this chat uses (#155). A new chat keeps the pick here until its first message creates it. + const [newChatScope, setNewChatScope] = useState('personal') + const setScope = useSetChatScope() + const chatScope = sessionId ? ((session?.memory_scope as string | undefined) ?? 'personal') : newChatScope + const onChatScopeChange = useCallback( + (scope: string) => { + if (!sessionId) { + setNewChatScope(scope) + return + } + setScope.mutate( + { id: sessionId, scope }, + { + onSuccess: () => toast.success(`This chat now uses your ${scopeLabel(scope)} memories`), + onError: (e) => toast.error("Couldn't change this chat's memories", { description: errorMessage(e) }) + } + ) + }, + [sessionId, setScope] + ) + const newChatOwnScope = newChatScope !== 'personal' ? newChatScope : null + // A brand-new chat navigates to its session once the engine assigns an id. useEffect(() => { if (!sessionId && resolvedId) navigate(`/chat/${resolvedId}`, { replace: true }) @@ -125,13 +149,13 @@ function ChatView({ const onSend = useCallback( async (text: string, files: AttachmentView[]) => { - if (!sessionId && newChatModel) { - // a new chat with its own model: create it first so even its first reply uses that model (#312) + if (!sessionId && (newChatModel || newChatOwnScope)) { + // a new chat with its own model (#312) or memories (#155): create it first so even its first reply uses them if (creating) return let id: string setCreating(true) // until this view moves to the new chat, so a second message can't start another one try { - id = (await api.sessions.create({ incognito: newIncognito, model: newChatModel })).session_id + id = (await api.sessions.create({ incognito: newIncognito, model: newChatModel, memory_scope: newChatOwnScope })).session_id } catch (err) { setCreating(false) setInject({ text, nonce: Date.now() }) // the message box was cleared: put the text back @@ -140,7 +164,16 @@ function ChatView({ } useChat.getState().adopt(id, { incognito: newIncognito }) const now = new Date().toISOString() - const row: Session = { id, title: null, channel: 'desktop', created_at: now, updated_at: now, incognito: newIncognito, model: newChatModel } + const row: Session = { + id, + title: null, + channel: 'desktop', + created_at: now, + updated_at: now, + incognito: newIncognito, + model: newChatModel, + memory_scope: newChatScope + } queryClient.setQueryData(qk.sessions, (old) => [row, ...(old ?? []).filter((s) => s.id !== id)]) await send(id, { text, attachments: files }) navigate(`/chat/${id}`, { replace: true }) @@ -149,7 +182,7 @@ function ChatView({ const key = await send(sessionId ?? null, { text, attachments: files, incognito: !sessionId && newIncognito }) if (!sessionId) setPendingKey(key) }, - [send, sessionId, newIncognito, newChatModel, navigate, creating] + [send, sessionId, newIncognito, newChatModel, newChatOwnScope, newChatScope, navigate, creating] ) const onPick = (s: Pick) => { @@ -224,6 +257,7 @@ function ChatView({ {incognito ? : } {streaming && {assistantName} is replying…} + {sessionId && } } onClick={toggleBrowser} active={browserOpen} /> @@ -238,6 +272,10 @@ function ChatView({ )}
+
+ Memories + +
void; className?: string }) { + const [open, setOpen] = useState(false) + const [custom, setCustom] = useState('') + const scopes = useMemoryScopes() + const current = value || 'personal' + const projects = (scopes.data ?? []).filter((s) => !s.built_in).map((s) => s.name) + if (!['personal', 'work', ...projects].includes(current)) projects.unshift(current) + const count = (name: string) => scopes.data?.find((s) => s.name === name)?.count + + const pick = (v: string) => { + if (v !== current) onChange(v) + setOpen(false) + setCustom('') + } + + return ( + + + + + + + +
+ Which memories this chat uses. New ones it learns are kept there too. Memories set to every chat are always used. +
+ + + {projects.length > 0 &&
Projects
} +
+ {projects.map((p) => ( + + ))} +
+
{ + e.preventDefault() + const name = custom.trim().replace(/\s+/g, ' ').slice(0, 40) + if (name && name.toLowerCase() !== 'always') pick(name) + }} + > + setCustom(e.target.value)} placeholder="New project, e.g. Kestrel launch" aria-label="New project" /> +
+
+
+ ) +} + +function Option({ active, onClick, icon, hint, children }: { active: boolean; onClick: () => void; icon: ReactNode; hint?: number; children: ReactNode }) { + return ( + + ) +} diff --git a/desktop/src/features/chat/memoryToasts.ts b/desktop/src/features/chat/memoryToasts.ts index cc7c8ed8f..e7af229ba 100644 --- a/desktop/src/features/chat/memoryToasts.ts +++ b/desktop/src/features/chat/memoryToasts.ts @@ -6,6 +6,7 @@ import { useEffect } from 'react' import { useNavigate } from 'react-router' import { toast } from 'sonner' +import { scopeLabel } from '@/features/memory/bits' import { qk } from '@/hooks/queryKeys' import { api, errorMessage } from '@/lib/api' import { queryClient } from '@/lib/queryClient' @@ -55,7 +56,9 @@ export function useMemorySaveToasts(sessionId: string | undefined) { }) return } + const where = d.scope && d.scope !== 'personal' ? (d.scope === 'always' ? 'For every chat' : `In ${scopeLabel(d.scope)}`) : undefined toast(`${d.action === 'UPDATE' ? 'Updated' : 'Remembered'}: ${text}`, { + description: where, // the memory scope it was saved to (#155), when not Personal action: { label: 'Undo', onClick: () => void undo(d) }, duration: 8000 }) diff --git a/desktop/src/features/memory/MemoryDetail.tsx b/desktop/src/features/memory/MemoryDetail.tsx index f16d1af01..63dcf0749 100644 --- a/desktop/src/features/memory/MemoryDetail.tsx +++ b/desktop/src/features/memory/MemoryDetail.tsx @@ -1,9 +1,10 @@ import { IconCalendar, IconCheck, IconClock, IconHistory, IconHourglassHigh, IconPencil, IconTrash, IconX } from '@tabler/icons-react' import { useEffect, useState } from 'react' -import { Alert, Button, ConfirmDialog, Sheet, Textarea } from '@/components/ui' +import { Alert, Button, ConfirmDialog, Select, Sheet, Textarea } from '@/components/ui' +import { useMemoryScopes } from '@/hooks/memory' import { useHotkey } from '@/hooks/useHotkey' import { formatDateTime } from '@/lib/utils' -import { ExpiryBadge, SourceChip, TopicPill } from './bits' +import { ExpiryBadge, ScopeIcon, scopeLabel, SourceChip, TopicPill } from './bits' import { useNow, useOptimisticMemoryActions } from './hooks' import { countdown, expiresInMs, recentRelative, sourceMeta, type MemoryWithHistory } from './meta' @@ -18,7 +19,8 @@ export function MemoryDetail({ onOpenChange: (open: boolean) => void onForgetSource: (source: string) => void }) { - const { update, remove } = useOptimisticMemoryActions() + const { update, remove, move } = useOptimisticMemoryActions() + const scopes = useMemoryScopes() const [editing, setEditing] = useState(false) const [draft, setDraft] = useState('') const [confirm, setConfirm] = useState(false) @@ -108,6 +110,19 @@ export function MemoryDetail({
{src.description}
+ } label="Used in"> + p.set({ scope: v === '__all' ? null : v })} + options={[ + { value: '__all', label: 'All chats' }, + ...scopeNames.map((n) => ({ value: n, label: `${scopeLabel(n)} (${scopeCount(n)})`, icon: })) + ]} + />