Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 44 additions & 6 deletions desktop/src/features/chat/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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<string>('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 })
Expand All @@ -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
Expand All @@ -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<Session[]>(qk.sessions, (old) => [row, ...(old ?? []).filter((s) => s.id !== id)])
await send(id, { text, attachments: files })
navigate(`/chat/${id}`, { replace: true })
Expand All @@ -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<Suggestion, 'prompt' | 'autoSend'>) => {
Expand Down Expand Up @@ -224,6 +257,7 @@ function ChatView({
{incognito ? <IncognitoBadge /> : <ChannelBadge channel={session?.channel} withLabel />}
<span className="flex-1" />
{streaming && <span className="text-xs text-fg-subtle">{assistantName} is replying…</span>}
{sessionId && <ChatScopeMenu value={chatScope} onChange={onChatScopeChange} />}
<HelpersTray sessionId={sessionId} />
<IconButton size="sm" label="Browser live view" icon={<IconWorldWww size={16} />} onClick={toggleBrowser} active={browserOpen} />
</div>
Expand All @@ -238,6 +272,10 @@ function ChatView({
<EmptyChat userName={bootstrap.data?.assistant.user_name} onPick={onPick} />
)}
<div className="mt-8">
<div className="mb-1.5 flex items-center justify-end gap-1.5 text-xs text-fg-subtle">
<span>Memories</span>
<ChatScopeMenu value={newChatScope} onChange={onChatScopeChange} />
</div>
<Composer
draftKey="new"
assistantName={assistantName}
Expand Down
88 changes: 88 additions & 0 deletions desktop/src/features/chat/ChatScopeMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { IconBriefcase, IconCheck, IconFolder, IconHome } from '@tabler/icons-react'
import { useState, type ReactNode } from 'react'
import { Input, Popover, PopoverContent, PopoverTrigger, Tooltip } from '@/components/ui'
import { ScopeIcon, scopeLabel } from '@/features/memory/bits'
import { useMemoryScopes } from '@/hooks/memory'
import { cn } from '@/lib/utils'

/**
* Which memories this chat uses (#155): Personal, Work or a project. Recall reads that scope plus the memories kept
* for every chat, and new memories from the chat are saved there.
*/
export function ChatScopeMenu({ value, onChange, className }: { value: string | null | undefined; onChange: (v: string) => 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 (
<Popover open={open} onOpenChange={setOpen}>
<Tooltip content={`This chat uses your ${scopeLabel(current)} memories`}>
<PopoverTrigger asChild>
<button
type="button"
aria-label="Memories for this chat"
data-chat-scope={current}
className={cn(
'flex h-8 max-w-48 shrink-0 items-center gap-1.5 rounded-lg px-2 text-xs font-medium transition-colors',
current === 'personal' ? 'text-fg-subtle hover:bg-hover hover:text-fg' : 'bg-accent/12 text-accent-text hover:bg-accent/18',
className
)}
>
<ScopeIcon scope={current} />
<span className="truncate">{scopeLabel(current)}</span>
</button>
</PopoverTrigger>
</Tooltip>
<PopoverContent align="end" className="w-72 p-1.5">
<div className="px-2.5 pb-1.5 pt-1 text-xs text-fg-subtle">
Which memories this chat uses. New ones it learns are kept there too. Memories set to every chat are always used.
</div>
<Option active={current === 'personal'} onClick={() => pick('personal')} icon={<IconHome size={15} />} hint={count('personal')}>
Personal
</Option>
<Option active={current === 'work'} onClick={() => pick('work')} icon={<IconBriefcase size={15} />} hint={count('work')}>
Work
</Option>
{projects.length > 0 && <div className="mx-2.5 mb-1 mt-2 text-2xs font-medium uppercase tracking-wide text-fg-faint">Projects</div>}
<div className="max-h-44 overflow-y-auto">
{projects.map((p) => (
<Option key={p} active={current === p} onClick={() => pick(p)} icon={<IconFolder size={15} />} hint={count(p)}>
{p}
</Option>
))}
</div>
<form
className="mt-1.5 border-t border-border p-1.5 pt-2"
onSubmit={(e) => {
e.preventDefault()
const name = custom.trim().replace(/\s+/g, ' ').slice(0, 40)
if (name && name.toLowerCase() !== 'always') pick(name)
}}
>
<Input size="sm" value={custom} onChange={(e) => setCustom(e.target.value)} placeholder="New project, e.g. Kestrel launch" aria-label="New project" />
</form>
</PopoverContent>
</Popover>
)
}

function Option({ active, onClick, icon, hint, children }: { active: boolean; onClick: () => void; icon: ReactNode; hint?: number; children: ReactNode }) {
return (
<button type="button" onClick={onClick} className="flex h-8 w-full items-center gap-2 rounded-lg px-2.5 text-left text-sm text-fg hover:bg-active">
<span className="shrink-0 text-fg-subtle">{icon}</span>
<span className="min-w-0 flex-1 truncate">{children}</span>
{hint !== undefined && <span className="text-2xs tabular-nums text-fg-subtle">{hint}</span>}
<IconCheck size={14} className={cn('shrink-0 text-accent-text', !active && 'invisible')} />
</button>
)
}
3 changes: 3 additions & 0 deletions desktop/src/features/chat/memoryToasts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
})
Expand Down
27 changes: 24 additions & 3 deletions desktop/src/features/memory/MemoryDetail.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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)
Expand Down Expand Up @@ -108,6 +110,19 @@ export function MemoryDetail({
</div>
<div className="text-xs text-fg-subtle">{src.description}</div>
</Row>
<Row icon={<ScopeIcon scope={memory.scope} size={14} />} label="Used in">
<Select
size="sm"
className="w-full"
aria-label="Move to"
value={memory.scope ?? 'personal'}
onValueChange={(v) => v !== (memory.scope ?? 'personal') && move.mutate({ id: memory.id, scope: v })}
options={scopeOptions(scopes.data?.map((s) => s.name) ?? [], memory.scope ?? 'personal')}
/>
<div className="mt-1 text-xs text-fg-subtle">
{memory.scope === 'always' ? 'Every chat can use it.' : `Only ${scopeLabel(memory.scope)} chats use it, plus anything you ask about directly.`}
</div>
</Row>
<Row icon={<IconCalendar size={14} />} label="Learned">
{formatDateTime(memory.created_at)} {recentRelative(memory.created_at) && <span className="text-fg-subtle">· {recentRelative(memory.created_at)}</span>}
</Row>
Expand Down Expand Up @@ -150,6 +165,12 @@ export function MemoryDetail({
)
}

function scopeOptions(names: string[], current: string) {
const all = ['personal', 'work', 'always', ...names.filter((n) => !['personal', 'work', 'always'].includes(n))]
if (!all.includes(current)) all.push(current)
return all.map((n) => ({ value: n, label: scopeLabel(n), icon: <ScopeIcon scope={n} size={14} /> }))
}

function Row({ icon, label, children }: { icon: React.ReactNode; label: string; children: React.ReactNode }) {
return (
<div className="flex gap-3 px-3.5 py-3">
Expand Down
3 changes: 2 additions & 1 deletion desktop/src/features/memory/MemoryList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { AnimatePresence, motion } from 'motion/react'
import { useMemo } from 'react'
import type { Memory } from '@/lib/types'
import { cn, formatTime, relativeTime } from '@/lib/utils'
import { ExpiryBadge, SourceChip, TopicPill } from './bits'
import { ExpiryBadge, ScopeBadge, SourceChip, TopicPill } from './bits'
import { useNow } from './hooks'
import { dayKey, dayLabel, primaryTopic, topicMeta } from './meta'

Expand All @@ -29,6 +29,7 @@ function MemoryCard({ memory, selected, onSelect, now, compact }: { memory: Memo
<TopicPill key={t} topic={t} size="xs" />
))}
<ExpiryBadge memory={memory} now={now} className="h-4.5" />
<ScopeBadge scope={memory.scope} />
{typeof memory.similarity === 'number' && (
<span className="rounded-full bg-accent/12 px-1.5 text-2xs font-medium text-accent-text">{Math.round(memory.similarity * 100)}% match</span>
)}
Expand Down
Loading
Loading