From 9aa1855287a7be465b90ade473df13cef4fa06f5 Mon Sep 17 00:00:00 2001 From: itsskofficial Date: Thu, 15 Oct 2026 19:27:22 +0530 Subject: [PATCH 1/6] feat(memory): memory scopes for work, personal and projects (#155) Memories are kept for Personal (default; existing memories migrate there), Work, a named project, or every chat. Chats, tasks and messaging apps have a scope: recall reads it plus the always memories, saving goes into it, and work chats leave out personal profile files and relationship insights. Chat header picker, Memory screen filter, move and reset. SCHEMA_VERSION 11. --- CHANGELOG.md | 7 + desktop/src/features/chat/ChatPage.tsx | 50 ++- desktop/src/features/chat/ChatScopeMenu.tsx | 88 +++++ desktop/src/features/chat/memoryToasts.ts | 3 + desktop/src/features/memory/MemoryDetail.tsx | 27 +- desktop/src/features/memory/MemoryList.tsx | 3 +- desktop/src/features/memory/MemoryPage.tsx | 61 +++- desktop/src/features/memory/bits.tsx | 31 +- desktop/src/features/memory/dialogs.tsx | 20 +- desktop/src/features/memory/hooks.ts | 23 +- desktop/src/hooks/core.ts | 12 + desktop/src/hooks/memory.ts | 15 + desktop/src/hooks/queryKeys.ts | 1 + desktop/src/lib/api.ts | 21 +- desktop/src/lib/types.ts | 28 +- docs/API.md | 82 ++++- sentient/agent/loop.py | 52 ++- sentient/config/schema.py | 4 + sentient/evolution/service.py | 7 +- sentient/gateway/routes/core.py | 36 +- sentient/gateway/routes/memory.py | 43 ++- sentient/gateway/routes/tasks.py | 4 +- sentient/memory/dreaming.py | 6 +- sentient/memory/export.py | 23 +- sentient/memory/facts.py | 187 +++++++--- sentient/memory/review.py | 10 +- sentient/memory/scopes.py | 156 ++++++++ sentient/memory/usermodel.py | 16 +- sentient/store/db.py | 25 +- sentient/store/schema.sql | 6 +- sentient/tasks/executor.py | 6 +- sentient/tasks/repo.py | 4 +- sentient/tasks/schema.sql | 1 + sentient/tasks/service.py | 11 +- sentient/tasks/swarm.py | 2 + sentient/tasks/tools.py | 8 +- sentient/tools/base.py | 3 + sentient/tools/builtin/memory_tool.py | 24 +- tests/memory/test_review.py | 2 +- tests/memory/test_review_followups.py | 4 +- tests/memory/test_scopes.py | 358 +++++++++++++++++++ 41 files changed, 1321 insertions(+), 149 deletions(-) create mode 100644 desktop/src/features/chat/ChatScopeMenu.tsx create mode 100644 sentient/memory/scopes.py create mode 100644 tests/memory/test_scopes.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e2cce6951..c720b6448 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ 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 never brings up personal things like family or health unless you ask about them; what Sentient learns there + is kept for work too. 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. - **A safety check for skills and MCP servers from outside.** Skills brought over from Hermes, and skills you add with the new **Add from a link** button in Skills (a SKILL.md, or a GitHub folder that holds one), wait for your review with a plain safety report: where it came from, and whether it tries to send your data to a fixed address, 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: })) + ]} + />